@stardeck-customer-apps/testing 0.3.0 → 0.4.0

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.js CHANGED
@@ -33,10 +33,11 @@ __export(index_exports, {
33
33
  CONTROL_PLANE_TEST_URL: () => CONTROL_PLANE_TEST_URL,
34
34
  DATA_STORE_TEST_HOST: () => DATA_STORE_TEST_HOST,
35
35
  DEFAULT_SCHEMA_PATH: () => DEFAULT_SCHEMA_PATH,
36
+ STORAGE_TEST_HOST: () => STORAGE_TEST_HOST,
37
+ STORAGE_TEST_URL: () => STORAGE_TEST_URL,
36
38
  TEST_ENV_DEFAULTS: () => TEST_ENV_DEFAULTS,
37
39
  WORKFLOW_NAME_PREFIX: () => WORKFLOW_NAME_PREFIX,
38
40
  callRoute: () => callRoute,
39
- createModuleApp: () => createModuleApp,
40
41
  createTestApp: () => createTestApp,
41
42
  describeWorkflow: () => describeWorkflow,
42
43
  parseWorkflowName: () => parseWorkflowName
@@ -44,7 +45,7 @@ __export(index_exports, {
44
45
  module.exports = __toCommonJS(index_exports);
45
46
 
46
47
  // src/test-app.ts
47
- var import_node_crypto4 = __toESM(require("crypto"));
48
+ var import_node_crypto8 = __toESM(require("crypto"));
48
49
  var import_node_fs = require("fs");
49
50
  var import_node_path = require("path");
50
51
 
@@ -99,6 +100,16 @@ var state = globalSingleton("state", () => ({
99
100
  emailCounter: 0,
100
101
  identities: /* @__PURE__ */ new Map(),
101
102
  identityLinks: [],
103
+ checkouts: [],
104
+ checkoutCounter: 0,
105
+ sessionStatuses: /* @__PURE__ */ new Map(),
106
+ paymentLinks: /* @__PURE__ */ new Map(),
107
+ products: [],
108
+ uploads: [],
109
+ uploadCounter: 0,
110
+ storageFiles: /* @__PURE__ */ new Map(),
111
+ presignPending: /* @__PURE__ */ new Map(),
112
+ messages: [],
102
113
  allowNetwork: false
103
114
  }));
104
115
  function requireDb() {
@@ -114,13 +125,16 @@ function requireDb() {
114
125
  var TEST_DOMAIN_SUFFIX = ".stardeck.test";
115
126
  var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
116
127
  var DATA_STORE_TEST_HOST = "db.stardeck.test";
128
+ var STORAGE_TEST_URL = "https://storage.stardeck.test";
129
+ var STORAGE_TEST_HOST = "storage.stardeck.test";
117
130
  var TEST_ENV_DEFAULTS = {
118
131
  CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
119
132
  DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
120
133
  ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
121
134
  PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
122
135
  DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
123
- DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`
136
+ DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`,
137
+ STORAGE_URL: STORAGE_TEST_URL
124
138
  };
125
139
  var DEFAULT_TEST_USER = {
126
140
  id: "test-user-1",
@@ -176,6 +190,13 @@ function success(data) {
176
190
  function failure(error, status = 400) {
177
191
  return json({ success: false, error }, status);
178
192
  }
193
+ async function readJsonBody(request) {
194
+ try {
195
+ return await request.json();
196
+ } catch {
197
+ return {};
198
+ }
199
+ }
179
200
 
180
201
  // src/simulator/data-store.ts
181
202
  function quoteIdent(name) {
@@ -658,13 +679,6 @@ function now() {
658
679
  function linksFor(identityId) {
659
680
  return state.identityLinks.filter((l) => l.identityId === identityId);
660
681
  }
661
- async function readBody(request) {
662
- try {
663
- return await request.json();
664
- } catch {
665
- return {};
666
- }
667
- }
668
682
  function handleList(request) {
669
683
  const typeParam = new URL(request.url).searchParams.get("type");
670
684
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
@@ -672,7 +686,7 @@ function handleList(request) {
672
686
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
673
687
  }
674
688
  async function handleCreate(request) {
675
- const body = await readBody(request);
689
+ const body = await readJsonBody(request);
676
690
  const type = body.type;
677
691
  if (type !== "person" && type !== "account") {
678
692
  return failure("type must be 'person' or 'account'");
@@ -709,7 +723,7 @@ function handleGet(identityId) {
709
723
  async function handleUpdate(identityId, request) {
710
724
  const identity = state.identities.get(identityId);
711
725
  if (!identity) return failure("identity not found", 404);
712
- const body = await readBody(request);
726
+ const body = await readJsonBody(request);
713
727
  if (body.displayName !== void 0) {
714
728
  identity.displayName = body.displayName;
715
729
  }
@@ -731,7 +745,7 @@ async function handleAttachLink(identityId, request) {
731
745
  if (identity.status !== "active") {
732
746
  return failure("links attach only to active persons");
733
747
  }
734
- const body = await readBody(request);
748
+ const body = await readJsonBody(request);
735
749
  const kind = body.kind;
736
750
  const externalId = body.externalId;
737
751
  if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
@@ -788,6 +802,679 @@ function createDirectory() {
788
802
  };
789
803
  }
790
804
 
805
+ // src/simulator/payments.ts
806
+ var import_node_crypto5 = __toESM(require("crypto"));
807
+
808
+ // src/next/headers-shim.ts
809
+ var import_node_async_hooks = require("async_hooks");
810
+ var requestScopeStorage = globalSingleton(
811
+ "request-scope",
812
+ () => new import_node_async_hooks.AsyncLocalStorage()
813
+ );
814
+
815
+ // src/next/call-route.ts
816
+ function parseCookieHeader(header) {
817
+ const map = /* @__PURE__ */ new Map();
818
+ if (!header) return map;
819
+ for (const part of header.split(";")) {
820
+ const eq = part.indexOf("=");
821
+ if (eq === -1) continue;
822
+ map.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
823
+ }
824
+ return map;
825
+ }
826
+ async function importNextServer() {
827
+ try {
828
+ return await import("next/server.js");
829
+ } catch {
830
+ return await import("next/server");
831
+ }
832
+ }
833
+ async function callRoute(handler, options = {}) {
834
+ const { NextRequest } = await importNextServer();
835
+ const path = options.path ?? "/api/test-route";
836
+ const url = new URL(`http://localhost:3333${path}`);
837
+ for (const [key, value] of Object.entries(options.searchParams ?? {})) {
838
+ url.searchParams.set(key, value);
839
+ }
840
+ const method = options.method ?? (options.body !== void 0 ? "POST" : "GET");
841
+ const headers = new Headers(options.headers);
842
+ const user = options.user !== void 0 ? options.user : state.currentUser;
843
+ if (user && !headers.has("x-stardeck-user")) {
844
+ headers.set("x-stardeck-user", JSON.stringify(user));
845
+ }
846
+ if (options.body !== void 0 && !headers.has("Content-Type")) {
847
+ headers.set("Content-Type", "application/json");
848
+ }
849
+ const cookiePairs = Object.entries(options.cookies ?? {});
850
+ if (cookiePairs.length > 0) {
851
+ const existing = headers.get("cookie");
852
+ const cookieString = cookiePairs.map(([k, v]) => `${k}=${v}`).join("; ");
853
+ headers.set("cookie", existing ? `${existing}; ${cookieString}` : cookieString);
854
+ }
855
+ const request = new NextRequest(url, {
856
+ method,
857
+ headers,
858
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
859
+ });
860
+ const scope = {
861
+ headers,
862
+ cookies: parseCookieHeader(headers.get("cookie"))
863
+ };
864
+ try {
865
+ return await requestScopeStorage.run(
866
+ scope,
867
+ () => Promise.resolve(
868
+ handler(request, {
869
+ params: Promise.resolve(options.params ?? {})
870
+ })
871
+ )
872
+ );
873
+ } catch (error) {
874
+ const redirect = decodeNextRedirect(error);
875
+ if (redirect) return redirect;
876
+ throw error;
877
+ }
878
+ }
879
+ function decodeNextRedirect(error) {
880
+ const digest = error?.digest;
881
+ if (typeof digest !== "string" || !digest.startsWith("NEXT_REDIRECT")) return null;
882
+ const parts = digest.split(";");
883
+ const location = parts[2] ?? "/";
884
+ const status = Number(parts[3]) || 307;
885
+ return new Response(null, { status, headers: { location } });
886
+ }
887
+
888
+ // src/simulator/webhook-signing.ts
889
+ var import_node_crypto4 = __toESM(require("crypto"));
890
+ function signEventDelivery(secret, context, rawBody) {
891
+ const payload = {
892
+ type: "deployment-request",
893
+ organizationId: context.organizationId,
894
+ projectId: context.projectId,
895
+ deploymentId: context.deploymentId,
896
+ timestamp: Math.floor(Date.now() / 1e3),
897
+ nonce: import_node_crypto4.default.randomUUID()
898
+ };
899
+ const payloadJson = JSON.stringify(payload);
900
+ const payloadB64 = Buffer.from(payloadJson).toString("base64");
901
+ const signature = import_node_crypto4.default.createHmac("sha256", secret).update(payloadJson).update(rawBody).digest("hex");
902
+ return `${payloadB64}.${signature}`;
903
+ }
904
+
905
+ // src/simulator/payments.ts
906
+ function payErr(error, status = 400, code) {
907
+ return json(code ? { error, code } : { error }, status);
908
+ }
909
+ function nextCheckoutId() {
910
+ state.checkoutCounter += 1;
911
+ return `cs_test_${state.checkoutCounter}`;
912
+ }
913
+ function nextPaymentLinkId() {
914
+ state.checkoutCounter += 1;
915
+ return `plink_test_${state.checkoutCounter}`;
916
+ }
917
+ function seedStripeSession(id, body) {
918
+ const lineItems = body.lineItems ?? [];
919
+ let amountTotal = null;
920
+ let currency = null;
921
+ if (lineItems.length > 0) {
922
+ amountTotal = lineItems.reduce(
923
+ (sum, item) => sum + (item.priceData?.unitAmount ?? 0) * (item.quantity ?? 1),
924
+ 0
925
+ );
926
+ currency = lineItems[0].priceData?.currency ?? null;
927
+ }
928
+ state.sessionStatuses.set(id, {
929
+ id,
930
+ status: "open",
931
+ paymentStatus: "unpaid",
932
+ mode: body.mode ?? "payment",
933
+ amountTotal,
934
+ currency,
935
+ customerEmail: body.customerEmail ? String(body.customerEmail) : null,
936
+ metadata: body.metadata ?? {},
937
+ expiresAt: Math.floor(Date.now() / 1e3) + 3600
938
+ });
939
+ }
940
+ function seedBeamLink(id, body, merchantId) {
941
+ const order = body.order;
942
+ state.paymentLinks.set(id, {
943
+ paymentLinkId: id,
944
+ merchantId,
945
+ url: `https://beam.test/pay/${id}`,
946
+ status: "ACTIVE",
947
+ order: {
948
+ netAmount: Number(order?.netAmount ?? 0),
949
+ currency: String(order?.currency ?? "THB"),
950
+ description: String(order?.description ?? ""),
951
+ referenceId: order?.referenceId ? String(order.referenceId) : void 0,
952
+ internalNote: order?.internalNote ? String(order.internalNote) : void 0,
953
+ orderItems: order?.orderItems
954
+ },
955
+ redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
956
+ linkSettings: body.linkSettings,
957
+ collectDeliveryAddress: body.collectDeliveryAddress === true
958
+ });
959
+ }
960
+ async function deliverEvent(envelope, handler, options) {
961
+ const rawBody = JSON.stringify(envelope);
962
+ const secret = options?.deploymentSecret ?? TEST_ENV_DEFAULTS.DEPLOYMENT_SECRET;
963
+ const authHeader = signEventDelivery(
964
+ secret,
965
+ {
966
+ organizationId: TEST_ENV_DEFAULTS.ORGANIZATION_ID,
967
+ projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
968
+ deploymentId: TEST_ENV_DEFAULTS.DEPLOYMENT_ID
969
+ },
970
+ rawBody
971
+ );
972
+ return callRoute(handler, {
973
+ method: "POST",
974
+ path: options?.path ?? "/api/payments/webhooks",
975
+ params: { path: ["webhooks"] },
976
+ headers: { "X-Stardeck-Auth": authHeader },
977
+ body: envelope
978
+ });
979
+ }
980
+ async function handlePaymentsRequest(request, url) {
981
+ const pathname = url.pathname;
982
+ if (/\/bolt-connections/.test(pathname) || /\/bolt-intents/.test(pathname) || /\/charges(\/|$)/.test(pathname) || /\/billing-portal$/.test(pathname)) {
983
+ return payErr("Not found", 404, "NOT_FOUND");
984
+ }
985
+ const beamProductsMatch = pathname.match(
986
+ /^\/api\/store\/beam\/([^/]+)\/payment-links(?:\/([^/]+))?$/
987
+ );
988
+ if (beamProductsMatch) {
989
+ const merchantId = beamProductsMatch[1];
990
+ const linkId = beamProductsMatch[2];
991
+ if (!linkId && request.method === "POST") {
992
+ const body = await readJsonBody(request);
993
+ const id = nextPaymentLinkId();
994
+ const checkoutUrl = `https://beam.test/pay/${id}`;
995
+ const captured = {
996
+ id,
997
+ url: checkoutUrl,
998
+ provider: "beam",
999
+ options: body,
1000
+ metadata: body.metadata,
1001
+ createdAt: /* @__PURE__ */ new Date()
1002
+ };
1003
+ state.checkouts.push(captured);
1004
+ seedBeamLink(id, body, merchantId);
1005
+ return json({ id, url: checkoutUrl });
1006
+ }
1007
+ if (linkId && request.method === "GET") {
1008
+ const link = state.paymentLinks.get(linkId);
1009
+ if (!link) return payErr("Payment link not found", 404, "NOT_FOUND");
1010
+ return json({ paymentLink: link });
1011
+ }
1012
+ }
1013
+ const stripeStoreMatch = pathname.match(/^\/api\/store\/([^/]+)\/(.+)$/);
1014
+ if (stripeStoreMatch) {
1015
+ const accountId = stripeStoreMatch[1];
1016
+ const subPath = stripeStoreMatch[2];
1017
+ if (accountId === "beam") {
1018
+ return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
1019
+ }
1020
+ if (subPath === "products" && request.method === "GET") {
1021
+ return json({ products: state.products });
1022
+ }
1023
+ const productMatch = subPath.match(/^products\/([^/]+)$/);
1024
+ if (productMatch && request.method === "GET") {
1025
+ const product = state.products.find((p) => p.id === productMatch[1]);
1026
+ if (!product) return payErr("Product not found", 404, "NOT_FOUND");
1027
+ return json({ product });
1028
+ }
1029
+ if (subPath === "checkout" && request.method === "POST") {
1030
+ const body = await readJsonBody(request);
1031
+ const id = nextCheckoutId();
1032
+ const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
1033
+ const captured = {
1034
+ id,
1035
+ url: checkoutUrl,
1036
+ provider: "stripe",
1037
+ options: body,
1038
+ mode: body.mode,
1039
+ metadata: body.metadata,
1040
+ createdAt: /* @__PURE__ */ new Date()
1041
+ };
1042
+ state.checkouts.push(captured);
1043
+ seedStripeSession(id, body);
1044
+ return json({ id, url: checkoutUrl });
1045
+ }
1046
+ const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
1047
+ if (sessionMatch && request.method === "GET") {
1048
+ const session = state.sessionStatuses.get(sessionMatch[1]);
1049
+ if (!session) return payErr("Checkout session not found", 404, "NOT_FOUND");
1050
+ return json({ session });
1051
+ }
1052
+ }
1053
+ return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
1054
+ }
1055
+ function createPayments() {
1056
+ return {
1057
+ get checkouts() {
1058
+ return [...state.checkouts];
1059
+ },
1060
+ latest() {
1061
+ return state.checkouts[state.checkouts.length - 1];
1062
+ },
1063
+ setProducts(products) {
1064
+ state.products = products;
1065
+ },
1066
+ markPaid(id) {
1067
+ const session = state.sessionStatuses.get(id);
1068
+ if (session) {
1069
+ session.status = "complete";
1070
+ session.paymentStatus = "paid";
1071
+ return;
1072
+ }
1073
+ const link = state.paymentLinks.get(id);
1074
+ if (link) {
1075
+ link.status = "PAID";
1076
+ return;
1077
+ }
1078
+ throw new Error(`[stardeck-testing] Unknown checkout or payment link id: ${id}`);
1079
+ },
1080
+ setSessionStatus(id, status) {
1081
+ const session = state.sessionStatuses.get(id);
1082
+ if (!session) {
1083
+ throw new Error(`[stardeck-testing] Unknown checkout session id: ${id}`);
1084
+ }
1085
+ Object.assign(session, status);
1086
+ },
1087
+ setPaymentLinkStatus(id, status) {
1088
+ const link = state.paymentLinks.get(id);
1089
+ if (!link) {
1090
+ throw new Error(`[stardeck-testing] Unknown payment link id: ${id}`);
1091
+ }
1092
+ link.status = status;
1093
+ },
1094
+ async deliverStripeEvent(handler, event, options) {
1095
+ const envelope = {
1096
+ id: `evt_test_${import_node_crypto5.default.randomUUID()}`,
1097
+ kind: "stripe_webhook",
1098
+ timestamp: Date.now(),
1099
+ stripeEvent: {
1100
+ type: event.type,
1101
+ accountId: event.accountId ?? process.env.STRIPE_CONNECT_ACCOUNT_ID ?? "acct_test",
1102
+ data: event.data
1103
+ }
1104
+ };
1105
+ return deliverEvent(envelope, handler, options);
1106
+ },
1107
+ async deliverBeamEvent(handler, event, options) {
1108
+ const envelope = {
1109
+ id: `beam_evt_test_${import_node_crypto5.default.randomUUID()}`,
1110
+ kind: "beam_webhook",
1111
+ timestamp: Date.now(),
1112
+ beamEvent: {
1113
+ type: event.type,
1114
+ payload: event.payload
1115
+ }
1116
+ };
1117
+ return deliverEvent(envelope, handler, options);
1118
+ },
1119
+ clear() {
1120
+ state.checkouts = [];
1121
+ state.checkoutCounter = 0;
1122
+ state.sessionStatuses.clear();
1123
+ state.paymentLinks.clear();
1124
+ state.products = [];
1125
+ },
1126
+ get count() {
1127
+ return state.checkouts.length;
1128
+ }
1129
+ };
1130
+ }
1131
+
1132
+ // src/simulator/storage.ts
1133
+ var import_node_crypto6 = __toESM(require("crypto"));
1134
+ function storageErr(error, status = 400) {
1135
+ return json({ error }, status);
1136
+ }
1137
+ function fileExtension(filename) {
1138
+ const dot = filename.lastIndexOf(".");
1139
+ return dot === -1 ? "bin" : filename.slice(dot + 1);
1140
+ }
1141
+ function buildKey(fileId, filename) {
1142
+ const orgId = process.env.ORGANIZATION_ID ?? TEST_ENV_DEFAULTS.ORGANIZATION_ID;
1143
+ const projectId = process.env.PROJECT_ID ?? TEST_ENV_DEFAULTS.PROJECT_ID;
1144
+ const deploymentId = process.env.DEPLOYMENT_ID ?? TEST_ENV_DEFAULTS.DEPLOYMENT_ID;
1145
+ return `${orgId}/${projectId}/${deploymentId}/${fileId}.${fileExtension(filename)}`;
1146
+ }
1147
+ function buildUrl(key) {
1148
+ return `${STORAGE_TEST_URL}/files/${key}`;
1149
+ }
1150
+ function toUploadResponse(record) {
1151
+ return {
1152
+ id: record.id,
1153
+ key: record.key,
1154
+ filename: record.filename,
1155
+ contentType: record.contentType,
1156
+ sizeBytes: record.sizeBytes,
1157
+ url: record.url,
1158
+ uploadedAt: record.uploadedAt,
1159
+ isPublic: record.isPublic,
1160
+ metadata: record.metadata
1161
+ };
1162
+ }
1163
+ function captureUpload(record, method, path) {
1164
+ const captured = {
1165
+ id: record.id,
1166
+ key: record.key,
1167
+ filename: record.filename,
1168
+ contentType: record.contentType,
1169
+ sizeBytes: record.sizeBytes,
1170
+ url: record.url,
1171
+ uploadedAt: record.uploadedAt,
1172
+ isPublic: record.isPublic,
1173
+ metadata: record.metadata,
1174
+ method,
1175
+ path
1176
+ };
1177
+ state.uploads.push(captured);
1178
+ }
1179
+ function createFileRecord(input) {
1180
+ state.uploadCounter += 1;
1181
+ const id = `file_test_${state.uploadCounter}`;
1182
+ const key = buildKey(id, input.filename);
1183
+ return {
1184
+ id,
1185
+ key,
1186
+ filename: input.filename,
1187
+ contentType: input.contentType,
1188
+ sizeBytes: input.sizeBytes,
1189
+ url: buildUrl(key),
1190
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
1191
+ metadata: input.metadata,
1192
+ isPublic: input.isPublic,
1193
+ deleted: false
1194
+ };
1195
+ }
1196
+ function activeFiles() {
1197
+ return [...state.storageFiles.values()].filter((f) => !f.deleted);
1198
+ }
1199
+ async function handleStorageRequest(request, url) {
1200
+ const pathname = url.pathname;
1201
+ if (pathname.startsWith("/upload/presign/multipart")) {
1202
+ return storageErr("Not found", 404);
1203
+ }
1204
+ const presignedPutMatch = pathname.match(/^\/presigned\/([^/]+)$/);
1205
+ if (presignedPutMatch && request.method === "PUT") {
1206
+ const fileId = presignedPutMatch[1];
1207
+ if (!state.presignPending.has(fileId)) {
1208
+ return storageErr("Presign session not found", 404);
1209
+ }
1210
+ return new Response(null, {
1211
+ status: 200,
1212
+ headers: { etag: `"${import_node_crypto6.default.randomUUID()}"` }
1213
+ });
1214
+ }
1215
+ if (pathname === "/upload/presign/complete" && request.method === "POST") {
1216
+ const body = await readJsonBody(request);
1217
+ const fileId = String(body.fileId ?? "");
1218
+ const pending = state.presignPending.get(fileId);
1219
+ if (!pending) return storageErr("Presign session not found", 404);
1220
+ const record = {
1221
+ id: fileId,
1222
+ key: pending.key,
1223
+ filename: pending.fileName,
1224
+ contentType: pending.contentType,
1225
+ sizeBytes: pending.sizeBytes,
1226
+ url: buildUrl(pending.key),
1227
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
1228
+ metadata: pending.metadata,
1229
+ isPublic: pending.isPublic,
1230
+ deleted: false
1231
+ };
1232
+ state.storageFiles.set(record.id, record);
1233
+ state.presignPending.delete(fileId);
1234
+ captureUpload(record, "POST", "/upload/presign/complete");
1235
+ return json(toUploadResponse(record));
1236
+ }
1237
+ if (pathname === "/upload/presign" && request.method === "POST") {
1238
+ const body = await readJsonBody(request);
1239
+ const fileName = String(body.fileName ?? "upload.bin");
1240
+ const contentType = String(body.contentType ?? "application/octet-stream");
1241
+ const sizeBytes = Number(body.sizeBytes ?? 0);
1242
+ const isPublic = body.isPublic !== false;
1243
+ const metadata = body.metadata;
1244
+ state.uploadCounter += 1;
1245
+ const fileId = `file_test_${state.uploadCounter}`;
1246
+ const key = buildKey(fileId, fileName);
1247
+ state.presignPending.set(fileId, {
1248
+ fileName,
1249
+ contentType,
1250
+ sizeBytes,
1251
+ isPublic,
1252
+ metadata,
1253
+ key
1254
+ });
1255
+ return json({
1256
+ fileId,
1257
+ key,
1258
+ presignedUrl: `${STORAGE_TEST_URL}/presigned/${fileId}`,
1259
+ contentType,
1260
+ expiresIn: 3600
1261
+ });
1262
+ }
1263
+ if (pathname === "/upload" && request.method === "POST") {
1264
+ const isPublic = url.searchParams.get("isPublic") !== "false";
1265
+ const formData = await request.formData();
1266
+ const file = formData.get("file");
1267
+ if (!(file instanceof Blob)) {
1268
+ return storageErr("file is required");
1269
+ }
1270
+ const filename = formData.get("filename") ?? (file instanceof File ? file.name : "upload.bin");
1271
+ const metadataRaw = formData.get("metadata");
1272
+ let metadata;
1273
+ if (typeof metadataRaw === "string" && metadataRaw) {
1274
+ try {
1275
+ metadata = JSON.parse(metadataRaw);
1276
+ } catch {
1277
+ return storageErr("metadata must be valid JSON");
1278
+ }
1279
+ }
1280
+ const record = createFileRecord({
1281
+ filename,
1282
+ contentType: file.type || "application/octet-stream",
1283
+ sizeBytes: file.size,
1284
+ isPublic,
1285
+ metadata
1286
+ });
1287
+ state.storageFiles.set(record.id, record);
1288
+ captureUpload(record, "POST", "/upload");
1289
+ return json(toUploadResponse(record));
1290
+ }
1291
+ const fileMatch = pathname.match(/^\/api\/files\/([^/]+)$/);
1292
+ if (fileMatch) {
1293
+ const fileId = fileMatch[1];
1294
+ const record = state.storageFiles.get(fileId);
1295
+ if (!record || record.deleted) {
1296
+ return storageErr("File not found", 404);
1297
+ }
1298
+ if (request.method === "GET") {
1299
+ return json(toUploadResponse(record));
1300
+ }
1301
+ if (request.method === "PATCH") {
1302
+ const body = await readJsonBody(request);
1303
+ if (body.fileName !== void 0) record.filename = String(body.fileName);
1304
+ if (body.metadata !== void 0) {
1305
+ record.metadata = body.metadata;
1306
+ }
1307
+ if (body.isPublic !== void 0) record.isPublic = body.isPublic === true;
1308
+ return json(toUploadResponse(record));
1309
+ }
1310
+ if (request.method === "DELETE") {
1311
+ record.deleted = true;
1312
+ return json({ success: true });
1313
+ }
1314
+ }
1315
+ if (pathname === "/api/files" && request.method === "GET") {
1316
+ const parseParam = (raw, fallback, min) => {
1317
+ const n = Number(raw);
1318
+ return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback;
1319
+ };
1320
+ const limit = parseParam(url.searchParams.get("limit"), 50, 1);
1321
+ const offset = parseParam(url.searchParams.get("offset"), 0, 0);
1322
+ const prefix = url.searchParams.get("prefix") ?? "";
1323
+ let files = activeFiles();
1324
+ if (prefix) {
1325
+ files = files.filter((f) => f.key.startsWith(prefix) || f.filename.startsWith(prefix));
1326
+ }
1327
+ const total = files.length;
1328
+ const slice = files.slice(offset, offset + limit);
1329
+ return json({
1330
+ files: slice.map((f) => toUploadResponse(f)),
1331
+ total,
1332
+ limit,
1333
+ offset,
1334
+ hasMore: offset + slice.length < total
1335
+ });
1336
+ }
1337
+ return storageErr(`No storage simulator for ${request.method} ${pathname}`, 404);
1338
+ }
1339
+ function createStorage() {
1340
+ return {
1341
+ get uploads() {
1342
+ return [...state.uploads];
1343
+ },
1344
+ latest() {
1345
+ return state.uploads[state.uploads.length - 1];
1346
+ },
1347
+ clear() {
1348
+ state.uploads = [];
1349
+ state.uploadCounter = 0;
1350
+ state.storageFiles.clear();
1351
+ state.presignPending.clear();
1352
+ },
1353
+ get count() {
1354
+ return state.uploads.length;
1355
+ }
1356
+ };
1357
+ }
1358
+
1359
+ // src/simulator/messaging.ts
1360
+ var import_node_crypto7 = __toESM(require("crypto"));
1361
+ function captureMessage(channel, recipient, body, connectionId) {
1362
+ const message = {
1363
+ channel,
1364
+ recipient,
1365
+ body: {
1366
+ text: body.text ? String(body.text) : void 0,
1367
+ blocks: body.blocks,
1368
+ threadTs: body.threadTs ? String(body.threadTs) : void 0,
1369
+ messagingType: body.messagingType ? String(body.messagingType) : void 0,
1370
+ tag: body.tag ? String(body.tag) : void 0
1371
+ },
1372
+ connectionId,
1373
+ sentAt: /* @__PURE__ */ new Date()
1374
+ };
1375
+ state.messages.push(message);
1376
+ }
1377
+ async function handleMessagingRequest(request, channel, subPath) {
1378
+ const method = request.method;
1379
+ if ((subPath === "" || subPath === "/" || subPath === "/connections") && method === "GET") {
1380
+ return success({ connections: [] });
1381
+ }
1382
+ if (channel === "slack" && subPath === "/send" && method === "POST") {
1383
+ const body = await readJsonBody(request);
1384
+ if (!body.text && !body.blocks) {
1385
+ return failure("text or blocks is required");
1386
+ }
1387
+ const slackChannel = String(body.channel ?? "");
1388
+ if (!slackChannel) {
1389
+ return failure("channel is required");
1390
+ }
1391
+ captureMessage(
1392
+ "slack",
1393
+ slackChannel,
1394
+ body,
1395
+ body.connectionId ? String(body.connectionId) : void 0
1396
+ );
1397
+ return success({
1398
+ ok: true,
1399
+ ts: `${Date.now()}.${import_node_crypto7.default.randomUUID().slice(0, 6)}`,
1400
+ channel: slackChannel
1401
+ });
1402
+ }
1403
+ if (channel === "line" && subPath === "/push" && method === "POST") {
1404
+ const body = await readJsonBody(request);
1405
+ const userId = String(body.userId ?? "");
1406
+ const message = body.message;
1407
+ if (!userId) return failure("userId is required");
1408
+ if (!message || message.type !== "text" || !message.text) {
1409
+ return failure("message must be { type: 'text', text: string }");
1410
+ }
1411
+ if (message.text.length > 5e3) {
1412
+ return failure("message text exceeds maximum length");
1413
+ }
1414
+ captureMessage(
1415
+ "line",
1416
+ userId,
1417
+ { text: message.text },
1418
+ body.connectionId ? String(body.connectionId) : void 0
1419
+ );
1420
+ return success({ success: true });
1421
+ }
1422
+ if (channel === "facebook" && subPath === "/send" && method === "POST") {
1423
+ const body = await readJsonBody(request);
1424
+ const recipientId = String(body.recipientId ?? "");
1425
+ const message = body.message;
1426
+ if (!recipientId) return failure("recipientId is required");
1427
+ if (!message || message.type !== "text" || !message.text) {
1428
+ return failure("message must be { type: 'text', text: string }");
1429
+ }
1430
+ if (message.text.length > 2e3) {
1431
+ return failure("message text exceeds maximum length");
1432
+ }
1433
+ captureMessage(
1434
+ "facebook",
1435
+ recipientId,
1436
+ {
1437
+ text: message.text,
1438
+ messagingType: body.messagingType ? String(body.messagingType) : void 0,
1439
+ tag: body.tag ? String(body.tag) : void 0
1440
+ },
1441
+ body.connectionId ? String(body.connectionId) : void 0
1442
+ );
1443
+ return success({ success: true });
1444
+ }
1445
+ return failure(`No messaging simulator for ${method} .../integrations/${channel}${subPath}`, 404);
1446
+ }
1447
+ function createMessages() {
1448
+ return {
1449
+ all() {
1450
+ return [...state.messages];
1451
+ },
1452
+ latest() {
1453
+ return state.messages[state.messages.length - 1];
1454
+ },
1455
+ to(recipient) {
1456
+ return state.messages.filter((m) => m.recipient === recipient);
1457
+ },
1458
+ channel(kind) {
1459
+ const filtered = () => state.messages.filter((m) => m.channel === kind);
1460
+ return {
1461
+ all: () => filtered(),
1462
+ latest: () => {
1463
+ const pool = filtered();
1464
+ return pool[pool.length - 1];
1465
+ },
1466
+ to: (recipient) => filtered().filter((m) => m.recipient === recipient)
1467
+ };
1468
+ },
1469
+ clear() {
1470
+ state.messages = [];
1471
+ },
1472
+ get count() {
1473
+ return state.messages.length;
1474
+ }
1475
+ };
1476
+ }
1477
+
791
1478
  // src/simulator/router.ts
792
1479
  var fetchHolder = globalSingleton("fetch-holder", () => ({
793
1480
  originalFetch: null
@@ -795,6 +1482,21 @@ var fetchHolder = globalSingleton("fetch-holder", () => ({
795
1482
  function isLocalHost(hostname) {
796
1483
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
797
1484
  }
1485
+ function requiresDeploymentHmac(request, url) {
1486
+ const isStorageHost = url.hostname === STORAGE_TEST_HOST;
1487
+ const isPresignedPut = isStorageHost && request.method === "PUT" && /^\/presigned\/[^/]+$/.test(url.pathname);
1488
+ if (isStorageHost) {
1489
+ return !isPresignedPut;
1490
+ }
1491
+ const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
1492
+ const isEmail = url.pathname === "/api/email/send";
1493
+ const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
1494
+ const storeMatch = url.pathname.match(/^\/api\/store\//);
1495
+ const messagingMatch = url.pathname.match(
1496
+ /^\/api\/deployments\/[^/]+\/integrations\/(slack|line|facebook)(\/.*)?$/
1497
+ );
1498
+ return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch);
1499
+ }
798
1500
  async function handleSimulatedRequest(request, url) {
799
1501
  if (url.pathname === "/sql") {
800
1502
  return handleNeonSql(requireDb(), request);
@@ -806,7 +1508,12 @@ async function handleSimulatedRequest(request, url) {
806
1508
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
807
1509
  const isEmail = url.pathname === "/api/email/send";
808
1510
  const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
809
- if (dataStoreMatch || isEmail || identitiesMatch) {
1511
+ const storeMatch = url.pathname.match(/^\/api\/store\//);
1512
+ const messagingMatch = url.pathname.match(
1513
+ /^\/api\/deployments\/[^/]+\/integrations\/(slack|line|facebook)(\/.*)?$/
1514
+ );
1515
+ const isStorageHost = url.hostname === STORAGE_TEST_HOST;
1516
+ if (requiresDeploymentHmac(request, url)) {
810
1517
  const authHeader = request.headers.get("X-Stardeck-Auth");
811
1518
  if (!authHeader) {
812
1519
  return failure("Missing authentication header", 401);
@@ -816,34 +1523,44 @@ async function handleSimulatedRequest(request, url) {
816
1523
  return failure("Invalid authentication", 401);
817
1524
  }
818
1525
  }
1526
+ if (isStorageHost) {
1527
+ return handleStorageRequest(request, url);
1528
+ }
819
1529
  if (isEmail && request.method === "POST") {
820
1530
  return handleEmailSend(request);
821
1531
  }
822
1532
  if (identitiesMatch) {
823
1533
  return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
824
1534
  }
1535
+ if (storeMatch) {
1536
+ return handlePaymentsRequest(request, url);
1537
+ }
1538
+ if (messagingMatch) {
1539
+ const channel = messagingMatch[1];
1540
+ return handleMessagingRequest(request, channel, messagingMatch[2] ?? "");
1541
+ }
825
1542
  if (dataStoreMatch) {
826
1543
  const subPath = dataStoreMatch[1] ?? "";
827
1544
  const db = requireDb();
828
- const readBody2 = async () => await request.json();
1545
+ const readBody = async () => await request.json();
829
1546
  if (subPath === "/query" && request.method === "POST") {
830
- return handleQuery(db, await readBody2());
1547
+ return handleQuery(db, await readBody());
831
1548
  }
832
1549
  if (subPath === "/mutate" && request.method === "POST") {
833
- return handleMutate(db, await readBody2());
1550
+ return handleMutate(db, await readBody());
834
1551
  }
835
1552
  if (subPath === "/schema" && request.method === "GET") {
836
1553
  return handleGetSchema(db);
837
1554
  }
838
1555
  if (subPath === "/schema/tables" && request.method === "POST") {
839
- return handleCreateTable(db, await readBody2());
1556
+ return handleCreateTable(db, await readBody());
840
1557
  }
841
1558
  if (subPath === "/schema/columns" && request.method === "POST") {
842
- return handleAddColumn(db, await readBody2());
1559
+ return handleAddColumn(db, await readBody());
843
1560
  }
844
1561
  }
845
1562
  return failure(
846
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
1563
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, auth verify/refresh, Neon /sql.`,
847
1564
  404
848
1565
  );
849
1566
  }
@@ -929,10 +1646,16 @@ async function createTestApp(options = {}) {
929
1646
  }
930
1647
  const inbox = createInbox();
931
1648
  const directory = createDirectory();
1649
+ const payments = createPayments();
1650
+ const storage = createStorage();
1651
+ const messages = createMessages();
932
1652
  const app = {
933
1653
  db,
934
1654
  inbox,
935
1655
  identities: directory,
1656
+ payments,
1657
+ storage,
1658
+ messages,
936
1659
  async query(sql, params = []) {
937
1660
  const result = await db.query(sql, params);
938
1661
  return result.rows;
@@ -948,8 +1671,8 @@ async function createTestApp(options = {}) {
948
1671
  issueSession(user) {
949
1672
  const fullUser = buildUser(user);
950
1673
  const tokens = {
951
- accessToken: `test-access-${import_node_crypto4.default.randomUUID()}`,
952
- refreshToken: `test-refresh-${import_node_crypto4.default.randomUUID()}`
1674
+ accessToken: `test-access-${import_node_crypto8.default.randomUUID()}`,
1675
+ refreshToken: `test-refresh-${import_node_crypto8.default.randomUUID()}`
953
1676
  };
954
1677
  state.sessions.set(tokens.accessToken, fullUser);
955
1678
  state.refreshSessions.set(tokens.refreshToken, fullUser);
@@ -965,6 +1688,9 @@ async function createTestApp(options = {}) {
965
1688
  state.emailCounter = 0;
966
1689
  state.identities.clear();
967
1690
  state.identityLinks = [];
1691
+ payments.clear();
1692
+ storage.clear();
1693
+ messages.clear();
968
1694
  },
969
1695
  async close() {
970
1696
  state.db = null;
@@ -972,8 +1698,12 @@ async function createTestApp(options = {}) {
972
1698
  state.sessions.clear();
973
1699
  state.refreshSessions.clear();
974
1700
  state.emails = [];
1701
+ state.emailCounter = 0;
975
1702
  state.identities.clear();
976
1703
  state.identityLinks = [];
1704
+ payments.clear();
1705
+ storage.clear();
1706
+ messages.clear();
977
1707
  uninstallFetchRouter();
978
1708
  await db.close();
979
1709
  }
@@ -981,119 +1711,6 @@ async function createTestApp(options = {}) {
981
1711
  return app;
982
1712
  }
983
1713
 
984
- // src/module-app.ts
985
- var import_core = require("@stardeck-customer-apps/core");
986
- async function createModuleApp(options) {
987
- const schemaSql = options.modules.map((m) => (0, import_core.renderSchemaOpsToSql)(m.schema)).join("\n\n");
988
- const app = await createTestApp({ schemaSql, allowNetwork: options.allowNetwork });
989
- const sql = { query: (text, params) => app.db.query(text, params ?? []) };
990
- const data = (0, import_core.makeSqlPort)(sql);
991
- const { createIntegrationsClient } = await import("@stardeck-customer-apps/integrations-sdk");
992
- const identities = createIntegrationsClient({
993
- controlPlaneUrl: TEST_ENV_DEFAULTS.CONTROL_PLANE_URL,
994
- organizationId: TEST_ENV_DEFAULTS.ORGANIZATION_ID,
995
- projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
996
- deploymentId: TEST_ENV_DEFAULTS.DEPLOYMENT_ID,
997
- deploymentSecret: TEST_ENV_DEFAULTS.DEPLOYMENT_SECRET
998
- }).identities;
999
- const runSeed = async () => {
1000
- if (options.seed) await options.seed({ data, identities });
1001
- };
1002
- await runSeed();
1003
- return {
1004
- app,
1005
- data,
1006
- identities,
1007
- async reset() {
1008
- await app.reset();
1009
- await runSeed();
1010
- },
1011
- async close() {
1012
- await app.close();
1013
- }
1014
- };
1015
- }
1016
-
1017
- // src/next/headers-shim.ts
1018
- var import_node_async_hooks = require("async_hooks");
1019
- var requestScopeStorage = globalSingleton(
1020
- "request-scope",
1021
- () => new import_node_async_hooks.AsyncLocalStorage()
1022
- );
1023
-
1024
- // src/next/call-route.ts
1025
- function parseCookieHeader(header) {
1026
- const map = /* @__PURE__ */ new Map();
1027
- if (!header) return map;
1028
- for (const part of header.split(";")) {
1029
- const eq = part.indexOf("=");
1030
- if (eq === -1) continue;
1031
- map.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
1032
- }
1033
- return map;
1034
- }
1035
- async function importNextServer() {
1036
- try {
1037
- return await import("next/server.js");
1038
- } catch {
1039
- return await import("next/server");
1040
- }
1041
- }
1042
- async function callRoute(handler, options = {}) {
1043
- const { NextRequest } = await importNextServer();
1044
- const path = options.path ?? "/api/test-route";
1045
- const url = new URL(`http://localhost:3333${path}`);
1046
- for (const [key, value] of Object.entries(options.searchParams ?? {})) {
1047
- url.searchParams.set(key, value);
1048
- }
1049
- const method = options.method ?? (options.body !== void 0 ? "POST" : "GET");
1050
- const headers = new Headers(options.headers);
1051
- const user = options.user !== void 0 ? options.user : state.currentUser;
1052
- if (user && !headers.has("x-stardeck-user")) {
1053
- headers.set("x-stardeck-user", JSON.stringify(user));
1054
- }
1055
- if (options.body !== void 0 && !headers.has("Content-Type")) {
1056
- headers.set("Content-Type", "application/json");
1057
- }
1058
- const cookiePairs = Object.entries(options.cookies ?? {});
1059
- if (cookiePairs.length > 0) {
1060
- const existing = headers.get("cookie");
1061
- const cookieString = cookiePairs.map(([k, v]) => `${k}=${v}`).join("; ");
1062
- headers.set("cookie", existing ? `${existing}; ${cookieString}` : cookieString);
1063
- }
1064
- const request = new NextRequest(url, {
1065
- method,
1066
- headers,
1067
- body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
1068
- });
1069
- const scope = {
1070
- headers,
1071
- cookies: parseCookieHeader(headers.get("cookie"))
1072
- };
1073
- try {
1074
- return await requestScopeStorage.run(
1075
- scope,
1076
- () => Promise.resolve(
1077
- handler(request, {
1078
- params: Promise.resolve(options.params ?? {})
1079
- })
1080
- )
1081
- );
1082
- } catch (error) {
1083
- const redirect = decodeNextRedirect(error);
1084
- if (redirect) return redirect;
1085
- throw error;
1086
- }
1087
- }
1088
- function decodeNextRedirect(error) {
1089
- const digest = error?.digest;
1090
- if (typeof digest !== "string" || !digest.startsWith("NEXT_REDIRECT")) return null;
1091
- const parts = digest.split(";");
1092
- const location = parts[2] ?? "/";
1093
- const status = Number(parts[3]) || 307;
1094
- return new Response(null, { status, headers: { location } });
1095
- }
1096
-
1097
1714
  // src/workflow.ts
1098
1715
  var import_vitest = require("vitest");
1099
1716
  function describeWorkflow(name, fn) {
@@ -1108,10 +1725,11 @@ function parseWorkflowName(describeTitle) {
1108
1725
  CONTROL_PLANE_TEST_URL,
1109
1726
  DATA_STORE_TEST_HOST,
1110
1727
  DEFAULT_SCHEMA_PATH,
1728
+ STORAGE_TEST_HOST,
1729
+ STORAGE_TEST_URL,
1111
1730
  TEST_ENV_DEFAULTS,
1112
1731
  WORKFLOW_NAME_PREFIX,
1113
1732
  callRoute,
1114
- createModuleApp,
1115
1733
  createTestApp,
1116
1734
  describeWorkflow,
1117
1735
  parseWorkflowName