@stardeck-customer-apps/testing 0.14.0 → 0.16.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
@@ -106,6 +106,31 @@ function globalSingleton(key, create) {
106
106
  return holder[symbol];
107
107
  }
108
108
 
109
+ // src/constants.ts
110
+ var TEST_DOMAIN_SUFFIX = ".stardeck.test";
111
+ var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
112
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
113
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
114
+ var STORAGE_TEST_URL = "https://storage.stardeck.test";
115
+ var STORAGE_TEST_HOST = "storage.stardeck.test";
116
+ var APP_TEST_ORIGIN = "http://localhost:3333";
117
+ var TEST_ENV_DEFAULTS = {
118
+ CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
119
+ DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
120
+ ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
121
+ PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
122
+ DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
123
+ STORAGE_URL: STORAGE_TEST_URL
124
+ };
125
+ var DEFAULT_TEST_USER = {
126
+ id: "test-user-1",
127
+ email: "test-user@example.com",
128
+ name: "Test User",
129
+ role: "member",
130
+ permissions: []
131
+ };
132
+ var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
133
+
109
134
  // src/state.ts
110
135
  var state = globalSingleton("state", () => ({
111
136
  db: null,
@@ -117,6 +142,10 @@ var state = globalSingleton("state", () => ({
117
142
  emailCounter: 0,
118
143
  identities: /* @__PURE__ */ new Map(),
119
144
  identityLinks: [],
145
+ identityClaims: /* @__PURE__ */ new Map(),
146
+ identityStaffAssistEnabled: false,
147
+ identitySuppressClaimDelivery: false,
148
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
120
149
  checkouts: [],
121
150
  checkoutCounter: 0,
122
151
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -144,6 +173,9 @@ var state = globalSingleton("state", () => ({
144
173
  edgeBindings: /* @__PURE__ */ new Map(),
145
174
  edgeCapabilities: null,
146
175
  edgePeripheralCapabilities: /* @__PURE__ */ new Map(),
176
+ starlensCalls: [],
177
+ starlensReplies: [],
178
+ starlensDefaultReply: null,
147
179
  allowNetwork: false
148
180
  }));
149
181
  function requireDb() {
@@ -155,30 +187,6 @@ function requireDb() {
155
187
  return state.db;
156
188
  }
157
189
 
158
- // src/constants.ts
159
- var TEST_DOMAIN_SUFFIX = ".stardeck.test";
160
- var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
161
- var DATA_STORE_TEST_HOST = "db.stardeck.test";
162
- var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
163
- var STORAGE_TEST_URL = "https://storage.stardeck.test";
164
- var STORAGE_TEST_HOST = "storage.stardeck.test";
165
- var TEST_ENV_DEFAULTS = {
166
- CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
167
- DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
168
- ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
169
- PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
170
- DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
171
- STORAGE_URL: STORAGE_TEST_URL
172
- };
173
- var DEFAULT_TEST_USER = {
174
- id: "test-user-1",
175
- email: "test-user@example.com",
176
- name: "Test User",
177
- role: "member",
178
- permissions: []
179
- };
180
- var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
181
-
182
190
  // src/simulator/hmac.ts
183
191
  var import_node_crypto = __toESM(require("crypto"));
184
192
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -229,8 +237,8 @@ function json(body, status = 200) {
229
237
  function success(data) {
230
238
  return json({ success: true, data });
231
239
  }
232
- function failure(error, status = 400) {
233
- return json({ success: false, error }, status);
240
+ function failure(error, status = 400, code) {
241
+ return json({ success: false, error, ...code ? { code } : {} }, status);
234
242
  }
235
243
  async function readJsonBody(request) {
236
244
  try {
@@ -718,6 +726,12 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
718
726
  "dashboard_user"
719
727
  ]);
720
728
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
729
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
730
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
731
+ var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
732
+ var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
733
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
734
+ var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
721
735
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
722
736
  var SEARCH_DEFAULT_LIMIT = 50;
723
737
  var SEARCH_MAX_LIMIT = 100;
@@ -800,6 +814,11 @@ function matchingLinks(kind, externalId) {
800
814
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
801
815
  );
802
816
  }
817
+ function sortedMatchingLinks(kind, externalId) {
818
+ return matchingLinks(kind, externalId).sort(
819
+ (a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
820
+ );
821
+ }
803
822
  function normalizedLinkKey(link) {
804
823
  return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
805
824
  }
@@ -913,9 +932,10 @@ async function handleAttachLink(identityId, request) {
913
932
  );
914
933
  if ("error" in normalized) return failure(normalized.error);
915
934
  const { kind, externalId } = normalized;
916
- const [existing] = matchingLinks(kind, externalId);
917
- if (existing) {
918
- if (existing.identityId === identityId) return success({ link: existing });
935
+ const matches = matchingLinks(kind, externalId);
936
+ const ownRow = matches.find((link2) => link2.identityId === identityId);
937
+ if (ownRow) return success({ link: ownRow });
938
+ if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
919
939
  return failure("identifier already linked to another identity", 409);
920
940
  }
921
941
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -932,7 +952,7 @@ async function handleResolve(request) {
932
952
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
933
953
  return failure("displayName must be a string");
934
954
  }
935
- const links = matchingLinks(normalized.kind, normalized.externalId);
955
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
936
956
  if (links.length > 0) {
937
957
  const canonicalIds = /* @__PURE__ */ new Set();
938
958
  for (const link of links) {
@@ -943,12 +963,15 @@ async function handleResolve(request) {
943
963
  return failure(`cannot resolve identity link: ${message}`, 409);
944
964
  }
945
965
  }
946
- if (canonicalIds.size !== 1) {
966
+ const verifiedOwners = new Set(
967
+ links.filter((link) => link.verified).map((link) => link.identityId)
968
+ );
969
+ if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
947
970
  return failure("identifier is linked to multiple identities", 409);
948
971
  }
949
- const canonicalId = [...canonicalIds][0];
950
- const hasVerifiedLink = links.some((link) => link.verified);
951
- if (provenance === "guest" && hasVerifiedLink) {
972
+ const [chosen] = links;
973
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
974
+ if (provenance === "guest" && chosen.verified) {
952
975
  return success({ identityId: null, reason: "verified_conflict" });
953
976
  }
954
977
  return success({ identityId: canonicalId, created: false });
@@ -1050,6 +1073,116 @@ async function handleAliases(request) {
1050
1073
  }
1051
1074
  return success({ aliases });
1052
1075
  }
1076
+ function claimView(claim) {
1077
+ const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
1078
+ return {
1079
+ claimId: claim.claimId,
1080
+ flow: claim.flow,
1081
+ state: state_,
1082
+ refusalReason: claim.refusalReason,
1083
+ completedIdentityId: claim.completedIdentityId,
1084
+ expiresAt: claim.expiresAt
1085
+ };
1086
+ }
1087
+ function validateClaimStaff(flow, staff) {
1088
+ if (flow === "invite") {
1089
+ if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
1090
+ return { staffActorRef: null };
1091
+ }
1092
+ if (!staff || typeof staff !== "object") {
1093
+ return { error: `staff is required for a ${flow} claim` };
1094
+ }
1095
+ const { actorRef, grant } = staff;
1096
+ if (typeof actorRef !== "string" || actorRef.length === 0) {
1097
+ return { error: "staff.actorRef is required" };
1098
+ }
1099
+ if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1100
+ return { staffActorRef: actorRef };
1101
+ }
1102
+ async function handleCreateClaim(request) {
1103
+ const body = await readJsonBody(request);
1104
+ const flow = body.flow;
1105
+ if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1106
+ return failure("flow must be one of: invite, assist, transfer");
1107
+ }
1108
+ if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1109
+ return failure("identityId must be a uuid");
1110
+ }
1111
+ const normalized = normalizeLinkInput(
1112
+ body.kind,
1113
+ body.externalId,
1114
+ CLAIM_KINDS,
1115
+ "kind must be one of: email, phone"
1116
+ );
1117
+ if ("error" in normalized) return failure(normalized.error);
1118
+ let returnTo = null;
1119
+ if (body.returnTo !== void 0) {
1120
+ if (typeof body.returnTo !== "string") return failure("returnTo must be a string");
1121
+ let parsed;
1122
+ try {
1123
+ parsed = new URL(body.returnTo);
1124
+ } catch {
1125
+ return failure("returnTo must be an absolute URL");
1126
+ }
1127
+ returnTo = state.identityAllowedReturnOrigins.has(parsed.origin) ? body.returnTo : null;
1128
+ }
1129
+ if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1130
+ return failure("deliver must be a boolean");
1131
+ }
1132
+ const staff = validateClaimStaff(flow, body.staff);
1133
+ if ("error" in staff) return failure(staff.error);
1134
+ if (flow !== "invite" && !state.identityStaffAssistEnabled) {
1135
+ return failure(
1136
+ "staff-assisted profile claiming is not enabled for this project",
1137
+ 403,
1138
+ STAFF_ASSISTANCE_NOT_ENABLED
1139
+ );
1140
+ }
1141
+ const identity = state.identities.get(body.identityId);
1142
+ if (!identity || identity.type !== "person" || identity.status !== "active") {
1143
+ return failure("identity not found", 404);
1144
+ }
1145
+ const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1146
+ (link) => link.identityId === identity.id
1147
+ );
1148
+ if (!holdsContact) {
1149
+ return failure("identity does not hold that contact", 409);
1150
+ }
1151
+ const claimId = import_node_crypto3.default.randomUUID();
1152
+ const deliver = body.deliver === true;
1153
+ const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
1154
+ const claim = {
1155
+ claimId,
1156
+ flow,
1157
+ identityId: identity.id,
1158
+ kind: normalized.kind,
1159
+ externalId: normalized.externalId,
1160
+ returnTo,
1161
+ staffActorRef: staff.staffActorRef,
1162
+ state: "pending",
1163
+ refusalReason: null,
1164
+ completedIdentityId: null,
1165
+ claimantClerkUserId: null,
1166
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1167
+ delivered: deliver ? !state.identitySuppressClaimDelivery : null,
1168
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1169
+ createdAt: now()
1170
+ };
1171
+ state.identityClaims.set(claimId, claim);
1172
+ return success({
1173
+ claimId,
1174
+ claimUrl: claim.claimUrl,
1175
+ expiresAt: claim.expiresAt,
1176
+ // Absent unless asked for: an app delivering its own link must not read a
1177
+ // missing field as "nothing was sent".
1178
+ ...deliver ? { delivered: claim.delivered } : {}
1179
+ });
1180
+ }
1181
+ function handleGetClaim(claimId) {
1182
+ const claim = state.identityClaims.get(claimId);
1183
+ if (!claim) return failure("claim not found", 404);
1184
+ return success(claimView(claim));
1185
+ }
1053
1186
  async function handleIdentitiesRequest(request, subPath) {
1054
1187
  const method = request.method;
1055
1188
  if (subPath === "" || subPath === "/") {
@@ -1065,6 +1198,13 @@ async function handleIdentitiesRequest(request, subPath) {
1065
1198
  if (subPath === "/search" && method === "GET") {
1066
1199
  return handleSearch(request);
1067
1200
  }
1201
+ if (subPath === "/claims" && method === "POST") {
1202
+ return handleCreateClaim(request);
1203
+ }
1204
+ const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
1205
+ if (claimMatch && method === "GET") {
1206
+ return handleGetClaim(decodeURIComponent(claimMatch[1]));
1207
+ }
1068
1208
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
1069
1209
  if (linksMatch && method === "POST") {
1070
1210
  return handleAttachLink(linksMatch[1], request);
@@ -1089,11 +1229,13 @@ function seedVerifiedLink(identityId, params) {
1089
1229
  );
1090
1230
  if ("error" in normalized) throw new Error(normalized.error);
1091
1231
  const { kind, externalId } = normalized;
1092
- const [existing] = matchingLinks(kind, externalId);
1232
+ const matches = matchingLinks(kind, externalId);
1233
+ const blocker = matches.find(
1234
+ (link2) => link2.identityId !== identityId && (link2.verified || !DUPLICABLE_UNVERIFIED_KINDS.has(link2.kind))
1235
+ );
1236
+ if (blocker) throw new Error("identifier already linked to another identity");
1237
+ const existing = matches.find((link2) => link2.identityId === identityId);
1093
1238
  if (existing) {
1094
- if (existing.identityId !== identityId) {
1095
- throw new Error("identifier already linked to another identity");
1096
- }
1097
1239
  existing.verified = true;
1098
1240
  return existing;
1099
1241
  }
@@ -1123,7 +1265,12 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1123
1265
  );
1124
1266
  for (const sourceLink of sourceLinks) {
1125
1267
  const key = normalizedLinkKey(sourceLink);
1126
- if (otherLinks.some((otherLink) => normalizedLinkKey(otherLink) === key)) {
1268
+ const conflicting = otherLinks.some(
1269
+ (otherLink) => normalizedLinkKey(otherLink) === key && // Same partial rule as the index: a third identity's UNVERIFIED copy of a
1270
+ // shared email/phone is allowed to coexist and must not block the merge.
1271
+ (!DUPLICABLE_UNVERIFIED_KINDS.has(key.split(":")[0]) || otherLink.verified && sourceLink.verified)
1272
+ );
1273
+ if (conflicting) {
1127
1274
  throw new Error("merge would conflict with a link owned by another identity");
1128
1275
  }
1129
1276
  }
@@ -1150,6 +1297,45 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1150
1297
  source.mergedIntoId = canonical.id;
1151
1298
  source.updatedAt = now();
1152
1299
  }
1300
+ function requireClaim(claimId) {
1301
+ const claim = state.identityClaims.get(claimId);
1302
+ if (!claim) throw new Error("claim not found");
1303
+ return claim;
1304
+ }
1305
+ function completeClaim(claimId, params) {
1306
+ const claim = requireClaim(claimId);
1307
+ if (claim.state !== "pending") return claim;
1308
+ if (Date.parse(claim.expiresAt) <= Date.now()) {
1309
+ claim.state = "expired";
1310
+ return claim;
1311
+ }
1312
+ if (!params.clerkUserId) throw new Error("clerkUserId is required");
1313
+ const rows = matchingLinks(claim.kind, claim.externalId);
1314
+ const target = rows.find((link) => link.identityId === claim.identityId);
1315
+ if (!target) {
1316
+ claim.state = "invalidated";
1317
+ return claim;
1318
+ }
1319
+ const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
1320
+ if (otherVerified) {
1321
+ claim.state = "refused";
1322
+ claim.refusalReason = "contact_owned";
1323
+ return claim;
1324
+ }
1325
+ target.verified = true;
1326
+ claim.state = "completed";
1327
+ claim.completedIdentityId = claim.identityId;
1328
+ claim.claimantClerkUserId = params.clerkUserId;
1329
+ return claim;
1330
+ }
1331
+ function refuseClaim(claimId, reason) {
1332
+ const claim = requireClaim(claimId);
1333
+ if (claim.state !== "pending") return claim;
1334
+ if (!reason) throw new Error("a refusal reason is required");
1335
+ claim.state = "refused";
1336
+ claim.refusalReason = reason;
1337
+ return claim;
1338
+ }
1153
1339
  function createDirectory() {
1154
1340
  return {
1155
1341
  all: () => [...state.identities.values()],
@@ -1157,9 +1343,26 @@ function createDirectory() {
1157
1343
  links: (identityId) => linksFor(identityId),
1158
1344
  seedVerifiedLink,
1159
1345
  merge,
1346
+ claims: () => [...state.identityClaims.values()],
1347
+ claim: (claimId) => state.identityClaims.get(claimId),
1348
+ enableStaffAssist: (enabled = true) => {
1349
+ state.identityStaffAssistEnabled = enabled;
1350
+ },
1351
+ suppressClaimDelivery: (enabled = true) => {
1352
+ state.identitySuppressClaimDelivery = enabled;
1353
+ },
1354
+ allowReturnOrigin: (origin) => {
1355
+ state.identityAllowedReturnOrigins.add(new URL(origin).origin);
1356
+ },
1357
+ completeClaim,
1358
+ refuseClaim,
1160
1359
  clear: () => {
1161
1360
  state.identities.clear();
1162
1361
  state.identityLinks = [];
1362
+ state.identityClaims.clear();
1363
+ state.identityStaffAssistEnabled = false;
1364
+ state.identitySuppressClaimDelivery = false;
1365
+ state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
1163
1366
  },
1164
1367
  get count() {
1165
1368
  return state.identities.size;
@@ -1198,7 +1401,7 @@ async function importNextServer() {
1198
1401
  async function callRoute(handler, options = {}) {
1199
1402
  const { NextRequest } = await importNextServer();
1200
1403
  const path = options.path ?? "/api/test-route";
1201
- const url = new URL(`http://localhost:3333${path}`);
1404
+ const url = new URL(`${APP_TEST_ORIGIN}${path}`);
1202
1405
  for (const [key, value] of Object.entries(options.searchParams ?? {})) {
1203
1406
  url.searchParams.set(key, value);
1204
1407
  }
@@ -2553,6 +2756,104 @@ function createEdge() {
2553
2756
  };
2554
2757
  }
2555
2758
 
2759
+ // src/simulator/starlens.ts
2760
+ function starlensFailure(error, status, code) {
2761
+ return json({ success: false, error, code }, status);
2762
+ }
2763
+ function nullFlagged(output, issues) {
2764
+ const copy = JSON.parse(JSON.stringify(output ?? null));
2765
+ for (const { path } of issues) {
2766
+ const keys = path.replace(/^output\./, "").replace(/\[(\d+)\]/g, ".$1").split(".");
2767
+ const last = keys.pop();
2768
+ const parent = keys.reduce(
2769
+ (o, k) => o != null && typeof o === "object" ? o[k] : void 0,
2770
+ copy
2771
+ );
2772
+ if (parent != null && typeof parent === "object" && Object.prototype.hasOwnProperty.call(parent, last)) {
2773
+ parent[last] = null;
2774
+ }
2775
+ }
2776
+ return copy;
2777
+ }
2778
+ async function handleStarlensExtract(request, signedDeploymentId) {
2779
+ const body = await readJsonBody(request);
2780
+ if (signedDeploymentId !== null && body.deploymentId !== signedDeploymentId) {
2781
+ return starlensFailure("Deployment ID mismatch", 401, "AUTHENTICATION_ERROR");
2782
+ }
2783
+ const inputs = Array.isArray(body.inputs) ? body.inputs : [];
2784
+ if (inputs.length === 0) {
2785
+ return starlensFailure(
2786
+ "Validation failed \u2014 inputs: at least one input",
2787
+ 400,
2788
+ "VALIDATION_ERROR"
2789
+ );
2790
+ }
2791
+ const schema = body.schema;
2792
+ if (!schema || schema.type !== "object" || typeof schema.properties !== "object") {
2793
+ return starlensFailure(
2794
+ "Validation failed \u2014 schema.properties is required",
2795
+ 400,
2796
+ "VALIDATION_ERROR"
2797
+ );
2798
+ }
2799
+ const call = {
2800
+ version: typeof body.version === "string" ? body.version : "latest",
2801
+ inputs: inputs.map((i) => ({
2802
+ name: typeof i.filename === "string" ? i.filename : typeof i.url === "string" ? i.url : "upload",
2803
+ mediaType: typeof i.mediaType === "string" ? i.mediaType : null,
2804
+ source: typeof i.url === "string" ? "url" : "data"
2805
+ })),
2806
+ schema,
2807
+ instructions: typeof body.instructions === "string" ? body.instructions : void 0
2808
+ };
2809
+ state.starlensCalls.push(call);
2810
+ const reply = state.starlensReplies.shift() ?? state.starlensDefaultReply;
2811
+ if (!reply) {
2812
+ return starlensFailure(
2813
+ "[stardeck-testing] No Starlens reply registered. Call app.starlens.mock({ output, issues }) or app.starlens.reject(reason) before the request.",
2814
+ 502,
2815
+ "model_failed"
2816
+ );
2817
+ }
2818
+ if ("reject" in reply) {
2819
+ return starlensFailure(reply.reject, 422, "unsupported_document");
2820
+ }
2821
+ const issues = reply.issues ?? [];
2822
+ return success({
2823
+ version: call.version === "latest" ? "v1" : call.version,
2824
+ output: nullFlagged(reply.output, issues),
2825
+ issues,
2826
+ usage: { inputTokens: 0, outputTokens: 0, cost: 0 }
2827
+ });
2828
+ }
2829
+ function createStarlens() {
2830
+ return {
2831
+ mock(reply) {
2832
+ state.starlensReplies.push(reply);
2833
+ },
2834
+ reject(reason) {
2835
+ state.starlensReplies.push({ reject: reason });
2836
+ },
2837
+ mockDefault(reply) {
2838
+ state.starlensDefaultReply = reply;
2839
+ },
2840
+ get calls() {
2841
+ return state.starlensCalls;
2842
+ },
2843
+ latestCall() {
2844
+ return state.starlensCalls[state.starlensCalls.length - 1];
2845
+ },
2846
+ clear() {
2847
+ state.starlensCalls = [];
2848
+ state.starlensReplies = [];
2849
+ state.starlensDefaultReply = null;
2850
+ },
2851
+ get count() {
2852
+ return state.starlensCalls.length;
2853
+ }
2854
+ };
2855
+ }
2856
+
2556
2857
  // src/simulator/router.ts
2557
2858
  var fetchHolder = globalSingleton("fetch-holder", () => ({
2558
2859
  originalFetch: null
@@ -2574,7 +2875,8 @@ function requiresDeploymentHmac(request, url) {
2574
2875
  /^\/api\/deployments\/[^/]+\/integrations\/(slack|line|facebook)(\/.*)?$/
2575
2876
  );
2576
2877
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
2577
- return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
2878
+ const isStarlens = url.pathname === "/api/starlens/extract";
2879
+ return !!(isStarlens || dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
2578
2880
  }
2579
2881
  function deploymentIdFromPath(url) {
2580
2882
  const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
@@ -2604,6 +2906,7 @@ async function handleSimulatedRequest(request, url) {
2604
2906
  );
2605
2907
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
2606
2908
  const isStorageHost = url.hostname === STORAGE_TEST_HOST;
2909
+ let signedDeploymentId = null;
2607
2910
  if (requiresDeploymentHmac(request, url)) {
2608
2911
  const authHeader = request.headers.get("X-Stardeck-Auth");
2609
2912
  if (!authHeader) {
@@ -2615,6 +2918,7 @@ async function handleSimulatedRequest(request, url) {
2615
2918
  if (!authPayload || pathDeploymentId !== null && authPayload.deploymentId !== pathDeploymentId) {
2616
2919
  return failure("Invalid authentication", 401);
2617
2920
  }
2921
+ signedDeploymentId = authPayload.deploymentId;
2618
2922
  }
2619
2923
  if (isStorageHost) {
2620
2924
  return handleStorageRequest(request, url);
@@ -2635,6 +2939,9 @@ async function handleSimulatedRequest(request, url) {
2635
2939
  if (edgeMatch) {
2636
2940
  return handleEdgeRequest(request, edgeMatch[1] ?? "");
2637
2941
  }
2942
+ if (url.pathname === "/api/starlens/extract" && request.method === "POST") {
2943
+ return handleStarlensExtract(request, signedDeploymentId);
2944
+ }
2638
2945
  if (dataStoreMatch) {
2639
2946
  const subPath = dataStoreMatch[1] ?? "";
2640
2947
  const db = requireDb();
@@ -2656,7 +2963,7 @@ async function handleSimulatedRequest(request, url) {
2656
2963
  }
2657
2964
  }
2658
2965
  return failure(
2659
- `[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, edge print/display/bindings, auth verify/refresh, Neon /sql.`,
2966
+ `[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, edge print/display/bindings, starlens extract, auth verify/refresh, Neon /sql.`,
2660
2967
  404
2661
2968
  );
2662
2969
  }
@@ -2824,6 +3131,7 @@ async function createTestApp(options = {}) {
2824
3131
  const storage = createStorage();
2825
3132
  const messages = createMessages();
2826
3133
  const edge = createEdge();
3134
+ const starlens = createStarlens();
2827
3135
  const app = {
2828
3136
  db,
2829
3137
  inbox,
@@ -2832,6 +3140,7 @@ async function createTestApp(options = {}) {
2832
3140
  storage,
2833
3141
  messages,
2834
3142
  edge,
3143
+ starlens,
2835
3144
  async query(sql, params = []) {
2836
3145
  const result = await db.query(sql, params);
2837
3146
  return result.rows;
@@ -2867,12 +3176,12 @@ async function createTestApp(options = {}) {
2867
3176
  state.refreshSessions.clear();
2868
3177
  state.emails = [];
2869
3178
  state.emailCounter = 0;
2870
- state.identities.clear();
2871
- state.identityLinks = [];
3179
+ directory.clear();
2872
3180
  payments.clear();
2873
3181
  storage.clear();
2874
3182
  messages.clear();
2875
3183
  edge.clear();
3184
+ starlens.clear();
2876
3185
  },
2877
3186
  async close() {
2878
3187
  state.db = null;
@@ -2881,12 +3190,12 @@ async function createTestApp(options = {}) {
2881
3190
  state.refreshSessions.clear();
2882
3191
  state.emails = [];
2883
3192
  state.emailCounter = 0;
2884
- state.identities.clear();
2885
- state.identityLinks = [];
3193
+ directory.clear();
2886
3194
  payments.clear();
2887
3195
  storage.clear();
2888
3196
  messages.clear();
2889
3197
  edge.clear();
3198
+ starlens.clear();
2890
3199
  uninstallFetchRouter();
2891
3200
  await db.close();
2892
3201
  }