@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.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/test-app.ts
2
- import crypto4 from "crypto";
2
+ import crypto8 from "crypto";
3
3
  import { readFileSync, existsSync } from "fs";
4
4
  import { resolve } from "path";
5
5
 
@@ -54,6 +54,16 @@ var state = globalSingleton("state", () => ({
54
54
  emailCounter: 0,
55
55
  identities: /* @__PURE__ */ new Map(),
56
56
  identityLinks: [],
57
+ checkouts: [],
58
+ checkoutCounter: 0,
59
+ sessionStatuses: /* @__PURE__ */ new Map(),
60
+ paymentLinks: /* @__PURE__ */ new Map(),
61
+ products: [],
62
+ uploads: [],
63
+ uploadCounter: 0,
64
+ storageFiles: /* @__PURE__ */ new Map(),
65
+ presignPending: /* @__PURE__ */ new Map(),
66
+ messages: [],
57
67
  allowNetwork: false
58
68
  }));
59
69
  function requireDb() {
@@ -69,13 +79,16 @@ function requireDb() {
69
79
  var TEST_DOMAIN_SUFFIX = ".stardeck.test";
70
80
  var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
71
81
  var DATA_STORE_TEST_HOST = "db.stardeck.test";
82
+ var STORAGE_TEST_URL = "https://storage.stardeck.test";
83
+ var STORAGE_TEST_HOST = "storage.stardeck.test";
72
84
  var TEST_ENV_DEFAULTS = {
73
85
  CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
74
86
  DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
75
87
  ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
76
88
  PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
77
89
  DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
78
- DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`
90
+ DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`,
91
+ STORAGE_URL: STORAGE_TEST_URL
79
92
  };
80
93
  var DEFAULT_TEST_USER = {
81
94
  id: "test-user-1",
@@ -131,6 +144,13 @@ function success(data) {
131
144
  function failure(error, status = 400) {
132
145
  return json({ success: false, error }, status);
133
146
  }
147
+ async function readJsonBody(request) {
148
+ try {
149
+ return await request.json();
150
+ } catch {
151
+ return {};
152
+ }
153
+ }
134
154
 
135
155
  // src/simulator/data-store.ts
136
156
  function quoteIdent(name) {
@@ -613,13 +633,6 @@ function now() {
613
633
  function linksFor(identityId) {
614
634
  return state.identityLinks.filter((l) => l.identityId === identityId);
615
635
  }
616
- async function readBody(request) {
617
- try {
618
- return await request.json();
619
- } catch {
620
- return {};
621
- }
622
- }
623
636
  function handleList(request) {
624
637
  const typeParam = new URL(request.url).searchParams.get("type");
625
638
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
@@ -627,7 +640,7 @@ function handleList(request) {
627
640
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
628
641
  }
629
642
  async function handleCreate(request) {
630
- const body = await readBody(request);
643
+ const body = await readJsonBody(request);
631
644
  const type = body.type;
632
645
  if (type !== "person" && type !== "account") {
633
646
  return failure("type must be 'person' or 'account'");
@@ -664,7 +677,7 @@ function handleGet(identityId) {
664
677
  async function handleUpdate(identityId, request) {
665
678
  const identity = state.identities.get(identityId);
666
679
  if (!identity) return failure("identity not found", 404);
667
- const body = await readBody(request);
680
+ const body = await readJsonBody(request);
668
681
  if (body.displayName !== void 0) {
669
682
  identity.displayName = body.displayName;
670
683
  }
@@ -686,7 +699,7 @@ async function handleAttachLink(identityId, request) {
686
699
  if (identity.status !== "active") {
687
700
  return failure("links attach only to active persons");
688
701
  }
689
- const body = await readBody(request);
702
+ const body = await readJsonBody(request);
690
703
  const kind = body.kind;
691
704
  const externalId = body.externalId;
692
705
  if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
@@ -743,6 +756,679 @@ function createDirectory() {
743
756
  };
744
757
  }
745
758
 
759
+ // src/simulator/payments.ts
760
+ import crypto5 from "crypto";
761
+
762
+ // src/next/headers-shim.ts
763
+ import { AsyncLocalStorage } from "async_hooks";
764
+ var requestScopeStorage = globalSingleton(
765
+ "request-scope",
766
+ () => new AsyncLocalStorage()
767
+ );
768
+
769
+ // src/next/call-route.ts
770
+ function parseCookieHeader(header) {
771
+ const map = /* @__PURE__ */ new Map();
772
+ if (!header) return map;
773
+ for (const part of header.split(";")) {
774
+ const eq = part.indexOf("=");
775
+ if (eq === -1) continue;
776
+ map.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
777
+ }
778
+ return map;
779
+ }
780
+ async function importNextServer() {
781
+ try {
782
+ return await import("next/server.js");
783
+ } catch {
784
+ return await import("next/server");
785
+ }
786
+ }
787
+ async function callRoute(handler, options = {}) {
788
+ const { NextRequest } = await importNextServer();
789
+ const path = options.path ?? "/api/test-route";
790
+ const url = new URL(`http://localhost:3333${path}`);
791
+ for (const [key, value] of Object.entries(options.searchParams ?? {})) {
792
+ url.searchParams.set(key, value);
793
+ }
794
+ const method = options.method ?? (options.body !== void 0 ? "POST" : "GET");
795
+ const headers = new Headers(options.headers);
796
+ const user = options.user !== void 0 ? options.user : state.currentUser;
797
+ if (user && !headers.has("x-stardeck-user")) {
798
+ headers.set("x-stardeck-user", JSON.stringify(user));
799
+ }
800
+ if (options.body !== void 0 && !headers.has("Content-Type")) {
801
+ headers.set("Content-Type", "application/json");
802
+ }
803
+ const cookiePairs = Object.entries(options.cookies ?? {});
804
+ if (cookiePairs.length > 0) {
805
+ const existing = headers.get("cookie");
806
+ const cookieString = cookiePairs.map(([k, v]) => `${k}=${v}`).join("; ");
807
+ headers.set("cookie", existing ? `${existing}; ${cookieString}` : cookieString);
808
+ }
809
+ const request = new NextRequest(url, {
810
+ method,
811
+ headers,
812
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
813
+ });
814
+ const scope = {
815
+ headers,
816
+ cookies: parseCookieHeader(headers.get("cookie"))
817
+ };
818
+ try {
819
+ return await requestScopeStorage.run(
820
+ scope,
821
+ () => Promise.resolve(
822
+ handler(request, {
823
+ params: Promise.resolve(options.params ?? {})
824
+ })
825
+ )
826
+ );
827
+ } catch (error) {
828
+ const redirect = decodeNextRedirect(error);
829
+ if (redirect) return redirect;
830
+ throw error;
831
+ }
832
+ }
833
+ function decodeNextRedirect(error) {
834
+ const digest = error?.digest;
835
+ if (typeof digest !== "string" || !digest.startsWith("NEXT_REDIRECT")) return null;
836
+ const parts = digest.split(";");
837
+ const location = parts[2] ?? "/";
838
+ const status = Number(parts[3]) || 307;
839
+ return new Response(null, { status, headers: { location } });
840
+ }
841
+
842
+ // src/simulator/webhook-signing.ts
843
+ import crypto4 from "crypto";
844
+ function signEventDelivery(secret, context, rawBody) {
845
+ const payload = {
846
+ type: "deployment-request",
847
+ organizationId: context.organizationId,
848
+ projectId: context.projectId,
849
+ deploymentId: context.deploymentId,
850
+ timestamp: Math.floor(Date.now() / 1e3),
851
+ nonce: crypto4.randomUUID()
852
+ };
853
+ const payloadJson = JSON.stringify(payload);
854
+ const payloadB64 = Buffer.from(payloadJson).toString("base64");
855
+ const signature = crypto4.createHmac("sha256", secret).update(payloadJson).update(rawBody).digest("hex");
856
+ return `${payloadB64}.${signature}`;
857
+ }
858
+
859
+ // src/simulator/payments.ts
860
+ function payErr(error, status = 400, code) {
861
+ return json(code ? { error, code } : { error }, status);
862
+ }
863
+ function nextCheckoutId() {
864
+ state.checkoutCounter += 1;
865
+ return `cs_test_${state.checkoutCounter}`;
866
+ }
867
+ function nextPaymentLinkId() {
868
+ state.checkoutCounter += 1;
869
+ return `plink_test_${state.checkoutCounter}`;
870
+ }
871
+ function seedStripeSession(id, body) {
872
+ const lineItems = body.lineItems ?? [];
873
+ let amountTotal = null;
874
+ let currency = null;
875
+ if (lineItems.length > 0) {
876
+ amountTotal = lineItems.reduce(
877
+ (sum, item) => sum + (item.priceData?.unitAmount ?? 0) * (item.quantity ?? 1),
878
+ 0
879
+ );
880
+ currency = lineItems[0].priceData?.currency ?? null;
881
+ }
882
+ state.sessionStatuses.set(id, {
883
+ id,
884
+ status: "open",
885
+ paymentStatus: "unpaid",
886
+ mode: body.mode ?? "payment",
887
+ amountTotal,
888
+ currency,
889
+ customerEmail: body.customerEmail ? String(body.customerEmail) : null,
890
+ metadata: body.metadata ?? {},
891
+ expiresAt: Math.floor(Date.now() / 1e3) + 3600
892
+ });
893
+ }
894
+ function seedBeamLink(id, body, merchantId) {
895
+ const order = body.order;
896
+ state.paymentLinks.set(id, {
897
+ paymentLinkId: id,
898
+ merchantId,
899
+ url: `https://beam.test/pay/${id}`,
900
+ status: "ACTIVE",
901
+ order: {
902
+ netAmount: Number(order?.netAmount ?? 0),
903
+ currency: String(order?.currency ?? "THB"),
904
+ description: String(order?.description ?? ""),
905
+ referenceId: order?.referenceId ? String(order.referenceId) : void 0,
906
+ internalNote: order?.internalNote ? String(order.internalNote) : void 0,
907
+ orderItems: order?.orderItems
908
+ },
909
+ redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
910
+ linkSettings: body.linkSettings,
911
+ collectDeliveryAddress: body.collectDeliveryAddress === true
912
+ });
913
+ }
914
+ async function deliverEvent(envelope, handler, options) {
915
+ const rawBody = JSON.stringify(envelope);
916
+ const secret = options?.deploymentSecret ?? TEST_ENV_DEFAULTS.DEPLOYMENT_SECRET;
917
+ const authHeader = signEventDelivery(
918
+ secret,
919
+ {
920
+ organizationId: TEST_ENV_DEFAULTS.ORGANIZATION_ID,
921
+ projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
922
+ deploymentId: TEST_ENV_DEFAULTS.DEPLOYMENT_ID
923
+ },
924
+ rawBody
925
+ );
926
+ return callRoute(handler, {
927
+ method: "POST",
928
+ path: options?.path ?? "/api/payments/webhooks",
929
+ params: { path: ["webhooks"] },
930
+ headers: { "X-Stardeck-Auth": authHeader },
931
+ body: envelope
932
+ });
933
+ }
934
+ async function handlePaymentsRequest(request, url) {
935
+ const pathname = url.pathname;
936
+ if (/\/bolt-connections/.test(pathname) || /\/bolt-intents/.test(pathname) || /\/charges(\/|$)/.test(pathname) || /\/billing-portal$/.test(pathname)) {
937
+ return payErr("Not found", 404, "NOT_FOUND");
938
+ }
939
+ const beamProductsMatch = pathname.match(
940
+ /^\/api\/store\/beam\/([^/]+)\/payment-links(?:\/([^/]+))?$/
941
+ );
942
+ if (beamProductsMatch) {
943
+ const merchantId = beamProductsMatch[1];
944
+ const linkId = beamProductsMatch[2];
945
+ if (!linkId && request.method === "POST") {
946
+ const body = await readJsonBody(request);
947
+ const id = nextPaymentLinkId();
948
+ const checkoutUrl = `https://beam.test/pay/${id}`;
949
+ const captured = {
950
+ id,
951
+ url: checkoutUrl,
952
+ provider: "beam",
953
+ options: body,
954
+ metadata: body.metadata,
955
+ createdAt: /* @__PURE__ */ new Date()
956
+ };
957
+ state.checkouts.push(captured);
958
+ seedBeamLink(id, body, merchantId);
959
+ return json({ id, url: checkoutUrl });
960
+ }
961
+ if (linkId && request.method === "GET") {
962
+ const link = state.paymentLinks.get(linkId);
963
+ if (!link) return payErr("Payment link not found", 404, "NOT_FOUND");
964
+ return json({ paymentLink: link });
965
+ }
966
+ }
967
+ const stripeStoreMatch = pathname.match(/^\/api\/store\/([^/]+)\/(.+)$/);
968
+ if (stripeStoreMatch) {
969
+ const accountId = stripeStoreMatch[1];
970
+ const subPath = stripeStoreMatch[2];
971
+ if (accountId === "beam") {
972
+ return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
973
+ }
974
+ if (subPath === "products" && request.method === "GET") {
975
+ return json({ products: state.products });
976
+ }
977
+ const productMatch = subPath.match(/^products\/([^/]+)$/);
978
+ if (productMatch && request.method === "GET") {
979
+ const product = state.products.find((p) => p.id === productMatch[1]);
980
+ if (!product) return payErr("Product not found", 404, "NOT_FOUND");
981
+ return json({ product });
982
+ }
983
+ if (subPath === "checkout" && request.method === "POST") {
984
+ const body = await readJsonBody(request);
985
+ const id = nextCheckoutId();
986
+ const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
987
+ const captured = {
988
+ id,
989
+ url: checkoutUrl,
990
+ provider: "stripe",
991
+ options: body,
992
+ mode: body.mode,
993
+ metadata: body.metadata,
994
+ createdAt: /* @__PURE__ */ new Date()
995
+ };
996
+ state.checkouts.push(captured);
997
+ seedStripeSession(id, body);
998
+ return json({ id, url: checkoutUrl });
999
+ }
1000
+ const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
1001
+ if (sessionMatch && request.method === "GET") {
1002
+ const session = state.sessionStatuses.get(sessionMatch[1]);
1003
+ if (!session) return payErr("Checkout session not found", 404, "NOT_FOUND");
1004
+ return json({ session });
1005
+ }
1006
+ }
1007
+ return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
1008
+ }
1009
+ function createPayments() {
1010
+ return {
1011
+ get checkouts() {
1012
+ return [...state.checkouts];
1013
+ },
1014
+ latest() {
1015
+ return state.checkouts[state.checkouts.length - 1];
1016
+ },
1017
+ setProducts(products) {
1018
+ state.products = products;
1019
+ },
1020
+ markPaid(id) {
1021
+ const session = state.sessionStatuses.get(id);
1022
+ if (session) {
1023
+ session.status = "complete";
1024
+ session.paymentStatus = "paid";
1025
+ return;
1026
+ }
1027
+ const link = state.paymentLinks.get(id);
1028
+ if (link) {
1029
+ link.status = "PAID";
1030
+ return;
1031
+ }
1032
+ throw new Error(`[stardeck-testing] Unknown checkout or payment link id: ${id}`);
1033
+ },
1034
+ setSessionStatus(id, status) {
1035
+ const session = state.sessionStatuses.get(id);
1036
+ if (!session) {
1037
+ throw new Error(`[stardeck-testing] Unknown checkout session id: ${id}`);
1038
+ }
1039
+ Object.assign(session, status);
1040
+ },
1041
+ setPaymentLinkStatus(id, status) {
1042
+ const link = state.paymentLinks.get(id);
1043
+ if (!link) {
1044
+ throw new Error(`[stardeck-testing] Unknown payment link id: ${id}`);
1045
+ }
1046
+ link.status = status;
1047
+ },
1048
+ async deliverStripeEvent(handler, event, options) {
1049
+ const envelope = {
1050
+ id: `evt_test_${crypto5.randomUUID()}`,
1051
+ kind: "stripe_webhook",
1052
+ timestamp: Date.now(),
1053
+ stripeEvent: {
1054
+ type: event.type,
1055
+ accountId: event.accountId ?? process.env.STRIPE_CONNECT_ACCOUNT_ID ?? "acct_test",
1056
+ data: event.data
1057
+ }
1058
+ };
1059
+ return deliverEvent(envelope, handler, options);
1060
+ },
1061
+ async deliverBeamEvent(handler, event, options) {
1062
+ const envelope = {
1063
+ id: `beam_evt_test_${crypto5.randomUUID()}`,
1064
+ kind: "beam_webhook",
1065
+ timestamp: Date.now(),
1066
+ beamEvent: {
1067
+ type: event.type,
1068
+ payload: event.payload
1069
+ }
1070
+ };
1071
+ return deliverEvent(envelope, handler, options);
1072
+ },
1073
+ clear() {
1074
+ state.checkouts = [];
1075
+ state.checkoutCounter = 0;
1076
+ state.sessionStatuses.clear();
1077
+ state.paymentLinks.clear();
1078
+ state.products = [];
1079
+ },
1080
+ get count() {
1081
+ return state.checkouts.length;
1082
+ }
1083
+ };
1084
+ }
1085
+
1086
+ // src/simulator/storage.ts
1087
+ import crypto6 from "crypto";
1088
+ function storageErr(error, status = 400) {
1089
+ return json({ error }, status);
1090
+ }
1091
+ function fileExtension(filename) {
1092
+ const dot = filename.lastIndexOf(".");
1093
+ return dot === -1 ? "bin" : filename.slice(dot + 1);
1094
+ }
1095
+ function buildKey(fileId, filename) {
1096
+ const orgId = process.env.ORGANIZATION_ID ?? TEST_ENV_DEFAULTS.ORGANIZATION_ID;
1097
+ const projectId = process.env.PROJECT_ID ?? TEST_ENV_DEFAULTS.PROJECT_ID;
1098
+ const deploymentId = process.env.DEPLOYMENT_ID ?? TEST_ENV_DEFAULTS.DEPLOYMENT_ID;
1099
+ return `${orgId}/${projectId}/${deploymentId}/${fileId}.${fileExtension(filename)}`;
1100
+ }
1101
+ function buildUrl(key) {
1102
+ return `${STORAGE_TEST_URL}/files/${key}`;
1103
+ }
1104
+ function toUploadResponse(record) {
1105
+ return {
1106
+ id: record.id,
1107
+ key: record.key,
1108
+ filename: record.filename,
1109
+ contentType: record.contentType,
1110
+ sizeBytes: record.sizeBytes,
1111
+ url: record.url,
1112
+ uploadedAt: record.uploadedAt,
1113
+ isPublic: record.isPublic,
1114
+ metadata: record.metadata
1115
+ };
1116
+ }
1117
+ function captureUpload(record, method, path) {
1118
+ const captured = {
1119
+ id: record.id,
1120
+ key: record.key,
1121
+ filename: record.filename,
1122
+ contentType: record.contentType,
1123
+ sizeBytes: record.sizeBytes,
1124
+ url: record.url,
1125
+ uploadedAt: record.uploadedAt,
1126
+ isPublic: record.isPublic,
1127
+ metadata: record.metadata,
1128
+ method,
1129
+ path
1130
+ };
1131
+ state.uploads.push(captured);
1132
+ }
1133
+ function createFileRecord(input) {
1134
+ state.uploadCounter += 1;
1135
+ const id = `file_test_${state.uploadCounter}`;
1136
+ const key = buildKey(id, input.filename);
1137
+ return {
1138
+ id,
1139
+ key,
1140
+ filename: input.filename,
1141
+ contentType: input.contentType,
1142
+ sizeBytes: input.sizeBytes,
1143
+ url: buildUrl(key),
1144
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
1145
+ metadata: input.metadata,
1146
+ isPublic: input.isPublic,
1147
+ deleted: false
1148
+ };
1149
+ }
1150
+ function activeFiles() {
1151
+ return [...state.storageFiles.values()].filter((f) => !f.deleted);
1152
+ }
1153
+ async function handleStorageRequest(request, url) {
1154
+ const pathname = url.pathname;
1155
+ if (pathname.startsWith("/upload/presign/multipart")) {
1156
+ return storageErr("Not found", 404);
1157
+ }
1158
+ const presignedPutMatch = pathname.match(/^\/presigned\/([^/]+)$/);
1159
+ if (presignedPutMatch && request.method === "PUT") {
1160
+ const fileId = presignedPutMatch[1];
1161
+ if (!state.presignPending.has(fileId)) {
1162
+ return storageErr("Presign session not found", 404);
1163
+ }
1164
+ return new Response(null, {
1165
+ status: 200,
1166
+ headers: { etag: `"${crypto6.randomUUID()}"` }
1167
+ });
1168
+ }
1169
+ if (pathname === "/upload/presign/complete" && request.method === "POST") {
1170
+ const body = await readJsonBody(request);
1171
+ const fileId = String(body.fileId ?? "");
1172
+ const pending = state.presignPending.get(fileId);
1173
+ if (!pending) return storageErr("Presign session not found", 404);
1174
+ const record = {
1175
+ id: fileId,
1176
+ key: pending.key,
1177
+ filename: pending.fileName,
1178
+ contentType: pending.contentType,
1179
+ sizeBytes: pending.sizeBytes,
1180
+ url: buildUrl(pending.key),
1181
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
1182
+ metadata: pending.metadata,
1183
+ isPublic: pending.isPublic,
1184
+ deleted: false
1185
+ };
1186
+ state.storageFiles.set(record.id, record);
1187
+ state.presignPending.delete(fileId);
1188
+ captureUpload(record, "POST", "/upload/presign/complete");
1189
+ return json(toUploadResponse(record));
1190
+ }
1191
+ if (pathname === "/upload/presign" && request.method === "POST") {
1192
+ const body = await readJsonBody(request);
1193
+ const fileName = String(body.fileName ?? "upload.bin");
1194
+ const contentType = String(body.contentType ?? "application/octet-stream");
1195
+ const sizeBytes = Number(body.sizeBytes ?? 0);
1196
+ const isPublic = body.isPublic !== false;
1197
+ const metadata = body.metadata;
1198
+ state.uploadCounter += 1;
1199
+ const fileId = `file_test_${state.uploadCounter}`;
1200
+ const key = buildKey(fileId, fileName);
1201
+ state.presignPending.set(fileId, {
1202
+ fileName,
1203
+ contentType,
1204
+ sizeBytes,
1205
+ isPublic,
1206
+ metadata,
1207
+ key
1208
+ });
1209
+ return json({
1210
+ fileId,
1211
+ key,
1212
+ presignedUrl: `${STORAGE_TEST_URL}/presigned/${fileId}`,
1213
+ contentType,
1214
+ expiresIn: 3600
1215
+ });
1216
+ }
1217
+ if (pathname === "/upload" && request.method === "POST") {
1218
+ const isPublic = url.searchParams.get("isPublic") !== "false";
1219
+ const formData = await request.formData();
1220
+ const file = formData.get("file");
1221
+ if (!(file instanceof Blob)) {
1222
+ return storageErr("file is required");
1223
+ }
1224
+ const filename = formData.get("filename") ?? (file instanceof File ? file.name : "upload.bin");
1225
+ const metadataRaw = formData.get("metadata");
1226
+ let metadata;
1227
+ if (typeof metadataRaw === "string" && metadataRaw) {
1228
+ try {
1229
+ metadata = JSON.parse(metadataRaw);
1230
+ } catch {
1231
+ return storageErr("metadata must be valid JSON");
1232
+ }
1233
+ }
1234
+ const record = createFileRecord({
1235
+ filename,
1236
+ contentType: file.type || "application/octet-stream",
1237
+ sizeBytes: file.size,
1238
+ isPublic,
1239
+ metadata
1240
+ });
1241
+ state.storageFiles.set(record.id, record);
1242
+ captureUpload(record, "POST", "/upload");
1243
+ return json(toUploadResponse(record));
1244
+ }
1245
+ const fileMatch = pathname.match(/^\/api\/files\/([^/]+)$/);
1246
+ if (fileMatch) {
1247
+ const fileId = fileMatch[1];
1248
+ const record = state.storageFiles.get(fileId);
1249
+ if (!record || record.deleted) {
1250
+ return storageErr("File not found", 404);
1251
+ }
1252
+ if (request.method === "GET") {
1253
+ return json(toUploadResponse(record));
1254
+ }
1255
+ if (request.method === "PATCH") {
1256
+ const body = await readJsonBody(request);
1257
+ if (body.fileName !== void 0) record.filename = String(body.fileName);
1258
+ if (body.metadata !== void 0) {
1259
+ record.metadata = body.metadata;
1260
+ }
1261
+ if (body.isPublic !== void 0) record.isPublic = body.isPublic === true;
1262
+ return json(toUploadResponse(record));
1263
+ }
1264
+ if (request.method === "DELETE") {
1265
+ record.deleted = true;
1266
+ return json({ success: true });
1267
+ }
1268
+ }
1269
+ if (pathname === "/api/files" && request.method === "GET") {
1270
+ const parseParam = (raw, fallback, min) => {
1271
+ const n = Number(raw);
1272
+ return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback;
1273
+ };
1274
+ const limit = parseParam(url.searchParams.get("limit"), 50, 1);
1275
+ const offset = parseParam(url.searchParams.get("offset"), 0, 0);
1276
+ const prefix = url.searchParams.get("prefix") ?? "";
1277
+ let files = activeFiles();
1278
+ if (prefix) {
1279
+ files = files.filter((f) => f.key.startsWith(prefix) || f.filename.startsWith(prefix));
1280
+ }
1281
+ const total = files.length;
1282
+ const slice = files.slice(offset, offset + limit);
1283
+ return json({
1284
+ files: slice.map((f) => toUploadResponse(f)),
1285
+ total,
1286
+ limit,
1287
+ offset,
1288
+ hasMore: offset + slice.length < total
1289
+ });
1290
+ }
1291
+ return storageErr(`No storage simulator for ${request.method} ${pathname}`, 404);
1292
+ }
1293
+ function createStorage() {
1294
+ return {
1295
+ get uploads() {
1296
+ return [...state.uploads];
1297
+ },
1298
+ latest() {
1299
+ return state.uploads[state.uploads.length - 1];
1300
+ },
1301
+ clear() {
1302
+ state.uploads = [];
1303
+ state.uploadCounter = 0;
1304
+ state.storageFiles.clear();
1305
+ state.presignPending.clear();
1306
+ },
1307
+ get count() {
1308
+ return state.uploads.length;
1309
+ }
1310
+ };
1311
+ }
1312
+
1313
+ // src/simulator/messaging.ts
1314
+ import crypto7 from "crypto";
1315
+ function captureMessage(channel, recipient, body, connectionId) {
1316
+ const message = {
1317
+ channel,
1318
+ recipient,
1319
+ body: {
1320
+ text: body.text ? String(body.text) : void 0,
1321
+ blocks: body.blocks,
1322
+ threadTs: body.threadTs ? String(body.threadTs) : void 0,
1323
+ messagingType: body.messagingType ? String(body.messagingType) : void 0,
1324
+ tag: body.tag ? String(body.tag) : void 0
1325
+ },
1326
+ connectionId,
1327
+ sentAt: /* @__PURE__ */ new Date()
1328
+ };
1329
+ state.messages.push(message);
1330
+ }
1331
+ async function handleMessagingRequest(request, channel, subPath) {
1332
+ const method = request.method;
1333
+ if ((subPath === "" || subPath === "/" || subPath === "/connections") && method === "GET") {
1334
+ return success({ connections: [] });
1335
+ }
1336
+ if (channel === "slack" && subPath === "/send" && method === "POST") {
1337
+ const body = await readJsonBody(request);
1338
+ if (!body.text && !body.blocks) {
1339
+ return failure("text or blocks is required");
1340
+ }
1341
+ const slackChannel = String(body.channel ?? "");
1342
+ if (!slackChannel) {
1343
+ return failure("channel is required");
1344
+ }
1345
+ captureMessage(
1346
+ "slack",
1347
+ slackChannel,
1348
+ body,
1349
+ body.connectionId ? String(body.connectionId) : void 0
1350
+ );
1351
+ return success({
1352
+ ok: true,
1353
+ ts: `${Date.now()}.${crypto7.randomUUID().slice(0, 6)}`,
1354
+ channel: slackChannel
1355
+ });
1356
+ }
1357
+ if (channel === "line" && subPath === "/push" && method === "POST") {
1358
+ const body = await readJsonBody(request);
1359
+ const userId = String(body.userId ?? "");
1360
+ const message = body.message;
1361
+ if (!userId) return failure("userId is required");
1362
+ if (!message || message.type !== "text" || !message.text) {
1363
+ return failure("message must be { type: 'text', text: string }");
1364
+ }
1365
+ if (message.text.length > 5e3) {
1366
+ return failure("message text exceeds maximum length");
1367
+ }
1368
+ captureMessage(
1369
+ "line",
1370
+ userId,
1371
+ { text: message.text },
1372
+ body.connectionId ? String(body.connectionId) : void 0
1373
+ );
1374
+ return success({ success: true });
1375
+ }
1376
+ if (channel === "facebook" && subPath === "/send" && method === "POST") {
1377
+ const body = await readJsonBody(request);
1378
+ const recipientId = String(body.recipientId ?? "");
1379
+ const message = body.message;
1380
+ if (!recipientId) return failure("recipientId is required");
1381
+ if (!message || message.type !== "text" || !message.text) {
1382
+ return failure("message must be { type: 'text', text: string }");
1383
+ }
1384
+ if (message.text.length > 2e3) {
1385
+ return failure("message text exceeds maximum length");
1386
+ }
1387
+ captureMessage(
1388
+ "facebook",
1389
+ recipientId,
1390
+ {
1391
+ text: message.text,
1392
+ messagingType: body.messagingType ? String(body.messagingType) : void 0,
1393
+ tag: body.tag ? String(body.tag) : void 0
1394
+ },
1395
+ body.connectionId ? String(body.connectionId) : void 0
1396
+ );
1397
+ return success({ success: true });
1398
+ }
1399
+ return failure(`No messaging simulator for ${method} .../integrations/${channel}${subPath}`, 404);
1400
+ }
1401
+ function createMessages() {
1402
+ return {
1403
+ all() {
1404
+ return [...state.messages];
1405
+ },
1406
+ latest() {
1407
+ return state.messages[state.messages.length - 1];
1408
+ },
1409
+ to(recipient) {
1410
+ return state.messages.filter((m) => m.recipient === recipient);
1411
+ },
1412
+ channel(kind) {
1413
+ const filtered = () => state.messages.filter((m) => m.channel === kind);
1414
+ return {
1415
+ all: () => filtered(),
1416
+ latest: () => {
1417
+ const pool = filtered();
1418
+ return pool[pool.length - 1];
1419
+ },
1420
+ to: (recipient) => filtered().filter((m) => m.recipient === recipient)
1421
+ };
1422
+ },
1423
+ clear() {
1424
+ state.messages = [];
1425
+ },
1426
+ get count() {
1427
+ return state.messages.length;
1428
+ }
1429
+ };
1430
+ }
1431
+
746
1432
  // src/simulator/router.ts
747
1433
  var fetchHolder = globalSingleton("fetch-holder", () => ({
748
1434
  originalFetch: null
@@ -750,6 +1436,21 @@ var fetchHolder = globalSingleton("fetch-holder", () => ({
750
1436
  function isLocalHost(hostname) {
751
1437
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
752
1438
  }
1439
+ function requiresDeploymentHmac(request, url) {
1440
+ const isStorageHost = url.hostname === STORAGE_TEST_HOST;
1441
+ const isPresignedPut = isStorageHost && request.method === "PUT" && /^\/presigned\/[^/]+$/.test(url.pathname);
1442
+ if (isStorageHost) {
1443
+ return !isPresignedPut;
1444
+ }
1445
+ const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
1446
+ const isEmail = url.pathname === "/api/email/send";
1447
+ const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
1448
+ const storeMatch = url.pathname.match(/^\/api\/store\//);
1449
+ const messagingMatch = url.pathname.match(
1450
+ /^\/api\/deployments\/[^/]+\/integrations\/(slack|line|facebook)(\/.*)?$/
1451
+ );
1452
+ return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch);
1453
+ }
753
1454
  async function handleSimulatedRequest(request, url) {
754
1455
  if (url.pathname === "/sql") {
755
1456
  return handleNeonSql(requireDb(), request);
@@ -761,7 +1462,12 @@ async function handleSimulatedRequest(request, url) {
761
1462
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
762
1463
  const isEmail = url.pathname === "/api/email/send";
763
1464
  const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
764
- if (dataStoreMatch || isEmail || identitiesMatch) {
1465
+ const storeMatch = url.pathname.match(/^\/api\/store\//);
1466
+ const messagingMatch = url.pathname.match(
1467
+ /^\/api\/deployments\/[^/]+\/integrations\/(slack|line|facebook)(\/.*)?$/
1468
+ );
1469
+ const isStorageHost = url.hostname === STORAGE_TEST_HOST;
1470
+ if (requiresDeploymentHmac(request, url)) {
765
1471
  const authHeader = request.headers.get("X-Stardeck-Auth");
766
1472
  if (!authHeader) {
767
1473
  return failure("Missing authentication header", 401);
@@ -771,34 +1477,44 @@ async function handleSimulatedRequest(request, url) {
771
1477
  return failure("Invalid authentication", 401);
772
1478
  }
773
1479
  }
1480
+ if (isStorageHost) {
1481
+ return handleStorageRequest(request, url);
1482
+ }
774
1483
  if (isEmail && request.method === "POST") {
775
1484
  return handleEmailSend(request);
776
1485
  }
777
1486
  if (identitiesMatch) {
778
1487
  return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
779
1488
  }
1489
+ if (storeMatch) {
1490
+ return handlePaymentsRequest(request, url);
1491
+ }
1492
+ if (messagingMatch) {
1493
+ const channel = messagingMatch[1];
1494
+ return handleMessagingRequest(request, channel, messagingMatch[2] ?? "");
1495
+ }
780
1496
  if (dataStoreMatch) {
781
1497
  const subPath = dataStoreMatch[1] ?? "";
782
1498
  const db = requireDb();
783
- const readBody2 = async () => await request.json();
1499
+ const readBody = async () => await request.json();
784
1500
  if (subPath === "/query" && request.method === "POST") {
785
- return handleQuery(db, await readBody2());
1501
+ return handleQuery(db, await readBody());
786
1502
  }
787
1503
  if (subPath === "/mutate" && request.method === "POST") {
788
- return handleMutate(db, await readBody2());
1504
+ return handleMutate(db, await readBody());
789
1505
  }
790
1506
  if (subPath === "/schema" && request.method === "GET") {
791
1507
  return handleGetSchema(db);
792
1508
  }
793
1509
  if (subPath === "/schema/tables" && request.method === "POST") {
794
- return handleCreateTable(db, await readBody2());
1510
+ return handleCreateTable(db, await readBody());
795
1511
  }
796
1512
  if (subPath === "/schema/columns" && request.method === "POST") {
797
- return handleAddColumn(db, await readBody2());
1513
+ return handleAddColumn(db, await readBody());
798
1514
  }
799
1515
  }
800
1516
  return failure(
801
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
1517
+ `[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.`,
802
1518
  404
803
1519
  );
804
1520
  }
@@ -884,10 +1600,16 @@ async function createTestApp(options = {}) {
884
1600
  }
885
1601
  const inbox = createInbox();
886
1602
  const directory = createDirectory();
1603
+ const payments = createPayments();
1604
+ const storage = createStorage();
1605
+ const messages = createMessages();
887
1606
  const app = {
888
1607
  db,
889
1608
  inbox,
890
1609
  identities: directory,
1610
+ payments,
1611
+ storage,
1612
+ messages,
891
1613
  async query(sql, params = []) {
892
1614
  const result = await db.query(sql, params);
893
1615
  return result.rows;
@@ -903,8 +1625,8 @@ async function createTestApp(options = {}) {
903
1625
  issueSession(user) {
904
1626
  const fullUser = buildUser(user);
905
1627
  const tokens = {
906
- accessToken: `test-access-${crypto4.randomUUID()}`,
907
- refreshToken: `test-refresh-${crypto4.randomUUID()}`
1628
+ accessToken: `test-access-${crypto8.randomUUID()}`,
1629
+ refreshToken: `test-refresh-${crypto8.randomUUID()}`
908
1630
  };
909
1631
  state.sessions.set(tokens.accessToken, fullUser);
910
1632
  state.refreshSessions.set(tokens.refreshToken, fullUser);
@@ -920,6 +1642,9 @@ async function createTestApp(options = {}) {
920
1642
  state.emailCounter = 0;
921
1643
  state.identities.clear();
922
1644
  state.identityLinks = [];
1645
+ payments.clear();
1646
+ storage.clear();
1647
+ messages.clear();
923
1648
  },
924
1649
  async close() {
925
1650
  state.db = null;
@@ -927,8 +1652,12 @@ async function createTestApp(options = {}) {
927
1652
  state.sessions.clear();
928
1653
  state.refreshSessions.clear();
929
1654
  state.emails = [];
1655
+ state.emailCounter = 0;
930
1656
  state.identities.clear();
931
1657
  state.identityLinks = [];
1658
+ payments.clear();
1659
+ storage.clear();
1660
+ messages.clear();
932
1661
  uninstallFetchRouter();
933
1662
  await db.close();
934
1663
  }
@@ -936,122 +1665,6 @@ async function createTestApp(options = {}) {
936
1665
  return app;
937
1666
  }
938
1667
 
939
- // src/module-app.ts
940
- import {
941
- makeSqlPort,
942
- renderSchemaOpsToSql
943
- } from "@stardeck-customer-apps/core";
944
- async function createModuleApp(options) {
945
- const schemaSql = options.modules.map((m) => renderSchemaOpsToSql(m.schema)).join("\n\n");
946
- const app = await createTestApp({ schemaSql, allowNetwork: options.allowNetwork });
947
- const sql = { query: (text, params) => app.db.query(text, params ?? []) };
948
- const data = makeSqlPort(sql);
949
- const { createIntegrationsClient } = await import("@stardeck-customer-apps/integrations-sdk");
950
- const identities = createIntegrationsClient({
951
- controlPlaneUrl: TEST_ENV_DEFAULTS.CONTROL_PLANE_URL,
952
- organizationId: TEST_ENV_DEFAULTS.ORGANIZATION_ID,
953
- projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
954
- deploymentId: TEST_ENV_DEFAULTS.DEPLOYMENT_ID,
955
- deploymentSecret: TEST_ENV_DEFAULTS.DEPLOYMENT_SECRET
956
- }).identities;
957
- const runSeed = async () => {
958
- if (options.seed) await options.seed({ data, identities });
959
- };
960
- await runSeed();
961
- return {
962
- app,
963
- data,
964
- identities,
965
- async reset() {
966
- await app.reset();
967
- await runSeed();
968
- },
969
- async close() {
970
- await app.close();
971
- }
972
- };
973
- }
974
-
975
- // src/next/headers-shim.ts
976
- import { AsyncLocalStorage } from "async_hooks";
977
- var requestScopeStorage = globalSingleton(
978
- "request-scope",
979
- () => new AsyncLocalStorage()
980
- );
981
-
982
- // src/next/call-route.ts
983
- function parseCookieHeader(header) {
984
- const map = /* @__PURE__ */ new Map();
985
- if (!header) return map;
986
- for (const part of header.split(";")) {
987
- const eq = part.indexOf("=");
988
- if (eq === -1) continue;
989
- map.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
990
- }
991
- return map;
992
- }
993
- async function importNextServer() {
994
- try {
995
- return await import("next/server.js");
996
- } catch {
997
- return await import("next/server");
998
- }
999
- }
1000
- async function callRoute(handler, options = {}) {
1001
- const { NextRequest } = await importNextServer();
1002
- const path = options.path ?? "/api/test-route";
1003
- const url = new URL(`http://localhost:3333${path}`);
1004
- for (const [key, value] of Object.entries(options.searchParams ?? {})) {
1005
- url.searchParams.set(key, value);
1006
- }
1007
- const method = options.method ?? (options.body !== void 0 ? "POST" : "GET");
1008
- const headers = new Headers(options.headers);
1009
- const user = options.user !== void 0 ? options.user : state.currentUser;
1010
- if (user && !headers.has("x-stardeck-user")) {
1011
- headers.set("x-stardeck-user", JSON.stringify(user));
1012
- }
1013
- if (options.body !== void 0 && !headers.has("Content-Type")) {
1014
- headers.set("Content-Type", "application/json");
1015
- }
1016
- const cookiePairs = Object.entries(options.cookies ?? {});
1017
- if (cookiePairs.length > 0) {
1018
- const existing = headers.get("cookie");
1019
- const cookieString = cookiePairs.map(([k, v]) => `${k}=${v}`).join("; ");
1020
- headers.set("cookie", existing ? `${existing}; ${cookieString}` : cookieString);
1021
- }
1022
- const request = new NextRequest(url, {
1023
- method,
1024
- headers,
1025
- body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
1026
- });
1027
- const scope = {
1028
- headers,
1029
- cookies: parseCookieHeader(headers.get("cookie"))
1030
- };
1031
- try {
1032
- return await requestScopeStorage.run(
1033
- scope,
1034
- () => Promise.resolve(
1035
- handler(request, {
1036
- params: Promise.resolve(options.params ?? {})
1037
- })
1038
- )
1039
- );
1040
- } catch (error) {
1041
- const redirect = decodeNextRedirect(error);
1042
- if (redirect) return redirect;
1043
- throw error;
1044
- }
1045
- }
1046
- function decodeNextRedirect(error) {
1047
- const digest = error?.digest;
1048
- if (typeof digest !== "string" || !digest.startsWith("NEXT_REDIRECT")) return null;
1049
- const parts = digest.split(";");
1050
- const location = parts[2] ?? "/";
1051
- const status = Number(parts[3]) || 307;
1052
- return new Response(null, { status, headers: { location } });
1053
- }
1054
-
1055
1668
  // src/workflow.ts
1056
1669
  import { describe } from "vitest";
1057
1670
  function describeWorkflow(name, fn) {
@@ -1065,10 +1678,11 @@ export {
1065
1678
  CONTROL_PLANE_TEST_URL,
1066
1679
  DATA_STORE_TEST_HOST,
1067
1680
  DEFAULT_SCHEMA_PATH,
1681
+ STORAGE_TEST_HOST,
1682
+ STORAGE_TEST_URL,
1068
1683
  TEST_ENV_DEFAULTS,
1069
1684
  WORKFLOW_NAME_PREFIX,
1070
1685
  callRoute,
1071
- createModuleApp,
1072
1686
  createTestApp,
1073
1687
  describeWorkflow,
1074
1688
  parseWorkflowName