@stardeck-customer-apps/testing 0.15.0 → 0.16.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 +40 -4
- package/dist/index.d.mts +63 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +242 -44
- package/dist/index.mjs +242 -44
- package/dist/next/headers-shim.js +9 -0
- package/dist/next/headers-shim.mjs +9 -0
- package/dist/setup.js +163 -26
- package/dist/setup.mjs +163 -26
- package/package.json +2 -2
package/dist/setup.js
CHANGED
|
@@ -30,6 +30,23 @@ function globalSingleton(key, create) {
|
|
|
30
30
|
return holder[symbol];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// src/constants.ts
|
|
34
|
+
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
35
|
+
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
36
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
37
|
+
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
38
|
+
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
39
|
+
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
40
|
+
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
41
|
+
var TEST_ENV_DEFAULTS = {
|
|
42
|
+
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
43
|
+
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
44
|
+
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
45
|
+
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
46
|
+
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
47
|
+
STORAGE_URL: STORAGE_TEST_URL
|
|
48
|
+
};
|
|
49
|
+
|
|
33
50
|
// src/state.ts
|
|
34
51
|
var state = globalSingleton("state", () => ({
|
|
35
52
|
db: null,
|
|
@@ -41,6 +58,10 @@ var state = globalSingleton("state", () => ({
|
|
|
41
58
|
emailCounter: 0,
|
|
42
59
|
identities: /* @__PURE__ */ new Map(),
|
|
43
60
|
identityLinks: [],
|
|
61
|
+
identityClaims: /* @__PURE__ */ new Map(),
|
|
62
|
+
identityStaffAssistEnabled: false,
|
|
63
|
+
identitySuppressClaimDelivery: false,
|
|
64
|
+
identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
|
|
44
65
|
checkouts: [],
|
|
45
66
|
checkoutCounter: 0,
|
|
46
67
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
@@ -82,22 +103,6 @@ function requireDb() {
|
|
|
82
103
|
return state.db;
|
|
83
104
|
}
|
|
84
105
|
|
|
85
|
-
// src/constants.ts
|
|
86
|
-
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
87
|
-
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
88
|
-
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
89
|
-
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
90
|
-
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
91
|
-
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
92
|
-
var TEST_ENV_DEFAULTS = {
|
|
93
|
-
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
94
|
-
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
95
|
-
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
96
|
-
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
97
|
-
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
98
|
-
STORAGE_URL: STORAGE_TEST_URL
|
|
99
|
-
};
|
|
100
|
-
|
|
101
106
|
// src/simulator/hmac.ts
|
|
102
107
|
var import_node_crypto = __toESM(require("crypto"));
|
|
103
108
|
var TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
@@ -148,8 +153,8 @@ function json(body, status = 200) {
|
|
|
148
153
|
function success(data) {
|
|
149
154
|
return json({ success: true, data });
|
|
150
155
|
}
|
|
151
|
-
function failure(error, status = 400) {
|
|
152
|
-
return json({ success: false, error }, status);
|
|
156
|
+
function failure(error, status = 400, code) {
|
|
157
|
+
return json({ success: false, error, ...code ? { code } : {} }, status);
|
|
153
158
|
}
|
|
154
159
|
async function readJsonBody(request) {
|
|
155
160
|
try {
|
|
@@ -626,6 +631,12 @@ async function handleEmailSend(request) {
|
|
|
626
631
|
var import_node_crypto3 = __toESM(require("crypto"));
|
|
627
632
|
var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
|
|
628
633
|
var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
|
|
634
|
+
var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
635
|
+
var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
|
|
636
|
+
var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
637
|
+
var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
|
|
638
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
639
|
+
var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
|
|
629
640
|
var E164_PHONE = /^\+[1-9]\d{1,14}$/;
|
|
630
641
|
var SEARCH_DEFAULT_LIMIT = 50;
|
|
631
642
|
var SEARCH_MAX_LIMIT = 100;
|
|
@@ -708,6 +719,11 @@ function matchingLinks(kind, externalId) {
|
|
|
708
719
|
(link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
|
|
709
720
|
);
|
|
710
721
|
}
|
|
722
|
+
function sortedMatchingLinks(kind, externalId) {
|
|
723
|
+
return matchingLinks(kind, externalId).sort(
|
|
724
|
+
(a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
|
|
725
|
+
);
|
|
726
|
+
}
|
|
711
727
|
function createPerson(displayName) {
|
|
712
728
|
const createdAt = now();
|
|
713
729
|
const identity = {
|
|
@@ -818,9 +834,10 @@ async function handleAttachLink(identityId, request) {
|
|
|
818
834
|
);
|
|
819
835
|
if ("error" in normalized) return failure(normalized.error);
|
|
820
836
|
const { kind, externalId } = normalized;
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
837
|
+
const matches = matchingLinks(kind, externalId);
|
|
838
|
+
const ownRow = matches.find((link2) => link2.identityId === identityId);
|
|
839
|
+
if (ownRow) return success({ link: ownRow });
|
|
840
|
+
if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
|
|
824
841
|
return failure("identifier already linked to another identity", 409);
|
|
825
842
|
}
|
|
826
843
|
const link = createUnverifiedLink(identityId, kind, externalId);
|
|
@@ -837,7 +854,7 @@ async function handleResolve(request) {
|
|
|
837
854
|
if (body.displayName !== void 0 && typeof body.displayName !== "string") {
|
|
838
855
|
return failure("displayName must be a string");
|
|
839
856
|
}
|
|
840
|
-
const links =
|
|
857
|
+
const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
|
|
841
858
|
if (links.length > 0) {
|
|
842
859
|
const canonicalIds = /* @__PURE__ */ new Set();
|
|
843
860
|
for (const link of links) {
|
|
@@ -848,12 +865,15 @@ async function handleResolve(request) {
|
|
|
848
865
|
return failure(`cannot resolve identity link: ${message}`, 409);
|
|
849
866
|
}
|
|
850
867
|
}
|
|
851
|
-
|
|
868
|
+
const verifiedOwners = new Set(
|
|
869
|
+
links.filter((link) => link.verified).map((link) => link.identityId)
|
|
870
|
+
);
|
|
871
|
+
if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
|
|
852
872
|
return failure("identifier is linked to multiple identities", 409);
|
|
853
873
|
}
|
|
854
|
-
const
|
|
855
|
-
const
|
|
856
|
-
if (provenance === "guest" &&
|
|
874
|
+
const [chosen] = links;
|
|
875
|
+
const canonicalId = canonicalIdentity(chosen.identityId).id;
|
|
876
|
+
if (provenance === "guest" && chosen.verified) {
|
|
857
877
|
return success({ identityId: null, reason: "verified_conflict" });
|
|
858
878
|
}
|
|
859
879
|
return success({ identityId: canonicalId, created: false });
|
|
@@ -955,6 +975,116 @@ async function handleAliases(request) {
|
|
|
955
975
|
}
|
|
956
976
|
return success({ aliases });
|
|
957
977
|
}
|
|
978
|
+
function claimView(claim) {
|
|
979
|
+
const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
|
|
980
|
+
return {
|
|
981
|
+
claimId: claim.claimId,
|
|
982
|
+
flow: claim.flow,
|
|
983
|
+
state: state_,
|
|
984
|
+
refusalReason: claim.refusalReason,
|
|
985
|
+
completedIdentityId: claim.completedIdentityId,
|
|
986
|
+
expiresAt: claim.expiresAt
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
function validateClaimStaff(flow, staff) {
|
|
990
|
+
if (flow === "invite") {
|
|
991
|
+
if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
|
|
992
|
+
return { staffActorRef: null };
|
|
993
|
+
}
|
|
994
|
+
if (!staff || typeof staff !== "object") {
|
|
995
|
+
return { error: `staff is required for a ${flow} claim` };
|
|
996
|
+
}
|
|
997
|
+
const { actorRef, grant } = staff;
|
|
998
|
+
if (typeof actorRef !== "string" || actorRef.length === 0) {
|
|
999
|
+
return { error: "staff.actorRef is required" };
|
|
1000
|
+
}
|
|
1001
|
+
if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
|
|
1002
|
+
return { staffActorRef: actorRef };
|
|
1003
|
+
}
|
|
1004
|
+
async function handleCreateClaim(request) {
|
|
1005
|
+
const body = await readJsonBody(request);
|
|
1006
|
+
const flow = body.flow;
|
|
1007
|
+
if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
|
|
1008
|
+
return failure("flow must be one of: invite, assist, transfer");
|
|
1009
|
+
}
|
|
1010
|
+
if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
|
|
1011
|
+
return failure("identityId must be a uuid");
|
|
1012
|
+
}
|
|
1013
|
+
const normalized = normalizeLinkInput(
|
|
1014
|
+
body.kind,
|
|
1015
|
+
body.externalId,
|
|
1016
|
+
CLAIM_KINDS,
|
|
1017
|
+
"kind must be one of: email, phone"
|
|
1018
|
+
);
|
|
1019
|
+
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
|
+
if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
|
|
1032
|
+
return failure("deliver must be a boolean");
|
|
1033
|
+
}
|
|
1034
|
+
const staff = validateClaimStaff(flow, body.staff);
|
|
1035
|
+
if ("error" in staff) return failure(staff.error);
|
|
1036
|
+
if (flow !== "invite" && !state.identityStaffAssistEnabled) {
|
|
1037
|
+
return failure(
|
|
1038
|
+
"staff-assisted profile claiming is not enabled for this project",
|
|
1039
|
+
403,
|
|
1040
|
+
STAFF_ASSISTANCE_NOT_ENABLED
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
const identity = state.identities.get(body.identityId);
|
|
1044
|
+
if (!identity || identity.type !== "person" || identity.status !== "active") {
|
|
1045
|
+
return failure("identity not found", 404);
|
|
1046
|
+
}
|
|
1047
|
+
const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
|
|
1048
|
+
(link) => link.identityId === identity.id
|
|
1049
|
+
);
|
|
1050
|
+
if (!holdsContact) {
|
|
1051
|
+
return failure("identity does not hold that contact", 409);
|
|
1052
|
+
}
|
|
1053
|
+
const claimId = import_node_crypto3.default.randomUUID();
|
|
1054
|
+
const deliver = body.deliver === true;
|
|
1055
|
+
const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
|
|
1056
|
+
const claim = {
|
|
1057
|
+
claimId,
|
|
1058
|
+
flow,
|
|
1059
|
+
identityId: identity.id,
|
|
1060
|
+
kind: normalized.kind,
|
|
1061
|
+
externalId: normalized.externalId,
|
|
1062
|
+
returnTo,
|
|
1063
|
+
staffActorRef: staff.staffActorRef,
|
|
1064
|
+
state: "pending",
|
|
1065
|
+
refusalReason: null,
|
|
1066
|
+
completedIdentityId: null,
|
|
1067
|
+
claimantClerkUserId: null,
|
|
1068
|
+
claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
|
|
1069
|
+
delivered: deliver ? !state.identitySuppressClaimDelivery : null,
|
|
1070
|
+
expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
|
|
1071
|
+
createdAt: now()
|
|
1072
|
+
};
|
|
1073
|
+
state.identityClaims.set(claimId, claim);
|
|
1074
|
+
return success({
|
|
1075
|
+
claimId,
|
|
1076
|
+
claimUrl: claim.claimUrl,
|
|
1077
|
+
expiresAt: claim.expiresAt,
|
|
1078
|
+
// Absent unless asked for: an app delivering its own link must not read a
|
|
1079
|
+
// missing field as "nothing was sent".
|
|
1080
|
+
...deliver ? { delivered: claim.delivered } : {}
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
function handleGetClaim(claimId) {
|
|
1084
|
+
const claim = state.identityClaims.get(claimId);
|
|
1085
|
+
if (!claim) return failure("claim not found", 404);
|
|
1086
|
+
return success(claimView(claim));
|
|
1087
|
+
}
|
|
958
1088
|
async function handleIdentitiesRequest(request, subPath) {
|
|
959
1089
|
const method = request.method;
|
|
960
1090
|
if (subPath === "" || subPath === "/") {
|
|
@@ -970,6 +1100,13 @@ async function handleIdentitiesRequest(request, subPath) {
|
|
|
970
1100
|
if (subPath === "/search" && method === "GET") {
|
|
971
1101
|
return handleSearch(request);
|
|
972
1102
|
}
|
|
1103
|
+
if (subPath === "/claims" && method === "POST") {
|
|
1104
|
+
return handleCreateClaim(request);
|
|
1105
|
+
}
|
|
1106
|
+
const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
|
|
1107
|
+
if (claimMatch && method === "GET") {
|
|
1108
|
+
return handleGetClaim(decodeURIComponent(claimMatch[1]));
|
|
1109
|
+
}
|
|
973
1110
|
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
974
1111
|
if (linksMatch && method === "POST") {
|
|
975
1112
|
return handleAttachLink(linksMatch[1], request);
|
package/dist/setup.mjs
CHANGED
|
@@ -6,6 +6,23 @@ function globalSingleton(key, create) {
|
|
|
6
6
|
return holder[symbol];
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// src/constants.ts
|
|
10
|
+
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
11
|
+
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
12
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
13
|
+
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
14
|
+
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
15
|
+
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
16
|
+
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
17
|
+
var TEST_ENV_DEFAULTS = {
|
|
18
|
+
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
19
|
+
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
20
|
+
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
21
|
+
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
22
|
+
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
23
|
+
STORAGE_URL: STORAGE_TEST_URL
|
|
24
|
+
};
|
|
25
|
+
|
|
9
26
|
// src/state.ts
|
|
10
27
|
var state = globalSingleton("state", () => ({
|
|
11
28
|
db: null,
|
|
@@ -17,6 +34,10 @@ var state = globalSingleton("state", () => ({
|
|
|
17
34
|
emailCounter: 0,
|
|
18
35
|
identities: /* @__PURE__ */ new Map(),
|
|
19
36
|
identityLinks: [],
|
|
37
|
+
identityClaims: /* @__PURE__ */ new Map(),
|
|
38
|
+
identityStaffAssistEnabled: false,
|
|
39
|
+
identitySuppressClaimDelivery: false,
|
|
40
|
+
identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
|
|
20
41
|
checkouts: [],
|
|
21
42
|
checkoutCounter: 0,
|
|
22
43
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
@@ -58,22 +79,6 @@ function requireDb() {
|
|
|
58
79
|
return state.db;
|
|
59
80
|
}
|
|
60
81
|
|
|
61
|
-
// src/constants.ts
|
|
62
|
-
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
63
|
-
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
64
|
-
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
65
|
-
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
66
|
-
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
67
|
-
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
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
|
-
|
|
77
82
|
// src/simulator/hmac.ts
|
|
78
83
|
import crypto from "crypto";
|
|
79
84
|
var TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
@@ -124,8 +129,8 @@ function json(body, status = 200) {
|
|
|
124
129
|
function success(data) {
|
|
125
130
|
return json({ success: true, data });
|
|
126
131
|
}
|
|
127
|
-
function failure(error, status = 400) {
|
|
128
|
-
return json({ success: false, error }, status);
|
|
132
|
+
function failure(error, status = 400, code) {
|
|
133
|
+
return json({ success: false, error, ...code ? { code } : {} }, status);
|
|
129
134
|
}
|
|
130
135
|
async function readJsonBody(request) {
|
|
131
136
|
try {
|
|
@@ -602,6 +607,12 @@ async function handleEmailSend(request) {
|
|
|
602
607
|
import crypto3 from "crypto";
|
|
603
608
|
var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
|
|
604
609
|
var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
|
|
610
|
+
var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
611
|
+
var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
|
|
612
|
+
var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
613
|
+
var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
|
|
614
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
615
|
+
var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
|
|
605
616
|
var E164_PHONE = /^\+[1-9]\d{1,14}$/;
|
|
606
617
|
var SEARCH_DEFAULT_LIMIT = 50;
|
|
607
618
|
var SEARCH_MAX_LIMIT = 100;
|
|
@@ -684,6 +695,11 @@ function matchingLinks(kind, externalId) {
|
|
|
684
695
|
(link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
|
|
685
696
|
);
|
|
686
697
|
}
|
|
698
|
+
function sortedMatchingLinks(kind, externalId) {
|
|
699
|
+
return matchingLinks(kind, externalId).sort(
|
|
700
|
+
(a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
|
|
701
|
+
);
|
|
702
|
+
}
|
|
687
703
|
function createPerson(displayName) {
|
|
688
704
|
const createdAt = now();
|
|
689
705
|
const identity = {
|
|
@@ -794,9 +810,10 @@ async function handleAttachLink(identityId, request) {
|
|
|
794
810
|
);
|
|
795
811
|
if ("error" in normalized) return failure(normalized.error);
|
|
796
812
|
const { kind, externalId } = normalized;
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
813
|
+
const matches = matchingLinks(kind, externalId);
|
|
814
|
+
const ownRow = matches.find((link2) => link2.identityId === identityId);
|
|
815
|
+
if (ownRow) return success({ link: ownRow });
|
|
816
|
+
if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
|
|
800
817
|
return failure("identifier already linked to another identity", 409);
|
|
801
818
|
}
|
|
802
819
|
const link = createUnverifiedLink(identityId, kind, externalId);
|
|
@@ -813,7 +830,7 @@ async function handleResolve(request) {
|
|
|
813
830
|
if (body.displayName !== void 0 && typeof body.displayName !== "string") {
|
|
814
831
|
return failure("displayName must be a string");
|
|
815
832
|
}
|
|
816
|
-
const links =
|
|
833
|
+
const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
|
|
817
834
|
if (links.length > 0) {
|
|
818
835
|
const canonicalIds = /* @__PURE__ */ new Set();
|
|
819
836
|
for (const link of links) {
|
|
@@ -824,12 +841,15 @@ async function handleResolve(request) {
|
|
|
824
841
|
return failure(`cannot resolve identity link: ${message}`, 409);
|
|
825
842
|
}
|
|
826
843
|
}
|
|
827
|
-
|
|
844
|
+
const verifiedOwners = new Set(
|
|
845
|
+
links.filter((link) => link.verified).map((link) => link.identityId)
|
|
846
|
+
);
|
|
847
|
+
if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
|
|
828
848
|
return failure("identifier is linked to multiple identities", 409);
|
|
829
849
|
}
|
|
830
|
-
const
|
|
831
|
-
const
|
|
832
|
-
if (provenance === "guest" &&
|
|
850
|
+
const [chosen] = links;
|
|
851
|
+
const canonicalId = canonicalIdentity(chosen.identityId).id;
|
|
852
|
+
if (provenance === "guest" && chosen.verified) {
|
|
833
853
|
return success({ identityId: null, reason: "verified_conflict" });
|
|
834
854
|
}
|
|
835
855
|
return success({ identityId: canonicalId, created: false });
|
|
@@ -931,6 +951,116 @@ async function handleAliases(request) {
|
|
|
931
951
|
}
|
|
932
952
|
return success({ aliases });
|
|
933
953
|
}
|
|
954
|
+
function claimView(claim) {
|
|
955
|
+
const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
|
|
956
|
+
return {
|
|
957
|
+
claimId: claim.claimId,
|
|
958
|
+
flow: claim.flow,
|
|
959
|
+
state: state_,
|
|
960
|
+
refusalReason: claim.refusalReason,
|
|
961
|
+
completedIdentityId: claim.completedIdentityId,
|
|
962
|
+
expiresAt: claim.expiresAt
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function validateClaimStaff(flow, staff) {
|
|
966
|
+
if (flow === "invite") {
|
|
967
|
+
if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
|
|
968
|
+
return { staffActorRef: null };
|
|
969
|
+
}
|
|
970
|
+
if (!staff || typeof staff !== "object") {
|
|
971
|
+
return { error: `staff is required for a ${flow} claim` };
|
|
972
|
+
}
|
|
973
|
+
const { actorRef, grant } = staff;
|
|
974
|
+
if (typeof actorRef !== "string" || actorRef.length === 0) {
|
|
975
|
+
return { error: "staff.actorRef is required" };
|
|
976
|
+
}
|
|
977
|
+
if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
|
|
978
|
+
return { staffActorRef: actorRef };
|
|
979
|
+
}
|
|
980
|
+
async function handleCreateClaim(request) {
|
|
981
|
+
const body = await readJsonBody(request);
|
|
982
|
+
const flow = body.flow;
|
|
983
|
+
if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
|
|
984
|
+
return failure("flow must be one of: invite, assist, transfer");
|
|
985
|
+
}
|
|
986
|
+
if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
|
|
987
|
+
return failure("identityId must be a uuid");
|
|
988
|
+
}
|
|
989
|
+
const normalized = normalizeLinkInput(
|
|
990
|
+
body.kind,
|
|
991
|
+
body.externalId,
|
|
992
|
+
CLAIM_KINDS,
|
|
993
|
+
"kind must be one of: email, phone"
|
|
994
|
+
);
|
|
995
|
+
if ("error" in normalized) return failure(normalized.error);
|
|
996
|
+
let returnTo = null;
|
|
997
|
+
if (body.returnTo !== void 0) {
|
|
998
|
+
if (typeof body.returnTo !== "string") return failure("returnTo must be a string");
|
|
999
|
+
let parsed;
|
|
1000
|
+
try {
|
|
1001
|
+
parsed = new URL(body.returnTo);
|
|
1002
|
+
} catch {
|
|
1003
|
+
return failure("returnTo must be an absolute URL");
|
|
1004
|
+
}
|
|
1005
|
+
returnTo = state.identityAllowedReturnOrigins.has(parsed.origin) ? body.returnTo : null;
|
|
1006
|
+
}
|
|
1007
|
+
if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
|
|
1008
|
+
return failure("deliver must be a boolean");
|
|
1009
|
+
}
|
|
1010
|
+
const staff = validateClaimStaff(flow, body.staff);
|
|
1011
|
+
if ("error" in staff) return failure(staff.error);
|
|
1012
|
+
if (flow !== "invite" && !state.identityStaffAssistEnabled) {
|
|
1013
|
+
return failure(
|
|
1014
|
+
"staff-assisted profile claiming is not enabled for this project",
|
|
1015
|
+
403,
|
|
1016
|
+
STAFF_ASSISTANCE_NOT_ENABLED
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
const identity = state.identities.get(body.identityId);
|
|
1020
|
+
if (!identity || identity.type !== "person" || identity.status !== "active") {
|
|
1021
|
+
return failure("identity not found", 404);
|
|
1022
|
+
}
|
|
1023
|
+
const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
|
|
1024
|
+
(link) => link.identityId === identity.id
|
|
1025
|
+
);
|
|
1026
|
+
if (!holdsContact) {
|
|
1027
|
+
return failure("identity does not hold that contact", 409);
|
|
1028
|
+
}
|
|
1029
|
+
const claimId = crypto3.randomUUID();
|
|
1030
|
+
const deliver = body.deliver === true;
|
|
1031
|
+
const handle = crypto3.randomBytes(32).toString("base64url");
|
|
1032
|
+
const claim = {
|
|
1033
|
+
claimId,
|
|
1034
|
+
flow,
|
|
1035
|
+
identityId: identity.id,
|
|
1036
|
+
kind: normalized.kind,
|
|
1037
|
+
externalId: normalized.externalId,
|
|
1038
|
+
returnTo,
|
|
1039
|
+
staffActorRef: staff.staffActorRef,
|
|
1040
|
+
state: "pending",
|
|
1041
|
+
refusalReason: null,
|
|
1042
|
+
completedIdentityId: null,
|
|
1043
|
+
claimantClerkUserId: null,
|
|
1044
|
+
claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
|
|
1045
|
+
delivered: deliver ? !state.identitySuppressClaimDelivery : null,
|
|
1046
|
+
expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
|
|
1047
|
+
createdAt: now()
|
|
1048
|
+
};
|
|
1049
|
+
state.identityClaims.set(claimId, claim);
|
|
1050
|
+
return success({
|
|
1051
|
+
claimId,
|
|
1052
|
+
claimUrl: claim.claimUrl,
|
|
1053
|
+
expiresAt: claim.expiresAt,
|
|
1054
|
+
// Absent unless asked for: an app delivering its own link must not read a
|
|
1055
|
+
// missing field as "nothing was sent".
|
|
1056
|
+
...deliver ? { delivered: claim.delivered } : {}
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
function handleGetClaim(claimId) {
|
|
1060
|
+
const claim = state.identityClaims.get(claimId);
|
|
1061
|
+
if (!claim) return failure("claim not found", 404);
|
|
1062
|
+
return success(claimView(claim));
|
|
1063
|
+
}
|
|
934
1064
|
async function handleIdentitiesRequest(request, subPath) {
|
|
935
1065
|
const method = request.method;
|
|
936
1066
|
if (subPath === "" || subPath === "/") {
|
|
@@ -946,6 +1076,13 @@ async function handleIdentitiesRequest(request, subPath) {
|
|
|
946
1076
|
if (subPath === "/search" && method === "GET") {
|
|
947
1077
|
return handleSearch(request);
|
|
948
1078
|
}
|
|
1079
|
+
if (subPath === "/claims" && method === "POST") {
|
|
1080
|
+
return handleCreateClaim(request);
|
|
1081
|
+
}
|
|
1082
|
+
const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
|
|
1083
|
+
if (claimMatch && method === "GET") {
|
|
1084
|
+
return handleGetClaim(decodeURIComponent(claimMatch[1]));
|
|
1085
|
+
}
|
|
949
1086
|
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
950
1087
|
if (linksMatch && method === "POST") {
|
|
951
1088
|
return handleAttachLink(linksMatch[1], request);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@stardeck-customer-apps/edge-sdk": ">=1.4.0",
|
|
69
|
-
"@stardeck-customer-apps/integrations-sdk": ">=1.
|
|
69
|
+
"@stardeck-customer-apps/integrations-sdk": ">=1.16.0",
|
|
70
70
|
"@stardeck-customer-apps/payments-sdk": ">=0.1.0",
|
|
71
71
|
"@stardeck-customer-apps/storage-sdk": ">=0.1.0",
|
|
72
72
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|