@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.mjs CHANGED
@@ -57,6 +57,31 @@ function globalSingleton(key, create) {
57
57
  return holder[symbol];
58
58
  }
59
59
 
60
+ // src/constants.ts
61
+ var TEST_DOMAIN_SUFFIX = ".stardeck.test";
62
+ var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
63
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
64
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
65
+ var STORAGE_TEST_URL = "https://storage.stardeck.test";
66
+ var STORAGE_TEST_HOST = "storage.stardeck.test";
67
+ var APP_TEST_ORIGIN = "http://localhost:3333";
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
+ var DEFAULT_TEST_USER = {
77
+ id: "test-user-1",
78
+ email: "test-user@example.com",
79
+ name: "Test User",
80
+ role: "member",
81
+ permissions: []
82
+ };
83
+ var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
84
+
60
85
  // src/state.ts
61
86
  var state = globalSingleton("state", () => ({
62
87
  db: null,
@@ -68,6 +93,11 @@ var state = globalSingleton("state", () => ({
68
93
  emailCounter: 0,
69
94
  identities: /* @__PURE__ */ new Map(),
70
95
  identityLinks: [],
96
+ identityClaims: /* @__PURE__ */ new Map(),
97
+ identityStaffAssistEnabled: false,
98
+ identityBearerLinksEnabled: true,
99
+ identitySuppressClaimDelivery: false,
100
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
71
101
  checkouts: [],
72
102
  checkoutCounter: 0,
73
103
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -109,30 +139,6 @@ function requireDb() {
109
139
  return state.db;
110
140
  }
111
141
 
112
- // src/constants.ts
113
- var TEST_DOMAIN_SUFFIX = ".stardeck.test";
114
- var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
115
- var DATA_STORE_TEST_HOST = "db.stardeck.test";
116
- var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
117
- var STORAGE_TEST_URL = "https://storage.stardeck.test";
118
- var STORAGE_TEST_HOST = "storage.stardeck.test";
119
- var TEST_ENV_DEFAULTS = {
120
- CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
121
- DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
122
- ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
123
- PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
124
- DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
125
- STORAGE_URL: STORAGE_TEST_URL
126
- };
127
- var DEFAULT_TEST_USER = {
128
- id: "test-user-1",
129
- email: "test-user@example.com",
130
- name: "Test User",
131
- role: "member",
132
- permissions: []
133
- };
134
- var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
135
-
136
142
  // src/simulator/hmac.ts
137
143
  import crypto from "crypto";
138
144
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -183,8 +189,8 @@ function json(body, status = 200) {
183
189
  function success(data) {
184
190
  return json({ success: true, data });
185
191
  }
186
- function failure(error, status = 400) {
187
- return json({ success: false, error }, status);
192
+ function failure(error, status = 400, code) {
193
+ return json({ success: false, error, ...code ? { code } : {} }, status);
188
194
  }
189
195
  async function readJsonBody(request) {
190
196
  try {
@@ -672,9 +678,17 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
672
678
  "dashboard_user"
673
679
  ]);
674
680
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
681
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
682
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
683
+ var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
684
+ var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
685
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
686
+ var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
687
+ var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
675
688
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
676
689
  var SEARCH_DEFAULT_LIMIT = 50;
677
690
  var SEARCH_MAX_LIMIT = 100;
691
+ var TEAM_ACCOUNT_FIELDS_MESSAGE = "This person's name and email come from their team account. Change them in Members settings.";
678
692
  function asciiLowercaseTrim(value) {
679
693
  const trimmed = value.trim();
680
694
  let out = "";
@@ -754,6 +768,11 @@ function matchingLinks(kind, externalId) {
754
768
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
755
769
  );
756
770
  }
771
+ function sortedMatchingLinks(kind, externalId) {
772
+ return matchingLinks(kind, externalId).sort(
773
+ (a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
774
+ );
775
+ }
757
776
  function normalizedLinkKey(link) {
758
777
  return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
759
778
  }
@@ -789,11 +808,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
789
808
  state.identityLinks.push(link);
790
809
  return link;
791
810
  }
811
+ function isTeamOnly(identityId) {
812
+ const kinds = linksFor(identityId).map((link) => link.kind);
813
+ return kinds.includes("dashboard_user") && !kinds.includes("project_auth_user");
814
+ }
815
+ function isCurrentTeamMember(identityId) {
816
+ return linksFor(identityId).some((link) => link.kind === "dashboard_user" && link.verified);
817
+ }
818
+ function hasRecordedContact(identityId) {
819
+ return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
820
+ }
792
821
  function handleList(request) {
793
822
  const typeParam = new URL(request.url).searchParams.get("type");
794
823
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
795
824
  const identities = [...state.identities.values()].filter(
796
- (identity) => identity.status !== "merged"
825
+ (identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
797
826
  );
798
827
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
799
828
  }
@@ -837,11 +866,18 @@ async function handleUpdate(identityId, request) {
837
866
  const identity = state.identities.get(identityId);
838
867
  if (!identity) return failure("identity not found", 404);
839
868
  const body = await readJsonBody(request);
869
+ const profile = body.profile;
870
+ if (isCurrentTeamMember(identityId)) {
871
+ const changesName = body.displayName !== void 0 && body.displayName !== identity.displayName;
872
+ const changesEmail = profile !== void 0 && Object.hasOwn(profile, "email") && profile.email !== identity.profile.email;
873
+ if (changesName || changesEmail) return failure(TEAM_ACCOUNT_FIELDS_MESSAGE, 409);
874
+ }
840
875
  if (body.displayName !== void 0) {
841
876
  identity.displayName = body.displayName;
842
877
  }
843
- if (body.profile !== void 0) {
844
- identity.profile = body.profile;
878
+ if (profile !== void 0) {
879
+ const keptEmail = isCurrentTeamMember(identityId) && !Object.hasOwn(profile, "email") && Object.hasOwn(identity.profile, "email");
880
+ identity.profile = keptEmail ? { ...profile, email: identity.profile.email } : profile;
845
881
  }
846
882
  if (body.externalRef !== void 0) {
847
883
  identity.externalRef = body.externalRef;
@@ -867,9 +903,10 @@ async function handleAttachLink(identityId, request) {
867
903
  );
868
904
  if ("error" in normalized) return failure(normalized.error);
869
905
  const { kind, externalId } = normalized;
870
- const [existing] = matchingLinks(kind, externalId);
871
- if (existing) {
872
- if (existing.identityId === identityId) return success({ link: existing });
906
+ const matches = matchingLinks(kind, externalId);
907
+ const ownRow = matches.find((link2) => link2.identityId === identityId);
908
+ if (ownRow) return success({ link: ownRow });
909
+ if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
873
910
  return failure("identifier already linked to another identity", 409);
874
911
  }
875
912
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -886,7 +923,7 @@ async function handleResolve(request) {
886
923
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
887
924
  return failure("displayName must be a string");
888
925
  }
889
- const links = matchingLinks(normalized.kind, normalized.externalId);
926
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
890
927
  if (links.length > 0) {
891
928
  const canonicalIds = /* @__PURE__ */ new Set();
892
929
  for (const link of links) {
@@ -897,12 +934,15 @@ async function handleResolve(request) {
897
934
  return failure(`cannot resolve identity link: ${message}`, 409);
898
935
  }
899
936
  }
900
- if (canonicalIds.size !== 1) {
937
+ const verifiedOwners = new Set(
938
+ links.filter((link) => link.verified).map((link) => link.identityId)
939
+ );
940
+ if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
901
941
  return failure("identifier is linked to multiple identities", 409);
902
942
  }
903
- const canonicalId = [...canonicalIds][0];
904
- const hasVerifiedLink = links.some((link) => link.verified);
905
- if (provenance === "guest" && hasVerifiedLink) {
943
+ const [chosen] = links;
944
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
945
+ if (provenance === "guest" && chosen.verified) {
906
946
  return success({ identityId: null, reason: "verified_conflict" });
907
947
  }
908
948
  return success({ identityId: canonicalId, created: false });
@@ -939,6 +979,36 @@ function identityMatchesQuery(identity, query) {
939
979
  (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
940
980
  );
941
981
  }
982
+ function parseSearchAttributes(raw) {
983
+ if (raw === null) return {};
984
+ let parsed;
985
+ try {
986
+ parsed = JSON.parse(raw);
987
+ } catch {
988
+ return "attributes: attributes must be a JSON object";
989
+ }
990
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
991
+ return "attributes: attributes must be a JSON object";
992
+ }
993
+ if (Object.hasOwn(parsed, "__proto__")) return "attributes: attribute keys may not be __proto__";
994
+ const entries = Object.entries(parsed);
995
+ if (entries.length > 10) return "attributes: attributes may filter on at most 10 keys";
996
+ for (const [key, value] of entries) {
997
+ if (!["string", "number", "boolean"].includes(typeof value)) {
998
+ return `attributes.${key}: attribute values must be a string, number or boolean`;
999
+ }
1000
+ if (key.length < 1 || key.length > 100) {
1001
+ return `attributes.${key}: attribute keys must be 1-100 characters`;
1002
+ }
1003
+ if (typeof value === "string" && value.length > 500) {
1004
+ return `attributes.${key}: attribute values must be at most 500 characters`;
1005
+ }
1006
+ }
1007
+ return parsed;
1008
+ }
1009
+ function identityMatchesAttributes(identity, attributes) {
1010
+ return Object.entries(attributes).every(([key, value]) => identity.profile[key] === value);
1011
+ }
942
1012
  function handleSearch(request) {
943
1013
  const url = new URL(request.url);
944
1014
  const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
@@ -951,10 +1021,14 @@ function handleSearch(request) {
951
1021
  return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
952
1022
  }
953
1023
  }
1024
+ const attributes = parseSearchAttributes(url.searchParams.get("attributes"));
1025
+ if (typeof attributes === "string") {
1026
+ return json({ success: false, error: "Invalid query parameters", details: attributes }, 400);
1027
+ }
954
1028
  const rawCursor = url.searchParams.get("cursor");
955
1029
  const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
956
1030
  if (rawCursor && !cursor) return failure("cursor is invalid");
957
- const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
1031
+ 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);
958
1032
  const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
959
1033
  const pageStart = start < 0 ? identities.length : start;
960
1034
  const items = identities.slice(pageStart, pageStart + limit);
@@ -1004,6 +1078,160 @@ async function handleAliases(request) {
1004
1078
  }
1005
1079
  return success({ aliases });
1006
1080
  }
1081
+ function claimView(claim) {
1082
+ const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
1083
+ return {
1084
+ claimId: claim.claimId,
1085
+ flow: claim.flow,
1086
+ state: state_,
1087
+ refusalReason: claim.refusalReason,
1088
+ completedIdentityId: claim.completedIdentityId,
1089
+ expiresAt: claim.expiresAt
1090
+ };
1091
+ }
1092
+ function validateClaimStaff(flow, staff) {
1093
+ if (flow === "invite") {
1094
+ if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
1095
+ return { staffActorRef: null };
1096
+ }
1097
+ if (!staff || typeof staff !== "object") {
1098
+ return { error: `staff is required for a ${flow} claim` };
1099
+ }
1100
+ const { actorRef, grant } = staff;
1101
+ if (typeof actorRef !== "string" || actorRef.length === 0) {
1102
+ return { error: "staff.actorRef is required" };
1103
+ }
1104
+ if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1105
+ return { staffActorRef: actorRef };
1106
+ }
1107
+ function parseReturnTo(raw) {
1108
+ if (raw === void 0) return { returnTo: null };
1109
+ if (typeof raw !== "string") return { error: "returnTo must be a string" };
1110
+ let parsed;
1111
+ try {
1112
+ parsed = new URL(raw);
1113
+ } catch {
1114
+ return { error: "returnTo must be an absolute URL" };
1115
+ }
1116
+ return { returnTo: state.identityAllowedReturnOrigins.has(parsed.origin) ? raw : null };
1117
+ }
1118
+ function claimableIdentity(identityId) {
1119
+ const identity = state.identities.get(identityId);
1120
+ if (!identity || identity.type !== "person" || identity.status !== "active") return null;
1121
+ return identity;
1122
+ }
1123
+ function mintClaim(params) {
1124
+ const claimId = crypto3.randomUUID();
1125
+ const handle = crypto3.randomBytes(32).toString("base64url");
1126
+ const claim = {
1127
+ claimId,
1128
+ flow: params.flow,
1129
+ identityId: params.identityId,
1130
+ kind: params.kind,
1131
+ externalId: params.externalId,
1132
+ returnTo: params.returnTo,
1133
+ staffActorRef: params.staffActorRef,
1134
+ state: "pending",
1135
+ refusalReason: null,
1136
+ completedIdentityId: null,
1137
+ claimantClerkUserId: null,
1138
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1139
+ delivered: params.deliver ? !state.identitySuppressClaimDelivery : null,
1140
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1141
+ createdAt: now()
1142
+ };
1143
+ state.identityClaims.set(claimId, claim);
1144
+ return success({
1145
+ claimId,
1146
+ claimUrl: claim.claimUrl,
1147
+ expiresAt: claim.expiresAt,
1148
+ // Absent unless asked for: an app delivering its own link must not read a
1149
+ // missing field as "nothing was sent".
1150
+ ...params.deliver ? { delivered: claim.delivered } : {}
1151
+ });
1152
+ }
1153
+ function createBearerClaim(body, returnTo) {
1154
+ for (const field of ["kind", "externalId", "deliver", "staff"]) {
1155
+ if (body[field] !== void 0) {
1156
+ return failure(`a bearer claim takes no ${field}: it is raised against no contact`);
1157
+ }
1158
+ }
1159
+ if (!state.identityBearerLinksEnabled) {
1160
+ return failure(
1161
+ "profile invite links are not enabled for this project",
1162
+ 403,
1163
+ BEARER_LINKS_NOT_ENABLED
1164
+ );
1165
+ }
1166
+ const identity = claimableIdentity(body.identityId);
1167
+ if (!identity) return failure("identity not found", 404);
1168
+ if (hasRecordedContact(identity.id)) {
1169
+ return failure("identity has an email or phone on record; send an invite claim instead", 409);
1170
+ }
1171
+ return mintClaim({
1172
+ flow: "bearer",
1173
+ identityId: identity.id,
1174
+ kind: null,
1175
+ externalId: null,
1176
+ returnTo,
1177
+ staffActorRef: null,
1178
+ deliver: false
1179
+ });
1180
+ }
1181
+ async function handleCreateClaim(request) {
1182
+ const body = await readJsonBody(request);
1183
+ const flow = body.flow;
1184
+ if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1185
+ return failure("flow must be one of: invite, assist, transfer, bearer");
1186
+ }
1187
+ if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1188
+ return failure("identityId must be a uuid");
1189
+ }
1190
+ const parsedReturnTo = parseReturnTo(body.returnTo);
1191
+ if ("error" in parsedReturnTo) return failure(parsedReturnTo.error);
1192
+ if (flow === "bearer") return createBearerClaim(body, parsedReturnTo.returnTo);
1193
+ const normalized = normalizeLinkInput(
1194
+ body.kind,
1195
+ body.externalId,
1196
+ CLAIM_KINDS,
1197
+ "kind must be one of: email, phone"
1198
+ );
1199
+ if ("error" in normalized) return failure(normalized.error);
1200
+ if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1201
+ return failure("deliver must be a boolean");
1202
+ }
1203
+ const staff = validateClaimStaff(flow, body.staff);
1204
+ if ("error" in staff) return failure(staff.error);
1205
+ if (flow !== "invite" && !state.identityStaffAssistEnabled) {
1206
+ return failure(
1207
+ "staff-assisted profile claiming is not enabled for this project",
1208
+ 403,
1209
+ STAFF_ASSISTANCE_NOT_ENABLED
1210
+ );
1211
+ }
1212
+ const identity = claimableIdentity(body.identityId);
1213
+ if (!identity) return failure("identity not found", 404);
1214
+ const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1215
+ (link) => link.identityId === identity.id
1216
+ );
1217
+ if (!holdsContact) {
1218
+ return failure("identity does not hold that contact", 409);
1219
+ }
1220
+ return mintClaim({
1221
+ flow,
1222
+ identityId: identity.id,
1223
+ kind: normalized.kind,
1224
+ externalId: normalized.externalId,
1225
+ returnTo: parsedReturnTo.returnTo,
1226
+ staffActorRef: staff.staffActorRef,
1227
+ deliver: body.deliver === true
1228
+ });
1229
+ }
1230
+ function handleGetClaim(claimId) {
1231
+ const claim = state.identityClaims.get(claimId);
1232
+ if (!claim) return failure("claim not found", 404);
1233
+ return success(claimView(claim));
1234
+ }
1007
1235
  async function handleIdentitiesRequest(request, subPath) {
1008
1236
  const method = request.method;
1009
1237
  if (subPath === "" || subPath === "/") {
@@ -1019,6 +1247,13 @@ async function handleIdentitiesRequest(request, subPath) {
1019
1247
  if (subPath === "/search" && method === "GET") {
1020
1248
  return handleSearch(request);
1021
1249
  }
1250
+ if (subPath === "/claims" && method === "POST") {
1251
+ return handleCreateClaim(request);
1252
+ }
1253
+ const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
1254
+ if (claimMatch && method === "GET") {
1255
+ return handleGetClaim(decodeURIComponent(claimMatch[1]));
1256
+ }
1022
1257
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
1023
1258
  if (linksMatch && method === "POST") {
1024
1259
  return handleAttachLink(linksMatch[1], request);
@@ -1043,11 +1278,13 @@ function seedVerifiedLink(identityId, params) {
1043
1278
  );
1044
1279
  if ("error" in normalized) throw new Error(normalized.error);
1045
1280
  const { kind, externalId } = normalized;
1046
- const [existing] = matchingLinks(kind, externalId);
1281
+ const matches = matchingLinks(kind, externalId);
1282
+ const blocker = matches.find(
1283
+ (link2) => link2.identityId !== identityId && (link2.verified || !DUPLICABLE_UNVERIFIED_KINDS.has(link2.kind))
1284
+ );
1285
+ if (blocker) throw new Error("identifier already linked to another identity");
1286
+ const existing = matches.find((link2) => link2.identityId === identityId);
1047
1287
  if (existing) {
1048
- if (existing.identityId !== identityId) {
1049
- throw new Error("identifier already linked to another identity");
1050
- }
1051
1288
  existing.verified = true;
1052
1289
  return existing;
1053
1290
  }
@@ -1077,7 +1314,12 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1077
1314
  );
1078
1315
  for (const sourceLink of sourceLinks) {
1079
1316
  const key = normalizedLinkKey(sourceLink);
1080
- if (otherLinks.some((otherLink) => normalizedLinkKey(otherLink) === key)) {
1317
+ const conflicting = otherLinks.some(
1318
+ (otherLink) => normalizedLinkKey(otherLink) === key && // Same partial rule as the index: a third identity's UNVERIFIED copy of a
1319
+ // shared email/phone is allowed to coexist and must not block the merge.
1320
+ (!DUPLICABLE_UNVERIFIED_KINDS.has(key.split(":")[0]) || otherLink.verified && sourceLink.verified)
1321
+ );
1322
+ if (conflicting) {
1081
1323
  throw new Error("merge would conflict with a link owned by another identity");
1082
1324
  }
1083
1325
  }
@@ -1104,6 +1346,89 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1104
1346
  source.mergedIntoId = canonical.id;
1105
1347
  source.updatedAt = now();
1106
1348
  }
1349
+ function requireClaim(claimId) {
1350
+ const claim = state.identityClaims.get(claimId);
1351
+ if (!claim) throw new Error("claim not found");
1352
+ return claim;
1353
+ }
1354
+ function completeClaim(claimId, params) {
1355
+ const claim = requireClaim(claimId);
1356
+ if (claim.state !== "pending") return claim;
1357
+ if (Date.parse(claim.expiresAt) <= Date.now()) {
1358
+ claim.state = "expired";
1359
+ return claim;
1360
+ }
1361
+ if (!params.clerkUserId) throw new Error("clerkUserId is required");
1362
+ if (claim.flow === "bearer") return completeBearerClaim(claim, params.clerkUserId);
1363
+ const rows = matchingLinks(claim.kind, claim.externalId);
1364
+ const target = rows.find((link) => link.identityId === claim.identityId);
1365
+ if (!target) {
1366
+ claim.state = "invalidated";
1367
+ return claim;
1368
+ }
1369
+ const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
1370
+ if (otherVerified) {
1371
+ claim.state = "refused";
1372
+ claim.refusalReason = "contact_owned";
1373
+ return claim;
1374
+ }
1375
+ if (claim.flow !== "transfer" && ownedByAnotherLogin(claim.identityId, params.clerkUserId)) {
1376
+ claim.state = "refused";
1377
+ claim.refusalReason = "owned_elsewhere";
1378
+ return claim;
1379
+ }
1380
+ target.verified = true;
1381
+ connectLogin(claim, params.clerkUserId);
1382
+ claim.state = "completed";
1383
+ claim.completedIdentityId = claim.identityId;
1384
+ claim.claimantClerkUserId = params.clerkUserId;
1385
+ return claim;
1386
+ }
1387
+ function ownedByAnotherLogin(identityId, clerkUserId) {
1388
+ return linksFor(identityId).some(
1389
+ (link) => link.kind === "project_auth_user" && link.externalId !== clerkUserId
1390
+ );
1391
+ }
1392
+ function connectLogin(claim, clerkUserId) {
1393
+ const isLogin = (link) => link.identityId === claim.identityId && link.kind === "project_auth_user";
1394
+ if (claim.flow === "transfer") {
1395
+ state.identityLinks = state.identityLinks.filter(
1396
+ (link) => !isLogin(link) || link.externalId === clerkUserId
1397
+ );
1398
+ }
1399
+ state.identityLinks = state.identityLinks.filter(
1400
+ (link) => link.kind !== "project_auth_user" || link.externalId !== clerkUserId || link.identityId === claim.identityId
1401
+ );
1402
+ if (!state.identityLinks.some((link) => isLogin(link) && link.externalId === clerkUserId)) {
1403
+ createUnverifiedLink(claim.identityId, "project_auth_user", clerkUserId).verified = true;
1404
+ }
1405
+ }
1406
+ function completeBearerClaim(claim, clerkUserId) {
1407
+ const identity = state.identities.get(claim.identityId);
1408
+ if (!identity || identity.type !== "person" || identity.status !== "active") {
1409
+ claim.state = "invalidated";
1410
+ return claim;
1411
+ }
1412
+ const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
1413
+ if (refusal) {
1414
+ claim.state = "refused";
1415
+ claim.refusalReason = refusal;
1416
+ return claim;
1417
+ }
1418
+ connectLogin(claim, clerkUserId);
1419
+ claim.state = "completed";
1420
+ claim.completedIdentityId = claim.identityId;
1421
+ claim.claimantClerkUserId = clerkUserId;
1422
+ return claim;
1423
+ }
1424
+ function refuseClaim(claimId, reason) {
1425
+ const claim = requireClaim(claimId);
1426
+ if (claim.state !== "pending") return claim;
1427
+ if (!reason) throw new Error("a refusal reason is required");
1428
+ claim.state = "refused";
1429
+ claim.refusalReason = reason;
1430
+ return claim;
1431
+ }
1107
1432
  function createDirectory() {
1108
1433
  return {
1109
1434
  all: () => [...state.identities.values()],
@@ -1111,9 +1436,30 @@ function createDirectory() {
1111
1436
  links: (identityId) => linksFor(identityId),
1112
1437
  seedVerifiedLink,
1113
1438
  merge,
1439
+ claims: () => [...state.identityClaims.values()],
1440
+ claim: (claimId) => state.identityClaims.get(claimId),
1441
+ enableStaffAssist: (enabled = true) => {
1442
+ state.identityStaffAssistEnabled = enabled;
1443
+ },
1444
+ enableBearerLinks: (enabled = true) => {
1445
+ state.identityBearerLinksEnabled = enabled;
1446
+ },
1447
+ suppressClaimDelivery: (enabled = true) => {
1448
+ state.identitySuppressClaimDelivery = enabled;
1449
+ },
1450
+ allowReturnOrigin: (origin) => {
1451
+ state.identityAllowedReturnOrigins.add(new URL(origin).origin);
1452
+ },
1453
+ completeClaim,
1454
+ refuseClaim,
1114
1455
  clear: () => {
1115
1456
  state.identities.clear();
1116
1457
  state.identityLinks = [];
1458
+ state.identityClaims.clear();
1459
+ state.identityStaffAssistEnabled = false;
1460
+ state.identityBearerLinksEnabled = true;
1461
+ state.identitySuppressClaimDelivery = false;
1462
+ state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
1117
1463
  },
1118
1464
  get count() {
1119
1465
  return state.identities.size;
@@ -1152,7 +1498,7 @@ async function importNextServer() {
1152
1498
  async function callRoute(handler, options = {}) {
1153
1499
  const { NextRequest } = await importNextServer();
1154
1500
  const path = options.path ?? "/api/test-route";
1155
- const url = new URL(`http://localhost:3333${path}`);
1501
+ const url = new URL(`${APP_TEST_ORIGIN}${path}`);
1156
1502
  for (const [key, value] of Object.entries(options.searchParams ?? {})) {
1157
1503
  url.searchParams.set(key, value);
1158
1504
  }
@@ -2927,8 +3273,7 @@ async function createTestApp(options = {}) {
2927
3273
  state.refreshSessions.clear();
2928
3274
  state.emails = [];
2929
3275
  state.emailCounter = 0;
2930
- state.identities.clear();
2931
- state.identityLinks = [];
3276
+ directory.clear();
2932
3277
  payments.clear();
2933
3278
  storage.clear();
2934
3279
  messages.clear();
@@ -2942,8 +3287,7 @@ async function createTestApp(options = {}) {
2942
3287
  state.refreshSessions.clear();
2943
3288
  state.emails = [];
2944
3289
  state.emailCounter = 0;
2945
- state.identities.clear();
2946
- state.identityLinks = [];
3290
+ directory.clear();
2947
3291
  payments.clear();
2948
3292
  storage.clear();
2949
3293
  messages.clear();
@@ -36,6 +36,11 @@ function globalSingleton(key, create) {
36
36
  return holder[symbol];
37
37
  }
38
38
 
39
+ // src/constants.ts
40
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
41
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
42
+ var APP_TEST_ORIGIN = "http://localhost:3333";
43
+
39
44
  // src/state.ts
40
45
  var state = globalSingleton("state", () => ({
41
46
  db: null,
@@ -47,6 +52,11 @@ var state = globalSingleton("state", () => ({
47
52
  emailCounter: 0,
48
53
  identities: /* @__PURE__ */ new Map(),
49
54
  identityLinks: [],
55
+ identityClaims: /* @__PURE__ */ new Map(),
56
+ identityStaffAssistEnabled: false,
57
+ identityBearerLinksEnabled: true,
58
+ identitySuppressClaimDelivery: false,
59
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
50
60
  checkouts: [],
51
61
  checkoutCounter: 0,
52
62
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -9,6 +9,11 @@ function globalSingleton(key, create) {
9
9
  return holder[symbol];
10
10
  }
11
11
 
12
+ // src/constants.ts
13
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
14
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
15
+ var APP_TEST_ORIGIN = "http://localhost:3333";
16
+
12
17
  // src/state.ts
13
18
  var state = globalSingleton("state", () => ({
14
19
  db: null,
@@ -20,6 +25,11 @@ var state = globalSingleton("state", () => ({
20
25
  emailCounter: 0,
21
26
  identities: /* @__PURE__ */ new Map(),
22
27
  identityLinks: [],
28
+ identityClaims: /* @__PURE__ */ new Map(),
29
+ identityStaffAssistEnabled: false,
30
+ identityBearerLinksEnabled: true,
31
+ identitySuppressClaimDelivery: false,
32
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
23
33
  checkouts: [],
24
34
  checkoutCounter: 0,
25
35
  sessionStatuses: /* @__PURE__ */ new Map(),