@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/dist/index.mjs CHANGED
@@ -95,6 +95,7 @@ var state = globalSingleton("state", () => ({
95
95
  identityLinks: [],
96
96
  identityClaims: /* @__PURE__ */ new Map(),
97
97
  identityStaffAssistEnabled: false,
98
+ identityBearerLinksEnabled: true,
98
99
  identitySuppressClaimDelivery: false,
99
100
  identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
100
101
  checkouts: [],
@@ -678,14 +679,16 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
678
679
  ]);
679
680
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
680
681
  var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
681
- var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
682
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
682
683
  var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
683
684
  var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
684
685
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
685
686
  var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
687
+ var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
686
688
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
687
689
  var SEARCH_DEFAULT_LIMIT = 50;
688
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.";
689
692
  function asciiLowercaseTrim(value) {
690
693
  const trimmed = value.trim();
691
694
  let out = "";
@@ -805,11 +808,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
805
808
  state.identityLinks.push(link);
806
809
  return link;
807
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
+ }
808
821
  function handleList(request) {
809
822
  const typeParam = new URL(request.url).searchParams.get("type");
810
823
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
811
824
  const identities = [...state.identities.values()].filter(
812
- (identity) => identity.status !== "merged"
825
+ (identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
813
826
  );
814
827
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
815
828
  }
@@ -853,11 +866,18 @@ async function handleUpdate(identityId, request) {
853
866
  const identity = state.identities.get(identityId);
854
867
  if (!identity) return failure("identity not found", 404);
855
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
+ }
856
875
  if (body.displayName !== void 0) {
857
876
  identity.displayName = body.displayName;
858
877
  }
859
- if (body.profile !== void 0) {
860
- 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;
861
881
  }
862
882
  if (body.externalRef !== void 0) {
863
883
  identity.externalRef = body.externalRef;
@@ -959,6 +979,36 @@ function identityMatchesQuery(identity, query) {
959
979
  (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
960
980
  );
961
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
+ }
962
1012
  function handleSearch(request) {
963
1013
  const url = new URL(request.url);
964
1014
  const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
@@ -971,10 +1021,14 @@ function handleSearch(request) {
971
1021
  return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
972
1022
  }
973
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
+ }
974
1028
  const rawCursor = url.searchParams.get("cursor");
975
1029
  const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
976
1030
  if (rawCursor && !cursor) return failure("cursor is invalid");
977
- 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);
978
1032
  const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
979
1033
  const pageStart = start < 0 ? identities.length : start;
980
1034
  const items = identities.slice(pageStart, pageStart + limit);
@@ -1050,15 +1104,92 @@ function validateClaimStaff(flow, staff) {
1050
1104
  if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1051
1105
  return { staffActorRef: actorRef };
1052
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
+ }
1053
1181
  async function handleCreateClaim(request) {
1054
1182
  const body = await readJsonBody(request);
1055
1183
  const flow = body.flow;
1056
1184
  if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1057
- return failure("flow must be one of: invite, assist, transfer");
1185
+ return failure("flow must be one of: invite, assist, transfer, bearer");
1058
1186
  }
1059
1187
  if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1060
1188
  return failure("identityId must be a uuid");
1061
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);
1062
1193
  const normalized = normalizeLinkInput(
1063
1194
  body.kind,
1064
1195
  body.externalId,
@@ -1066,17 +1197,6 @@ async function handleCreateClaim(request) {
1066
1197
  "kind must be one of: email, phone"
1067
1198
  );
1068
1199
  if ("error" in normalized) return failure(normalized.error);
1069
- let returnTo = null;
1070
- if (body.returnTo !== void 0) {
1071
- if (typeof body.returnTo !== "string") return failure("returnTo must be a string");
1072
- let parsed;
1073
- try {
1074
- parsed = new URL(body.returnTo);
1075
- } catch {
1076
- return failure("returnTo must be an absolute URL");
1077
- }
1078
- returnTo = state.identityAllowedReturnOrigins.has(parsed.origin) ? body.returnTo : null;
1079
- }
1080
1200
  if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1081
1201
  return failure("deliver must be a boolean");
1082
1202
  }
@@ -1089,44 +1209,22 @@ async function handleCreateClaim(request) {
1089
1209
  STAFF_ASSISTANCE_NOT_ENABLED
1090
1210
  );
1091
1211
  }
1092
- const identity = state.identities.get(body.identityId);
1093
- if (!identity || identity.type !== "person" || identity.status !== "active") {
1094
- return failure("identity not found", 404);
1095
- }
1212
+ const identity = claimableIdentity(body.identityId);
1213
+ if (!identity) return failure("identity not found", 404);
1096
1214
  const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1097
1215
  (link) => link.identityId === identity.id
1098
1216
  );
1099
1217
  if (!holdsContact) {
1100
1218
  return failure("identity does not hold that contact", 409);
1101
1219
  }
1102
- const claimId = crypto3.randomUUID();
1103
- const deliver = body.deliver === true;
1104
- const handle = crypto3.randomBytes(32).toString("base64url");
1105
- const claim = {
1106
- claimId,
1220
+ return mintClaim({
1107
1221
  flow,
1108
1222
  identityId: identity.id,
1109
1223
  kind: normalized.kind,
1110
1224
  externalId: normalized.externalId,
1111
- returnTo,
1225
+ returnTo: parsedReturnTo.returnTo,
1112
1226
  staffActorRef: staff.staffActorRef,
1113
- state: "pending",
1114
- refusalReason: null,
1115
- completedIdentityId: null,
1116
- claimantClerkUserId: null,
1117
- claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1118
- delivered: deliver ? !state.identitySuppressClaimDelivery : null,
1119
- expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1120
- createdAt: now()
1121
- };
1122
- state.identityClaims.set(claimId, claim);
1123
- return success({
1124
- claimId,
1125
- claimUrl: claim.claimUrl,
1126
- expiresAt: claim.expiresAt,
1127
- // Absent unless asked for: an app delivering its own link must not read a
1128
- // missing field as "nothing was sent".
1129
- ...deliver ? { delivered: claim.delivered } : {}
1227
+ deliver: body.deliver === true
1130
1228
  });
1131
1229
  }
1132
1230
  function handleGetClaim(claimId) {
@@ -1261,24 +1359,73 @@ function completeClaim(claimId, params) {
1261
1359
  return claim;
1262
1360
  }
1263
1361
  if (!params.clerkUserId) throw new Error("clerkUserId is required");
1362
+ if (claim.flow === "bearer") return completeBearerClaim(claim, params.clerkUserId);
1264
1363
  const rows = matchingLinks(claim.kind, claim.externalId);
1265
1364
  const target = rows.find((link) => link.identityId === claim.identityId);
1266
1365
  if (!target) {
1267
1366
  claim.state = "invalidated";
1268
1367
  return claim;
1269
1368
  }
1369
+ const claimantIdentityId = state.identityLinks.find(
1370
+ (link) => link.kind === "project_auth_user" && link.externalId === params.clerkUserId
1371
+ )?.identityId;
1270
1372
  const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
1271
- if (otherVerified) {
1373
+ const ownProof = otherVerified !== void 0 && otherVerified.identityId === claimantIdentityId;
1374
+ if (otherVerified && !ownProof) {
1272
1375
  claim.state = "refused";
1273
1376
  claim.refusalReason = "contact_owned";
1274
1377
  return claim;
1275
1378
  }
1379
+ if (claim.flow !== "transfer" && ownedByAnotherLogin(claim.identityId, params.clerkUserId)) {
1380
+ claim.state = "refused";
1381
+ claim.refusalReason = "owned_elsewhere";
1382
+ return claim;
1383
+ }
1384
+ if (ownProof) state.identityLinks = state.identityLinks.filter((link) => link !== otherVerified);
1276
1385
  target.verified = true;
1386
+ connectLogin(claim, params.clerkUserId);
1277
1387
  claim.state = "completed";
1278
1388
  claim.completedIdentityId = claim.identityId;
1279
1389
  claim.claimantClerkUserId = params.clerkUserId;
1280
1390
  return claim;
1281
1391
  }
1392
+ function ownedByAnotherLogin(identityId, clerkUserId) {
1393
+ return linksFor(identityId).some(
1394
+ (link) => link.kind === "project_auth_user" && link.externalId !== clerkUserId
1395
+ );
1396
+ }
1397
+ function connectLogin(claim, clerkUserId) {
1398
+ const isLogin = (link) => link.identityId === claim.identityId && link.kind === "project_auth_user";
1399
+ if (claim.flow === "transfer") {
1400
+ state.identityLinks = state.identityLinks.filter(
1401
+ (link) => !isLogin(link) || link.externalId === clerkUserId
1402
+ );
1403
+ }
1404
+ state.identityLinks = state.identityLinks.filter(
1405
+ (link) => link.kind !== "project_auth_user" || link.externalId !== clerkUserId || link.identityId === claim.identityId
1406
+ );
1407
+ if (!state.identityLinks.some((link) => isLogin(link) && link.externalId === clerkUserId)) {
1408
+ createUnverifiedLink(claim.identityId, "project_auth_user", clerkUserId).verified = true;
1409
+ }
1410
+ }
1411
+ function completeBearerClaim(claim, clerkUserId) {
1412
+ const identity = state.identities.get(claim.identityId);
1413
+ if (!identity || identity.type !== "person" || identity.status !== "active") {
1414
+ claim.state = "invalidated";
1415
+ return claim;
1416
+ }
1417
+ const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
1418
+ if (refusal) {
1419
+ claim.state = "refused";
1420
+ claim.refusalReason = refusal;
1421
+ return claim;
1422
+ }
1423
+ connectLogin(claim, clerkUserId);
1424
+ claim.state = "completed";
1425
+ claim.completedIdentityId = claim.identityId;
1426
+ claim.claimantClerkUserId = clerkUserId;
1427
+ return claim;
1428
+ }
1282
1429
  function refuseClaim(claimId, reason) {
1283
1430
  const claim = requireClaim(claimId);
1284
1431
  if (claim.state !== "pending") return claim;
@@ -1299,6 +1446,9 @@ function createDirectory() {
1299
1446
  enableStaffAssist: (enabled = true) => {
1300
1447
  state.identityStaffAssistEnabled = enabled;
1301
1448
  },
1449
+ enableBearerLinks: (enabled = true) => {
1450
+ state.identityBearerLinksEnabled = enabled;
1451
+ },
1302
1452
  suppressClaimDelivery: (enabled = true) => {
1303
1453
  state.identitySuppressClaimDelivery = enabled;
1304
1454
  },
@@ -1312,6 +1462,7 @@ function createDirectory() {
1312
1462
  state.identityLinks = [];
1313
1463
  state.identityClaims.clear();
1314
1464
  state.identityStaffAssistEnabled = false;
1465
+ state.identityBearerLinksEnabled = true;
1315
1466
  state.identitySuppressClaimDelivery = false;
1316
1467
  state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
1317
1468
  },
@@ -54,6 +54,7 @@ var state = globalSingleton("state", () => ({
54
54
  identityLinks: [],
55
55
  identityClaims: /* @__PURE__ */ new Map(),
56
56
  identityStaffAssistEnabled: false,
57
+ identityBearerLinksEnabled: true,
57
58
  identitySuppressClaimDelivery: false,
58
59
  identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
59
60
  checkouts: [],
@@ -27,6 +27,7 @@ var state = globalSingleton("state", () => ({
27
27
  identityLinks: [],
28
28
  identityClaims: /* @__PURE__ */ new Map(),
29
29
  identityStaffAssistEnabled: false,
30
+ identityBearerLinksEnabled: true,
30
31
  identitySuppressClaimDelivery: false,
31
32
  identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
32
33
  checkouts: [],
package/dist/setup.js CHANGED
@@ -60,6 +60,7 @@ var state = globalSingleton("state", () => ({
60
60
  identityLinks: [],
61
61
  identityClaims: /* @__PURE__ */ new Map(),
62
62
  identityStaffAssistEnabled: false,
63
+ identityBearerLinksEnabled: true,
63
64
  identitySuppressClaimDelivery: false,
64
65
  identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
65
66
  checkouts: [],
@@ -632,14 +633,16 @@ var import_node_crypto3 = __toESM(require("crypto"));
632
633
  var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
633
634
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
634
635
  var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
635
- var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
636
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
636
637
  var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
637
638
  var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
638
639
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
639
640
  var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
641
+ var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
640
642
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
641
643
  var SEARCH_DEFAULT_LIMIT = 50;
642
644
  var SEARCH_MAX_LIMIT = 100;
645
+ var TEAM_ACCOUNT_FIELDS_MESSAGE = "This person's name and email come from their team account. Change them in Members settings.";
643
646
  function asciiLowercaseTrim(value) {
644
647
  const trimmed = value.trim();
645
648
  let out = "";
@@ -756,11 +759,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
756
759
  state.identityLinks.push(link);
757
760
  return link;
758
761
  }
762
+ function isTeamOnly(identityId) {
763
+ const kinds = linksFor(identityId).map((link) => link.kind);
764
+ return kinds.includes("dashboard_user") && !kinds.includes("project_auth_user");
765
+ }
766
+ function isCurrentTeamMember(identityId) {
767
+ return linksFor(identityId).some((link) => link.kind === "dashboard_user" && link.verified);
768
+ }
769
+ function hasRecordedContact(identityId) {
770
+ return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
771
+ }
759
772
  function handleList(request) {
760
773
  const typeParam = new URL(request.url).searchParams.get("type");
761
774
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
762
775
  const identities = [...state.identities.values()].filter(
763
- (identity) => identity.status !== "merged"
776
+ (identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
764
777
  );
765
778
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
766
779
  }
@@ -804,11 +817,18 @@ async function handleUpdate(identityId, request) {
804
817
  const identity = state.identities.get(identityId);
805
818
  if (!identity) return failure("identity not found", 404);
806
819
  const body = await readJsonBody(request);
820
+ const profile = body.profile;
821
+ if (isCurrentTeamMember(identityId)) {
822
+ const changesName = body.displayName !== void 0 && body.displayName !== identity.displayName;
823
+ const changesEmail = profile !== void 0 && Object.hasOwn(profile, "email") && profile.email !== identity.profile.email;
824
+ if (changesName || changesEmail) return failure(TEAM_ACCOUNT_FIELDS_MESSAGE, 409);
825
+ }
807
826
  if (body.displayName !== void 0) {
808
827
  identity.displayName = body.displayName;
809
828
  }
810
- if (body.profile !== void 0) {
811
- identity.profile = body.profile;
829
+ if (profile !== void 0) {
830
+ const keptEmail = isCurrentTeamMember(identityId) && !Object.hasOwn(profile, "email") && Object.hasOwn(identity.profile, "email");
831
+ identity.profile = keptEmail ? { ...profile, email: identity.profile.email } : profile;
812
832
  }
813
833
  if (body.externalRef !== void 0) {
814
834
  identity.externalRef = body.externalRef;
@@ -910,6 +930,36 @@ function identityMatchesQuery(identity, query) {
910
930
  (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
911
931
  );
912
932
  }
933
+ function parseSearchAttributes(raw) {
934
+ if (raw === null) return {};
935
+ let parsed;
936
+ try {
937
+ parsed = JSON.parse(raw);
938
+ } catch {
939
+ return "attributes: attributes must be a JSON object";
940
+ }
941
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
942
+ return "attributes: attributes must be a JSON object";
943
+ }
944
+ if (Object.hasOwn(parsed, "__proto__")) return "attributes: attribute keys may not be __proto__";
945
+ const entries = Object.entries(parsed);
946
+ if (entries.length > 10) return "attributes: attributes may filter on at most 10 keys";
947
+ for (const [key, value] of entries) {
948
+ if (!["string", "number", "boolean"].includes(typeof value)) {
949
+ return `attributes.${key}: attribute values must be a string, number or boolean`;
950
+ }
951
+ if (key.length < 1 || key.length > 100) {
952
+ return `attributes.${key}: attribute keys must be 1-100 characters`;
953
+ }
954
+ if (typeof value === "string" && value.length > 500) {
955
+ return `attributes.${key}: attribute values must be at most 500 characters`;
956
+ }
957
+ }
958
+ return parsed;
959
+ }
960
+ function identityMatchesAttributes(identity, attributes) {
961
+ return Object.entries(attributes).every(([key, value]) => identity.profile[key] === value);
962
+ }
913
963
  function handleSearch(request) {
914
964
  const url = new URL(request.url);
915
965
  const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
@@ -922,10 +972,14 @@ function handleSearch(request) {
922
972
  return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
923
973
  }
924
974
  }
975
+ const attributes = parseSearchAttributes(url.searchParams.get("attributes"));
976
+ if (typeof attributes === "string") {
977
+ return json({ success: false, error: "Invalid query parameters", details: attributes }, 400);
978
+ }
925
979
  const rawCursor = url.searchParams.get("cursor");
926
980
  const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
927
981
  if (rawCursor && !cursor) return failure("cursor is invalid");
928
- const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
982
+ 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);
929
983
  const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
930
984
  const pageStart = start < 0 ? identities.length : start;
931
985
  const items = identities.slice(pageStart, pageStart + limit);
@@ -1001,15 +1055,92 @@ function validateClaimStaff(flow, staff) {
1001
1055
  if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1002
1056
  return { staffActorRef: actorRef };
1003
1057
  }
1058
+ function parseReturnTo(raw) {
1059
+ if (raw === void 0) return { returnTo: null };
1060
+ if (typeof raw !== "string") return { error: "returnTo must be a string" };
1061
+ let parsed;
1062
+ try {
1063
+ parsed = new URL(raw);
1064
+ } catch {
1065
+ return { error: "returnTo must be an absolute URL" };
1066
+ }
1067
+ return { returnTo: state.identityAllowedReturnOrigins.has(parsed.origin) ? raw : null };
1068
+ }
1069
+ function claimableIdentity(identityId) {
1070
+ const identity = state.identities.get(identityId);
1071
+ if (!identity || identity.type !== "person" || identity.status !== "active") return null;
1072
+ return identity;
1073
+ }
1074
+ function mintClaim(params) {
1075
+ const claimId = import_node_crypto3.default.randomUUID();
1076
+ const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
1077
+ const claim = {
1078
+ claimId,
1079
+ flow: params.flow,
1080
+ identityId: params.identityId,
1081
+ kind: params.kind,
1082
+ externalId: params.externalId,
1083
+ returnTo: params.returnTo,
1084
+ staffActorRef: params.staffActorRef,
1085
+ state: "pending",
1086
+ refusalReason: null,
1087
+ completedIdentityId: null,
1088
+ claimantClerkUserId: null,
1089
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1090
+ delivered: params.deliver ? !state.identitySuppressClaimDelivery : null,
1091
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1092
+ createdAt: now()
1093
+ };
1094
+ state.identityClaims.set(claimId, claim);
1095
+ return success({
1096
+ claimId,
1097
+ claimUrl: claim.claimUrl,
1098
+ expiresAt: claim.expiresAt,
1099
+ // Absent unless asked for: an app delivering its own link must not read a
1100
+ // missing field as "nothing was sent".
1101
+ ...params.deliver ? { delivered: claim.delivered } : {}
1102
+ });
1103
+ }
1104
+ function createBearerClaim(body, returnTo) {
1105
+ for (const field of ["kind", "externalId", "deliver", "staff"]) {
1106
+ if (body[field] !== void 0) {
1107
+ return failure(`a bearer claim takes no ${field}: it is raised against no contact`);
1108
+ }
1109
+ }
1110
+ if (!state.identityBearerLinksEnabled) {
1111
+ return failure(
1112
+ "profile invite links are not enabled for this project",
1113
+ 403,
1114
+ BEARER_LINKS_NOT_ENABLED
1115
+ );
1116
+ }
1117
+ const identity = claimableIdentity(body.identityId);
1118
+ if (!identity) return failure("identity not found", 404);
1119
+ if (hasRecordedContact(identity.id)) {
1120
+ return failure("identity has an email or phone on record; send an invite claim instead", 409);
1121
+ }
1122
+ return mintClaim({
1123
+ flow: "bearer",
1124
+ identityId: identity.id,
1125
+ kind: null,
1126
+ externalId: null,
1127
+ returnTo,
1128
+ staffActorRef: null,
1129
+ deliver: false
1130
+ });
1131
+ }
1004
1132
  async function handleCreateClaim(request) {
1005
1133
  const body = await readJsonBody(request);
1006
1134
  const flow = body.flow;
1007
1135
  if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1008
- return failure("flow must be one of: invite, assist, transfer");
1136
+ return failure("flow must be one of: invite, assist, transfer, bearer");
1009
1137
  }
1010
1138
  if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1011
1139
  return failure("identityId must be a uuid");
1012
1140
  }
1141
+ const parsedReturnTo = parseReturnTo(body.returnTo);
1142
+ if ("error" in parsedReturnTo) return failure(parsedReturnTo.error);
1143
+ if (flow === "bearer") return createBearerClaim(body, parsedReturnTo.returnTo);
1013
1144
  const normalized = normalizeLinkInput(
1014
1145
  body.kind,
1015
1146
  body.externalId,
@@ -1017,17 +1148,6 @@ async function handleCreateClaim(request) {
1017
1148
  "kind must be one of: email, phone"
1018
1149
  );
1019
1150
  if ("error" in normalized) return failure(normalized.error);
1020
- let returnTo = null;
1021
- if (body.returnTo !== void 0) {
1022
- if (typeof body.returnTo !== "string") return failure("returnTo must be a string");
1023
- let parsed;
1024
- try {
1025
- parsed = new URL(body.returnTo);
1026
- } catch {
1027
- return failure("returnTo must be an absolute URL");
1028
- }
1029
- returnTo = state.identityAllowedReturnOrigins.has(parsed.origin) ? body.returnTo : null;
1030
- }
1031
1151
  if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1032
1152
  return failure("deliver must be a boolean");
1033
1153
  }
@@ -1040,44 +1160,22 @@ async function handleCreateClaim(request) {
1040
1160
  STAFF_ASSISTANCE_NOT_ENABLED
1041
1161
  );
1042
1162
  }
1043
- const identity = state.identities.get(body.identityId);
1044
- if (!identity || identity.type !== "person" || identity.status !== "active") {
1045
- return failure("identity not found", 404);
1046
- }
1163
+ const identity = claimableIdentity(body.identityId);
1164
+ if (!identity) return failure("identity not found", 404);
1047
1165
  const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1048
1166
  (link) => link.identityId === identity.id
1049
1167
  );
1050
1168
  if (!holdsContact) {
1051
1169
  return failure("identity does not hold that contact", 409);
1052
1170
  }
1053
- const claimId = import_node_crypto3.default.randomUUID();
1054
- const deliver = body.deliver === true;
1055
- const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
1056
- const claim = {
1057
- claimId,
1171
+ return mintClaim({
1058
1172
  flow,
1059
1173
  identityId: identity.id,
1060
1174
  kind: normalized.kind,
1061
1175
  externalId: normalized.externalId,
1062
- returnTo,
1176
+ returnTo: parsedReturnTo.returnTo,
1063
1177
  staffActorRef: staff.staffActorRef,
1064
- state: "pending",
1065
- refusalReason: null,
1066
- completedIdentityId: null,
1067
- claimantClerkUserId: null,
1068
- claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1069
- delivered: deliver ? !state.identitySuppressClaimDelivery : null,
1070
- expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1071
- createdAt: now()
1072
- };
1073
- state.identityClaims.set(claimId, claim);
1074
- return success({
1075
- claimId,
1076
- claimUrl: claim.claimUrl,
1077
- expiresAt: claim.expiresAt,
1078
- // Absent unless asked for: an app delivering its own link must not read a
1079
- // missing field as "nothing was sent".
1080
- ...deliver ? { delivered: claim.delivered } : {}
1178
+ deliver: body.deliver === true
1081
1179
  });
1082
1180
  }
1083
1181
  function handleGetClaim(claimId) {