@stardeck-customer-apps/testing 0.15.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 +71 -5
- package/dist/index.d.mts +70 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +392 -48
- package/dist/index.mjs +392 -48
- package/dist/next/headers-shim.js +10 -0
- package/dist/next/headers-shim.mjs +10 -0
- package/dist/setup.js +265 -30
- package/dist/setup.mjs +265 -30
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -106,6 +106,31 @@ function globalSingleton(key, create) {
|
|
|
106
106
|
return holder[symbol];
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// src/constants.ts
|
|
110
|
+
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
111
|
+
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
112
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
113
|
+
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
114
|
+
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
115
|
+
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
116
|
+
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
117
|
+
var TEST_ENV_DEFAULTS = {
|
|
118
|
+
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
119
|
+
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
120
|
+
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
121
|
+
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
122
|
+
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
123
|
+
STORAGE_URL: STORAGE_TEST_URL
|
|
124
|
+
};
|
|
125
|
+
var DEFAULT_TEST_USER = {
|
|
126
|
+
id: "test-user-1",
|
|
127
|
+
email: "test-user@example.com",
|
|
128
|
+
name: "Test User",
|
|
129
|
+
role: "member",
|
|
130
|
+
permissions: []
|
|
131
|
+
};
|
|
132
|
+
var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
133
|
+
|
|
109
134
|
// src/state.ts
|
|
110
135
|
var state = globalSingleton("state", () => ({
|
|
111
136
|
db: null,
|
|
@@ -117,6 +142,11 @@ var state = globalSingleton("state", () => ({
|
|
|
117
142
|
emailCounter: 0,
|
|
118
143
|
identities: /* @__PURE__ */ new Map(),
|
|
119
144
|
identityLinks: [],
|
|
145
|
+
identityClaims: /* @__PURE__ */ new Map(),
|
|
146
|
+
identityStaffAssistEnabled: false,
|
|
147
|
+
identityBearerLinksEnabled: true,
|
|
148
|
+
identitySuppressClaimDelivery: false,
|
|
149
|
+
identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
|
|
120
150
|
checkouts: [],
|
|
121
151
|
checkoutCounter: 0,
|
|
122
152
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
@@ -158,30 +188,6 @@ function requireDb() {
|
|
|
158
188
|
return state.db;
|
|
159
189
|
}
|
|
160
190
|
|
|
161
|
-
// src/constants.ts
|
|
162
|
-
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
163
|
-
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
164
|
-
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
165
|
-
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
166
|
-
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
167
|
-
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
168
|
-
var TEST_ENV_DEFAULTS = {
|
|
169
|
-
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
170
|
-
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
171
|
-
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
172
|
-
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
173
|
-
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
174
|
-
STORAGE_URL: STORAGE_TEST_URL
|
|
175
|
-
};
|
|
176
|
-
var DEFAULT_TEST_USER = {
|
|
177
|
-
id: "test-user-1",
|
|
178
|
-
email: "test-user@example.com",
|
|
179
|
-
name: "Test User",
|
|
180
|
-
role: "member",
|
|
181
|
-
permissions: []
|
|
182
|
-
};
|
|
183
|
-
var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
184
|
-
|
|
185
191
|
// src/simulator/hmac.ts
|
|
186
192
|
var import_node_crypto = __toESM(require("crypto"));
|
|
187
193
|
var TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
@@ -232,8 +238,8 @@ function json(body, status = 200) {
|
|
|
232
238
|
function success(data) {
|
|
233
239
|
return json({ success: true, data });
|
|
234
240
|
}
|
|
235
|
-
function failure(error, status = 400) {
|
|
236
|
-
return json({ success: false, error }, status);
|
|
241
|
+
function failure(error, status = 400, code) {
|
|
242
|
+
return json({ success: false, error, ...code ? { code } : {} }, status);
|
|
237
243
|
}
|
|
238
244
|
async function readJsonBody(request) {
|
|
239
245
|
try {
|
|
@@ -721,9 +727,17 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
|
|
|
721
727
|
"dashboard_user"
|
|
722
728
|
]);
|
|
723
729
|
var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
|
|
730
|
+
var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
731
|
+
var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
|
|
732
|
+
var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
733
|
+
var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
|
|
734
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
735
|
+
var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
|
|
736
|
+
var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
|
|
724
737
|
var E164_PHONE = /^\+[1-9]\d{1,14}$/;
|
|
725
738
|
var SEARCH_DEFAULT_LIMIT = 50;
|
|
726
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.";
|
|
727
741
|
function asciiLowercaseTrim(value) {
|
|
728
742
|
const trimmed = value.trim();
|
|
729
743
|
let out = "";
|
|
@@ -803,6 +817,11 @@ function matchingLinks(kind, externalId) {
|
|
|
803
817
|
(link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
|
|
804
818
|
);
|
|
805
819
|
}
|
|
820
|
+
function sortedMatchingLinks(kind, externalId) {
|
|
821
|
+
return matchingLinks(kind, externalId).sort(
|
|
822
|
+
(a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
|
|
823
|
+
);
|
|
824
|
+
}
|
|
806
825
|
function normalizedLinkKey(link) {
|
|
807
826
|
return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
|
|
808
827
|
}
|
|
@@ -838,11 +857,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
|
|
|
838
857
|
state.identityLinks.push(link);
|
|
839
858
|
return link;
|
|
840
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
|
+
}
|
|
841
870
|
function handleList(request) {
|
|
842
871
|
const typeParam = new URL(request.url).searchParams.get("type");
|
|
843
872
|
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
844
873
|
const identities = [...state.identities.values()].filter(
|
|
845
|
-
(identity) => identity.status !== "merged"
|
|
874
|
+
(identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
|
|
846
875
|
);
|
|
847
876
|
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
848
877
|
}
|
|
@@ -886,11 +915,18 @@ async function handleUpdate(identityId, request) {
|
|
|
886
915
|
const identity = state.identities.get(identityId);
|
|
887
916
|
if (!identity) return failure("identity not found", 404);
|
|
888
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
|
+
}
|
|
889
924
|
if (body.displayName !== void 0) {
|
|
890
925
|
identity.displayName = body.displayName;
|
|
891
926
|
}
|
|
892
|
-
if (
|
|
893
|
-
|
|
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;
|
|
894
930
|
}
|
|
895
931
|
if (body.externalRef !== void 0) {
|
|
896
932
|
identity.externalRef = body.externalRef;
|
|
@@ -916,9 +952,10 @@ async function handleAttachLink(identityId, request) {
|
|
|
916
952
|
);
|
|
917
953
|
if ("error" in normalized) return failure(normalized.error);
|
|
918
954
|
const { kind, externalId } = normalized;
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
955
|
+
const matches = matchingLinks(kind, externalId);
|
|
956
|
+
const ownRow = matches.find((link2) => link2.identityId === identityId);
|
|
957
|
+
if (ownRow) return success({ link: ownRow });
|
|
958
|
+
if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
|
|
922
959
|
return failure("identifier already linked to another identity", 409);
|
|
923
960
|
}
|
|
924
961
|
const link = createUnverifiedLink(identityId, kind, externalId);
|
|
@@ -935,7 +972,7 @@ async function handleResolve(request) {
|
|
|
935
972
|
if (body.displayName !== void 0 && typeof body.displayName !== "string") {
|
|
936
973
|
return failure("displayName must be a string");
|
|
937
974
|
}
|
|
938
|
-
const links =
|
|
975
|
+
const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
|
|
939
976
|
if (links.length > 0) {
|
|
940
977
|
const canonicalIds = /* @__PURE__ */ new Set();
|
|
941
978
|
for (const link of links) {
|
|
@@ -946,12 +983,15 @@ async function handleResolve(request) {
|
|
|
946
983
|
return failure(`cannot resolve identity link: ${message}`, 409);
|
|
947
984
|
}
|
|
948
985
|
}
|
|
949
|
-
|
|
986
|
+
const verifiedOwners = new Set(
|
|
987
|
+
links.filter((link) => link.verified).map((link) => link.identityId)
|
|
988
|
+
);
|
|
989
|
+
if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
|
|
950
990
|
return failure("identifier is linked to multiple identities", 409);
|
|
951
991
|
}
|
|
952
|
-
const
|
|
953
|
-
const
|
|
954
|
-
if (provenance === "guest" &&
|
|
992
|
+
const [chosen] = links;
|
|
993
|
+
const canonicalId = canonicalIdentity(chosen.identityId).id;
|
|
994
|
+
if (provenance === "guest" && chosen.verified) {
|
|
955
995
|
return success({ identityId: null, reason: "verified_conflict" });
|
|
956
996
|
}
|
|
957
997
|
return success({ identityId: canonicalId, created: false });
|
|
@@ -988,6 +1028,36 @@ function identityMatchesQuery(identity, query) {
|
|
|
988
1028
|
(link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
|
|
989
1029
|
);
|
|
990
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
|
+
}
|
|
991
1061
|
function handleSearch(request) {
|
|
992
1062
|
const url = new URL(request.url);
|
|
993
1063
|
const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
|
|
@@ -1000,10 +1070,14 @@ function handleSearch(request) {
|
|
|
1000
1070
|
return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
|
|
1001
1071
|
}
|
|
1002
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
|
+
}
|
|
1003
1077
|
const rawCursor = url.searchParams.get("cursor");
|
|
1004
1078
|
const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
|
|
1005
1079
|
if (rawCursor && !cursor) return failure("cursor is invalid");
|
|
1006
|
-
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);
|
|
1007
1081
|
const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
|
|
1008
1082
|
const pageStart = start < 0 ? identities.length : start;
|
|
1009
1083
|
const items = identities.slice(pageStart, pageStart + limit);
|
|
@@ -1053,6 +1127,160 @@ async function handleAliases(request) {
|
|
|
1053
1127
|
}
|
|
1054
1128
|
return success({ aliases });
|
|
1055
1129
|
}
|
|
1130
|
+
function claimView(claim) {
|
|
1131
|
+
const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
|
|
1132
|
+
return {
|
|
1133
|
+
claimId: claim.claimId,
|
|
1134
|
+
flow: claim.flow,
|
|
1135
|
+
state: state_,
|
|
1136
|
+
refusalReason: claim.refusalReason,
|
|
1137
|
+
completedIdentityId: claim.completedIdentityId,
|
|
1138
|
+
expiresAt: claim.expiresAt
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
function validateClaimStaff(flow, staff) {
|
|
1142
|
+
if (flow === "invite") {
|
|
1143
|
+
if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
|
|
1144
|
+
return { staffActorRef: null };
|
|
1145
|
+
}
|
|
1146
|
+
if (!staff || typeof staff !== "object") {
|
|
1147
|
+
return { error: `staff is required for a ${flow} claim` };
|
|
1148
|
+
}
|
|
1149
|
+
const { actorRef, grant } = staff;
|
|
1150
|
+
if (typeof actorRef !== "string" || actorRef.length === 0) {
|
|
1151
|
+
return { error: "staff.actorRef is required" };
|
|
1152
|
+
}
|
|
1153
|
+
if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
|
|
1154
|
+
return { staffActorRef: actorRef };
|
|
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
|
+
}
|
|
1230
|
+
async function handleCreateClaim(request) {
|
|
1231
|
+
const body = await readJsonBody(request);
|
|
1232
|
+
const flow = body.flow;
|
|
1233
|
+
if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
|
|
1234
|
+
return failure("flow must be one of: invite, assist, transfer, bearer");
|
|
1235
|
+
}
|
|
1236
|
+
if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
|
|
1237
|
+
return failure("identityId must be a uuid");
|
|
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);
|
|
1242
|
+
const normalized = normalizeLinkInput(
|
|
1243
|
+
body.kind,
|
|
1244
|
+
body.externalId,
|
|
1245
|
+
CLAIM_KINDS,
|
|
1246
|
+
"kind must be one of: email, phone"
|
|
1247
|
+
);
|
|
1248
|
+
if ("error" in normalized) return failure(normalized.error);
|
|
1249
|
+
if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
|
|
1250
|
+
return failure("deliver must be a boolean");
|
|
1251
|
+
}
|
|
1252
|
+
const staff = validateClaimStaff(flow, body.staff);
|
|
1253
|
+
if ("error" in staff) return failure(staff.error);
|
|
1254
|
+
if (flow !== "invite" && !state.identityStaffAssistEnabled) {
|
|
1255
|
+
return failure(
|
|
1256
|
+
"staff-assisted profile claiming is not enabled for this project",
|
|
1257
|
+
403,
|
|
1258
|
+
STAFF_ASSISTANCE_NOT_ENABLED
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
const identity = claimableIdentity(body.identityId);
|
|
1262
|
+
if (!identity) return failure("identity not found", 404);
|
|
1263
|
+
const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
|
|
1264
|
+
(link) => link.identityId === identity.id
|
|
1265
|
+
);
|
|
1266
|
+
if (!holdsContact) {
|
|
1267
|
+
return failure("identity does not hold that contact", 409);
|
|
1268
|
+
}
|
|
1269
|
+
return mintClaim({
|
|
1270
|
+
flow,
|
|
1271
|
+
identityId: identity.id,
|
|
1272
|
+
kind: normalized.kind,
|
|
1273
|
+
externalId: normalized.externalId,
|
|
1274
|
+
returnTo: parsedReturnTo.returnTo,
|
|
1275
|
+
staffActorRef: staff.staffActorRef,
|
|
1276
|
+
deliver: body.deliver === true
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
function handleGetClaim(claimId) {
|
|
1280
|
+
const claim = state.identityClaims.get(claimId);
|
|
1281
|
+
if (!claim) return failure("claim not found", 404);
|
|
1282
|
+
return success(claimView(claim));
|
|
1283
|
+
}
|
|
1056
1284
|
async function handleIdentitiesRequest(request, subPath) {
|
|
1057
1285
|
const method = request.method;
|
|
1058
1286
|
if (subPath === "" || subPath === "/") {
|
|
@@ -1068,6 +1296,13 @@ async function handleIdentitiesRequest(request, subPath) {
|
|
|
1068
1296
|
if (subPath === "/search" && method === "GET") {
|
|
1069
1297
|
return handleSearch(request);
|
|
1070
1298
|
}
|
|
1299
|
+
if (subPath === "/claims" && method === "POST") {
|
|
1300
|
+
return handleCreateClaim(request);
|
|
1301
|
+
}
|
|
1302
|
+
const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
|
|
1303
|
+
if (claimMatch && method === "GET") {
|
|
1304
|
+
return handleGetClaim(decodeURIComponent(claimMatch[1]));
|
|
1305
|
+
}
|
|
1071
1306
|
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
1072
1307
|
if (linksMatch && method === "POST") {
|
|
1073
1308
|
return handleAttachLink(linksMatch[1], request);
|
|
@@ -1092,11 +1327,13 @@ function seedVerifiedLink(identityId, params) {
|
|
|
1092
1327
|
);
|
|
1093
1328
|
if ("error" in normalized) throw new Error(normalized.error);
|
|
1094
1329
|
const { kind, externalId } = normalized;
|
|
1095
|
-
const
|
|
1330
|
+
const matches = matchingLinks(kind, externalId);
|
|
1331
|
+
const blocker = matches.find(
|
|
1332
|
+
(link2) => link2.identityId !== identityId && (link2.verified || !DUPLICABLE_UNVERIFIED_KINDS.has(link2.kind))
|
|
1333
|
+
);
|
|
1334
|
+
if (blocker) throw new Error("identifier already linked to another identity");
|
|
1335
|
+
const existing = matches.find((link2) => link2.identityId === identityId);
|
|
1096
1336
|
if (existing) {
|
|
1097
|
-
if (existing.identityId !== identityId) {
|
|
1098
|
-
throw new Error("identifier already linked to another identity");
|
|
1099
|
-
}
|
|
1100
1337
|
existing.verified = true;
|
|
1101
1338
|
return existing;
|
|
1102
1339
|
}
|
|
@@ -1126,7 +1363,12 @@ function merge(sourceIdentityId, canonicalIdentityId) {
|
|
|
1126
1363
|
);
|
|
1127
1364
|
for (const sourceLink of sourceLinks) {
|
|
1128
1365
|
const key = normalizedLinkKey(sourceLink);
|
|
1129
|
-
|
|
1366
|
+
const conflicting = otherLinks.some(
|
|
1367
|
+
(otherLink) => normalizedLinkKey(otherLink) === key && // Same partial rule as the index: a third identity's UNVERIFIED copy of a
|
|
1368
|
+
// shared email/phone is allowed to coexist and must not block the merge.
|
|
1369
|
+
(!DUPLICABLE_UNVERIFIED_KINDS.has(key.split(":")[0]) || otherLink.verified && sourceLink.verified)
|
|
1370
|
+
);
|
|
1371
|
+
if (conflicting) {
|
|
1130
1372
|
throw new Error("merge would conflict with a link owned by another identity");
|
|
1131
1373
|
}
|
|
1132
1374
|
}
|
|
@@ -1153,6 +1395,89 @@ function merge(sourceIdentityId, canonicalIdentityId) {
|
|
|
1153
1395
|
source.mergedIntoId = canonical.id;
|
|
1154
1396
|
source.updatedAt = now();
|
|
1155
1397
|
}
|
|
1398
|
+
function requireClaim(claimId) {
|
|
1399
|
+
const claim = state.identityClaims.get(claimId);
|
|
1400
|
+
if (!claim) throw new Error("claim not found");
|
|
1401
|
+
return claim;
|
|
1402
|
+
}
|
|
1403
|
+
function completeClaim(claimId, params) {
|
|
1404
|
+
const claim = requireClaim(claimId);
|
|
1405
|
+
if (claim.state !== "pending") return claim;
|
|
1406
|
+
if (Date.parse(claim.expiresAt) <= Date.now()) {
|
|
1407
|
+
claim.state = "expired";
|
|
1408
|
+
return claim;
|
|
1409
|
+
}
|
|
1410
|
+
if (!params.clerkUserId) throw new Error("clerkUserId is required");
|
|
1411
|
+
if (claim.flow === "bearer") return completeBearerClaim(claim, params.clerkUserId);
|
|
1412
|
+
const rows = matchingLinks(claim.kind, claim.externalId);
|
|
1413
|
+
const target = rows.find((link) => link.identityId === claim.identityId);
|
|
1414
|
+
if (!target) {
|
|
1415
|
+
claim.state = "invalidated";
|
|
1416
|
+
return claim;
|
|
1417
|
+
}
|
|
1418
|
+
const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
|
|
1419
|
+
if (otherVerified) {
|
|
1420
|
+
claim.state = "refused";
|
|
1421
|
+
claim.refusalReason = "contact_owned";
|
|
1422
|
+
return claim;
|
|
1423
|
+
}
|
|
1424
|
+
if (claim.flow !== "transfer" && ownedByAnotherLogin(claim.identityId, params.clerkUserId)) {
|
|
1425
|
+
claim.state = "refused";
|
|
1426
|
+
claim.refusalReason = "owned_elsewhere";
|
|
1427
|
+
return claim;
|
|
1428
|
+
}
|
|
1429
|
+
target.verified = true;
|
|
1430
|
+
connectLogin(claim, params.clerkUserId);
|
|
1431
|
+
claim.state = "completed";
|
|
1432
|
+
claim.completedIdentityId = claim.identityId;
|
|
1433
|
+
claim.claimantClerkUserId = params.clerkUserId;
|
|
1434
|
+
return claim;
|
|
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
|
+
}
|
|
1473
|
+
function refuseClaim(claimId, reason) {
|
|
1474
|
+
const claim = requireClaim(claimId);
|
|
1475
|
+
if (claim.state !== "pending") return claim;
|
|
1476
|
+
if (!reason) throw new Error("a refusal reason is required");
|
|
1477
|
+
claim.state = "refused";
|
|
1478
|
+
claim.refusalReason = reason;
|
|
1479
|
+
return claim;
|
|
1480
|
+
}
|
|
1156
1481
|
function createDirectory() {
|
|
1157
1482
|
return {
|
|
1158
1483
|
all: () => [...state.identities.values()],
|
|
@@ -1160,9 +1485,30 @@ function createDirectory() {
|
|
|
1160
1485
|
links: (identityId) => linksFor(identityId),
|
|
1161
1486
|
seedVerifiedLink,
|
|
1162
1487
|
merge,
|
|
1488
|
+
claims: () => [...state.identityClaims.values()],
|
|
1489
|
+
claim: (claimId) => state.identityClaims.get(claimId),
|
|
1490
|
+
enableStaffAssist: (enabled = true) => {
|
|
1491
|
+
state.identityStaffAssistEnabled = enabled;
|
|
1492
|
+
},
|
|
1493
|
+
enableBearerLinks: (enabled = true) => {
|
|
1494
|
+
state.identityBearerLinksEnabled = enabled;
|
|
1495
|
+
},
|
|
1496
|
+
suppressClaimDelivery: (enabled = true) => {
|
|
1497
|
+
state.identitySuppressClaimDelivery = enabled;
|
|
1498
|
+
},
|
|
1499
|
+
allowReturnOrigin: (origin) => {
|
|
1500
|
+
state.identityAllowedReturnOrigins.add(new URL(origin).origin);
|
|
1501
|
+
},
|
|
1502
|
+
completeClaim,
|
|
1503
|
+
refuseClaim,
|
|
1163
1504
|
clear: () => {
|
|
1164
1505
|
state.identities.clear();
|
|
1165
1506
|
state.identityLinks = [];
|
|
1507
|
+
state.identityClaims.clear();
|
|
1508
|
+
state.identityStaffAssistEnabled = false;
|
|
1509
|
+
state.identityBearerLinksEnabled = true;
|
|
1510
|
+
state.identitySuppressClaimDelivery = false;
|
|
1511
|
+
state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
|
|
1166
1512
|
},
|
|
1167
1513
|
get count() {
|
|
1168
1514
|
return state.identities.size;
|
|
@@ -1201,7 +1547,7 @@ async function importNextServer() {
|
|
|
1201
1547
|
async function callRoute(handler, options = {}) {
|
|
1202
1548
|
const { NextRequest } = await importNextServer();
|
|
1203
1549
|
const path = options.path ?? "/api/test-route";
|
|
1204
|
-
const url = new URL(
|
|
1550
|
+
const url = new URL(`${APP_TEST_ORIGIN}${path}`);
|
|
1205
1551
|
for (const [key, value] of Object.entries(options.searchParams ?? {})) {
|
|
1206
1552
|
url.searchParams.set(key, value);
|
|
1207
1553
|
}
|
|
@@ -2976,8 +3322,7 @@ async function createTestApp(options = {}) {
|
|
|
2976
3322
|
state.refreshSessions.clear();
|
|
2977
3323
|
state.emails = [];
|
|
2978
3324
|
state.emailCounter = 0;
|
|
2979
|
-
|
|
2980
|
-
state.identityLinks = [];
|
|
3325
|
+
directory.clear();
|
|
2981
3326
|
payments.clear();
|
|
2982
3327
|
storage.clear();
|
|
2983
3328
|
messages.clear();
|
|
@@ -2991,8 +3336,7 @@ async function createTestApp(options = {}) {
|
|
|
2991
3336
|
state.refreshSessions.clear();
|
|
2992
3337
|
state.emails = [];
|
|
2993
3338
|
state.emailCounter = 0;
|
|
2994
|
-
|
|
2995
|
-
state.identityLinks = [];
|
|
3339
|
+
directory.clear();
|
|
2996
3340
|
payments.clear();
|
|
2997
3341
|
storage.clear();
|
|
2998
3342
|
messages.clear();
|