@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/SKILL.md CHANGED
@@ -341,6 +341,37 @@ externalId })` helper models a trusted platform/channel link, and
341
341
  `.merge(sourceId, canonicalId)` models governed merge redirects. They are
342
342
  setup helpers only; deployed app code cannot create verified links or merge
343
343
  identities through the integrations SDK.
344
+ - **Profile connections** (`client.identities.claims`) are driven from the same
345
+ accessor: `.claims()` and `.claim(claimId)` inspect what the app opened;
346
+ `.enableStaffAssist()` turns on the org opt-in that `assist`/`transfer` claims
347
+ need (off by default, so an un-opted project gets the real 403);
348
+ `.completeClaim(claimId, { clerkUserId })` models the customer finishing the
349
+ platform-hosted flow; `.refuseClaim(claimId, reason)` models reception or the
350
+ platform turning one down. `completeClaim` flips the claimed contact to
351
+ `verified`, or records `invalidated` when the contact is no longer on that
352
+ profile and `refused` / `contact_owned` when someone else already proved it.
353
+ Completing a finished claim replays the recorded result. It does not attach a
354
+ login or move history — the platform's merge core is not modelled.
355
+ `.allowReturnOrigin(origin)` widens the allowed `returnTo` origins (the test
356
+ app's own origin is allowed by default); a `returnTo` anywhere else is dropped
357
+ silently, exactly as the platform drops it. `claimUrl` carries a one-time
358
+ opaque handle, so never assert it contains the claim id, and `deliver: true`
359
+ comes back as `delivered`. `.suppressClaimDelivery()` models the platform
360
+ sending nothing (a suppressed quota, a bounced address): `deliver: true` then
361
+ returns `delivered: false` with the `claimUrl` still valid, so the app's own
362
+ fallback delivery can be tested.
363
+
364
+ ```ts
365
+ const claim = await integrations.identities.claims.create({
366
+ flow: "invite",
367
+ identityId: person.id,
368
+ kind: "phone",
369
+ externalId: "+15550100",
370
+ });
371
+ app.identities.completeClaim(claim.claimId, { clerkUserId: "user_1" });
372
+ expect((await integrations.identities.claims.get(claim.claimId)).state).toBe("completed");
373
+ ```
374
+
344
375
  - `app.payments` — checkouts created through payments-sdk:
345
376
  `.checkouts`, `.latest()`, `.setProducts()`, `.markPaid(id)`,
346
377
  `.setSessionStatus(id, status)`, `.setPaymentLinkStatus(id, status)`,
@@ -409,11 +440,16 @@ describe the code, and the owner can't tell from them what is or isn't covered.
409
440
  A–Z→a–z; Turkish İ is left unchanged), requires E.164 phone values, and
410
441
  always writes an unverified link. A guest lookup of a verified link returns
411
442
  `{ identityId: null, reason: "verified_conflict" }`; staff provenance may
412
- resolve either verified or unverified links. `attachLink` accepts only
413
- channel kinds (line/facebook/instagram/email/phone); login keys must be
414
- seeded via `app.identities.seedVerifiedLink`. `attachLink(..., { verified:
443
+ resolve either verified or unverified links. `attachLink` records a contact or
444
+ channel id (line/facebook/instagram/email/phone); login keys must be seeded
445
+ via `app.identities.seedVerifiedLink`. `attachLink(..., { verified:
415
446
  true })` remains source-compatible but the simulator (like the control plane)
416
- ignores that flag and returns `verified: false`. `update` replaces the
447
+ ignores that flag and returns `verified: false`. An **unverified** email or
448
+ phone may be recorded on several identities (a household sharing one number);
449
+ a verified holder makes a further _verified_ attach conflict, but unverified
450
+ copies still succeed, and `resolveOrCreate` prefers the verified owner and
451
+ otherwise the earliest unverified holder. Channel and login kinds stay
452
+ single-owner and 409 on a second holder. `update` replaces the
417
453
  `profile` object (not a merge), like the control plane. Inspect via
418
454
  `app.identities`; merge/archive are privileged setup operations, with only
419
455
  test-only merge modeling exposed above.
package/dist/index.d.mts CHANGED
@@ -105,6 +105,32 @@ interface CapturedIdentityLink {
105
105
  * the control plane that `client.identities` talks to in tests. Identities and
106
106
  * links created by app code under test land here; assert on them like `inbox`.
107
107
  */
108
+ interface CapturedIdentityClaim {
109
+ claimId: string;
110
+ flow: "invite" | "assist" | "transfer";
111
+ /** The profile the claim connects the customer to. */
112
+ identityId: string;
113
+ kind: "email" | "phone";
114
+ externalId: string;
115
+ returnTo: string | null;
116
+ /** The app's reference for the staff member, for assist/transfer claims. */
117
+ staffActorRef: string | null;
118
+ state: "pending" | "completed" | "expired" | "invalidated" | "refused";
119
+ refusalReason: string | null;
120
+ completedIdentityId: string | null;
121
+ /** Set by completeClaim; models the Clerk account that proved the contact. */
122
+ claimantClerkUserId: string | null;
123
+ /**
124
+ * Hosted claim link. Its path carries a one-time opaque handle, NOT the claim
125
+ * id — the platform never makes the customer's page derivable from an id an
126
+ * app already holds.
127
+ */
128
+ claimUrl: string;
129
+ /** Whether the platform sent the link itself (only when `deliver` was asked). */
130
+ delivered: boolean | null;
131
+ expiresAt: string;
132
+ createdAt: string;
133
+ }
108
134
  interface TestDirectory {
109
135
  /** All identities, oldest first. */
110
136
  all(): CapturedIdentity[];
@@ -126,6 +152,43 @@ interface TestDirectory {
126
152
  * identity. The production integrations SDK does not expose merge operations.
127
153
  */
128
154
  merge(sourceIdentityId: string, canonicalIdentityId: string): void;
155
+ /** Every profile-connection claim the app has opened, oldest first. */
156
+ claims(): CapturedIdentityClaim[];
157
+ /** A single claim by id, or undefined. */
158
+ claim(claimId: string): CapturedIdentityClaim | undefined;
159
+ /**
160
+ * Test setup only: turn on the org's staff-assistance opt-in. Without it,
161
+ * `claims.create` refuses `assist` and `transfer` with 403, like the platform.
162
+ */
163
+ enableStaffAssist(enabled?: boolean): void;
164
+ /**
165
+ * Test setup only: model the platform failing to send a claim link it was
166
+ * asked to deliver (a suppressed quota, a bounced address). `deliver: true`
167
+ * then comes back `delivered: false` with the `claimUrl` still valid, so an
168
+ * app's own fallback delivery can be tested.
169
+ */
170
+ suppressClaimDelivery(enabled?: boolean): void;
171
+ /**
172
+ * Test setup only: allow a claim `returnTo` on this origin. The harness app's
173
+ * own origin is allowed by default; anything else is dropped silently, like
174
+ * the platform's allowed-origins check.
175
+ */
176
+ allowReturnOrigin(origin: string): void;
177
+ /**
178
+ * Test setup only: model the customer finishing the platform-hosted claim —
179
+ * Clerk proof plus their confirmation. Flips the claimed contact to verified,
180
+ * or records `invalidated` (the contact was removed from that profile) or
181
+ * `refused` with `contact_owned` (someone else already proved it). Replaying a
182
+ * finished claim returns the recorded result unchanged.
183
+ *
184
+ * It does NOT attach a login link or move history: the platform's merge core
185
+ * is not modelled here, only the ownership decision the app can observe.
186
+ */
187
+ completeClaim(claimId: string, params: {
188
+ clerkUserId: string;
189
+ }): CapturedIdentityClaim;
190
+ /** Test setup only: model reception or the platform refusing a claim. */
191
+ refuseClaim(claimId: string, reason: string): CapturedIdentityClaim;
129
192
  clear(): void;
130
193
  get count(): number;
131
194
  }
package/dist/index.d.ts CHANGED
@@ -105,6 +105,32 @@ interface CapturedIdentityLink {
105
105
  * the control plane that `client.identities` talks to in tests. Identities and
106
106
  * links created by app code under test land here; assert on them like `inbox`.
107
107
  */
108
+ interface CapturedIdentityClaim {
109
+ claimId: string;
110
+ flow: "invite" | "assist" | "transfer";
111
+ /** The profile the claim connects the customer to. */
112
+ identityId: string;
113
+ kind: "email" | "phone";
114
+ externalId: string;
115
+ returnTo: string | null;
116
+ /** The app's reference for the staff member, for assist/transfer claims. */
117
+ staffActorRef: string | null;
118
+ state: "pending" | "completed" | "expired" | "invalidated" | "refused";
119
+ refusalReason: string | null;
120
+ completedIdentityId: string | null;
121
+ /** Set by completeClaim; models the Clerk account that proved the contact. */
122
+ claimantClerkUserId: string | null;
123
+ /**
124
+ * Hosted claim link. Its path carries a one-time opaque handle, NOT the claim
125
+ * id — the platform never makes the customer's page derivable from an id an
126
+ * app already holds.
127
+ */
128
+ claimUrl: string;
129
+ /** Whether the platform sent the link itself (only when `deliver` was asked). */
130
+ delivered: boolean | null;
131
+ expiresAt: string;
132
+ createdAt: string;
133
+ }
108
134
  interface TestDirectory {
109
135
  /** All identities, oldest first. */
110
136
  all(): CapturedIdentity[];
@@ -126,6 +152,43 @@ interface TestDirectory {
126
152
  * identity. The production integrations SDK does not expose merge operations.
127
153
  */
128
154
  merge(sourceIdentityId: string, canonicalIdentityId: string): void;
155
+ /** Every profile-connection claim the app has opened, oldest first. */
156
+ claims(): CapturedIdentityClaim[];
157
+ /** A single claim by id, or undefined. */
158
+ claim(claimId: string): CapturedIdentityClaim | undefined;
159
+ /**
160
+ * Test setup only: turn on the org's staff-assistance opt-in. Without it,
161
+ * `claims.create` refuses `assist` and `transfer` with 403, like the platform.
162
+ */
163
+ enableStaffAssist(enabled?: boolean): void;
164
+ /**
165
+ * Test setup only: model the platform failing to send a claim link it was
166
+ * asked to deliver (a suppressed quota, a bounced address). `deliver: true`
167
+ * then comes back `delivered: false` with the `claimUrl` still valid, so an
168
+ * app's own fallback delivery can be tested.
169
+ */
170
+ suppressClaimDelivery(enabled?: boolean): void;
171
+ /**
172
+ * Test setup only: allow a claim `returnTo` on this origin. The harness app's
173
+ * own origin is allowed by default; anything else is dropped silently, like
174
+ * the platform's allowed-origins check.
175
+ */
176
+ allowReturnOrigin(origin: string): void;
177
+ /**
178
+ * Test setup only: model the customer finishing the platform-hosted claim —
179
+ * Clerk proof plus their confirmation. Flips the claimed contact to verified,
180
+ * or records `invalidated` (the contact was removed from that profile) or
181
+ * `refused` with `contact_owned` (someone else already proved it). Replaying a
182
+ * finished claim returns the recorded result unchanged.
183
+ *
184
+ * It does NOT attach a login link or move history: the platform's merge core
185
+ * is not modelled here, only the ownership decision the app can observe.
186
+ */
187
+ completeClaim(claimId: string, params: {
188
+ clerkUserId: string;
189
+ }): CapturedIdentityClaim;
190
+ /** Test setup only: model reception or the platform refusing a claim. */
191
+ refuseClaim(claimId: string, reason: string): CapturedIdentityClaim;
129
192
  clear(): void;
130
193
  get count(): number;
131
194
  }
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,10 @@ 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
+ identitySuppressClaimDelivery: false,
148
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
120
149
  checkouts: [],
121
150
  checkoutCounter: 0,
122
151
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -158,30 +187,6 @@ function requireDb() {
158
187
  return state.db;
159
188
  }
160
189
 
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
190
  // src/simulator/hmac.ts
186
191
  var import_node_crypto = __toESM(require("crypto"));
187
192
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -232,8 +237,8 @@ function json(body, status = 200) {
232
237
  function success(data) {
233
238
  return json({ success: true, data });
234
239
  }
235
- function failure(error, status = 400) {
236
- return json({ success: false, error }, status);
240
+ function failure(error, status = 400, code) {
241
+ return json({ success: false, error, ...code ? { code } : {} }, status);
237
242
  }
238
243
  async function readJsonBody(request) {
239
244
  try {
@@ -721,6 +726,12 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
721
726
  "dashboard_user"
722
727
  ]);
723
728
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
729
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
730
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
731
+ var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
732
+ var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
733
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
734
+ var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
724
735
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
725
736
  var SEARCH_DEFAULT_LIMIT = 50;
726
737
  var SEARCH_MAX_LIMIT = 100;
@@ -803,6 +814,11 @@ function matchingLinks(kind, externalId) {
803
814
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
804
815
  );
805
816
  }
817
+ function sortedMatchingLinks(kind, externalId) {
818
+ return matchingLinks(kind, externalId).sort(
819
+ (a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
820
+ );
821
+ }
806
822
  function normalizedLinkKey(link) {
807
823
  return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
808
824
  }
@@ -916,9 +932,10 @@ async function handleAttachLink(identityId, request) {
916
932
  );
917
933
  if ("error" in normalized) return failure(normalized.error);
918
934
  const { kind, externalId } = normalized;
919
- const [existing] = matchingLinks(kind, externalId);
920
- if (existing) {
921
- if (existing.identityId === identityId) return success({ link: existing });
935
+ const matches = matchingLinks(kind, externalId);
936
+ const ownRow = matches.find((link2) => link2.identityId === identityId);
937
+ if (ownRow) return success({ link: ownRow });
938
+ if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
922
939
  return failure("identifier already linked to another identity", 409);
923
940
  }
924
941
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -935,7 +952,7 @@ async function handleResolve(request) {
935
952
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
936
953
  return failure("displayName must be a string");
937
954
  }
938
- const links = matchingLinks(normalized.kind, normalized.externalId);
955
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
939
956
  if (links.length > 0) {
940
957
  const canonicalIds = /* @__PURE__ */ new Set();
941
958
  for (const link of links) {
@@ -946,12 +963,15 @@ async function handleResolve(request) {
946
963
  return failure(`cannot resolve identity link: ${message}`, 409);
947
964
  }
948
965
  }
949
- if (canonicalIds.size !== 1) {
966
+ const verifiedOwners = new Set(
967
+ links.filter((link) => link.verified).map((link) => link.identityId)
968
+ );
969
+ if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
950
970
  return failure("identifier is linked to multiple identities", 409);
951
971
  }
952
- const canonicalId = [...canonicalIds][0];
953
- const hasVerifiedLink = links.some((link) => link.verified);
954
- if (provenance === "guest" && hasVerifiedLink) {
972
+ const [chosen] = links;
973
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
974
+ if (provenance === "guest" && chosen.verified) {
955
975
  return success({ identityId: null, reason: "verified_conflict" });
956
976
  }
957
977
  return success({ identityId: canonicalId, created: false });
@@ -1053,6 +1073,116 @@ async function handleAliases(request) {
1053
1073
  }
1054
1074
  return success({ aliases });
1055
1075
  }
1076
+ function claimView(claim) {
1077
+ const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
1078
+ return {
1079
+ claimId: claim.claimId,
1080
+ flow: claim.flow,
1081
+ state: state_,
1082
+ refusalReason: claim.refusalReason,
1083
+ completedIdentityId: claim.completedIdentityId,
1084
+ expiresAt: claim.expiresAt
1085
+ };
1086
+ }
1087
+ function validateClaimStaff(flow, staff) {
1088
+ if (flow === "invite") {
1089
+ if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
1090
+ return { staffActorRef: null };
1091
+ }
1092
+ if (!staff || typeof staff !== "object") {
1093
+ return { error: `staff is required for a ${flow} claim` };
1094
+ }
1095
+ const { actorRef, grant } = staff;
1096
+ if (typeof actorRef !== "string" || actorRef.length === 0) {
1097
+ return { error: "staff.actorRef is required" };
1098
+ }
1099
+ if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1100
+ return { staffActorRef: actorRef };
1101
+ }
1102
+ async function handleCreateClaim(request) {
1103
+ const body = await readJsonBody(request);
1104
+ const flow = body.flow;
1105
+ if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1106
+ return failure("flow must be one of: invite, assist, transfer");
1107
+ }
1108
+ if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1109
+ return failure("identityId must be a uuid");
1110
+ }
1111
+ const normalized = normalizeLinkInput(
1112
+ body.kind,
1113
+ body.externalId,
1114
+ CLAIM_KINDS,
1115
+ "kind must be one of: email, phone"
1116
+ );
1117
+ if ("error" in normalized) return failure(normalized.error);
1118
+ let returnTo = null;
1119
+ if (body.returnTo !== void 0) {
1120
+ if (typeof body.returnTo !== "string") return failure("returnTo must be a string");
1121
+ let parsed;
1122
+ try {
1123
+ parsed = new URL(body.returnTo);
1124
+ } catch {
1125
+ return failure("returnTo must be an absolute URL");
1126
+ }
1127
+ returnTo = state.identityAllowedReturnOrigins.has(parsed.origin) ? body.returnTo : null;
1128
+ }
1129
+ if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1130
+ return failure("deliver must be a boolean");
1131
+ }
1132
+ const staff = validateClaimStaff(flow, body.staff);
1133
+ if ("error" in staff) return failure(staff.error);
1134
+ if (flow !== "invite" && !state.identityStaffAssistEnabled) {
1135
+ return failure(
1136
+ "staff-assisted profile claiming is not enabled for this project",
1137
+ 403,
1138
+ STAFF_ASSISTANCE_NOT_ENABLED
1139
+ );
1140
+ }
1141
+ const identity = state.identities.get(body.identityId);
1142
+ if (!identity || identity.type !== "person" || identity.status !== "active") {
1143
+ return failure("identity not found", 404);
1144
+ }
1145
+ const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1146
+ (link) => link.identityId === identity.id
1147
+ );
1148
+ if (!holdsContact) {
1149
+ return failure("identity does not hold that contact", 409);
1150
+ }
1151
+ const claimId = import_node_crypto3.default.randomUUID();
1152
+ const deliver = body.deliver === true;
1153
+ const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
1154
+ const claim = {
1155
+ claimId,
1156
+ flow,
1157
+ identityId: identity.id,
1158
+ kind: normalized.kind,
1159
+ externalId: normalized.externalId,
1160
+ returnTo,
1161
+ staffActorRef: staff.staffActorRef,
1162
+ state: "pending",
1163
+ refusalReason: null,
1164
+ completedIdentityId: null,
1165
+ claimantClerkUserId: null,
1166
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1167
+ delivered: deliver ? !state.identitySuppressClaimDelivery : null,
1168
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1169
+ createdAt: now()
1170
+ };
1171
+ state.identityClaims.set(claimId, claim);
1172
+ return success({
1173
+ claimId,
1174
+ claimUrl: claim.claimUrl,
1175
+ expiresAt: claim.expiresAt,
1176
+ // Absent unless asked for: an app delivering its own link must not read a
1177
+ // missing field as "nothing was sent".
1178
+ ...deliver ? { delivered: claim.delivered } : {}
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
+ }
1056
1186
  async function handleIdentitiesRequest(request, subPath) {
1057
1187
  const method = request.method;
1058
1188
  if (subPath === "" || subPath === "/") {
@@ -1068,6 +1198,13 @@ async function handleIdentitiesRequest(request, subPath) {
1068
1198
  if (subPath === "/search" && method === "GET") {
1069
1199
  return handleSearch(request);
1070
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
+ }
1071
1208
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
1072
1209
  if (linksMatch && method === "POST") {
1073
1210
  return handleAttachLink(linksMatch[1], request);
@@ -1092,11 +1229,13 @@ function seedVerifiedLink(identityId, params) {
1092
1229
  );
1093
1230
  if ("error" in normalized) throw new Error(normalized.error);
1094
1231
  const { kind, externalId } = normalized;
1095
- const [existing] = matchingLinks(kind, externalId);
1232
+ const matches = matchingLinks(kind, externalId);
1233
+ const blocker = matches.find(
1234
+ (link2) => link2.identityId !== identityId && (link2.verified || !DUPLICABLE_UNVERIFIED_KINDS.has(link2.kind))
1235
+ );
1236
+ if (blocker) throw new Error("identifier already linked to another identity");
1237
+ const existing = matches.find((link2) => link2.identityId === identityId);
1096
1238
  if (existing) {
1097
- if (existing.identityId !== identityId) {
1098
- throw new Error("identifier already linked to another identity");
1099
- }
1100
1239
  existing.verified = true;
1101
1240
  return existing;
1102
1241
  }
@@ -1126,7 +1265,12 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1126
1265
  );
1127
1266
  for (const sourceLink of sourceLinks) {
1128
1267
  const key = normalizedLinkKey(sourceLink);
1129
- if (otherLinks.some((otherLink) => normalizedLinkKey(otherLink) === key)) {
1268
+ const conflicting = otherLinks.some(
1269
+ (otherLink) => normalizedLinkKey(otherLink) === key && // Same partial rule as the index: a third identity's UNVERIFIED copy of a
1270
+ // shared email/phone is allowed to coexist and must not block the merge.
1271
+ (!DUPLICABLE_UNVERIFIED_KINDS.has(key.split(":")[0]) || otherLink.verified && sourceLink.verified)
1272
+ );
1273
+ if (conflicting) {
1130
1274
  throw new Error("merge would conflict with a link owned by another identity");
1131
1275
  }
1132
1276
  }
@@ -1153,6 +1297,45 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1153
1297
  source.mergedIntoId = canonical.id;
1154
1298
  source.updatedAt = now();
1155
1299
  }
1300
+ function requireClaim(claimId) {
1301
+ const claim = state.identityClaims.get(claimId);
1302
+ if (!claim) throw new Error("claim not found");
1303
+ return claim;
1304
+ }
1305
+ function completeClaim(claimId, params) {
1306
+ const claim = requireClaim(claimId);
1307
+ if (claim.state !== "pending") return claim;
1308
+ if (Date.parse(claim.expiresAt) <= Date.now()) {
1309
+ claim.state = "expired";
1310
+ return claim;
1311
+ }
1312
+ if (!params.clerkUserId) throw new Error("clerkUserId is required");
1313
+ const rows = matchingLinks(claim.kind, claim.externalId);
1314
+ const target = rows.find((link) => link.identityId === claim.identityId);
1315
+ if (!target) {
1316
+ claim.state = "invalidated";
1317
+ return claim;
1318
+ }
1319
+ const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
1320
+ if (otherVerified) {
1321
+ claim.state = "refused";
1322
+ claim.refusalReason = "contact_owned";
1323
+ return claim;
1324
+ }
1325
+ target.verified = true;
1326
+ claim.state = "completed";
1327
+ claim.completedIdentityId = claim.identityId;
1328
+ claim.claimantClerkUserId = params.clerkUserId;
1329
+ return claim;
1330
+ }
1331
+ function refuseClaim(claimId, reason) {
1332
+ const claim = requireClaim(claimId);
1333
+ if (claim.state !== "pending") return claim;
1334
+ if (!reason) throw new Error("a refusal reason is required");
1335
+ claim.state = "refused";
1336
+ claim.refusalReason = reason;
1337
+ return claim;
1338
+ }
1156
1339
  function createDirectory() {
1157
1340
  return {
1158
1341
  all: () => [...state.identities.values()],
@@ -1160,9 +1343,26 @@ function createDirectory() {
1160
1343
  links: (identityId) => linksFor(identityId),
1161
1344
  seedVerifiedLink,
1162
1345
  merge,
1346
+ claims: () => [...state.identityClaims.values()],
1347
+ claim: (claimId) => state.identityClaims.get(claimId),
1348
+ enableStaffAssist: (enabled = true) => {
1349
+ state.identityStaffAssistEnabled = enabled;
1350
+ },
1351
+ suppressClaimDelivery: (enabled = true) => {
1352
+ state.identitySuppressClaimDelivery = enabled;
1353
+ },
1354
+ allowReturnOrigin: (origin) => {
1355
+ state.identityAllowedReturnOrigins.add(new URL(origin).origin);
1356
+ },
1357
+ completeClaim,
1358
+ refuseClaim,
1163
1359
  clear: () => {
1164
1360
  state.identities.clear();
1165
1361
  state.identityLinks = [];
1362
+ state.identityClaims.clear();
1363
+ state.identityStaffAssistEnabled = false;
1364
+ state.identitySuppressClaimDelivery = false;
1365
+ state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
1166
1366
  },
1167
1367
  get count() {
1168
1368
  return state.identities.size;
@@ -1201,7 +1401,7 @@ async function importNextServer() {
1201
1401
  async function callRoute(handler, options = {}) {
1202
1402
  const { NextRequest } = await importNextServer();
1203
1403
  const path = options.path ?? "/api/test-route";
1204
- const url = new URL(`http://localhost:3333${path}`);
1404
+ const url = new URL(`${APP_TEST_ORIGIN}${path}`);
1205
1405
  for (const [key, value] of Object.entries(options.searchParams ?? {})) {
1206
1406
  url.searchParams.set(key, value);
1207
1407
  }
@@ -2976,8 +3176,7 @@ async function createTestApp(options = {}) {
2976
3176
  state.refreshSessions.clear();
2977
3177
  state.emails = [];
2978
3178
  state.emailCounter = 0;
2979
- state.identities.clear();
2980
- state.identityLinks = [];
3179
+ directory.clear();
2981
3180
  payments.clear();
2982
3181
  storage.clear();
2983
3182
  messages.clear();
@@ -2991,8 +3190,7 @@ async function createTestApp(options = {}) {
2991
3190
  state.refreshSessions.clear();
2992
3191
  state.emails = [];
2993
3192
  state.emailCounter = 0;
2994
- state.identities.clear();
2995
- state.identityLinks = [];
3193
+ directory.clear();
2996
3194
  payments.clear();
2997
3195
  storage.clear();
2998
3196
  messages.clear();