@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/index.js CHANGED
@@ -106,6 +106,31 @@ function globalSingleton(key, create) {
106
106
  return holder[symbol];
107
107
  }
108
108
 
109
+ // src/constants.ts
110
+ var TEST_DOMAIN_SUFFIX = ".stardeck.test";
111
+ var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
112
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
113
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
114
+ var STORAGE_TEST_URL = "https://storage.stardeck.test";
115
+ var STORAGE_TEST_HOST = "storage.stardeck.test";
116
+ var APP_TEST_ORIGIN = "http://localhost:3333";
117
+ var TEST_ENV_DEFAULTS = {
118
+ CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
119
+ DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
120
+ ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
121
+ PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
122
+ DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
123
+ STORAGE_URL: STORAGE_TEST_URL
124
+ };
125
+ var DEFAULT_TEST_USER = {
126
+ id: "test-user-1",
127
+ email: "test-user@example.com",
128
+ name: "Test User",
129
+ role: "member",
130
+ permissions: []
131
+ };
132
+ var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
133
+
109
134
  // src/state.ts
110
135
  var state = globalSingleton("state", () => ({
111
136
  db: null,
@@ -117,6 +142,11 @@ var state = globalSingleton("state", () => ({
117
142
  emailCounter: 0,
118
143
  identities: /* @__PURE__ */ new Map(),
119
144
  identityLinks: [],
145
+ identityClaims: /* @__PURE__ */ new Map(),
146
+ identityStaffAssistEnabled: false,
147
+ identityBearerLinksEnabled: true,
148
+ identitySuppressClaimDelivery: false,
149
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
120
150
  checkouts: [],
121
151
  checkoutCounter: 0,
122
152
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -158,30 +188,6 @@ function requireDb() {
158
188
  return state.db;
159
189
  }
160
190
 
161
- // src/constants.ts
162
- var TEST_DOMAIN_SUFFIX = ".stardeck.test";
163
- var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
164
- var DATA_STORE_TEST_HOST = "db.stardeck.test";
165
- var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
166
- var STORAGE_TEST_URL = "https://storage.stardeck.test";
167
- var STORAGE_TEST_HOST = "storage.stardeck.test";
168
- var TEST_ENV_DEFAULTS = {
169
- CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
170
- DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
171
- ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
172
- PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
173
- DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
174
- STORAGE_URL: STORAGE_TEST_URL
175
- };
176
- var DEFAULT_TEST_USER = {
177
- id: "test-user-1",
178
- email: "test-user@example.com",
179
- name: "Test User",
180
- role: "member",
181
- permissions: []
182
- };
183
- var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
184
-
185
191
  // src/simulator/hmac.ts
186
192
  var import_node_crypto = __toESM(require("crypto"));
187
193
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -232,8 +238,8 @@ function json(body, status = 200) {
232
238
  function success(data) {
233
239
  return json({ success: true, data });
234
240
  }
235
- function failure(error, status = 400) {
236
- return json({ success: false, error }, status);
241
+ function failure(error, status = 400, code) {
242
+ return json({ success: false, error, ...code ? { code } : {} }, status);
237
243
  }
238
244
  async function readJsonBody(request) {
239
245
  try {
@@ -721,9 +727,17 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
721
727
  "dashboard_user"
722
728
  ]);
723
729
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
730
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
731
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
732
+ var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
733
+ var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
734
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
735
+ var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
736
+ var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
724
737
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
725
738
  var SEARCH_DEFAULT_LIMIT = 50;
726
739
  var SEARCH_MAX_LIMIT = 100;
740
+ var TEAM_ACCOUNT_FIELDS_MESSAGE = "This person's name and email come from their team account. Change them in Members settings.";
727
741
  function asciiLowercaseTrim(value) {
728
742
  const trimmed = value.trim();
729
743
  let out = "";
@@ -803,6 +817,11 @@ function matchingLinks(kind, externalId) {
803
817
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
804
818
  );
805
819
  }
820
+ function sortedMatchingLinks(kind, externalId) {
821
+ return matchingLinks(kind, externalId).sort(
822
+ (a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
823
+ );
824
+ }
806
825
  function normalizedLinkKey(link) {
807
826
  return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
808
827
  }
@@ -838,11 +857,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
838
857
  state.identityLinks.push(link);
839
858
  return link;
840
859
  }
860
+ function isTeamOnly(identityId) {
861
+ const kinds = linksFor(identityId).map((link) => link.kind);
862
+ return kinds.includes("dashboard_user") && !kinds.includes("project_auth_user");
863
+ }
864
+ function isCurrentTeamMember(identityId) {
865
+ return linksFor(identityId).some((link) => link.kind === "dashboard_user" && link.verified);
866
+ }
867
+ function hasRecordedContact(identityId) {
868
+ return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
869
+ }
841
870
  function handleList(request) {
842
871
  const typeParam = new URL(request.url).searchParams.get("type");
843
872
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
844
873
  const identities = [...state.identities.values()].filter(
845
- (identity) => identity.status !== "merged"
874
+ (identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
846
875
  );
847
876
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
848
877
  }
@@ -886,11 +915,18 @@ async function handleUpdate(identityId, request) {
886
915
  const identity = state.identities.get(identityId);
887
916
  if (!identity) return failure("identity not found", 404);
888
917
  const body = await readJsonBody(request);
918
+ const profile = body.profile;
919
+ if (isCurrentTeamMember(identityId)) {
920
+ const changesName = body.displayName !== void 0 && body.displayName !== identity.displayName;
921
+ const changesEmail = profile !== void 0 && Object.hasOwn(profile, "email") && profile.email !== identity.profile.email;
922
+ if (changesName || changesEmail) return failure(TEAM_ACCOUNT_FIELDS_MESSAGE, 409);
923
+ }
889
924
  if (body.displayName !== void 0) {
890
925
  identity.displayName = body.displayName;
891
926
  }
892
- if (body.profile !== void 0) {
893
- identity.profile = body.profile;
927
+ if (profile !== void 0) {
928
+ const keptEmail = isCurrentTeamMember(identityId) && !Object.hasOwn(profile, "email") && Object.hasOwn(identity.profile, "email");
929
+ identity.profile = keptEmail ? { ...profile, email: identity.profile.email } : profile;
894
930
  }
895
931
  if (body.externalRef !== void 0) {
896
932
  identity.externalRef = body.externalRef;
@@ -916,9 +952,10 @@ async function handleAttachLink(identityId, request) {
916
952
  );
917
953
  if ("error" in normalized) return failure(normalized.error);
918
954
  const { kind, externalId } = normalized;
919
- const [existing] = matchingLinks(kind, externalId);
920
- if (existing) {
921
- if (existing.identityId === identityId) return success({ link: existing });
955
+ const matches = matchingLinks(kind, externalId);
956
+ const ownRow = matches.find((link2) => link2.identityId === identityId);
957
+ if (ownRow) return success({ link: ownRow });
958
+ if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
922
959
  return failure("identifier already linked to another identity", 409);
923
960
  }
924
961
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -935,7 +972,7 @@ async function handleResolve(request) {
935
972
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
936
973
  return failure("displayName must be a string");
937
974
  }
938
- const links = matchingLinks(normalized.kind, normalized.externalId);
975
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
939
976
  if (links.length > 0) {
940
977
  const canonicalIds = /* @__PURE__ */ new Set();
941
978
  for (const link of links) {
@@ -946,12 +983,15 @@ async function handleResolve(request) {
946
983
  return failure(`cannot resolve identity link: ${message}`, 409);
947
984
  }
948
985
  }
949
- if (canonicalIds.size !== 1) {
986
+ const verifiedOwners = new Set(
987
+ links.filter((link) => link.verified).map((link) => link.identityId)
988
+ );
989
+ if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
950
990
  return failure("identifier is linked to multiple identities", 409);
951
991
  }
952
- const canonicalId = [...canonicalIds][0];
953
- const hasVerifiedLink = links.some((link) => link.verified);
954
- if (provenance === "guest" && hasVerifiedLink) {
992
+ const [chosen] = links;
993
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
994
+ if (provenance === "guest" && chosen.verified) {
955
995
  return success({ identityId: null, reason: "verified_conflict" });
956
996
  }
957
997
  return success({ identityId: canonicalId, created: false });
@@ -988,6 +1028,36 @@ function identityMatchesQuery(identity, query) {
988
1028
  (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
989
1029
  );
990
1030
  }
1031
+ function parseSearchAttributes(raw) {
1032
+ if (raw === null) return {};
1033
+ let parsed;
1034
+ try {
1035
+ parsed = JSON.parse(raw);
1036
+ } catch {
1037
+ return "attributes: attributes must be a JSON object";
1038
+ }
1039
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1040
+ return "attributes: attributes must be a JSON object";
1041
+ }
1042
+ if (Object.hasOwn(parsed, "__proto__")) return "attributes: attribute keys may not be __proto__";
1043
+ const entries = Object.entries(parsed);
1044
+ if (entries.length > 10) return "attributes: attributes may filter on at most 10 keys";
1045
+ for (const [key, value] of entries) {
1046
+ if (!["string", "number", "boolean"].includes(typeof value)) {
1047
+ return `attributes.${key}: attribute values must be a string, number or boolean`;
1048
+ }
1049
+ if (key.length < 1 || key.length > 100) {
1050
+ return `attributes.${key}: attribute keys must be 1-100 characters`;
1051
+ }
1052
+ if (typeof value === "string" && value.length > 500) {
1053
+ return `attributes.${key}: attribute values must be at most 500 characters`;
1054
+ }
1055
+ }
1056
+ return parsed;
1057
+ }
1058
+ function identityMatchesAttributes(identity, attributes) {
1059
+ return Object.entries(attributes).every(([key, value]) => identity.profile[key] === value);
1060
+ }
991
1061
  function handleSearch(request) {
992
1062
  const url = new URL(request.url);
993
1063
  const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
@@ -1000,10 +1070,14 @@ function handleSearch(request) {
1000
1070
  return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
1001
1071
  }
1002
1072
  }
1073
+ const attributes = parseSearchAttributes(url.searchParams.get("attributes"));
1074
+ if (typeof attributes === "string") {
1075
+ return json({ success: false, error: "Invalid query parameters", details: attributes }, 400);
1076
+ }
1003
1077
  const rawCursor = url.searchParams.get("cursor");
1004
1078
  const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
1005
1079
  if (rawCursor && !cursor) return failure("cursor is invalid");
1006
- const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
1080
+ 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);
1007
1081
  const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
1008
1082
  const pageStart = start < 0 ? identities.length : start;
1009
1083
  const items = identities.slice(pageStart, pageStart + limit);
@@ -1053,6 +1127,160 @@ async function handleAliases(request) {
1053
1127
  }
1054
1128
  return success({ aliases });
1055
1129
  }
1130
+ function claimView(claim) {
1131
+ const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
1132
+ return {
1133
+ claimId: claim.claimId,
1134
+ flow: claim.flow,
1135
+ state: state_,
1136
+ refusalReason: claim.refusalReason,
1137
+ completedIdentityId: claim.completedIdentityId,
1138
+ expiresAt: claim.expiresAt
1139
+ };
1140
+ }
1141
+ function validateClaimStaff(flow, staff) {
1142
+ if (flow === "invite") {
1143
+ if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
1144
+ return { staffActorRef: null };
1145
+ }
1146
+ if (!staff || typeof staff !== "object") {
1147
+ return { error: `staff is required for a ${flow} claim` };
1148
+ }
1149
+ const { actorRef, grant } = staff;
1150
+ if (typeof actorRef !== "string" || actorRef.length === 0) {
1151
+ return { error: "staff.actorRef is required" };
1152
+ }
1153
+ if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1154
+ return { staffActorRef: actorRef };
1155
+ }
1156
+ function parseReturnTo(raw) {
1157
+ if (raw === void 0) return { returnTo: null };
1158
+ if (typeof raw !== "string") return { error: "returnTo must be a string" };
1159
+ let parsed;
1160
+ try {
1161
+ parsed = new URL(raw);
1162
+ } catch {
1163
+ return { error: "returnTo must be an absolute URL" };
1164
+ }
1165
+ return { returnTo: state.identityAllowedReturnOrigins.has(parsed.origin) ? raw : null };
1166
+ }
1167
+ function claimableIdentity(identityId) {
1168
+ const identity = state.identities.get(identityId);
1169
+ if (!identity || identity.type !== "person" || identity.status !== "active") return null;
1170
+ return identity;
1171
+ }
1172
+ function mintClaim(params) {
1173
+ const claimId = import_node_crypto3.default.randomUUID();
1174
+ const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
1175
+ const claim = {
1176
+ claimId,
1177
+ flow: params.flow,
1178
+ identityId: params.identityId,
1179
+ kind: params.kind,
1180
+ externalId: params.externalId,
1181
+ returnTo: params.returnTo,
1182
+ staffActorRef: params.staffActorRef,
1183
+ state: "pending",
1184
+ refusalReason: null,
1185
+ completedIdentityId: null,
1186
+ claimantClerkUserId: null,
1187
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1188
+ delivered: params.deliver ? !state.identitySuppressClaimDelivery : null,
1189
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1190
+ createdAt: now()
1191
+ };
1192
+ state.identityClaims.set(claimId, claim);
1193
+ return success({
1194
+ claimId,
1195
+ claimUrl: claim.claimUrl,
1196
+ expiresAt: claim.expiresAt,
1197
+ // Absent unless asked for: an app delivering its own link must not read a
1198
+ // missing field as "nothing was sent".
1199
+ ...params.deliver ? { delivered: claim.delivered } : {}
1200
+ });
1201
+ }
1202
+ function createBearerClaim(body, returnTo) {
1203
+ for (const field of ["kind", "externalId", "deliver", "staff"]) {
1204
+ if (body[field] !== void 0) {
1205
+ return failure(`a bearer claim takes no ${field}: it is raised against no contact`);
1206
+ }
1207
+ }
1208
+ if (!state.identityBearerLinksEnabled) {
1209
+ return failure(
1210
+ "profile invite links are not enabled for this project",
1211
+ 403,
1212
+ BEARER_LINKS_NOT_ENABLED
1213
+ );
1214
+ }
1215
+ const identity = claimableIdentity(body.identityId);
1216
+ if (!identity) return failure("identity not found", 404);
1217
+ if (hasRecordedContact(identity.id)) {
1218
+ return failure("identity has an email or phone on record; send an invite claim instead", 409);
1219
+ }
1220
+ return mintClaim({
1221
+ flow: "bearer",
1222
+ identityId: identity.id,
1223
+ kind: null,
1224
+ externalId: null,
1225
+ returnTo,
1226
+ staffActorRef: null,
1227
+ deliver: false
1228
+ });
1229
+ }
1230
+ async function handleCreateClaim(request) {
1231
+ const body = await readJsonBody(request);
1232
+ const flow = body.flow;
1233
+ if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1234
+ return failure("flow must be one of: invite, assist, transfer, bearer");
1235
+ }
1236
+ if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1237
+ return failure("identityId must be a uuid");
1238
+ }
1239
+ const parsedReturnTo = parseReturnTo(body.returnTo);
1240
+ if ("error" in parsedReturnTo) return failure(parsedReturnTo.error);
1241
+ if (flow === "bearer") return createBearerClaim(body, parsedReturnTo.returnTo);
1242
+ const normalized = normalizeLinkInput(
1243
+ body.kind,
1244
+ body.externalId,
1245
+ CLAIM_KINDS,
1246
+ "kind must be one of: email, phone"
1247
+ );
1248
+ if ("error" in normalized) return failure(normalized.error);
1249
+ if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1250
+ return failure("deliver must be a boolean");
1251
+ }
1252
+ const staff = validateClaimStaff(flow, body.staff);
1253
+ if ("error" in staff) return failure(staff.error);
1254
+ if (flow !== "invite" && !state.identityStaffAssistEnabled) {
1255
+ return failure(
1256
+ "staff-assisted profile claiming is not enabled for this project",
1257
+ 403,
1258
+ STAFF_ASSISTANCE_NOT_ENABLED
1259
+ );
1260
+ }
1261
+ const identity = claimableIdentity(body.identityId);
1262
+ if (!identity) return failure("identity not found", 404);
1263
+ const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1264
+ (link) => link.identityId === identity.id
1265
+ );
1266
+ if (!holdsContact) {
1267
+ return failure("identity does not hold that contact", 409);
1268
+ }
1269
+ return mintClaim({
1270
+ flow,
1271
+ identityId: identity.id,
1272
+ kind: normalized.kind,
1273
+ externalId: normalized.externalId,
1274
+ returnTo: parsedReturnTo.returnTo,
1275
+ staffActorRef: staff.staffActorRef,
1276
+ deliver: body.deliver === true
1277
+ });
1278
+ }
1279
+ function handleGetClaim(claimId) {
1280
+ const claim = state.identityClaims.get(claimId);
1281
+ if (!claim) return failure("claim not found", 404);
1282
+ return success(claimView(claim));
1283
+ }
1056
1284
  async function handleIdentitiesRequest(request, subPath) {
1057
1285
  const method = request.method;
1058
1286
  if (subPath === "" || subPath === "/") {
@@ -1068,6 +1296,13 @@ async function handleIdentitiesRequest(request, subPath) {
1068
1296
  if (subPath === "/search" && method === "GET") {
1069
1297
  return handleSearch(request);
1070
1298
  }
1299
+ if (subPath === "/claims" && method === "POST") {
1300
+ return handleCreateClaim(request);
1301
+ }
1302
+ const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
1303
+ if (claimMatch && method === "GET") {
1304
+ return handleGetClaim(decodeURIComponent(claimMatch[1]));
1305
+ }
1071
1306
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
1072
1307
  if (linksMatch && method === "POST") {
1073
1308
  return handleAttachLink(linksMatch[1], request);
@@ -1092,11 +1327,13 @@ function seedVerifiedLink(identityId, params) {
1092
1327
  );
1093
1328
  if ("error" in normalized) throw new Error(normalized.error);
1094
1329
  const { kind, externalId } = normalized;
1095
- const [existing] = matchingLinks(kind, externalId);
1330
+ const matches = matchingLinks(kind, externalId);
1331
+ const blocker = matches.find(
1332
+ (link2) => link2.identityId !== identityId && (link2.verified || !DUPLICABLE_UNVERIFIED_KINDS.has(link2.kind))
1333
+ );
1334
+ if (blocker) throw new Error("identifier already linked to another identity");
1335
+ const existing = matches.find((link2) => link2.identityId === identityId);
1096
1336
  if (existing) {
1097
- if (existing.identityId !== identityId) {
1098
- throw new Error("identifier already linked to another identity");
1099
- }
1100
1337
  existing.verified = true;
1101
1338
  return existing;
1102
1339
  }
@@ -1126,7 +1363,12 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1126
1363
  );
1127
1364
  for (const sourceLink of sourceLinks) {
1128
1365
  const key = normalizedLinkKey(sourceLink);
1129
- if (otherLinks.some((otherLink) => normalizedLinkKey(otherLink) === key)) {
1366
+ const conflicting = otherLinks.some(
1367
+ (otherLink) => normalizedLinkKey(otherLink) === key && // Same partial rule as the index: a third identity's UNVERIFIED copy of a
1368
+ // shared email/phone is allowed to coexist and must not block the merge.
1369
+ (!DUPLICABLE_UNVERIFIED_KINDS.has(key.split(":")[0]) || otherLink.verified && sourceLink.verified)
1370
+ );
1371
+ if (conflicting) {
1130
1372
  throw new Error("merge would conflict with a link owned by another identity");
1131
1373
  }
1132
1374
  }
@@ -1153,6 +1395,89 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1153
1395
  source.mergedIntoId = canonical.id;
1154
1396
  source.updatedAt = now();
1155
1397
  }
1398
+ function requireClaim(claimId) {
1399
+ const claim = state.identityClaims.get(claimId);
1400
+ if (!claim) throw new Error("claim not found");
1401
+ return claim;
1402
+ }
1403
+ function completeClaim(claimId, params) {
1404
+ const claim = requireClaim(claimId);
1405
+ if (claim.state !== "pending") return claim;
1406
+ if (Date.parse(claim.expiresAt) <= Date.now()) {
1407
+ claim.state = "expired";
1408
+ return claim;
1409
+ }
1410
+ if (!params.clerkUserId) throw new Error("clerkUserId is required");
1411
+ if (claim.flow === "bearer") return completeBearerClaim(claim, params.clerkUserId);
1412
+ const rows = matchingLinks(claim.kind, claim.externalId);
1413
+ const target = rows.find((link) => link.identityId === claim.identityId);
1414
+ if (!target) {
1415
+ claim.state = "invalidated";
1416
+ return claim;
1417
+ }
1418
+ const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
1419
+ if (otherVerified) {
1420
+ claim.state = "refused";
1421
+ claim.refusalReason = "contact_owned";
1422
+ return claim;
1423
+ }
1424
+ if (claim.flow !== "transfer" && ownedByAnotherLogin(claim.identityId, params.clerkUserId)) {
1425
+ claim.state = "refused";
1426
+ claim.refusalReason = "owned_elsewhere";
1427
+ return claim;
1428
+ }
1429
+ target.verified = true;
1430
+ connectLogin(claim, params.clerkUserId);
1431
+ claim.state = "completed";
1432
+ claim.completedIdentityId = claim.identityId;
1433
+ claim.claimantClerkUserId = params.clerkUserId;
1434
+ return claim;
1435
+ }
1436
+ function ownedByAnotherLogin(identityId, clerkUserId) {
1437
+ return linksFor(identityId).some(
1438
+ (link) => link.kind === "project_auth_user" && link.externalId !== clerkUserId
1439
+ );
1440
+ }
1441
+ function connectLogin(claim, clerkUserId) {
1442
+ const isLogin = (link) => link.identityId === claim.identityId && link.kind === "project_auth_user";
1443
+ if (claim.flow === "transfer") {
1444
+ state.identityLinks = state.identityLinks.filter(
1445
+ (link) => !isLogin(link) || link.externalId === clerkUserId
1446
+ );
1447
+ }
1448
+ state.identityLinks = state.identityLinks.filter(
1449
+ (link) => link.kind !== "project_auth_user" || link.externalId !== clerkUserId || link.identityId === claim.identityId
1450
+ );
1451
+ if (!state.identityLinks.some((link) => isLogin(link) && link.externalId === clerkUserId)) {
1452
+ createUnverifiedLink(claim.identityId, "project_auth_user", clerkUserId).verified = true;
1453
+ }
1454
+ }
1455
+ function completeBearerClaim(claim, clerkUserId) {
1456
+ const identity = state.identities.get(claim.identityId);
1457
+ if (!identity || identity.type !== "person" || identity.status !== "active") {
1458
+ claim.state = "invalidated";
1459
+ return claim;
1460
+ }
1461
+ const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
1462
+ if (refusal) {
1463
+ claim.state = "refused";
1464
+ claim.refusalReason = refusal;
1465
+ return claim;
1466
+ }
1467
+ connectLogin(claim, clerkUserId);
1468
+ claim.state = "completed";
1469
+ claim.completedIdentityId = claim.identityId;
1470
+ claim.claimantClerkUserId = clerkUserId;
1471
+ return claim;
1472
+ }
1473
+ function refuseClaim(claimId, reason) {
1474
+ const claim = requireClaim(claimId);
1475
+ if (claim.state !== "pending") return claim;
1476
+ if (!reason) throw new Error("a refusal reason is required");
1477
+ claim.state = "refused";
1478
+ claim.refusalReason = reason;
1479
+ return claim;
1480
+ }
1156
1481
  function createDirectory() {
1157
1482
  return {
1158
1483
  all: () => [...state.identities.values()],
@@ -1160,9 +1485,30 @@ function createDirectory() {
1160
1485
  links: (identityId) => linksFor(identityId),
1161
1486
  seedVerifiedLink,
1162
1487
  merge,
1488
+ claims: () => [...state.identityClaims.values()],
1489
+ claim: (claimId) => state.identityClaims.get(claimId),
1490
+ enableStaffAssist: (enabled = true) => {
1491
+ state.identityStaffAssistEnabled = enabled;
1492
+ },
1493
+ enableBearerLinks: (enabled = true) => {
1494
+ state.identityBearerLinksEnabled = enabled;
1495
+ },
1496
+ suppressClaimDelivery: (enabled = true) => {
1497
+ state.identitySuppressClaimDelivery = enabled;
1498
+ },
1499
+ allowReturnOrigin: (origin) => {
1500
+ state.identityAllowedReturnOrigins.add(new URL(origin).origin);
1501
+ },
1502
+ completeClaim,
1503
+ refuseClaim,
1163
1504
  clear: () => {
1164
1505
  state.identities.clear();
1165
1506
  state.identityLinks = [];
1507
+ state.identityClaims.clear();
1508
+ state.identityStaffAssistEnabled = false;
1509
+ state.identityBearerLinksEnabled = true;
1510
+ state.identitySuppressClaimDelivery = false;
1511
+ state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
1166
1512
  },
1167
1513
  get count() {
1168
1514
  return state.identities.size;
@@ -1201,7 +1547,7 @@ async function importNextServer() {
1201
1547
  async function callRoute(handler, options = {}) {
1202
1548
  const { NextRequest } = await importNextServer();
1203
1549
  const path = options.path ?? "/api/test-route";
1204
- const url = new URL(`http://localhost:3333${path}`);
1550
+ const url = new URL(`${APP_TEST_ORIGIN}${path}`);
1205
1551
  for (const [key, value] of Object.entries(options.searchParams ?? {})) {
1206
1552
  url.searchParams.set(key, value);
1207
1553
  }
@@ -2976,8 +3322,7 @@ async function createTestApp(options = {}) {
2976
3322
  state.refreshSessions.clear();
2977
3323
  state.emails = [];
2978
3324
  state.emailCounter = 0;
2979
- state.identities.clear();
2980
- state.identityLinks = [];
3325
+ directory.clear();
2981
3326
  payments.clear();
2982
3327
  storage.clear();
2983
3328
  messages.clear();
@@ -2991,8 +3336,7 @@ async function createTestApp(options = {}) {
2991
3336
  state.refreshSessions.clear();
2992
3337
  state.emails = [];
2993
3338
  state.emailCounter = 0;
2994
- state.identities.clear();
2995
- state.identityLinks = [];
3339
+ directory.clear();
2996
3340
  payments.clear();
2997
3341
  storage.clear();
2998
3342
  messages.clear();