@stardeck-customer-apps/testing 0.15.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(),
@@ -82,22 +103,6 @@ function requireDb() {
82
103
  return state.db;
83
104
  }
84
105
 
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
106
  // src/simulator/hmac.ts
102
107
  var import_node_crypto = __toESM(require("crypto"));
103
108
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -148,8 +153,8 @@ function json(body, status = 200) {
148
153
  function success(data) {
149
154
  return json({ success: true, data });
150
155
  }
151
- function failure(error, status = 400) {
152
- return json({ success: false, error }, status);
156
+ function failure(error, status = 400, code) {
157
+ return json({ success: false, error, ...code ? { code } : {} }, status);
153
158
  }
154
159
  async function readJsonBody(request) {
155
160
  try {
@@ -626,6 +631,12 @@ async function handleEmailSend(request) {
626
631
  var import_node_crypto3 = __toESM(require("crypto"));
627
632
  var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
628
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";
629
640
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
630
641
  var SEARCH_DEFAULT_LIMIT = 50;
631
642
  var SEARCH_MAX_LIMIT = 100;
@@ -708,6 +719,11 @@ function matchingLinks(kind, externalId) {
708
719
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
709
720
  );
710
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
+ }
711
727
  function createPerson(displayName) {
712
728
  const createdAt = now();
713
729
  const identity = {
@@ -818,9 +834,10 @@ async function handleAttachLink(identityId, request) {
818
834
  );
819
835
  if ("error" in normalized) return failure(normalized.error);
820
836
  const { kind, externalId } = normalized;
821
- const [existing] = matchingLinks(kind, externalId);
822
- if (existing) {
823
- 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) {
824
841
  return failure("identifier already linked to another identity", 409);
825
842
  }
826
843
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -837,7 +854,7 @@ async function handleResolve(request) {
837
854
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
838
855
  return failure("displayName must be a string");
839
856
  }
840
- const links = matchingLinks(normalized.kind, normalized.externalId);
857
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
841
858
  if (links.length > 0) {
842
859
  const canonicalIds = /* @__PURE__ */ new Set();
843
860
  for (const link of links) {
@@ -848,12 +865,15 @@ async function handleResolve(request) {
848
865
  return failure(`cannot resolve identity link: ${message}`, 409);
849
866
  }
850
867
  }
851
- 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) {
852
872
  return failure("identifier is linked to multiple identities", 409);
853
873
  }
854
- const canonicalId = [...canonicalIds][0];
855
- const hasVerifiedLink = links.some((link) => link.verified);
856
- if (provenance === "guest" && hasVerifiedLink) {
874
+ const [chosen] = links;
875
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
876
+ if (provenance === "guest" && chosen.verified) {
857
877
  return success({ identityId: null, reason: "verified_conflict" });
858
878
  }
859
879
  return success({ identityId: canonicalId, created: false });
@@ -955,6 +975,116 @@ async function handleAliases(request) {
955
975
  }
956
976
  return success({ aliases });
957
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
+ }
958
1088
  async function handleIdentitiesRequest(request, subPath) {
959
1089
  const method = request.method;
960
1090
  if (subPath === "" || subPath === "/") {
@@ -970,6 +1100,13 @@ async function handleIdentitiesRequest(request, subPath) {
970
1100
  if (subPath === "/search" && method === "GET") {
971
1101
  return handleSearch(request);
972
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
+ }
973
1110
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
974
1111
  if (linksMatch && method === "POST") {
975
1112
  return handleAttachLink(linksMatch[1], request);
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(),
@@ -58,22 +79,6 @@ function requireDb() {
58
79
  return state.db;
59
80
  }
60
81
 
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
82
  // src/simulator/hmac.ts
78
83
  import crypto from "crypto";
79
84
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -124,8 +129,8 @@ function json(body, status = 200) {
124
129
  function success(data) {
125
130
  return json({ success: true, data });
126
131
  }
127
- function failure(error, status = 400) {
128
- return json({ success: false, error }, status);
132
+ function failure(error, status = 400, code) {
133
+ return json({ success: false, error, ...code ? { code } : {} }, status);
129
134
  }
130
135
  async function readJsonBody(request) {
131
136
  try {
@@ -602,6 +607,12 @@ async function handleEmailSend(request) {
602
607
  import crypto3 from "crypto";
603
608
  var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
604
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";
605
616
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
606
617
  var SEARCH_DEFAULT_LIMIT = 50;
607
618
  var SEARCH_MAX_LIMIT = 100;
@@ -684,6 +695,11 @@ function matchingLinks(kind, externalId) {
684
695
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
685
696
  );
686
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
+ }
687
703
  function createPerson(displayName) {
688
704
  const createdAt = now();
689
705
  const identity = {
@@ -794,9 +810,10 @@ async function handleAttachLink(identityId, request) {
794
810
  );
795
811
  if ("error" in normalized) return failure(normalized.error);
796
812
  const { kind, externalId } = normalized;
797
- const [existing] = matchingLinks(kind, externalId);
798
- if (existing) {
799
- 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) {
800
817
  return failure("identifier already linked to another identity", 409);
801
818
  }
802
819
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -813,7 +830,7 @@ async function handleResolve(request) {
813
830
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
814
831
  return failure("displayName must be a string");
815
832
  }
816
- const links = matchingLinks(normalized.kind, normalized.externalId);
833
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
817
834
  if (links.length > 0) {
818
835
  const canonicalIds = /* @__PURE__ */ new Set();
819
836
  for (const link of links) {
@@ -824,12 +841,15 @@ async function handleResolve(request) {
824
841
  return failure(`cannot resolve identity link: ${message}`, 409);
825
842
  }
826
843
  }
827
- 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) {
828
848
  return failure("identifier is linked to multiple identities", 409);
829
849
  }
830
- const canonicalId = [...canonicalIds][0];
831
- const hasVerifiedLink = links.some((link) => link.verified);
832
- if (provenance === "guest" && hasVerifiedLink) {
850
+ const [chosen] = links;
851
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
852
+ if (provenance === "guest" && chosen.verified) {
833
853
  return success({ identityId: null, reason: "verified_conflict" });
834
854
  }
835
855
  return success({ identityId: canonicalId, created: false });
@@ -931,6 +951,116 @@ async function handleAliases(request) {
931
951
  }
932
952
  return success({ aliases });
933
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
+ }
934
1064
  async function handleIdentitiesRequest(request, subPath) {
935
1065
  const method = request.method;
936
1066
  if (subPath === "" || subPath === "/") {
@@ -946,6 +1076,13 @@ async function handleIdentitiesRequest(request, subPath) {
946
1076
  if (subPath === "/search" && method === "GET") {
947
1077
  return handleSearch(request);
948
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
+ }
949
1086
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
950
1087
  if (linksMatch && method === "POST") {
951
1088
  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.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,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",