@stardeck-customer-apps/testing 0.16.0 → 0.18.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
@@ -350,8 +350,12 @@ externalId })` helper models a trusted platform/channel link, and
350
350
  platform turning one down. `completeClaim` flips the claimed contact to
351
351
  `verified`, or records `invalidated` when the contact is no longer on that
352
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.
353
+ A contact the claimant already proved on their own login profile is not
354
+ "someone else": the claim completes and the one verified copy ends up on the
355
+ claimed profile.
356
+ Completing a finished claim replays the recorded result. Completion attaches
357
+ the claimant's login to the profile (moving it off any profile it was on), but
358
+ does not move history — the platform's merge core is not modelled.
355
359
  `.allowReturnOrigin(origin)` widens the allowed `returnTo` origins (the test
356
360
  app's own origin is allowed by default); a `returnTo` anywhere else is dropped
357
361
  silently, exactly as the platform drops it. `claimUrl` carries a one-time
@@ -359,7 +363,22 @@ externalId })` helper models a trusted platform/channel link, and
359
363
  comes back as `delivered`. `.suppressClaimDelivery()` models the platform
360
364
  sending nothing (a suppressed quota, a bounced address): `deliver: true` then
361
365
  returns `delivered: false` with the `claimUrl` still valid, so the app's own
362
- fallback delivery can be tested.
366
+ fallback delivery can be tested. Bearer links are on by default, like the platform;
367
+ `.enableBearerLinks(false)` models a project whose admin turned them off. A bearer claim is raised against a
368
+ profile with no contact at all, so it takes no `kind`/`externalId`/`deliver`
369
+ (sending one is refused, not ignored) and `completeClaim` connects whoever
370
+ completes it — unless another login already holds that profile, which comes
371
+ back `refused` / `owned_elsewhere`. That refusal is the one a leaked bearer
372
+ link runs into, so test it. A profile that holds an email or phone is refused
373
+ at `create` with a 409 (send an `invite`); one that gains a contact before
374
+ the link is used completes as `refused` / `protected`, and a link still
375
+ outstanding when `.enableBearerLinks(false)` runs completes as `refused` /
376
+ `expired`. People seeded with only a `dashboard_user` link (team members) are
377
+ left out of `identities.list()` and `search()`, as on the platform.
378
+ `search({ attributes })` matches profile attributes exactly and by JSON type
379
+ (`5` never matches `"5"`) and rejects nested, array, `null` or more than 10
380
+ keys (or a `__proto__` key) with a `ValidationError` whose message matches the
381
+ platform's, like the platform.
363
382
 
364
383
  ```ts
365
384
  const claim = await integrations.identities.claims.create({
@@ -372,6 +391,17 @@ externalId })` helper models a trusted platform/channel link, and
372
391
  expect((await integrations.identities.claims.get(claim.claimId)).state).toBe("completed");
373
392
  ```
374
393
 
394
+ ```ts
395
+ // A name on a card, no email or phone: the link IS the authorisation.
396
+ const bearer = await integrations.identities.claims.create({
397
+ flow: "bearer",
398
+ identityId: walkIn.id,
399
+ });
400
+ expect(app.identities.completeClaim(bearer.claimId, { clerkUserId: "user_2" }).state).toBe(
401
+ "completed"
402
+ );
403
+ ```
404
+
375
405
  - `app.payments` — checkouts created through payments-sdk:
376
406
  `.checkouts`, `.latest()`, `.setProducts()`, `.markPaid(id)`,
377
407
  `.setSessionStatus(id, status)`, `.setPaymentLinkStatus(id, status)`,
@@ -450,7 +480,10 @@ true })` remains source-compatible but the simulator (like the control plane)
450
480
  copies still succeed, and `resolveOrCreate` prefers the verified owner and
451
481
  otherwise the earliest unverified holder. Channel and login kinds stay
452
482
  single-owner and 409 on a second holder. `update` replaces the
453
- `profile` object (not a merge), like the control plane. Inspect via
483
+ `profile` object (not a merge), like the control plane. A person with a
484
+ seeded `dashboard_user` link is a team member: changing their `displayName`
485
+ or `profile.email` is refused with a 409 `ValidationError`, and a new
486
+ `profile` without `email` keeps the current one, as on the platform. Inspect via
454
487
  `app.identities`; merge/archive are privileged setup operations, with only
455
488
  test-only merge modeling exposed above.
456
489
  - `session.user.identityId` is the platform-provisioned cross-app customer key.
package/dist/index.d.mts CHANGED
@@ -107,11 +107,12 @@ interface CapturedIdentityLink {
107
107
  */
108
108
  interface CapturedIdentityClaim {
109
109
  claimId: string;
110
- flow: "invite" | "assist" | "transfer";
110
+ flow: "invite" | "assist" | "transfer" | "bearer";
111
111
  /** The profile the claim connects the customer to. */
112
112
  identityId: string;
113
- kind: "email" | "phone";
114
- externalId: string;
113
+ /** The recorded contact this claim was raised against. Null for `bearer` only. */
114
+ kind: "email" | "phone" | null;
115
+ externalId: string | null;
115
116
  returnTo: string | null;
116
117
  /** The app's reference for the staff member, for assist/transfer claims. */
117
118
  staffActorRef: string | null;
@@ -161,6 +162,12 @@ interface TestDirectory {
161
162
  * `claims.create` refuses `assist` and `transfer` with 403, like the platform.
162
163
  */
163
164
  enableStaffAssist(enabled?: boolean): void;
165
+ /**
166
+ * Test setup only: set the project's bearer-link opt-in. It is on by default,
167
+ * like the platform; pass `false` to model an org that switched it off, and
168
+ * `claims.create({ flow: "bearer" })` is then refused with 403.
169
+ */
170
+ enableBearerLinks(enabled?: boolean): void;
164
171
  /**
165
172
  * Test setup only: model the platform failing to send a claim link it was
166
173
  * asked to deliver (a suppressed quota, a bounced address). `deliver: true`
@@ -178,7 +185,9 @@ interface TestDirectory {
178
185
  * Test setup only: model the customer finishing the platform-hosted claim —
179
186
  * Clerk proof plus their confirmation. Flips the claimed contact to verified,
180
187
  * or records `invalidated` (the contact was removed from that profile) or
181
- * `refused` with `contact_owned` (someone else already proved it). Replaying a
188
+ * `refused` with `contact_owned` (someone else already proved it — the
189
+ * claimant's own login profile does not count; its verified copy is dropped
190
+ * so the claimed profile holds the only one). Replaying a
182
191
  * finished claim returns the recorded result unchanged.
183
192
  *
184
193
  * It does NOT attach a login link or move history: the platform's merge core
package/dist/index.d.ts CHANGED
@@ -107,11 +107,12 @@ interface CapturedIdentityLink {
107
107
  */
108
108
  interface CapturedIdentityClaim {
109
109
  claimId: string;
110
- flow: "invite" | "assist" | "transfer";
110
+ flow: "invite" | "assist" | "transfer" | "bearer";
111
111
  /** The profile the claim connects the customer to. */
112
112
  identityId: string;
113
- kind: "email" | "phone";
114
- externalId: string;
113
+ /** The recorded contact this claim was raised against. Null for `bearer` only. */
114
+ kind: "email" | "phone" | null;
115
+ externalId: string | null;
115
116
  returnTo: string | null;
116
117
  /** The app's reference for the staff member, for assist/transfer claims. */
117
118
  staffActorRef: string | null;
@@ -161,6 +162,12 @@ interface TestDirectory {
161
162
  * `claims.create` refuses `assist` and `transfer` with 403, like the platform.
162
163
  */
163
164
  enableStaffAssist(enabled?: boolean): void;
165
+ /**
166
+ * Test setup only: set the project's bearer-link opt-in. It is on by default,
167
+ * like the platform; pass `false` to model an org that switched it off, and
168
+ * `claims.create({ flow: "bearer" })` is then refused with 403.
169
+ */
170
+ enableBearerLinks(enabled?: boolean): void;
164
171
  /**
165
172
  * Test setup only: model the platform failing to send a claim link it was
166
173
  * asked to deliver (a suppressed quota, a bounced address). `deliver: true`
@@ -178,7 +185,9 @@ interface TestDirectory {
178
185
  * Test setup only: model the customer finishing the platform-hosted claim —
179
186
  * Clerk proof plus their confirmation. Flips the claimed contact to verified,
180
187
  * or records `invalidated` (the contact was removed from that profile) or
181
- * `refused` with `contact_owned` (someone else already proved it). Replaying a
188
+ * `refused` with `contact_owned` (someone else already proved it — the
189
+ * claimant's own login profile does not count; its verified copy is dropped
190
+ * so the claimed profile holds the only one). Replaying a
182
191
  * finished claim returns the recorded result unchanged.
183
192
  *
184
193
  * It does NOT attach a login link or move history: the platform's merge core
package/dist/index.js CHANGED
@@ -144,6 +144,7 @@ var state = globalSingleton("state", () => ({
144
144
  identityLinks: [],
145
145
  identityClaims: /* @__PURE__ */ new Map(),
146
146
  identityStaffAssistEnabled: false,
147
+ identityBearerLinksEnabled: true,
147
148
  identitySuppressClaimDelivery: false,
148
149
  identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
149
150
  checkouts: [],
@@ -727,14 +728,16 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
727
728
  ]);
728
729
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
729
730
  var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
730
- var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
731
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
731
732
  var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
732
733
  var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
733
734
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
734
735
  var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
736
+ var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
735
737
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
736
738
  var SEARCH_DEFAULT_LIMIT = 50;
737
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.";
738
741
  function asciiLowercaseTrim(value) {
739
742
  const trimmed = value.trim();
740
743
  let out = "";
@@ -854,11 +857,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
854
857
  state.identityLinks.push(link);
855
858
  return link;
856
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
+ }
857
870
  function handleList(request) {
858
871
  const typeParam = new URL(request.url).searchParams.get("type");
859
872
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
860
873
  const identities = [...state.identities.values()].filter(
861
- (identity) => identity.status !== "merged"
874
+ (identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
862
875
  );
863
876
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
864
877
  }
@@ -902,11 +915,18 @@ async function handleUpdate(identityId, request) {
902
915
  const identity = state.identities.get(identityId);
903
916
  if (!identity) return failure("identity not found", 404);
904
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
+ }
905
924
  if (body.displayName !== void 0) {
906
925
  identity.displayName = body.displayName;
907
926
  }
908
- if (body.profile !== void 0) {
909
- 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;
910
930
  }
911
931
  if (body.externalRef !== void 0) {
912
932
  identity.externalRef = body.externalRef;
@@ -1008,6 +1028,36 @@ function identityMatchesQuery(identity, query) {
1008
1028
  (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
1009
1029
  );
1010
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
+ }
1011
1061
  function handleSearch(request) {
1012
1062
  const url = new URL(request.url);
1013
1063
  const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
@@ -1020,10 +1070,14 @@ function handleSearch(request) {
1020
1070
  return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
1021
1071
  }
1022
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
+ }
1023
1077
  const rawCursor = url.searchParams.get("cursor");
1024
1078
  const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
1025
1079
  if (rawCursor && !cursor) return failure("cursor is invalid");
1026
- 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);
1027
1081
  const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
1028
1082
  const pageStart = start < 0 ? identities.length : start;
1029
1083
  const items = identities.slice(pageStart, pageStart + limit);
@@ -1099,15 +1153,92 @@ function validateClaimStaff(flow, staff) {
1099
1153
  if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1100
1154
  return { staffActorRef: actorRef };
1101
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
+ }
1102
1230
  async function handleCreateClaim(request) {
1103
1231
  const body = await readJsonBody(request);
1104
1232
  const flow = body.flow;
1105
1233
  if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1106
- return failure("flow must be one of: invite, assist, transfer");
1234
+ return failure("flow must be one of: invite, assist, transfer, bearer");
1107
1235
  }
1108
1236
  if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1109
1237
  return failure("identityId must be a uuid");
1110
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);
1111
1242
  const normalized = normalizeLinkInput(
1112
1243
  body.kind,
1113
1244
  body.externalId,
@@ -1115,17 +1246,6 @@ async function handleCreateClaim(request) {
1115
1246
  "kind must be one of: email, phone"
1116
1247
  );
1117
1248
  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
1249
  if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1130
1250
  return failure("deliver must be a boolean");
1131
1251
  }
@@ -1138,44 +1258,22 @@ async function handleCreateClaim(request) {
1138
1258
  STAFF_ASSISTANCE_NOT_ENABLED
1139
1259
  );
1140
1260
  }
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
- }
1261
+ const identity = claimableIdentity(body.identityId);
1262
+ if (!identity) return failure("identity not found", 404);
1145
1263
  const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1146
1264
  (link) => link.identityId === identity.id
1147
1265
  );
1148
1266
  if (!holdsContact) {
1149
1267
  return failure("identity does not hold that contact", 409);
1150
1268
  }
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,
1269
+ return mintClaim({
1156
1270
  flow,
1157
1271
  identityId: identity.id,
1158
1272
  kind: normalized.kind,
1159
1273
  externalId: normalized.externalId,
1160
- returnTo,
1274
+ returnTo: parsedReturnTo.returnTo,
1161
1275
  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 } : {}
1276
+ deliver: body.deliver === true
1179
1277
  });
1180
1278
  }
1181
1279
  function handleGetClaim(claimId) {
@@ -1310,24 +1408,73 @@ function completeClaim(claimId, params) {
1310
1408
  return claim;
1311
1409
  }
1312
1410
  if (!params.clerkUserId) throw new Error("clerkUserId is required");
1411
+ if (claim.flow === "bearer") return completeBearerClaim(claim, params.clerkUserId);
1313
1412
  const rows = matchingLinks(claim.kind, claim.externalId);
1314
1413
  const target = rows.find((link) => link.identityId === claim.identityId);
1315
1414
  if (!target) {
1316
1415
  claim.state = "invalidated";
1317
1416
  return claim;
1318
1417
  }
1418
+ const claimantIdentityId = state.identityLinks.find(
1419
+ (link) => link.kind === "project_auth_user" && link.externalId === params.clerkUserId
1420
+ )?.identityId;
1319
1421
  const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
1320
- if (otherVerified) {
1422
+ const ownProof = otherVerified !== void 0 && otherVerified.identityId === claimantIdentityId;
1423
+ if (otherVerified && !ownProof) {
1321
1424
  claim.state = "refused";
1322
1425
  claim.refusalReason = "contact_owned";
1323
1426
  return claim;
1324
1427
  }
1428
+ if (claim.flow !== "transfer" && ownedByAnotherLogin(claim.identityId, params.clerkUserId)) {
1429
+ claim.state = "refused";
1430
+ claim.refusalReason = "owned_elsewhere";
1431
+ return claim;
1432
+ }
1433
+ if (ownProof) state.identityLinks = state.identityLinks.filter((link) => link !== otherVerified);
1325
1434
  target.verified = true;
1435
+ connectLogin(claim, params.clerkUserId);
1326
1436
  claim.state = "completed";
1327
1437
  claim.completedIdentityId = claim.identityId;
1328
1438
  claim.claimantClerkUserId = params.clerkUserId;
1329
1439
  return claim;
1330
1440
  }
1441
+ function ownedByAnotherLogin(identityId, clerkUserId) {
1442
+ return linksFor(identityId).some(
1443
+ (link) => link.kind === "project_auth_user" && link.externalId !== clerkUserId
1444
+ );
1445
+ }
1446
+ function connectLogin(claim, clerkUserId) {
1447
+ const isLogin = (link) => link.identityId === claim.identityId && link.kind === "project_auth_user";
1448
+ if (claim.flow === "transfer") {
1449
+ state.identityLinks = state.identityLinks.filter(
1450
+ (link) => !isLogin(link) || link.externalId === clerkUserId
1451
+ );
1452
+ }
1453
+ state.identityLinks = state.identityLinks.filter(
1454
+ (link) => link.kind !== "project_auth_user" || link.externalId !== clerkUserId || link.identityId === claim.identityId
1455
+ );
1456
+ if (!state.identityLinks.some((link) => isLogin(link) && link.externalId === clerkUserId)) {
1457
+ createUnverifiedLink(claim.identityId, "project_auth_user", clerkUserId).verified = true;
1458
+ }
1459
+ }
1460
+ function completeBearerClaim(claim, clerkUserId) {
1461
+ const identity = state.identities.get(claim.identityId);
1462
+ if (!identity || identity.type !== "person" || identity.status !== "active") {
1463
+ claim.state = "invalidated";
1464
+ return claim;
1465
+ }
1466
+ const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
1467
+ if (refusal) {
1468
+ claim.state = "refused";
1469
+ claim.refusalReason = refusal;
1470
+ return claim;
1471
+ }
1472
+ connectLogin(claim, clerkUserId);
1473
+ claim.state = "completed";
1474
+ claim.completedIdentityId = claim.identityId;
1475
+ claim.claimantClerkUserId = clerkUserId;
1476
+ return claim;
1477
+ }
1331
1478
  function refuseClaim(claimId, reason) {
1332
1479
  const claim = requireClaim(claimId);
1333
1480
  if (claim.state !== "pending") return claim;
@@ -1348,6 +1495,9 @@ function createDirectory() {
1348
1495
  enableStaffAssist: (enabled = true) => {
1349
1496
  state.identityStaffAssistEnabled = enabled;
1350
1497
  },
1498
+ enableBearerLinks: (enabled = true) => {
1499
+ state.identityBearerLinksEnabled = enabled;
1500
+ },
1351
1501
  suppressClaimDelivery: (enabled = true) => {
1352
1502
  state.identitySuppressClaimDelivery = enabled;
1353
1503
  },
@@ -1361,6 +1511,7 @@ function createDirectory() {
1361
1511
  state.identityLinks = [];
1362
1512
  state.identityClaims.clear();
1363
1513
  state.identityStaffAssistEnabled = false;
1514
+ state.identityBearerLinksEnabled = true;
1364
1515
  state.identitySuppressClaimDelivery = false;
1365
1516
  state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
1366
1517
  },