@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/setup.js CHANGED
@@ -30,6 +30,23 @@ function globalSingleton(key, create) {
30
30
  return holder[symbol];
31
31
  }
32
32
 
33
+ // src/constants.ts
34
+ var TEST_DOMAIN_SUFFIX = ".stardeck.test";
35
+ var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
36
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
37
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
38
+ var STORAGE_TEST_URL = "https://storage.stardeck.test";
39
+ var STORAGE_TEST_HOST = "storage.stardeck.test";
40
+ var APP_TEST_ORIGIN = "http://localhost:3333";
41
+ var TEST_ENV_DEFAULTS = {
42
+ CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
43
+ DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
44
+ ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
45
+ PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
46
+ DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
47
+ STORAGE_URL: STORAGE_TEST_URL
48
+ };
49
+
33
50
  // src/state.ts
34
51
  var state = globalSingleton("state", () => ({
35
52
  db: null,
@@ -41,6 +58,10 @@ var state = globalSingleton("state", () => ({
41
58
  emailCounter: 0,
42
59
  identities: /* @__PURE__ */ new Map(),
43
60
  identityLinks: [],
61
+ identityClaims: /* @__PURE__ */ new Map(),
62
+ identityStaffAssistEnabled: false,
63
+ identitySuppressClaimDelivery: false,
64
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
44
65
  checkouts: [],
45
66
  checkoutCounter: 0,
46
67
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -68,6 +89,9 @@ var state = globalSingleton("state", () => ({
68
89
  edgeBindings: /* @__PURE__ */ new Map(),
69
90
  edgeCapabilities: null,
70
91
  edgePeripheralCapabilities: /* @__PURE__ */ new Map(),
92
+ starlensCalls: [],
93
+ starlensReplies: [],
94
+ starlensDefaultReply: null,
71
95
  allowNetwork: false
72
96
  }));
73
97
  function requireDb() {
@@ -79,22 +103,6 @@ function requireDb() {
79
103
  return state.db;
80
104
  }
81
105
 
82
- // src/constants.ts
83
- var TEST_DOMAIN_SUFFIX = ".stardeck.test";
84
- var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
85
- var DATA_STORE_TEST_HOST = "db.stardeck.test";
86
- var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
87
- var STORAGE_TEST_URL = "https://storage.stardeck.test";
88
- var STORAGE_TEST_HOST = "storage.stardeck.test";
89
- var TEST_ENV_DEFAULTS = {
90
- CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
91
- DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
92
- ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
93
- PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
94
- DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
95
- STORAGE_URL: STORAGE_TEST_URL
96
- };
97
-
98
106
  // src/simulator/hmac.ts
99
107
  var import_node_crypto = __toESM(require("crypto"));
100
108
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -145,8 +153,8 @@ function json(body, status = 200) {
145
153
  function success(data) {
146
154
  return json({ success: true, data });
147
155
  }
148
- function failure(error, status = 400) {
149
- return json({ success: false, error }, status);
156
+ function failure(error, status = 400, code) {
157
+ return json({ success: false, error, ...code ? { code } : {} }, status);
150
158
  }
151
159
  async function readJsonBody(request) {
152
160
  try {
@@ -623,6 +631,12 @@ async function handleEmailSend(request) {
623
631
  var import_node_crypto3 = __toESM(require("crypto"));
624
632
  var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
625
633
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
634
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
635
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
636
+ var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
637
+ var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
638
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
639
+ var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
626
640
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
627
641
  var SEARCH_DEFAULT_LIMIT = 50;
628
642
  var SEARCH_MAX_LIMIT = 100;
@@ -705,6 +719,11 @@ function matchingLinks(kind, externalId) {
705
719
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
706
720
  );
707
721
  }
722
+ function sortedMatchingLinks(kind, externalId) {
723
+ return matchingLinks(kind, externalId).sort(
724
+ (a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
725
+ );
726
+ }
708
727
  function createPerson(displayName) {
709
728
  const createdAt = now();
710
729
  const identity = {
@@ -815,9 +834,10 @@ async function handleAttachLink(identityId, request) {
815
834
  );
816
835
  if ("error" in normalized) return failure(normalized.error);
817
836
  const { kind, externalId } = normalized;
818
- const [existing] = matchingLinks(kind, externalId);
819
- if (existing) {
820
- if (existing.identityId === identityId) return success({ link: existing });
837
+ const matches = matchingLinks(kind, externalId);
838
+ const ownRow = matches.find((link2) => link2.identityId === identityId);
839
+ if (ownRow) return success({ link: ownRow });
840
+ if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
821
841
  return failure("identifier already linked to another identity", 409);
822
842
  }
823
843
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -834,7 +854,7 @@ async function handleResolve(request) {
834
854
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
835
855
  return failure("displayName must be a string");
836
856
  }
837
- const links = matchingLinks(normalized.kind, normalized.externalId);
857
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
838
858
  if (links.length > 0) {
839
859
  const canonicalIds = /* @__PURE__ */ new Set();
840
860
  for (const link of links) {
@@ -845,12 +865,15 @@ async function handleResolve(request) {
845
865
  return failure(`cannot resolve identity link: ${message}`, 409);
846
866
  }
847
867
  }
848
- if (canonicalIds.size !== 1) {
868
+ const verifiedOwners = new Set(
869
+ links.filter((link) => link.verified).map((link) => link.identityId)
870
+ );
871
+ if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
849
872
  return failure("identifier is linked to multiple identities", 409);
850
873
  }
851
- const canonicalId = [...canonicalIds][0];
852
- const hasVerifiedLink = links.some((link) => link.verified);
853
- if (provenance === "guest" && hasVerifiedLink) {
874
+ const [chosen] = links;
875
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
876
+ if (provenance === "guest" && chosen.verified) {
854
877
  return success({ identityId: null, reason: "verified_conflict" });
855
878
  }
856
879
  return success({ identityId: canonicalId, created: false });
@@ -952,6 +975,116 @@ async function handleAliases(request) {
952
975
  }
953
976
  return success({ aliases });
954
977
  }
978
+ function claimView(claim) {
979
+ const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
980
+ return {
981
+ claimId: claim.claimId,
982
+ flow: claim.flow,
983
+ state: state_,
984
+ refusalReason: claim.refusalReason,
985
+ completedIdentityId: claim.completedIdentityId,
986
+ expiresAt: claim.expiresAt
987
+ };
988
+ }
989
+ function validateClaimStaff(flow, staff) {
990
+ if (flow === "invite") {
991
+ if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
992
+ return { staffActorRef: null };
993
+ }
994
+ if (!staff || typeof staff !== "object") {
995
+ return { error: `staff is required for a ${flow} claim` };
996
+ }
997
+ const { actorRef, grant } = staff;
998
+ if (typeof actorRef !== "string" || actorRef.length === 0) {
999
+ return { error: "staff.actorRef is required" };
1000
+ }
1001
+ if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1002
+ return { staffActorRef: actorRef };
1003
+ }
1004
+ async function handleCreateClaim(request) {
1005
+ const body = await readJsonBody(request);
1006
+ const flow = body.flow;
1007
+ if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1008
+ return failure("flow must be one of: invite, assist, transfer");
1009
+ }
1010
+ if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1011
+ return failure("identityId must be a uuid");
1012
+ }
1013
+ const normalized = normalizeLinkInput(
1014
+ body.kind,
1015
+ body.externalId,
1016
+ CLAIM_KINDS,
1017
+ "kind must be one of: email, phone"
1018
+ );
1019
+ if ("error" in normalized) return failure(normalized.error);
1020
+ let returnTo = null;
1021
+ if (body.returnTo !== void 0) {
1022
+ if (typeof body.returnTo !== "string") return failure("returnTo must be a string");
1023
+ let parsed;
1024
+ try {
1025
+ parsed = new URL(body.returnTo);
1026
+ } catch {
1027
+ return failure("returnTo must be an absolute URL");
1028
+ }
1029
+ returnTo = state.identityAllowedReturnOrigins.has(parsed.origin) ? body.returnTo : null;
1030
+ }
1031
+ if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1032
+ return failure("deliver must be a boolean");
1033
+ }
1034
+ const staff = validateClaimStaff(flow, body.staff);
1035
+ if ("error" in staff) return failure(staff.error);
1036
+ if (flow !== "invite" && !state.identityStaffAssistEnabled) {
1037
+ return failure(
1038
+ "staff-assisted profile claiming is not enabled for this project",
1039
+ 403,
1040
+ STAFF_ASSISTANCE_NOT_ENABLED
1041
+ );
1042
+ }
1043
+ const identity = state.identities.get(body.identityId);
1044
+ if (!identity || identity.type !== "person" || identity.status !== "active") {
1045
+ return failure("identity not found", 404);
1046
+ }
1047
+ const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1048
+ (link) => link.identityId === identity.id
1049
+ );
1050
+ if (!holdsContact) {
1051
+ return failure("identity does not hold that contact", 409);
1052
+ }
1053
+ const claimId = import_node_crypto3.default.randomUUID();
1054
+ const deliver = body.deliver === true;
1055
+ const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
1056
+ const claim = {
1057
+ claimId,
1058
+ flow,
1059
+ identityId: identity.id,
1060
+ kind: normalized.kind,
1061
+ externalId: normalized.externalId,
1062
+ returnTo,
1063
+ staffActorRef: staff.staffActorRef,
1064
+ state: "pending",
1065
+ refusalReason: null,
1066
+ completedIdentityId: null,
1067
+ claimantClerkUserId: null,
1068
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1069
+ delivered: deliver ? !state.identitySuppressClaimDelivery : null,
1070
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1071
+ createdAt: now()
1072
+ };
1073
+ state.identityClaims.set(claimId, claim);
1074
+ return success({
1075
+ claimId,
1076
+ claimUrl: claim.claimUrl,
1077
+ expiresAt: claim.expiresAt,
1078
+ // Absent unless asked for: an app delivering its own link must not read a
1079
+ // missing field as "nothing was sent".
1080
+ ...deliver ? { delivered: claim.delivered } : {}
1081
+ });
1082
+ }
1083
+ function handleGetClaim(claimId) {
1084
+ const claim = state.identityClaims.get(claimId);
1085
+ if (!claim) return failure("claim not found", 404);
1086
+ return success(claimView(claim));
1087
+ }
955
1088
  async function handleIdentitiesRequest(request, subPath) {
956
1089
  const method = request.method;
957
1090
  if (subPath === "" || subPath === "/") {
@@ -967,6 +1100,13 @@ async function handleIdentitiesRequest(request, subPath) {
967
1100
  if (subPath === "/search" && method === "GET") {
968
1101
  return handleSearch(request);
969
1102
  }
1103
+ if (subPath === "/claims" && method === "POST") {
1104
+ return handleCreateClaim(request);
1105
+ }
1106
+ const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
1107
+ if (claimMatch && method === "GET") {
1108
+ return handleGetClaim(decodeURIComponent(claimMatch[1]));
1109
+ }
970
1110
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
971
1111
  if (linksMatch && method === "POST") {
972
1112
  return handleAttachLink(linksMatch[1], request);
@@ -2000,6 +2140,77 @@ async function handleEdgeRequest(request, subPath) {
2000
2140
  return edgeFailure(`No edge simulator for ${method} .../edge${subPath}`, 404);
2001
2141
  }
2002
2142
 
2143
+ // src/simulator/starlens.ts
2144
+ function starlensFailure(error, status, code) {
2145
+ return json({ success: false, error, code }, status);
2146
+ }
2147
+ function nullFlagged(output, issues) {
2148
+ const copy = JSON.parse(JSON.stringify(output ?? null));
2149
+ for (const { path } of issues) {
2150
+ const keys = path.replace(/^output\./, "").replace(/\[(\d+)\]/g, ".$1").split(".");
2151
+ const last = keys.pop();
2152
+ const parent = keys.reduce(
2153
+ (o, k) => o != null && typeof o === "object" ? o[k] : void 0,
2154
+ copy
2155
+ );
2156
+ if (parent != null && typeof parent === "object" && Object.prototype.hasOwnProperty.call(parent, last)) {
2157
+ parent[last] = null;
2158
+ }
2159
+ }
2160
+ return copy;
2161
+ }
2162
+ async function handleStarlensExtract(request, signedDeploymentId) {
2163
+ const body = await readJsonBody(request);
2164
+ if (signedDeploymentId !== null && body.deploymentId !== signedDeploymentId) {
2165
+ return starlensFailure("Deployment ID mismatch", 401, "AUTHENTICATION_ERROR");
2166
+ }
2167
+ const inputs = Array.isArray(body.inputs) ? body.inputs : [];
2168
+ if (inputs.length === 0) {
2169
+ return starlensFailure(
2170
+ "Validation failed \u2014 inputs: at least one input",
2171
+ 400,
2172
+ "VALIDATION_ERROR"
2173
+ );
2174
+ }
2175
+ const schema = body.schema;
2176
+ if (!schema || schema.type !== "object" || typeof schema.properties !== "object") {
2177
+ return starlensFailure(
2178
+ "Validation failed \u2014 schema.properties is required",
2179
+ 400,
2180
+ "VALIDATION_ERROR"
2181
+ );
2182
+ }
2183
+ const call = {
2184
+ version: typeof body.version === "string" ? body.version : "latest",
2185
+ inputs: inputs.map((i) => ({
2186
+ name: typeof i.filename === "string" ? i.filename : typeof i.url === "string" ? i.url : "upload",
2187
+ mediaType: typeof i.mediaType === "string" ? i.mediaType : null,
2188
+ source: typeof i.url === "string" ? "url" : "data"
2189
+ })),
2190
+ schema,
2191
+ instructions: typeof body.instructions === "string" ? body.instructions : void 0
2192
+ };
2193
+ state.starlensCalls.push(call);
2194
+ const reply = state.starlensReplies.shift() ?? state.starlensDefaultReply;
2195
+ if (!reply) {
2196
+ return starlensFailure(
2197
+ "[stardeck-testing] No Starlens reply registered. Call app.starlens.mock({ output, issues }) or app.starlens.reject(reason) before the request.",
2198
+ 502,
2199
+ "model_failed"
2200
+ );
2201
+ }
2202
+ if ("reject" in reply) {
2203
+ return starlensFailure(reply.reject, 422, "unsupported_document");
2204
+ }
2205
+ const issues = reply.issues ?? [];
2206
+ return success({
2207
+ version: call.version === "latest" ? "v1" : call.version,
2208
+ output: nullFlagged(reply.output, issues),
2209
+ issues,
2210
+ usage: { inputTokens: 0, outputTokens: 0, cost: 0 }
2211
+ });
2212
+ }
2213
+
2003
2214
  // src/simulator/router.ts
2004
2215
  var fetchHolder = globalSingleton("fetch-holder", () => ({
2005
2216
  originalFetch: null
@@ -2021,7 +2232,8 @@ function requiresDeploymentHmac(request, url) {
2021
2232
  /^\/api\/deployments\/[^/]+\/integrations\/(slack|line|facebook)(\/.*)?$/
2022
2233
  );
2023
2234
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
2024
- return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
2235
+ const isStarlens = url.pathname === "/api/starlens/extract";
2236
+ return !!(isStarlens || dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
2025
2237
  }
2026
2238
  function deploymentIdFromPath(url) {
2027
2239
  const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
@@ -2051,6 +2263,7 @@ async function handleSimulatedRequest(request, url) {
2051
2263
  );
2052
2264
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
2053
2265
  const isStorageHost = url.hostname === STORAGE_TEST_HOST;
2266
+ let signedDeploymentId = null;
2054
2267
  if (requiresDeploymentHmac(request, url)) {
2055
2268
  const authHeader = request.headers.get("X-Stardeck-Auth");
2056
2269
  if (!authHeader) {
@@ -2062,6 +2275,7 @@ async function handleSimulatedRequest(request, url) {
2062
2275
  if (!authPayload || pathDeploymentId !== null && authPayload.deploymentId !== pathDeploymentId) {
2063
2276
  return failure("Invalid authentication", 401);
2064
2277
  }
2278
+ signedDeploymentId = authPayload.deploymentId;
2065
2279
  }
2066
2280
  if (isStorageHost) {
2067
2281
  return handleStorageRequest(request, url);
@@ -2082,6 +2296,9 @@ async function handleSimulatedRequest(request, url) {
2082
2296
  if (edgeMatch) {
2083
2297
  return handleEdgeRequest(request, edgeMatch[1] ?? "");
2084
2298
  }
2299
+ if (url.pathname === "/api/starlens/extract" && request.method === "POST") {
2300
+ return handleStarlensExtract(request, signedDeploymentId);
2301
+ }
2085
2302
  if (dataStoreMatch) {
2086
2303
  const subPath = dataStoreMatch[1] ?? "";
2087
2304
  const db = requireDb();
@@ -2103,7 +2320,7 @@ async function handleSimulatedRequest(request, url) {
2103
2320
  }
2104
2321
  }
2105
2322
  return failure(
2106
- `[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.`,
2323
+ `[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.`,
2107
2324
  404
2108
2325
  );
2109
2326
  }