@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 +37 -4
- package/dist/index.d.mts +13 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +196 -45
- package/dist/index.mjs +196 -45
- package/dist/next/headers-shim.js +1 -0
- package/dist/next/headers-shim.mjs +1 -0
- package/dist/setup.js +142 -44
- package/dist/setup.mjs +142 -44
- package/package.json +3 -5
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 (
|
|
860
|
-
|
|
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 =
|
|
1093
|
-
if (!identity
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
811
|
-
|
|
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 =
|
|
1044
|
-
if (!identity
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|