@stardeck-customer-apps/testing 0.15.0 → 0.17.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,11 @@ 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
+ identityBearerLinksEnabled: true,
40
+ identitySuppressClaimDelivery: false,
41
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
20
42
  checkouts: [],
21
43
  checkoutCounter: 0,
22
44
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -58,22 +80,6 @@ function requireDb() {
58
80
  return state.db;
59
81
  }
60
82
 
61
- // src/constants.ts
62
- var TEST_DOMAIN_SUFFIX = ".stardeck.test";
63
- var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
64
- var DATA_STORE_TEST_HOST = "db.stardeck.test";
65
- var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
66
- var STORAGE_TEST_URL = "https://storage.stardeck.test";
67
- var STORAGE_TEST_HOST = "storage.stardeck.test";
68
- var TEST_ENV_DEFAULTS = {
69
- CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
70
- DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
71
- ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
72
- PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
73
- DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
74
- STORAGE_URL: STORAGE_TEST_URL
75
- };
76
-
77
83
  // src/simulator/hmac.ts
78
84
  import crypto from "crypto";
79
85
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -124,8 +130,8 @@ function json(body, status = 200) {
124
130
  function success(data) {
125
131
  return json({ success: true, data });
126
132
  }
127
- function failure(error, status = 400) {
128
- return json({ success: false, error }, status);
133
+ function failure(error, status = 400, code) {
134
+ return json({ success: false, error, ...code ? { code } : {} }, status);
129
135
  }
130
136
  async function readJsonBody(request) {
131
137
  try {
@@ -602,9 +608,17 @@ async function handleEmailSend(request) {
602
608
  import crypto3 from "crypto";
603
609
  var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
604
610
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
611
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
612
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
613
+ var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
614
+ var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
615
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
616
+ var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
617
+ var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
605
618
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
606
619
  var SEARCH_DEFAULT_LIMIT = 50;
607
620
  var SEARCH_MAX_LIMIT = 100;
621
+ var TEAM_ACCOUNT_FIELDS_MESSAGE = "This person's name and email come from their team account. Change them in Members settings.";
608
622
  function asciiLowercaseTrim(value) {
609
623
  const trimmed = value.trim();
610
624
  let out = "";
@@ -684,6 +698,11 @@ function matchingLinks(kind, externalId) {
684
698
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
685
699
  );
686
700
  }
701
+ function sortedMatchingLinks(kind, externalId) {
702
+ return matchingLinks(kind, externalId).sort(
703
+ (a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
704
+ );
705
+ }
687
706
  function createPerson(displayName) {
688
707
  const createdAt = now();
689
708
  const identity = {
@@ -716,11 +735,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
716
735
  state.identityLinks.push(link);
717
736
  return link;
718
737
  }
738
+ function isTeamOnly(identityId) {
739
+ const kinds = linksFor(identityId).map((link) => link.kind);
740
+ return kinds.includes("dashboard_user") && !kinds.includes("project_auth_user");
741
+ }
742
+ function isCurrentTeamMember(identityId) {
743
+ return linksFor(identityId).some((link) => link.kind === "dashboard_user" && link.verified);
744
+ }
745
+ function hasRecordedContact(identityId) {
746
+ return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
747
+ }
719
748
  function handleList(request) {
720
749
  const typeParam = new URL(request.url).searchParams.get("type");
721
750
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
722
751
  const identities = [...state.identities.values()].filter(
723
- (identity) => identity.status !== "merged"
752
+ (identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
724
753
  );
725
754
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
726
755
  }
@@ -764,11 +793,18 @@ async function handleUpdate(identityId, request) {
764
793
  const identity = state.identities.get(identityId);
765
794
  if (!identity) return failure("identity not found", 404);
766
795
  const body = await readJsonBody(request);
796
+ const profile = body.profile;
797
+ if (isCurrentTeamMember(identityId)) {
798
+ const changesName = body.displayName !== void 0 && body.displayName !== identity.displayName;
799
+ const changesEmail = profile !== void 0 && Object.hasOwn(profile, "email") && profile.email !== identity.profile.email;
800
+ if (changesName || changesEmail) return failure(TEAM_ACCOUNT_FIELDS_MESSAGE, 409);
801
+ }
767
802
  if (body.displayName !== void 0) {
768
803
  identity.displayName = body.displayName;
769
804
  }
770
- if (body.profile !== void 0) {
771
- identity.profile = body.profile;
805
+ if (profile !== void 0) {
806
+ const keptEmail = isCurrentTeamMember(identityId) && !Object.hasOwn(profile, "email") && Object.hasOwn(identity.profile, "email");
807
+ identity.profile = keptEmail ? { ...profile, email: identity.profile.email } : profile;
772
808
  }
773
809
  if (body.externalRef !== void 0) {
774
810
  identity.externalRef = body.externalRef;
@@ -794,9 +830,10 @@ async function handleAttachLink(identityId, request) {
794
830
  );
795
831
  if ("error" in normalized) return failure(normalized.error);
796
832
  const { kind, externalId } = normalized;
797
- const [existing] = matchingLinks(kind, externalId);
798
- if (existing) {
799
- if (existing.identityId === identityId) return success({ link: existing });
833
+ const matches = matchingLinks(kind, externalId);
834
+ const ownRow = matches.find((link2) => link2.identityId === identityId);
835
+ if (ownRow) return success({ link: ownRow });
836
+ if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
800
837
  return failure("identifier already linked to another identity", 409);
801
838
  }
802
839
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -813,7 +850,7 @@ async function handleResolve(request) {
813
850
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
814
851
  return failure("displayName must be a string");
815
852
  }
816
- const links = matchingLinks(normalized.kind, normalized.externalId);
853
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
817
854
  if (links.length > 0) {
818
855
  const canonicalIds = /* @__PURE__ */ new Set();
819
856
  for (const link of links) {
@@ -824,12 +861,15 @@ async function handleResolve(request) {
824
861
  return failure(`cannot resolve identity link: ${message}`, 409);
825
862
  }
826
863
  }
827
- if (canonicalIds.size !== 1) {
864
+ const verifiedOwners = new Set(
865
+ links.filter((link) => link.verified).map((link) => link.identityId)
866
+ );
867
+ if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
828
868
  return failure("identifier is linked to multiple identities", 409);
829
869
  }
830
- const canonicalId = [...canonicalIds][0];
831
- const hasVerifiedLink = links.some((link) => link.verified);
832
- if (provenance === "guest" && hasVerifiedLink) {
870
+ const [chosen] = links;
871
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
872
+ if (provenance === "guest" && chosen.verified) {
833
873
  return success({ identityId: null, reason: "verified_conflict" });
834
874
  }
835
875
  return success({ identityId: canonicalId, created: false });
@@ -866,6 +906,36 @@ function identityMatchesQuery(identity, query) {
866
906
  (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
867
907
  );
868
908
  }
909
+ function parseSearchAttributes(raw) {
910
+ if (raw === null) return {};
911
+ let parsed;
912
+ try {
913
+ parsed = JSON.parse(raw);
914
+ } catch {
915
+ return "attributes: attributes must be a JSON object";
916
+ }
917
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
918
+ return "attributes: attributes must be a JSON object";
919
+ }
920
+ if (Object.hasOwn(parsed, "__proto__")) return "attributes: attribute keys may not be __proto__";
921
+ const entries = Object.entries(parsed);
922
+ if (entries.length > 10) return "attributes: attributes may filter on at most 10 keys";
923
+ for (const [key, value] of entries) {
924
+ if (!["string", "number", "boolean"].includes(typeof value)) {
925
+ return `attributes.${key}: attribute values must be a string, number or boolean`;
926
+ }
927
+ if (key.length < 1 || key.length > 100) {
928
+ return `attributes.${key}: attribute keys must be 1-100 characters`;
929
+ }
930
+ if (typeof value === "string" && value.length > 500) {
931
+ return `attributes.${key}: attribute values must be at most 500 characters`;
932
+ }
933
+ }
934
+ return parsed;
935
+ }
936
+ function identityMatchesAttributes(identity, attributes) {
937
+ return Object.entries(attributes).every(([key, value]) => identity.profile[key] === value);
938
+ }
869
939
  function handleSearch(request) {
870
940
  const url = new URL(request.url);
871
941
  const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
@@ -878,10 +948,14 @@ function handleSearch(request) {
878
948
  return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
879
949
  }
880
950
  }
951
+ const attributes = parseSearchAttributes(url.searchParams.get("attributes"));
952
+ if (typeof attributes === "string") {
953
+ return json({ success: false, error: "Invalid query parameters", details: attributes }, 400);
954
+ }
881
955
  const rawCursor = url.searchParams.get("cursor");
882
956
  const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
883
957
  if (rawCursor && !cursor) return failure("cursor is invalid");
884
- const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
958
+ const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => !isTeamOnly(identity.id)).filter((identity) => identityMatchesQuery(identity, query)).filter((identity) => identityMatchesAttributes(identity, attributes)).sort(compareIdentityOrder);
885
959
  const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
886
960
  const pageStart = start < 0 ? identities.length : start;
887
961
  const items = identities.slice(pageStart, pageStart + limit);
@@ -931,6 +1005,160 @@ async function handleAliases(request) {
931
1005
  }
932
1006
  return success({ aliases });
933
1007
  }
1008
+ function claimView(claim) {
1009
+ const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
1010
+ return {
1011
+ claimId: claim.claimId,
1012
+ flow: claim.flow,
1013
+ state: state_,
1014
+ refusalReason: claim.refusalReason,
1015
+ completedIdentityId: claim.completedIdentityId,
1016
+ expiresAt: claim.expiresAt
1017
+ };
1018
+ }
1019
+ function validateClaimStaff(flow, staff) {
1020
+ if (flow === "invite") {
1021
+ if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
1022
+ return { staffActorRef: null };
1023
+ }
1024
+ if (!staff || typeof staff !== "object") {
1025
+ return { error: `staff is required for a ${flow} claim` };
1026
+ }
1027
+ const { actorRef, grant } = staff;
1028
+ if (typeof actorRef !== "string" || actorRef.length === 0) {
1029
+ return { error: "staff.actorRef is required" };
1030
+ }
1031
+ if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1032
+ return { staffActorRef: actorRef };
1033
+ }
1034
+ function parseReturnTo(raw) {
1035
+ if (raw === void 0) return { returnTo: null };
1036
+ if (typeof raw !== "string") return { error: "returnTo must be a string" };
1037
+ let parsed;
1038
+ try {
1039
+ parsed = new URL(raw);
1040
+ } catch {
1041
+ return { error: "returnTo must be an absolute URL" };
1042
+ }
1043
+ return { returnTo: state.identityAllowedReturnOrigins.has(parsed.origin) ? raw : null };
1044
+ }
1045
+ function claimableIdentity(identityId) {
1046
+ const identity = state.identities.get(identityId);
1047
+ if (!identity || identity.type !== "person" || identity.status !== "active") return null;
1048
+ return identity;
1049
+ }
1050
+ function mintClaim(params) {
1051
+ const claimId = crypto3.randomUUID();
1052
+ const handle = crypto3.randomBytes(32).toString("base64url");
1053
+ const claim = {
1054
+ claimId,
1055
+ flow: params.flow,
1056
+ identityId: params.identityId,
1057
+ kind: params.kind,
1058
+ externalId: params.externalId,
1059
+ returnTo: params.returnTo,
1060
+ staffActorRef: params.staffActorRef,
1061
+ state: "pending",
1062
+ refusalReason: null,
1063
+ completedIdentityId: null,
1064
+ claimantClerkUserId: null,
1065
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1066
+ delivered: params.deliver ? !state.identitySuppressClaimDelivery : null,
1067
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1068
+ createdAt: now()
1069
+ };
1070
+ state.identityClaims.set(claimId, claim);
1071
+ return success({
1072
+ claimId,
1073
+ claimUrl: claim.claimUrl,
1074
+ expiresAt: claim.expiresAt,
1075
+ // Absent unless asked for: an app delivering its own link must not read a
1076
+ // missing field as "nothing was sent".
1077
+ ...params.deliver ? { delivered: claim.delivered } : {}
1078
+ });
1079
+ }
1080
+ function createBearerClaim(body, returnTo) {
1081
+ for (const field of ["kind", "externalId", "deliver", "staff"]) {
1082
+ if (body[field] !== void 0) {
1083
+ return failure(`a bearer claim takes no ${field}: it is raised against no contact`);
1084
+ }
1085
+ }
1086
+ if (!state.identityBearerLinksEnabled) {
1087
+ return failure(
1088
+ "profile invite links are not enabled for this project",
1089
+ 403,
1090
+ BEARER_LINKS_NOT_ENABLED
1091
+ );
1092
+ }
1093
+ const identity = claimableIdentity(body.identityId);
1094
+ if (!identity) return failure("identity not found", 404);
1095
+ if (hasRecordedContact(identity.id)) {
1096
+ return failure("identity has an email or phone on record; send an invite claim instead", 409);
1097
+ }
1098
+ return mintClaim({
1099
+ flow: "bearer",
1100
+ identityId: identity.id,
1101
+ kind: null,
1102
+ externalId: null,
1103
+ returnTo,
1104
+ staffActorRef: null,
1105
+ deliver: false
1106
+ });
1107
+ }
1108
+ async function handleCreateClaim(request) {
1109
+ const body = await readJsonBody(request);
1110
+ const flow = body.flow;
1111
+ if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1112
+ return failure("flow must be one of: invite, assist, transfer, bearer");
1113
+ }
1114
+ if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1115
+ return failure("identityId must be a uuid");
1116
+ }
1117
+ const parsedReturnTo = parseReturnTo(body.returnTo);
1118
+ if ("error" in parsedReturnTo) return failure(parsedReturnTo.error);
1119
+ if (flow === "bearer") return createBearerClaim(body, parsedReturnTo.returnTo);
1120
+ const normalized = normalizeLinkInput(
1121
+ body.kind,
1122
+ body.externalId,
1123
+ CLAIM_KINDS,
1124
+ "kind must be one of: email, phone"
1125
+ );
1126
+ if ("error" in normalized) return failure(normalized.error);
1127
+ if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1128
+ return failure("deliver must be a boolean");
1129
+ }
1130
+ const staff = validateClaimStaff(flow, body.staff);
1131
+ if ("error" in staff) return failure(staff.error);
1132
+ if (flow !== "invite" && !state.identityStaffAssistEnabled) {
1133
+ return failure(
1134
+ "staff-assisted profile claiming is not enabled for this project",
1135
+ 403,
1136
+ STAFF_ASSISTANCE_NOT_ENABLED
1137
+ );
1138
+ }
1139
+ const identity = claimableIdentity(body.identityId);
1140
+ if (!identity) return failure("identity not found", 404);
1141
+ const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1142
+ (link) => link.identityId === identity.id
1143
+ );
1144
+ if (!holdsContact) {
1145
+ return failure("identity does not hold that contact", 409);
1146
+ }
1147
+ return mintClaim({
1148
+ flow,
1149
+ identityId: identity.id,
1150
+ kind: normalized.kind,
1151
+ externalId: normalized.externalId,
1152
+ returnTo: parsedReturnTo.returnTo,
1153
+ staffActorRef: staff.staffActorRef,
1154
+ deliver: body.deliver === true
1155
+ });
1156
+ }
1157
+ function handleGetClaim(claimId) {
1158
+ const claim = state.identityClaims.get(claimId);
1159
+ if (!claim) return failure("claim not found", 404);
1160
+ return success(claimView(claim));
1161
+ }
934
1162
  async function handleIdentitiesRequest(request, subPath) {
935
1163
  const method = request.method;
936
1164
  if (subPath === "" || subPath === "/") {
@@ -946,6 +1174,13 @@ async function handleIdentitiesRequest(request, subPath) {
946
1174
  if (subPath === "/search" && method === "GET") {
947
1175
  return handleSearch(request);
948
1176
  }
1177
+ if (subPath === "/claims" && method === "POST") {
1178
+ return handleCreateClaim(request);
1179
+ }
1180
+ const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
1181
+ if (claimMatch && method === "GET") {
1182
+ return handleGetClaim(decodeURIComponent(claimMatch[1]));
1183
+ }
949
1184
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
950
1185
  if (linksMatch && method === "POST") {
951
1186
  return handleAttachLink(linksMatch[1], request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/testing",
3
- "version": "0.15.0",
3
+ "version": "0.17.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,7 +66,7 @@
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",