@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.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,11 @@ 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
+ identityBearerLinksEnabled: true,
64
+ identitySuppressClaimDelivery: false,
65
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
44
66
  checkouts: [],
45
67
  checkoutCounter: 0,
46
68
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -82,22 +104,6 @@ function requireDb() {
82
104
  return state.db;
83
105
  }
84
106
 
85
- // src/constants.ts
86
- var TEST_DOMAIN_SUFFIX = ".stardeck.test";
87
- var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
88
- var DATA_STORE_TEST_HOST = "db.stardeck.test";
89
- var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
90
- var STORAGE_TEST_URL = "https://storage.stardeck.test";
91
- var STORAGE_TEST_HOST = "storage.stardeck.test";
92
- var TEST_ENV_DEFAULTS = {
93
- CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
94
- DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
95
- ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
96
- PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
97
- DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
98
- STORAGE_URL: STORAGE_TEST_URL
99
- };
100
-
101
107
  // src/simulator/hmac.ts
102
108
  var import_node_crypto = __toESM(require("crypto"));
103
109
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -148,8 +154,8 @@ function json(body, status = 200) {
148
154
  function success(data) {
149
155
  return json({ success: true, data });
150
156
  }
151
- function failure(error, status = 400) {
152
- return json({ success: false, error }, status);
157
+ function failure(error, status = 400, code) {
158
+ return json({ success: false, error, ...code ? { code } : {} }, status);
153
159
  }
154
160
  async function readJsonBody(request) {
155
161
  try {
@@ -626,9 +632,17 @@ async function handleEmailSend(request) {
626
632
  var import_node_crypto3 = __toESM(require("crypto"));
627
633
  var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
628
634
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
635
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
636
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
637
+ var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
638
+ var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
639
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
640
+ var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
641
+ var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
629
642
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
630
643
  var SEARCH_DEFAULT_LIMIT = 50;
631
644
  var SEARCH_MAX_LIMIT = 100;
645
+ var TEAM_ACCOUNT_FIELDS_MESSAGE = "This person's name and email come from their team account. Change them in Members settings.";
632
646
  function asciiLowercaseTrim(value) {
633
647
  const trimmed = value.trim();
634
648
  let out = "";
@@ -708,6 +722,11 @@ function matchingLinks(kind, externalId) {
708
722
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
709
723
  );
710
724
  }
725
+ function sortedMatchingLinks(kind, externalId) {
726
+ return matchingLinks(kind, externalId).sort(
727
+ (a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
728
+ );
729
+ }
711
730
  function createPerson(displayName) {
712
731
  const createdAt = now();
713
732
  const identity = {
@@ -740,11 +759,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
740
759
  state.identityLinks.push(link);
741
760
  return link;
742
761
  }
762
+ function isTeamOnly(identityId) {
763
+ const kinds = linksFor(identityId).map((link) => link.kind);
764
+ return kinds.includes("dashboard_user") && !kinds.includes("project_auth_user");
765
+ }
766
+ function isCurrentTeamMember(identityId) {
767
+ return linksFor(identityId).some((link) => link.kind === "dashboard_user" && link.verified);
768
+ }
769
+ function hasRecordedContact(identityId) {
770
+ return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
771
+ }
743
772
  function handleList(request) {
744
773
  const typeParam = new URL(request.url).searchParams.get("type");
745
774
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
746
775
  const identities = [...state.identities.values()].filter(
747
- (identity) => identity.status !== "merged"
776
+ (identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
748
777
  );
749
778
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
750
779
  }
@@ -788,11 +817,18 @@ async function handleUpdate(identityId, request) {
788
817
  const identity = state.identities.get(identityId);
789
818
  if (!identity) return failure("identity not found", 404);
790
819
  const body = await readJsonBody(request);
820
+ const profile = body.profile;
821
+ if (isCurrentTeamMember(identityId)) {
822
+ const changesName = body.displayName !== void 0 && body.displayName !== identity.displayName;
823
+ const changesEmail = profile !== void 0 && Object.hasOwn(profile, "email") && profile.email !== identity.profile.email;
824
+ if (changesName || changesEmail) return failure(TEAM_ACCOUNT_FIELDS_MESSAGE, 409);
825
+ }
791
826
  if (body.displayName !== void 0) {
792
827
  identity.displayName = body.displayName;
793
828
  }
794
- if (body.profile !== void 0) {
795
- identity.profile = body.profile;
829
+ if (profile !== void 0) {
830
+ const keptEmail = isCurrentTeamMember(identityId) && !Object.hasOwn(profile, "email") && Object.hasOwn(identity.profile, "email");
831
+ identity.profile = keptEmail ? { ...profile, email: identity.profile.email } : profile;
796
832
  }
797
833
  if (body.externalRef !== void 0) {
798
834
  identity.externalRef = body.externalRef;
@@ -818,9 +854,10 @@ async function handleAttachLink(identityId, request) {
818
854
  );
819
855
  if ("error" in normalized) return failure(normalized.error);
820
856
  const { kind, externalId } = normalized;
821
- const [existing] = matchingLinks(kind, externalId);
822
- if (existing) {
823
- if (existing.identityId === identityId) return success({ link: existing });
857
+ const matches = matchingLinks(kind, externalId);
858
+ const ownRow = matches.find((link2) => link2.identityId === identityId);
859
+ if (ownRow) return success({ link: ownRow });
860
+ if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
824
861
  return failure("identifier already linked to another identity", 409);
825
862
  }
826
863
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -837,7 +874,7 @@ async function handleResolve(request) {
837
874
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
838
875
  return failure("displayName must be a string");
839
876
  }
840
- const links = matchingLinks(normalized.kind, normalized.externalId);
877
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
841
878
  if (links.length > 0) {
842
879
  const canonicalIds = /* @__PURE__ */ new Set();
843
880
  for (const link of links) {
@@ -848,12 +885,15 @@ async function handleResolve(request) {
848
885
  return failure(`cannot resolve identity link: ${message}`, 409);
849
886
  }
850
887
  }
851
- if (canonicalIds.size !== 1) {
888
+ const verifiedOwners = new Set(
889
+ links.filter((link) => link.verified).map((link) => link.identityId)
890
+ );
891
+ if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
852
892
  return failure("identifier is linked to multiple identities", 409);
853
893
  }
854
- const canonicalId = [...canonicalIds][0];
855
- const hasVerifiedLink = links.some((link) => link.verified);
856
- if (provenance === "guest" && hasVerifiedLink) {
894
+ const [chosen] = links;
895
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
896
+ if (provenance === "guest" && chosen.verified) {
857
897
  return success({ identityId: null, reason: "verified_conflict" });
858
898
  }
859
899
  return success({ identityId: canonicalId, created: false });
@@ -890,6 +930,36 @@ function identityMatchesQuery(identity, query) {
890
930
  (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
891
931
  );
892
932
  }
933
+ function parseSearchAttributes(raw) {
934
+ if (raw === null) return {};
935
+ let parsed;
936
+ try {
937
+ parsed = JSON.parse(raw);
938
+ } catch {
939
+ return "attributes: attributes must be a JSON object";
940
+ }
941
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
942
+ return "attributes: attributes must be a JSON object";
943
+ }
944
+ if (Object.hasOwn(parsed, "__proto__")) return "attributes: attribute keys may not be __proto__";
945
+ const entries = Object.entries(parsed);
946
+ if (entries.length > 10) return "attributes: attributes may filter on at most 10 keys";
947
+ for (const [key, value] of entries) {
948
+ if (!["string", "number", "boolean"].includes(typeof value)) {
949
+ return `attributes.${key}: attribute values must be a string, number or boolean`;
950
+ }
951
+ if (key.length < 1 || key.length > 100) {
952
+ return `attributes.${key}: attribute keys must be 1-100 characters`;
953
+ }
954
+ if (typeof value === "string" && value.length > 500) {
955
+ return `attributes.${key}: attribute values must be at most 500 characters`;
956
+ }
957
+ }
958
+ return parsed;
959
+ }
960
+ function identityMatchesAttributes(identity, attributes) {
961
+ return Object.entries(attributes).every(([key, value]) => identity.profile[key] === value);
962
+ }
893
963
  function handleSearch(request) {
894
964
  const url = new URL(request.url);
895
965
  const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
@@ -902,10 +972,14 @@ function handleSearch(request) {
902
972
  return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
903
973
  }
904
974
  }
975
+ const attributes = parseSearchAttributes(url.searchParams.get("attributes"));
976
+ if (typeof attributes === "string") {
977
+ return json({ success: false, error: "Invalid query parameters", details: attributes }, 400);
978
+ }
905
979
  const rawCursor = url.searchParams.get("cursor");
906
980
  const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
907
981
  if (rawCursor && !cursor) return failure("cursor is invalid");
908
- const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
982
+ 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);
909
983
  const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
910
984
  const pageStart = start < 0 ? identities.length : start;
911
985
  const items = identities.slice(pageStart, pageStart + limit);
@@ -955,6 +1029,160 @@ async function handleAliases(request) {
955
1029
  }
956
1030
  return success({ aliases });
957
1031
  }
1032
+ function claimView(claim) {
1033
+ const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
1034
+ return {
1035
+ claimId: claim.claimId,
1036
+ flow: claim.flow,
1037
+ state: state_,
1038
+ refusalReason: claim.refusalReason,
1039
+ completedIdentityId: claim.completedIdentityId,
1040
+ expiresAt: claim.expiresAt
1041
+ };
1042
+ }
1043
+ function validateClaimStaff(flow, staff) {
1044
+ if (flow === "invite") {
1045
+ if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
1046
+ return { staffActorRef: null };
1047
+ }
1048
+ if (!staff || typeof staff !== "object") {
1049
+ return { error: `staff is required for a ${flow} claim` };
1050
+ }
1051
+ const { actorRef, grant } = staff;
1052
+ if (typeof actorRef !== "string" || actorRef.length === 0) {
1053
+ return { error: "staff.actorRef is required" };
1054
+ }
1055
+ if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1056
+ return { staffActorRef: actorRef };
1057
+ }
1058
+ function parseReturnTo(raw) {
1059
+ if (raw === void 0) return { returnTo: null };
1060
+ if (typeof raw !== "string") return { error: "returnTo must be a string" };
1061
+ let parsed;
1062
+ try {
1063
+ parsed = new URL(raw);
1064
+ } catch {
1065
+ return { error: "returnTo must be an absolute URL" };
1066
+ }
1067
+ return { returnTo: state.identityAllowedReturnOrigins.has(parsed.origin) ? raw : null };
1068
+ }
1069
+ function claimableIdentity(identityId) {
1070
+ const identity = state.identities.get(identityId);
1071
+ if (!identity || identity.type !== "person" || identity.status !== "active") return null;
1072
+ return identity;
1073
+ }
1074
+ function mintClaim(params) {
1075
+ const claimId = import_node_crypto3.default.randomUUID();
1076
+ const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
1077
+ const claim = {
1078
+ claimId,
1079
+ flow: params.flow,
1080
+ identityId: params.identityId,
1081
+ kind: params.kind,
1082
+ externalId: params.externalId,
1083
+ returnTo: params.returnTo,
1084
+ staffActorRef: params.staffActorRef,
1085
+ state: "pending",
1086
+ refusalReason: null,
1087
+ completedIdentityId: null,
1088
+ claimantClerkUserId: null,
1089
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1090
+ delivered: params.deliver ? !state.identitySuppressClaimDelivery : null,
1091
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1092
+ createdAt: now()
1093
+ };
1094
+ state.identityClaims.set(claimId, claim);
1095
+ return success({
1096
+ claimId,
1097
+ claimUrl: claim.claimUrl,
1098
+ expiresAt: claim.expiresAt,
1099
+ // Absent unless asked for: an app delivering its own link must not read a
1100
+ // missing field as "nothing was sent".
1101
+ ...params.deliver ? { delivered: claim.delivered } : {}
1102
+ });
1103
+ }
1104
+ function createBearerClaim(body, returnTo) {
1105
+ for (const field of ["kind", "externalId", "deliver", "staff"]) {
1106
+ if (body[field] !== void 0) {
1107
+ return failure(`a bearer claim takes no ${field}: it is raised against no contact`);
1108
+ }
1109
+ }
1110
+ if (!state.identityBearerLinksEnabled) {
1111
+ return failure(
1112
+ "profile invite links are not enabled for this project",
1113
+ 403,
1114
+ BEARER_LINKS_NOT_ENABLED
1115
+ );
1116
+ }
1117
+ const identity = claimableIdentity(body.identityId);
1118
+ if (!identity) return failure("identity not found", 404);
1119
+ if (hasRecordedContact(identity.id)) {
1120
+ return failure("identity has an email or phone on record; send an invite claim instead", 409);
1121
+ }
1122
+ return mintClaim({
1123
+ flow: "bearer",
1124
+ identityId: identity.id,
1125
+ kind: null,
1126
+ externalId: null,
1127
+ returnTo,
1128
+ staffActorRef: null,
1129
+ deliver: false
1130
+ });
1131
+ }
1132
+ async function handleCreateClaim(request) {
1133
+ const body = await readJsonBody(request);
1134
+ const flow = body.flow;
1135
+ if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1136
+ return failure("flow must be one of: invite, assist, transfer, bearer");
1137
+ }
1138
+ if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1139
+ return failure("identityId must be a uuid");
1140
+ }
1141
+ const parsedReturnTo = parseReturnTo(body.returnTo);
1142
+ if ("error" in parsedReturnTo) return failure(parsedReturnTo.error);
1143
+ if (flow === "bearer") return createBearerClaim(body, parsedReturnTo.returnTo);
1144
+ const normalized = normalizeLinkInput(
1145
+ body.kind,
1146
+ body.externalId,
1147
+ CLAIM_KINDS,
1148
+ "kind must be one of: email, phone"
1149
+ );
1150
+ if ("error" in normalized) return failure(normalized.error);
1151
+ if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1152
+ return failure("deliver must be a boolean");
1153
+ }
1154
+ const staff = validateClaimStaff(flow, body.staff);
1155
+ if ("error" in staff) return failure(staff.error);
1156
+ if (flow !== "invite" && !state.identityStaffAssistEnabled) {
1157
+ return failure(
1158
+ "staff-assisted profile claiming is not enabled for this project",
1159
+ 403,
1160
+ STAFF_ASSISTANCE_NOT_ENABLED
1161
+ );
1162
+ }
1163
+ const identity = claimableIdentity(body.identityId);
1164
+ if (!identity) return failure("identity not found", 404);
1165
+ const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1166
+ (link) => link.identityId === identity.id
1167
+ );
1168
+ if (!holdsContact) {
1169
+ return failure("identity does not hold that contact", 409);
1170
+ }
1171
+ return mintClaim({
1172
+ flow,
1173
+ identityId: identity.id,
1174
+ kind: normalized.kind,
1175
+ externalId: normalized.externalId,
1176
+ returnTo: parsedReturnTo.returnTo,
1177
+ staffActorRef: staff.staffActorRef,
1178
+ deliver: body.deliver === true
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
+ }
958
1186
  async function handleIdentitiesRequest(request, subPath) {
959
1187
  const method = request.method;
960
1188
  if (subPath === "" || subPath === "/") {
@@ -970,6 +1198,13 @@ async function handleIdentitiesRequest(request, subPath) {
970
1198
  if (subPath === "/search" && method === "GET") {
971
1199
  return handleSearch(request);
972
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
+ }
973
1208
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
974
1209
  if (linksMatch && method === "POST") {
975
1210
  return handleAttachLink(linksMatch[1], request);