@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.mjs
CHANGED
|
@@ -57,6 +57,31 @@ function globalSingleton(key, create) {
|
|
|
57
57
|
return holder[symbol];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// src/constants.ts
|
|
61
|
+
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
62
|
+
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
63
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
64
|
+
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
65
|
+
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
66
|
+
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
67
|
+
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
68
|
+
var TEST_ENV_DEFAULTS = {
|
|
69
|
+
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
70
|
+
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
71
|
+
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
72
|
+
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
73
|
+
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
74
|
+
STORAGE_URL: STORAGE_TEST_URL
|
|
75
|
+
};
|
|
76
|
+
var DEFAULT_TEST_USER = {
|
|
77
|
+
id: "test-user-1",
|
|
78
|
+
email: "test-user@example.com",
|
|
79
|
+
name: "Test User",
|
|
80
|
+
role: "member",
|
|
81
|
+
permissions: []
|
|
82
|
+
};
|
|
83
|
+
var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
84
|
+
|
|
60
85
|
// src/state.ts
|
|
61
86
|
var state = globalSingleton("state", () => ({
|
|
62
87
|
db: null,
|
|
@@ -68,6 +93,11 @@ var state = globalSingleton("state", () => ({
|
|
|
68
93
|
emailCounter: 0,
|
|
69
94
|
identities: /* @__PURE__ */ new Map(),
|
|
70
95
|
identityLinks: [],
|
|
96
|
+
identityClaims: /* @__PURE__ */ new Map(),
|
|
97
|
+
identityStaffAssistEnabled: false,
|
|
98
|
+
identityBearerLinksEnabled: true,
|
|
99
|
+
identitySuppressClaimDelivery: false,
|
|
100
|
+
identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
|
|
71
101
|
checkouts: [],
|
|
72
102
|
checkoutCounter: 0,
|
|
73
103
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
@@ -109,30 +139,6 @@ function requireDb() {
|
|
|
109
139
|
return state.db;
|
|
110
140
|
}
|
|
111
141
|
|
|
112
|
-
// src/constants.ts
|
|
113
|
-
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
114
|
-
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
115
|
-
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
116
|
-
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
117
|
-
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
118
|
-
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
119
|
-
var TEST_ENV_DEFAULTS = {
|
|
120
|
-
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
121
|
-
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
122
|
-
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
123
|
-
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
124
|
-
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
125
|
-
STORAGE_URL: STORAGE_TEST_URL
|
|
126
|
-
};
|
|
127
|
-
var DEFAULT_TEST_USER = {
|
|
128
|
-
id: "test-user-1",
|
|
129
|
-
email: "test-user@example.com",
|
|
130
|
-
name: "Test User",
|
|
131
|
-
role: "member",
|
|
132
|
-
permissions: []
|
|
133
|
-
};
|
|
134
|
-
var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
135
|
-
|
|
136
142
|
// src/simulator/hmac.ts
|
|
137
143
|
import crypto from "crypto";
|
|
138
144
|
var TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
@@ -183,8 +189,8 @@ function json(body, status = 200) {
|
|
|
183
189
|
function success(data) {
|
|
184
190
|
return json({ success: true, data });
|
|
185
191
|
}
|
|
186
|
-
function failure(error, status = 400) {
|
|
187
|
-
return json({ success: false, error }, status);
|
|
192
|
+
function failure(error, status = 400, code) {
|
|
193
|
+
return json({ success: false, error, ...code ? { code } : {} }, status);
|
|
188
194
|
}
|
|
189
195
|
async function readJsonBody(request) {
|
|
190
196
|
try {
|
|
@@ -672,9 +678,17 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
|
|
|
672
678
|
"dashboard_user"
|
|
673
679
|
]);
|
|
674
680
|
var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
|
|
681
|
+
var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
682
|
+
var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
|
|
683
|
+
var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
684
|
+
var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
|
|
685
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
686
|
+
var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
|
|
687
|
+
var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
|
|
675
688
|
var E164_PHONE = /^\+[1-9]\d{1,14}$/;
|
|
676
689
|
var SEARCH_DEFAULT_LIMIT = 50;
|
|
677
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.";
|
|
678
692
|
function asciiLowercaseTrim(value) {
|
|
679
693
|
const trimmed = value.trim();
|
|
680
694
|
let out = "";
|
|
@@ -754,6 +768,11 @@ function matchingLinks(kind, externalId) {
|
|
|
754
768
|
(link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
|
|
755
769
|
);
|
|
756
770
|
}
|
|
771
|
+
function sortedMatchingLinks(kind, externalId) {
|
|
772
|
+
return matchingLinks(kind, externalId).sort(
|
|
773
|
+
(a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
|
|
774
|
+
);
|
|
775
|
+
}
|
|
757
776
|
function normalizedLinkKey(link) {
|
|
758
777
|
return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
|
|
759
778
|
}
|
|
@@ -789,11 +808,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
|
|
|
789
808
|
state.identityLinks.push(link);
|
|
790
809
|
return link;
|
|
791
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
|
+
}
|
|
792
821
|
function handleList(request) {
|
|
793
822
|
const typeParam = new URL(request.url).searchParams.get("type");
|
|
794
823
|
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
795
824
|
const identities = [...state.identities.values()].filter(
|
|
796
|
-
(identity) => identity.status !== "merged"
|
|
825
|
+
(identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
|
|
797
826
|
);
|
|
798
827
|
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
799
828
|
}
|
|
@@ -837,11 +866,18 @@ async function handleUpdate(identityId, request) {
|
|
|
837
866
|
const identity = state.identities.get(identityId);
|
|
838
867
|
if (!identity) return failure("identity not found", 404);
|
|
839
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
|
+
}
|
|
840
875
|
if (body.displayName !== void 0) {
|
|
841
876
|
identity.displayName = body.displayName;
|
|
842
877
|
}
|
|
843
|
-
if (
|
|
844
|
-
|
|
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;
|
|
845
881
|
}
|
|
846
882
|
if (body.externalRef !== void 0) {
|
|
847
883
|
identity.externalRef = body.externalRef;
|
|
@@ -867,9 +903,10 @@ async function handleAttachLink(identityId, request) {
|
|
|
867
903
|
);
|
|
868
904
|
if ("error" in normalized) return failure(normalized.error);
|
|
869
905
|
const { kind, externalId } = normalized;
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
906
|
+
const matches = matchingLinks(kind, externalId);
|
|
907
|
+
const ownRow = matches.find((link2) => link2.identityId === identityId);
|
|
908
|
+
if (ownRow) return success({ link: ownRow });
|
|
909
|
+
if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
|
|
873
910
|
return failure("identifier already linked to another identity", 409);
|
|
874
911
|
}
|
|
875
912
|
const link = createUnverifiedLink(identityId, kind, externalId);
|
|
@@ -886,7 +923,7 @@ async function handleResolve(request) {
|
|
|
886
923
|
if (body.displayName !== void 0 && typeof body.displayName !== "string") {
|
|
887
924
|
return failure("displayName must be a string");
|
|
888
925
|
}
|
|
889
|
-
const links =
|
|
926
|
+
const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
|
|
890
927
|
if (links.length > 0) {
|
|
891
928
|
const canonicalIds = /* @__PURE__ */ new Set();
|
|
892
929
|
for (const link of links) {
|
|
@@ -897,12 +934,15 @@ async function handleResolve(request) {
|
|
|
897
934
|
return failure(`cannot resolve identity link: ${message}`, 409);
|
|
898
935
|
}
|
|
899
936
|
}
|
|
900
|
-
|
|
937
|
+
const verifiedOwners = new Set(
|
|
938
|
+
links.filter((link) => link.verified).map((link) => link.identityId)
|
|
939
|
+
);
|
|
940
|
+
if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
|
|
901
941
|
return failure("identifier is linked to multiple identities", 409);
|
|
902
942
|
}
|
|
903
|
-
const
|
|
904
|
-
const
|
|
905
|
-
if (provenance === "guest" &&
|
|
943
|
+
const [chosen] = links;
|
|
944
|
+
const canonicalId = canonicalIdentity(chosen.identityId).id;
|
|
945
|
+
if (provenance === "guest" && chosen.verified) {
|
|
906
946
|
return success({ identityId: null, reason: "verified_conflict" });
|
|
907
947
|
}
|
|
908
948
|
return success({ identityId: canonicalId, created: false });
|
|
@@ -939,6 +979,36 @@ function identityMatchesQuery(identity, query) {
|
|
|
939
979
|
(link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
|
|
940
980
|
);
|
|
941
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
|
+
}
|
|
942
1012
|
function handleSearch(request) {
|
|
943
1013
|
const url = new URL(request.url);
|
|
944
1014
|
const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
|
|
@@ -951,10 +1021,14 @@ function handleSearch(request) {
|
|
|
951
1021
|
return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
|
|
952
1022
|
}
|
|
953
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
|
+
}
|
|
954
1028
|
const rawCursor = url.searchParams.get("cursor");
|
|
955
1029
|
const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
|
|
956
1030
|
if (rawCursor && !cursor) return failure("cursor is invalid");
|
|
957
|
-
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);
|
|
958
1032
|
const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
|
|
959
1033
|
const pageStart = start < 0 ? identities.length : start;
|
|
960
1034
|
const items = identities.slice(pageStart, pageStart + limit);
|
|
@@ -1004,6 +1078,160 @@ async function handleAliases(request) {
|
|
|
1004
1078
|
}
|
|
1005
1079
|
return success({ aliases });
|
|
1006
1080
|
}
|
|
1081
|
+
function claimView(claim) {
|
|
1082
|
+
const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
|
|
1083
|
+
return {
|
|
1084
|
+
claimId: claim.claimId,
|
|
1085
|
+
flow: claim.flow,
|
|
1086
|
+
state: state_,
|
|
1087
|
+
refusalReason: claim.refusalReason,
|
|
1088
|
+
completedIdentityId: claim.completedIdentityId,
|
|
1089
|
+
expiresAt: claim.expiresAt
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
function validateClaimStaff(flow, staff) {
|
|
1093
|
+
if (flow === "invite") {
|
|
1094
|
+
if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
|
|
1095
|
+
return { staffActorRef: null };
|
|
1096
|
+
}
|
|
1097
|
+
if (!staff || typeof staff !== "object") {
|
|
1098
|
+
return { error: `staff is required for a ${flow} claim` };
|
|
1099
|
+
}
|
|
1100
|
+
const { actorRef, grant } = staff;
|
|
1101
|
+
if (typeof actorRef !== "string" || actorRef.length === 0) {
|
|
1102
|
+
return { error: "staff.actorRef is required" };
|
|
1103
|
+
}
|
|
1104
|
+
if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
|
|
1105
|
+
return { staffActorRef: actorRef };
|
|
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
|
+
}
|
|
1181
|
+
async function handleCreateClaim(request) {
|
|
1182
|
+
const body = await readJsonBody(request);
|
|
1183
|
+
const flow = body.flow;
|
|
1184
|
+
if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
|
|
1185
|
+
return failure("flow must be one of: invite, assist, transfer, bearer");
|
|
1186
|
+
}
|
|
1187
|
+
if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
|
|
1188
|
+
return failure("identityId must be a uuid");
|
|
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);
|
|
1193
|
+
const normalized = normalizeLinkInput(
|
|
1194
|
+
body.kind,
|
|
1195
|
+
body.externalId,
|
|
1196
|
+
CLAIM_KINDS,
|
|
1197
|
+
"kind must be one of: email, phone"
|
|
1198
|
+
);
|
|
1199
|
+
if ("error" in normalized) return failure(normalized.error);
|
|
1200
|
+
if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
|
|
1201
|
+
return failure("deliver must be a boolean");
|
|
1202
|
+
}
|
|
1203
|
+
const staff = validateClaimStaff(flow, body.staff);
|
|
1204
|
+
if ("error" in staff) return failure(staff.error);
|
|
1205
|
+
if (flow !== "invite" && !state.identityStaffAssistEnabled) {
|
|
1206
|
+
return failure(
|
|
1207
|
+
"staff-assisted profile claiming is not enabled for this project",
|
|
1208
|
+
403,
|
|
1209
|
+
STAFF_ASSISTANCE_NOT_ENABLED
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
const identity = claimableIdentity(body.identityId);
|
|
1213
|
+
if (!identity) return failure("identity not found", 404);
|
|
1214
|
+
const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
|
|
1215
|
+
(link) => link.identityId === identity.id
|
|
1216
|
+
);
|
|
1217
|
+
if (!holdsContact) {
|
|
1218
|
+
return failure("identity does not hold that contact", 409);
|
|
1219
|
+
}
|
|
1220
|
+
return mintClaim({
|
|
1221
|
+
flow,
|
|
1222
|
+
identityId: identity.id,
|
|
1223
|
+
kind: normalized.kind,
|
|
1224
|
+
externalId: normalized.externalId,
|
|
1225
|
+
returnTo: parsedReturnTo.returnTo,
|
|
1226
|
+
staffActorRef: staff.staffActorRef,
|
|
1227
|
+
deliver: body.deliver === true
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
function handleGetClaim(claimId) {
|
|
1231
|
+
const claim = state.identityClaims.get(claimId);
|
|
1232
|
+
if (!claim) return failure("claim not found", 404);
|
|
1233
|
+
return success(claimView(claim));
|
|
1234
|
+
}
|
|
1007
1235
|
async function handleIdentitiesRequest(request, subPath) {
|
|
1008
1236
|
const method = request.method;
|
|
1009
1237
|
if (subPath === "" || subPath === "/") {
|
|
@@ -1019,6 +1247,13 @@ async function handleIdentitiesRequest(request, subPath) {
|
|
|
1019
1247
|
if (subPath === "/search" && method === "GET") {
|
|
1020
1248
|
return handleSearch(request);
|
|
1021
1249
|
}
|
|
1250
|
+
if (subPath === "/claims" && method === "POST") {
|
|
1251
|
+
return handleCreateClaim(request);
|
|
1252
|
+
}
|
|
1253
|
+
const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
|
|
1254
|
+
if (claimMatch && method === "GET") {
|
|
1255
|
+
return handleGetClaim(decodeURIComponent(claimMatch[1]));
|
|
1256
|
+
}
|
|
1022
1257
|
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
1023
1258
|
if (linksMatch && method === "POST") {
|
|
1024
1259
|
return handleAttachLink(linksMatch[1], request);
|
|
@@ -1043,11 +1278,13 @@ function seedVerifiedLink(identityId, params) {
|
|
|
1043
1278
|
);
|
|
1044
1279
|
if ("error" in normalized) throw new Error(normalized.error);
|
|
1045
1280
|
const { kind, externalId } = normalized;
|
|
1046
|
-
const
|
|
1281
|
+
const matches = matchingLinks(kind, externalId);
|
|
1282
|
+
const blocker = matches.find(
|
|
1283
|
+
(link2) => link2.identityId !== identityId && (link2.verified || !DUPLICABLE_UNVERIFIED_KINDS.has(link2.kind))
|
|
1284
|
+
);
|
|
1285
|
+
if (blocker) throw new Error("identifier already linked to another identity");
|
|
1286
|
+
const existing = matches.find((link2) => link2.identityId === identityId);
|
|
1047
1287
|
if (existing) {
|
|
1048
|
-
if (existing.identityId !== identityId) {
|
|
1049
|
-
throw new Error("identifier already linked to another identity");
|
|
1050
|
-
}
|
|
1051
1288
|
existing.verified = true;
|
|
1052
1289
|
return existing;
|
|
1053
1290
|
}
|
|
@@ -1077,7 +1314,12 @@ function merge(sourceIdentityId, canonicalIdentityId) {
|
|
|
1077
1314
|
);
|
|
1078
1315
|
for (const sourceLink of sourceLinks) {
|
|
1079
1316
|
const key = normalizedLinkKey(sourceLink);
|
|
1080
|
-
|
|
1317
|
+
const conflicting = otherLinks.some(
|
|
1318
|
+
(otherLink) => normalizedLinkKey(otherLink) === key && // Same partial rule as the index: a third identity's UNVERIFIED copy of a
|
|
1319
|
+
// shared email/phone is allowed to coexist and must not block the merge.
|
|
1320
|
+
(!DUPLICABLE_UNVERIFIED_KINDS.has(key.split(":")[0]) || otherLink.verified && sourceLink.verified)
|
|
1321
|
+
);
|
|
1322
|
+
if (conflicting) {
|
|
1081
1323
|
throw new Error("merge would conflict with a link owned by another identity");
|
|
1082
1324
|
}
|
|
1083
1325
|
}
|
|
@@ -1104,6 +1346,89 @@ function merge(sourceIdentityId, canonicalIdentityId) {
|
|
|
1104
1346
|
source.mergedIntoId = canonical.id;
|
|
1105
1347
|
source.updatedAt = now();
|
|
1106
1348
|
}
|
|
1349
|
+
function requireClaim(claimId) {
|
|
1350
|
+
const claim = state.identityClaims.get(claimId);
|
|
1351
|
+
if (!claim) throw new Error("claim not found");
|
|
1352
|
+
return claim;
|
|
1353
|
+
}
|
|
1354
|
+
function completeClaim(claimId, params) {
|
|
1355
|
+
const claim = requireClaim(claimId);
|
|
1356
|
+
if (claim.state !== "pending") return claim;
|
|
1357
|
+
if (Date.parse(claim.expiresAt) <= Date.now()) {
|
|
1358
|
+
claim.state = "expired";
|
|
1359
|
+
return claim;
|
|
1360
|
+
}
|
|
1361
|
+
if (!params.clerkUserId) throw new Error("clerkUserId is required");
|
|
1362
|
+
if (claim.flow === "bearer") return completeBearerClaim(claim, params.clerkUserId);
|
|
1363
|
+
const rows = matchingLinks(claim.kind, claim.externalId);
|
|
1364
|
+
const target = rows.find((link) => link.identityId === claim.identityId);
|
|
1365
|
+
if (!target) {
|
|
1366
|
+
claim.state = "invalidated";
|
|
1367
|
+
return claim;
|
|
1368
|
+
}
|
|
1369
|
+
const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
|
|
1370
|
+
if (otherVerified) {
|
|
1371
|
+
claim.state = "refused";
|
|
1372
|
+
claim.refusalReason = "contact_owned";
|
|
1373
|
+
return claim;
|
|
1374
|
+
}
|
|
1375
|
+
if (claim.flow !== "transfer" && ownedByAnotherLogin(claim.identityId, params.clerkUserId)) {
|
|
1376
|
+
claim.state = "refused";
|
|
1377
|
+
claim.refusalReason = "owned_elsewhere";
|
|
1378
|
+
return claim;
|
|
1379
|
+
}
|
|
1380
|
+
target.verified = true;
|
|
1381
|
+
connectLogin(claim, params.clerkUserId);
|
|
1382
|
+
claim.state = "completed";
|
|
1383
|
+
claim.completedIdentityId = claim.identityId;
|
|
1384
|
+
claim.claimantClerkUserId = params.clerkUserId;
|
|
1385
|
+
return claim;
|
|
1386
|
+
}
|
|
1387
|
+
function ownedByAnotherLogin(identityId, clerkUserId) {
|
|
1388
|
+
return linksFor(identityId).some(
|
|
1389
|
+
(link) => link.kind === "project_auth_user" && link.externalId !== clerkUserId
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
function connectLogin(claim, clerkUserId) {
|
|
1393
|
+
const isLogin = (link) => link.identityId === claim.identityId && link.kind === "project_auth_user";
|
|
1394
|
+
if (claim.flow === "transfer") {
|
|
1395
|
+
state.identityLinks = state.identityLinks.filter(
|
|
1396
|
+
(link) => !isLogin(link) || link.externalId === clerkUserId
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
state.identityLinks = state.identityLinks.filter(
|
|
1400
|
+
(link) => link.kind !== "project_auth_user" || link.externalId !== clerkUserId || link.identityId === claim.identityId
|
|
1401
|
+
);
|
|
1402
|
+
if (!state.identityLinks.some((link) => isLogin(link) && link.externalId === clerkUserId)) {
|
|
1403
|
+
createUnverifiedLink(claim.identityId, "project_auth_user", clerkUserId).verified = true;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function completeBearerClaim(claim, clerkUserId) {
|
|
1407
|
+
const identity = state.identities.get(claim.identityId);
|
|
1408
|
+
if (!identity || identity.type !== "person" || identity.status !== "active") {
|
|
1409
|
+
claim.state = "invalidated";
|
|
1410
|
+
return claim;
|
|
1411
|
+
}
|
|
1412
|
+
const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
|
|
1413
|
+
if (refusal) {
|
|
1414
|
+
claim.state = "refused";
|
|
1415
|
+
claim.refusalReason = refusal;
|
|
1416
|
+
return claim;
|
|
1417
|
+
}
|
|
1418
|
+
connectLogin(claim, clerkUserId);
|
|
1419
|
+
claim.state = "completed";
|
|
1420
|
+
claim.completedIdentityId = claim.identityId;
|
|
1421
|
+
claim.claimantClerkUserId = clerkUserId;
|
|
1422
|
+
return claim;
|
|
1423
|
+
}
|
|
1424
|
+
function refuseClaim(claimId, reason) {
|
|
1425
|
+
const claim = requireClaim(claimId);
|
|
1426
|
+
if (claim.state !== "pending") return claim;
|
|
1427
|
+
if (!reason) throw new Error("a refusal reason is required");
|
|
1428
|
+
claim.state = "refused";
|
|
1429
|
+
claim.refusalReason = reason;
|
|
1430
|
+
return claim;
|
|
1431
|
+
}
|
|
1107
1432
|
function createDirectory() {
|
|
1108
1433
|
return {
|
|
1109
1434
|
all: () => [...state.identities.values()],
|
|
@@ -1111,9 +1436,30 @@ function createDirectory() {
|
|
|
1111
1436
|
links: (identityId) => linksFor(identityId),
|
|
1112
1437
|
seedVerifiedLink,
|
|
1113
1438
|
merge,
|
|
1439
|
+
claims: () => [...state.identityClaims.values()],
|
|
1440
|
+
claim: (claimId) => state.identityClaims.get(claimId),
|
|
1441
|
+
enableStaffAssist: (enabled = true) => {
|
|
1442
|
+
state.identityStaffAssistEnabled = enabled;
|
|
1443
|
+
},
|
|
1444
|
+
enableBearerLinks: (enabled = true) => {
|
|
1445
|
+
state.identityBearerLinksEnabled = enabled;
|
|
1446
|
+
},
|
|
1447
|
+
suppressClaimDelivery: (enabled = true) => {
|
|
1448
|
+
state.identitySuppressClaimDelivery = enabled;
|
|
1449
|
+
},
|
|
1450
|
+
allowReturnOrigin: (origin) => {
|
|
1451
|
+
state.identityAllowedReturnOrigins.add(new URL(origin).origin);
|
|
1452
|
+
},
|
|
1453
|
+
completeClaim,
|
|
1454
|
+
refuseClaim,
|
|
1114
1455
|
clear: () => {
|
|
1115
1456
|
state.identities.clear();
|
|
1116
1457
|
state.identityLinks = [];
|
|
1458
|
+
state.identityClaims.clear();
|
|
1459
|
+
state.identityStaffAssistEnabled = false;
|
|
1460
|
+
state.identityBearerLinksEnabled = true;
|
|
1461
|
+
state.identitySuppressClaimDelivery = false;
|
|
1462
|
+
state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
|
|
1117
1463
|
},
|
|
1118
1464
|
get count() {
|
|
1119
1465
|
return state.identities.size;
|
|
@@ -1152,7 +1498,7 @@ async function importNextServer() {
|
|
|
1152
1498
|
async function callRoute(handler, options = {}) {
|
|
1153
1499
|
const { NextRequest } = await importNextServer();
|
|
1154
1500
|
const path = options.path ?? "/api/test-route";
|
|
1155
|
-
const url = new URL(
|
|
1501
|
+
const url = new URL(`${APP_TEST_ORIGIN}${path}`);
|
|
1156
1502
|
for (const [key, value] of Object.entries(options.searchParams ?? {})) {
|
|
1157
1503
|
url.searchParams.set(key, value);
|
|
1158
1504
|
}
|
|
@@ -2927,8 +3273,7 @@ async function createTestApp(options = {}) {
|
|
|
2927
3273
|
state.refreshSessions.clear();
|
|
2928
3274
|
state.emails = [];
|
|
2929
3275
|
state.emailCounter = 0;
|
|
2930
|
-
|
|
2931
|
-
state.identityLinks = [];
|
|
3276
|
+
directory.clear();
|
|
2932
3277
|
payments.clear();
|
|
2933
3278
|
storage.clear();
|
|
2934
3279
|
messages.clear();
|
|
@@ -2942,8 +3287,7 @@ async function createTestApp(options = {}) {
|
|
|
2942
3287
|
state.refreshSessions.clear();
|
|
2943
3288
|
state.emails = [];
|
|
2944
3289
|
state.emailCounter = 0;
|
|
2945
|
-
|
|
2946
|
-
state.identityLinks = [];
|
|
3290
|
+
directory.clear();
|
|
2947
3291
|
payments.clear();
|
|
2948
3292
|
storage.clear();
|
|
2949
3293
|
messages.clear();
|
|
@@ -36,6 +36,11 @@ function globalSingleton(key, create) {
|
|
|
36
36
|
return holder[symbol];
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// src/constants.ts
|
|
40
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
41
|
+
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
42
|
+
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
43
|
+
|
|
39
44
|
// src/state.ts
|
|
40
45
|
var state = globalSingleton("state", () => ({
|
|
41
46
|
db: null,
|
|
@@ -47,6 +52,11 @@ var state = globalSingleton("state", () => ({
|
|
|
47
52
|
emailCounter: 0,
|
|
48
53
|
identities: /* @__PURE__ */ new Map(),
|
|
49
54
|
identityLinks: [],
|
|
55
|
+
identityClaims: /* @__PURE__ */ new Map(),
|
|
56
|
+
identityStaffAssistEnabled: false,
|
|
57
|
+
identityBearerLinksEnabled: true,
|
|
58
|
+
identitySuppressClaimDelivery: false,
|
|
59
|
+
identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
|
|
50
60
|
checkouts: [],
|
|
51
61
|
checkoutCounter: 0,
|
|
52
62
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
@@ -9,6 +9,11 @@ function globalSingleton(key, create) {
|
|
|
9
9
|
return holder[symbol];
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// src/constants.ts
|
|
13
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
14
|
+
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
15
|
+
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
16
|
+
|
|
12
17
|
// src/state.ts
|
|
13
18
|
var state = globalSingleton("state", () => ({
|
|
14
19
|
db: null,
|
|
@@ -20,6 +25,11 @@ var state = globalSingleton("state", () => ({
|
|
|
20
25
|
emailCounter: 0,
|
|
21
26
|
identities: /* @__PURE__ */ new Map(),
|
|
22
27
|
identityLinks: [],
|
|
28
|
+
identityClaims: /* @__PURE__ */ new Map(),
|
|
29
|
+
identityStaffAssistEnabled: false,
|
|
30
|
+
identityBearerLinksEnabled: true,
|
|
31
|
+
identitySuppressClaimDelivery: false,
|
|
32
|
+
identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
|
|
23
33
|
checkouts: [],
|
|
24
34
|
checkoutCounter: 0,
|
|
25
35
|
sessionStatuses: /* @__PURE__ */ new Map(),
|