@stardeck-customer-apps/testing 0.16.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/SKILL.md CHANGED
@@ -350,8 +350,9 @@ 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
+ Completing a finished claim replays the recorded result. Completion attaches
354
+ the claimant's login to the profile (moving it off any profile it was on), but
355
+ does not move history — the platform's merge core is not modelled.
355
356
  `.allowReturnOrigin(origin)` widens the allowed `returnTo` origins (the test
356
357
  app's own origin is allowed by default); a `returnTo` anywhere else is dropped
357
358
  silently, exactly as the platform drops it. `claimUrl` carries a one-time
@@ -359,7 +360,22 @@ externalId })` helper models a trusted platform/channel link, and
359
360
  comes back as `delivered`. `.suppressClaimDelivery()` models the platform
360
361
  sending nothing (a suppressed quota, a bounced address): `deliver: true` then
361
362
  returns `delivered: false` with the `claimUrl` still valid, so the app's own
362
- fallback delivery can be tested.
363
+ fallback delivery can be tested. Bearer links are on by default, like the platform;
364
+ `.enableBearerLinks(false)` models a project whose admin turned them off. A bearer claim is raised against a
365
+ profile with no contact at all, so it takes no `kind`/`externalId`/`deliver`
366
+ (sending one is refused, not ignored) and `completeClaim` connects whoever
367
+ completes it — unless another login already holds that profile, which comes
368
+ back `refused` / `owned_elsewhere`. That refusal is the one a leaked bearer
369
+ link runs into, so test it. A profile that holds an email or phone is refused
370
+ at `create` with a 409 (send an `invite`); one that gains a contact before
371
+ the link is used completes as `refused` / `protected`, and a link still
372
+ outstanding when `.enableBearerLinks(false)` runs completes as `refused` /
373
+ `expired`. People seeded with only a `dashboard_user` link (team members) are
374
+ left out of `identities.list()` and `search()`, as on the platform.
375
+ `search({ attributes })` matches profile attributes exactly and by JSON type
376
+ (`5` never matches `"5"`) and rejects nested, array, `null` or more than 10
377
+ keys (or a `__proto__` key) with a `ValidationError` whose message matches the
378
+ platform's, like the platform.
363
379
 
364
380
  ```ts
365
381
  const claim = await integrations.identities.claims.create({
@@ -372,6 +388,17 @@ externalId })` helper models a trusted platform/channel link, and
372
388
  expect((await integrations.identities.claims.get(claim.claimId)).state).toBe("completed");
373
389
  ```
374
390
 
391
+ ```ts
392
+ // A name on a card, no email or phone: the link IS the authorisation.
393
+ const bearer = await integrations.identities.claims.create({
394
+ flow: "bearer",
395
+ identityId: walkIn.id,
396
+ });
397
+ expect(app.identities.completeClaim(bearer.claimId, { clerkUserId: "user_2" }).state).toBe(
398
+ "completed"
399
+ );
400
+ ```
401
+
375
402
  - `app.payments` — checkouts created through payments-sdk:
376
403
  `.checkouts`, `.latest()`, `.setProducts()`, `.markPaid(id)`,
377
404
  `.setSessionStatus(id, status)`, `.setPaymentLinkStatus(id, status)`,
@@ -450,7 +477,10 @@ true })` remains source-compatible but the simulator (like the control plane)
450
477
  copies still succeed, and `resolveOrCreate` prefers the verified owner and
451
478
  otherwise the earliest unverified holder. Channel and login kinds stay
452
479
  single-owner and 409 on a second holder. `update` replaces the
453
- `profile` object (not a merge), like the control plane. Inspect via
480
+ `profile` object (not a merge), like the control plane. A person with a
481
+ seeded `dashboard_user` link is a team member: changing their `displayName`
482
+ or `profile.email` is refused with a 409 `ValidationError`, and a new
483
+ `profile` without `email` keeps the current one, as on the platform. Inspect via
454
484
  `app.identities`; merge/archive are privileged setup operations, with only
455
485
  test-only merge modeling exposed above.
456
486
  - `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`
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`
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,6 +1408,7 @@ 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) {
@@ -1322,12 +1421,55 @@ function completeClaim(claimId, params) {
1322
1421
  claim.refusalReason = "contact_owned";
1323
1422
  return claim;
1324
1423
  }
1424
+ if (claim.flow !== "transfer" && ownedByAnotherLogin(claim.identityId, params.clerkUserId)) {
1425
+ claim.state = "refused";
1426
+ claim.refusalReason = "owned_elsewhere";
1427
+ return claim;
1428
+ }
1325
1429
  target.verified = true;
1430
+ connectLogin(claim, params.clerkUserId);
1326
1431
  claim.state = "completed";
1327
1432
  claim.completedIdentityId = claim.identityId;
1328
1433
  claim.claimantClerkUserId = params.clerkUserId;
1329
1434
  return claim;
1330
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
+ }
1331
1473
  function refuseClaim(claimId, reason) {
1332
1474
  const claim = requireClaim(claimId);
1333
1475
  if (claim.state !== "pending") return claim;
@@ -1348,6 +1490,9 @@ function createDirectory() {
1348
1490
  enableStaffAssist: (enabled = true) => {
1349
1491
  state.identityStaffAssistEnabled = enabled;
1350
1492
  },
1493
+ enableBearerLinks: (enabled = true) => {
1494
+ state.identityBearerLinksEnabled = enabled;
1495
+ },
1351
1496
  suppressClaimDelivery: (enabled = true) => {
1352
1497
  state.identitySuppressClaimDelivery = enabled;
1353
1498
  },
@@ -1361,6 +1506,7 @@ function createDirectory() {
1361
1506
  state.identityLinks = [];
1362
1507
  state.identityClaims.clear();
1363
1508
  state.identityStaffAssistEnabled = false;
1509
+ state.identityBearerLinksEnabled = true;
1364
1510
  state.identitySuppressClaimDelivery = false;
1365
1511
  state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
1366
1512
  },