@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.mjs CHANGED
@@ -6,6 +6,23 @@ function globalSingleton(key, create) {
6
6
  return holder[symbol];
7
7
  }
8
8
 
9
+ // src/constants.ts
10
+ var TEST_DOMAIN_SUFFIX = ".stardeck.test";
11
+ var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
12
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
13
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
14
+ var STORAGE_TEST_URL = "https://storage.stardeck.test";
15
+ var STORAGE_TEST_HOST = "storage.stardeck.test";
16
+ var APP_TEST_ORIGIN = "http://localhost:3333";
17
+ var TEST_ENV_DEFAULTS = {
18
+ CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
19
+ DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
20
+ ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
21
+ PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
22
+ DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
23
+ STORAGE_URL: STORAGE_TEST_URL
24
+ };
25
+
9
26
  // src/state.ts
10
27
  var state = globalSingleton("state", () => ({
11
28
  db: null,
@@ -17,6 +34,10 @@ var state = globalSingleton("state", () => ({
17
34
  emailCounter: 0,
18
35
  identities: /* @__PURE__ */ new Map(),
19
36
  identityLinks: [],
37
+ identityClaims: /* @__PURE__ */ new Map(),
38
+ identityStaffAssistEnabled: false,
39
+ identitySuppressClaimDelivery: false,
40
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
20
41
  checkouts: [],
21
42
  checkoutCounter: 0,
22
43
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -44,6 +65,9 @@ var state = globalSingleton("state", () => ({
44
65
  edgeBindings: /* @__PURE__ */ new Map(),
45
66
  edgeCapabilities: null,
46
67
  edgePeripheralCapabilities: /* @__PURE__ */ new Map(),
68
+ starlensCalls: [],
69
+ starlensReplies: [],
70
+ starlensDefaultReply: null,
47
71
  allowNetwork: false
48
72
  }));
49
73
  function requireDb() {
@@ -55,22 +79,6 @@ function requireDb() {
55
79
  return state.db;
56
80
  }
57
81
 
58
- // src/constants.ts
59
- var TEST_DOMAIN_SUFFIX = ".stardeck.test";
60
- var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
61
- var DATA_STORE_TEST_HOST = "db.stardeck.test";
62
- var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
63
- var STORAGE_TEST_URL = "https://storage.stardeck.test";
64
- var STORAGE_TEST_HOST = "storage.stardeck.test";
65
- var TEST_ENV_DEFAULTS = {
66
- CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
67
- DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
68
- ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
69
- PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
70
- DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
71
- STORAGE_URL: STORAGE_TEST_URL
72
- };
73
-
74
82
  // src/simulator/hmac.ts
75
83
  import crypto from "crypto";
76
84
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -121,8 +129,8 @@ function json(body, status = 200) {
121
129
  function success(data) {
122
130
  return json({ success: true, data });
123
131
  }
124
- function failure(error, status = 400) {
125
- return json({ success: false, error }, status);
132
+ function failure(error, status = 400, code) {
133
+ return json({ success: false, error, ...code ? { code } : {} }, status);
126
134
  }
127
135
  async function readJsonBody(request) {
128
136
  try {
@@ -599,6 +607,12 @@ async function handleEmailSend(request) {
599
607
  import crypto3 from "crypto";
600
608
  var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
601
609
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
610
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
611
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
612
+ var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
613
+ var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
614
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
615
+ var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
602
616
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
603
617
  var SEARCH_DEFAULT_LIMIT = 50;
604
618
  var SEARCH_MAX_LIMIT = 100;
@@ -681,6 +695,11 @@ function matchingLinks(kind, externalId) {
681
695
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
682
696
  );
683
697
  }
698
+ function sortedMatchingLinks(kind, externalId) {
699
+ return matchingLinks(kind, externalId).sort(
700
+ (a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
701
+ );
702
+ }
684
703
  function createPerson(displayName) {
685
704
  const createdAt = now();
686
705
  const identity = {
@@ -791,9 +810,10 @@ async function handleAttachLink(identityId, request) {
791
810
  );
792
811
  if ("error" in normalized) return failure(normalized.error);
793
812
  const { kind, externalId } = normalized;
794
- const [existing] = matchingLinks(kind, externalId);
795
- if (existing) {
796
- if (existing.identityId === identityId) return success({ link: existing });
813
+ const matches = matchingLinks(kind, externalId);
814
+ const ownRow = matches.find((link2) => link2.identityId === identityId);
815
+ if (ownRow) return success({ link: ownRow });
816
+ if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
797
817
  return failure("identifier already linked to another identity", 409);
798
818
  }
799
819
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -810,7 +830,7 @@ async function handleResolve(request) {
810
830
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
811
831
  return failure("displayName must be a string");
812
832
  }
813
- const links = matchingLinks(normalized.kind, normalized.externalId);
833
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
814
834
  if (links.length > 0) {
815
835
  const canonicalIds = /* @__PURE__ */ new Set();
816
836
  for (const link of links) {
@@ -821,12 +841,15 @@ async function handleResolve(request) {
821
841
  return failure(`cannot resolve identity link: ${message}`, 409);
822
842
  }
823
843
  }
824
- if (canonicalIds.size !== 1) {
844
+ const verifiedOwners = new Set(
845
+ links.filter((link) => link.verified).map((link) => link.identityId)
846
+ );
847
+ if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
825
848
  return failure("identifier is linked to multiple identities", 409);
826
849
  }
827
- const canonicalId = [...canonicalIds][0];
828
- const hasVerifiedLink = links.some((link) => link.verified);
829
- if (provenance === "guest" && hasVerifiedLink) {
850
+ const [chosen] = links;
851
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
852
+ if (provenance === "guest" && chosen.verified) {
830
853
  return success({ identityId: null, reason: "verified_conflict" });
831
854
  }
832
855
  return success({ identityId: canonicalId, created: false });
@@ -928,6 +951,116 @@ async function handleAliases(request) {
928
951
  }
929
952
  return success({ aliases });
930
953
  }
954
+ function claimView(claim) {
955
+ const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
956
+ return {
957
+ claimId: claim.claimId,
958
+ flow: claim.flow,
959
+ state: state_,
960
+ refusalReason: claim.refusalReason,
961
+ completedIdentityId: claim.completedIdentityId,
962
+ expiresAt: claim.expiresAt
963
+ };
964
+ }
965
+ function validateClaimStaff(flow, staff) {
966
+ if (flow === "invite") {
967
+ if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
968
+ return { staffActorRef: null };
969
+ }
970
+ if (!staff || typeof staff !== "object") {
971
+ return { error: `staff is required for a ${flow} claim` };
972
+ }
973
+ const { actorRef, grant } = staff;
974
+ if (typeof actorRef !== "string" || actorRef.length === 0) {
975
+ return { error: "staff.actorRef is required" };
976
+ }
977
+ if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
978
+ return { staffActorRef: actorRef };
979
+ }
980
+ async function handleCreateClaim(request) {
981
+ const body = await readJsonBody(request);
982
+ const flow = body.flow;
983
+ if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
984
+ return failure("flow must be one of: invite, assist, transfer");
985
+ }
986
+ if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
987
+ return failure("identityId must be a uuid");
988
+ }
989
+ const normalized = normalizeLinkInput(
990
+ body.kind,
991
+ body.externalId,
992
+ CLAIM_KINDS,
993
+ "kind must be one of: email, phone"
994
+ );
995
+ if ("error" in normalized) return failure(normalized.error);
996
+ let returnTo = null;
997
+ if (body.returnTo !== void 0) {
998
+ if (typeof body.returnTo !== "string") return failure("returnTo must be a string");
999
+ let parsed;
1000
+ try {
1001
+ parsed = new URL(body.returnTo);
1002
+ } catch {
1003
+ return failure("returnTo must be an absolute URL");
1004
+ }
1005
+ returnTo = state.identityAllowedReturnOrigins.has(parsed.origin) ? body.returnTo : null;
1006
+ }
1007
+ if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1008
+ return failure("deliver must be a boolean");
1009
+ }
1010
+ const staff = validateClaimStaff(flow, body.staff);
1011
+ if ("error" in staff) return failure(staff.error);
1012
+ if (flow !== "invite" && !state.identityStaffAssistEnabled) {
1013
+ return failure(
1014
+ "staff-assisted profile claiming is not enabled for this project",
1015
+ 403,
1016
+ STAFF_ASSISTANCE_NOT_ENABLED
1017
+ );
1018
+ }
1019
+ const identity = state.identities.get(body.identityId);
1020
+ if (!identity || identity.type !== "person" || identity.status !== "active") {
1021
+ return failure("identity not found", 404);
1022
+ }
1023
+ const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1024
+ (link) => link.identityId === identity.id
1025
+ );
1026
+ if (!holdsContact) {
1027
+ return failure("identity does not hold that contact", 409);
1028
+ }
1029
+ const claimId = crypto3.randomUUID();
1030
+ const deliver = body.deliver === true;
1031
+ const handle = crypto3.randomBytes(32).toString("base64url");
1032
+ const claim = {
1033
+ claimId,
1034
+ flow,
1035
+ identityId: identity.id,
1036
+ kind: normalized.kind,
1037
+ externalId: normalized.externalId,
1038
+ returnTo,
1039
+ staffActorRef: staff.staffActorRef,
1040
+ state: "pending",
1041
+ refusalReason: null,
1042
+ completedIdentityId: null,
1043
+ claimantClerkUserId: null,
1044
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1045
+ delivered: deliver ? !state.identitySuppressClaimDelivery : null,
1046
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1047
+ createdAt: now()
1048
+ };
1049
+ state.identityClaims.set(claimId, claim);
1050
+ return success({
1051
+ claimId,
1052
+ claimUrl: claim.claimUrl,
1053
+ expiresAt: claim.expiresAt,
1054
+ // Absent unless asked for: an app delivering its own link must not read a
1055
+ // missing field as "nothing was sent".
1056
+ ...deliver ? { delivered: claim.delivered } : {}
1057
+ });
1058
+ }
1059
+ function handleGetClaim(claimId) {
1060
+ const claim = state.identityClaims.get(claimId);
1061
+ if (!claim) return failure("claim not found", 404);
1062
+ return success(claimView(claim));
1063
+ }
931
1064
  async function handleIdentitiesRequest(request, subPath) {
932
1065
  const method = request.method;
933
1066
  if (subPath === "" || subPath === "/") {
@@ -943,6 +1076,13 @@ async function handleIdentitiesRequest(request, subPath) {
943
1076
  if (subPath === "/search" && method === "GET") {
944
1077
  return handleSearch(request);
945
1078
  }
1079
+ if (subPath === "/claims" && method === "POST") {
1080
+ return handleCreateClaim(request);
1081
+ }
1082
+ const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
1083
+ if (claimMatch && method === "GET") {
1084
+ return handleGetClaim(decodeURIComponent(claimMatch[1]));
1085
+ }
946
1086
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
947
1087
  if (linksMatch && method === "POST") {
948
1088
  return handleAttachLink(linksMatch[1], request);
@@ -1976,6 +2116,77 @@ async function handleEdgeRequest(request, subPath) {
1976
2116
  return edgeFailure(`No edge simulator for ${method} .../edge${subPath}`, 404);
1977
2117
  }
1978
2118
 
2119
+ // src/simulator/starlens.ts
2120
+ function starlensFailure(error, status, code) {
2121
+ return json({ success: false, error, code }, status);
2122
+ }
2123
+ function nullFlagged(output, issues) {
2124
+ const copy = JSON.parse(JSON.stringify(output ?? null));
2125
+ for (const { path } of issues) {
2126
+ const keys = path.replace(/^output\./, "").replace(/\[(\d+)\]/g, ".$1").split(".");
2127
+ const last = keys.pop();
2128
+ const parent = keys.reduce(
2129
+ (o, k) => o != null && typeof o === "object" ? o[k] : void 0,
2130
+ copy
2131
+ );
2132
+ if (parent != null && typeof parent === "object" && Object.prototype.hasOwnProperty.call(parent, last)) {
2133
+ parent[last] = null;
2134
+ }
2135
+ }
2136
+ return copy;
2137
+ }
2138
+ async function handleStarlensExtract(request, signedDeploymentId) {
2139
+ const body = await readJsonBody(request);
2140
+ if (signedDeploymentId !== null && body.deploymentId !== signedDeploymentId) {
2141
+ return starlensFailure("Deployment ID mismatch", 401, "AUTHENTICATION_ERROR");
2142
+ }
2143
+ const inputs = Array.isArray(body.inputs) ? body.inputs : [];
2144
+ if (inputs.length === 0) {
2145
+ return starlensFailure(
2146
+ "Validation failed \u2014 inputs: at least one input",
2147
+ 400,
2148
+ "VALIDATION_ERROR"
2149
+ );
2150
+ }
2151
+ const schema = body.schema;
2152
+ if (!schema || schema.type !== "object" || typeof schema.properties !== "object") {
2153
+ return starlensFailure(
2154
+ "Validation failed \u2014 schema.properties is required",
2155
+ 400,
2156
+ "VALIDATION_ERROR"
2157
+ );
2158
+ }
2159
+ const call = {
2160
+ version: typeof body.version === "string" ? body.version : "latest",
2161
+ inputs: inputs.map((i) => ({
2162
+ name: typeof i.filename === "string" ? i.filename : typeof i.url === "string" ? i.url : "upload",
2163
+ mediaType: typeof i.mediaType === "string" ? i.mediaType : null,
2164
+ source: typeof i.url === "string" ? "url" : "data"
2165
+ })),
2166
+ schema,
2167
+ instructions: typeof body.instructions === "string" ? body.instructions : void 0
2168
+ };
2169
+ state.starlensCalls.push(call);
2170
+ const reply = state.starlensReplies.shift() ?? state.starlensDefaultReply;
2171
+ if (!reply) {
2172
+ return starlensFailure(
2173
+ "[stardeck-testing] No Starlens reply registered. Call app.starlens.mock({ output, issues }) or app.starlens.reject(reason) before the request.",
2174
+ 502,
2175
+ "model_failed"
2176
+ );
2177
+ }
2178
+ if ("reject" in reply) {
2179
+ return starlensFailure(reply.reject, 422, "unsupported_document");
2180
+ }
2181
+ const issues = reply.issues ?? [];
2182
+ return success({
2183
+ version: call.version === "latest" ? "v1" : call.version,
2184
+ output: nullFlagged(reply.output, issues),
2185
+ issues,
2186
+ usage: { inputTokens: 0, outputTokens: 0, cost: 0 }
2187
+ });
2188
+ }
2189
+
1979
2190
  // src/simulator/router.ts
1980
2191
  var fetchHolder = globalSingleton("fetch-holder", () => ({
1981
2192
  originalFetch: null
@@ -1997,7 +2208,8 @@ function requiresDeploymentHmac(request, url) {
1997
2208
  /^\/api\/deployments\/[^/]+\/integrations\/(slack|line|facebook)(\/.*)?$/
1998
2209
  );
1999
2210
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
2000
- return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
2211
+ const isStarlens = url.pathname === "/api/starlens/extract";
2212
+ return !!(isStarlens || dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
2001
2213
  }
2002
2214
  function deploymentIdFromPath(url) {
2003
2215
  const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
@@ -2027,6 +2239,7 @@ async function handleSimulatedRequest(request, url) {
2027
2239
  );
2028
2240
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
2029
2241
  const isStorageHost = url.hostname === STORAGE_TEST_HOST;
2242
+ let signedDeploymentId = null;
2030
2243
  if (requiresDeploymentHmac(request, url)) {
2031
2244
  const authHeader = request.headers.get("X-Stardeck-Auth");
2032
2245
  if (!authHeader) {
@@ -2038,6 +2251,7 @@ async function handleSimulatedRequest(request, url) {
2038
2251
  if (!authPayload || pathDeploymentId !== null && authPayload.deploymentId !== pathDeploymentId) {
2039
2252
  return failure("Invalid authentication", 401);
2040
2253
  }
2254
+ signedDeploymentId = authPayload.deploymentId;
2041
2255
  }
2042
2256
  if (isStorageHost) {
2043
2257
  return handleStorageRequest(request, url);
@@ -2058,6 +2272,9 @@ async function handleSimulatedRequest(request, url) {
2058
2272
  if (edgeMatch) {
2059
2273
  return handleEdgeRequest(request, edgeMatch[1] ?? "");
2060
2274
  }
2275
+ if (url.pathname === "/api/starlens/extract" && request.method === "POST") {
2276
+ return handleStarlensExtract(request, signedDeploymentId);
2277
+ }
2061
2278
  if (dataStoreMatch) {
2062
2279
  const subPath = dataStoreMatch[1] ?? "";
2063
2280
  const db = requireDb();
@@ -2079,7 +2296,7 @@ async function handleSimulatedRequest(request, url) {
2079
2296
  }
2080
2297
  }
2081
2298
  return failure(
2082
- `[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.`,
2299
+ `[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.`,
2083
2300
  404
2084
2301
  );
2085
2302
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/testing",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -66,11 +66,12 @@
66
66
  },
67
67
  "peerDependencies": {
68
68
  "@stardeck-customer-apps/edge-sdk": ">=1.4.0",
69
- "@stardeck-customer-apps/integrations-sdk": ">=1.13.0",
69
+ "@stardeck-customer-apps/integrations-sdk": ">=1.16.0",
70
70
  "@stardeck-customer-apps/payments-sdk": ">=0.1.0",
71
71
  "@stardeck-customer-apps/storage-sdk": ">=0.1.0",
72
72
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
73
- "vitest": ">=2.0.0"
73
+ "vitest": ">=2.0.0",
74
+ "@stardeck-customer-apps/starlens": ">=0.1.0"
74
75
  },
75
76
  "peerDependenciesMeta": {
76
77
  "@stardeck-customer-apps/integrations-sdk": {
@@ -87,6 +88,9 @@
87
88
  },
88
89
  "next": {
89
90
  "optional": true
91
+ },
92
+ "@stardeck-customer-apps/starlens": {
93
+ "optional": true
90
94
  }
91
95
  },
92
96
  "devDependencies": {
@@ -106,6 +110,8 @@
106
110
  "tsup": "^8.0.0",
107
111
  "typescript": "^5.0.0",
108
112
  "typescript-eslint": "^8.0.0",
109
- "vitest": "^3.2.4"
113
+ "vitest": "^3.2.4",
114
+ "@stardeck-customer-apps/starlens": "*",
115
+ "zod": "^4.3.5"
110
116
  }
111
117
  }