@stardeck-customer-apps/testing 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +37 -4
- package/dist/index.d.mts +13 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +196 -45
- package/dist/index.mjs +196 -45
- package/dist/next/headers-shim.js +1 -0
- package/dist/next/headers-shim.mjs +1 -0
- package/dist/setup.js +142 -44
- package/dist/setup.mjs +142 -44
- package/package.json +3 -5
package/dist/setup.mjs
CHANGED
|
@@ -36,6 +36,7 @@ var state = globalSingleton("state", () => ({
|
|
|
36
36
|
identityLinks: [],
|
|
37
37
|
identityClaims: /* @__PURE__ */ new Map(),
|
|
38
38
|
identityStaffAssistEnabled: false,
|
|
39
|
+
identityBearerLinksEnabled: true,
|
|
39
40
|
identitySuppressClaimDelivery: false,
|
|
40
41
|
identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
|
|
41
42
|
checkouts: [],
|
|
@@ -608,14 +609,16 @@ import crypto3 from "crypto";
|
|
|
608
609
|
var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
|
|
609
610
|
var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
|
|
610
611
|
var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
611
|
-
var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
|
|
612
|
+
var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
|
|
612
613
|
var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
613
614
|
var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
|
|
614
615
|
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
615
616
|
var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
|
|
617
|
+
var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
|
|
616
618
|
var E164_PHONE = /^\+[1-9]\d{1,14}$/;
|
|
617
619
|
var SEARCH_DEFAULT_LIMIT = 50;
|
|
618
620
|
var SEARCH_MAX_LIMIT = 100;
|
|
621
|
+
var TEAM_ACCOUNT_FIELDS_MESSAGE = "This person's name and email come from their team account. Change them in Members settings.";
|
|
619
622
|
function asciiLowercaseTrim(value) {
|
|
620
623
|
const trimmed = value.trim();
|
|
621
624
|
let out = "";
|
|
@@ -732,11 +735,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
|
|
|
732
735
|
state.identityLinks.push(link);
|
|
733
736
|
return link;
|
|
734
737
|
}
|
|
738
|
+
function isTeamOnly(identityId) {
|
|
739
|
+
const kinds = linksFor(identityId).map((link) => link.kind);
|
|
740
|
+
return kinds.includes("dashboard_user") && !kinds.includes("project_auth_user");
|
|
741
|
+
}
|
|
742
|
+
function isCurrentTeamMember(identityId) {
|
|
743
|
+
return linksFor(identityId).some((link) => link.kind === "dashboard_user" && link.verified);
|
|
744
|
+
}
|
|
745
|
+
function hasRecordedContact(identityId) {
|
|
746
|
+
return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
|
|
747
|
+
}
|
|
735
748
|
function handleList(request) {
|
|
736
749
|
const typeParam = new URL(request.url).searchParams.get("type");
|
|
737
750
|
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
738
751
|
const identities = [...state.identities.values()].filter(
|
|
739
|
-
(identity) => identity.status !== "merged"
|
|
752
|
+
(identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
|
|
740
753
|
);
|
|
741
754
|
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
742
755
|
}
|
|
@@ -780,11 +793,18 @@ async function handleUpdate(identityId, request) {
|
|
|
780
793
|
const identity = state.identities.get(identityId);
|
|
781
794
|
if (!identity) return failure("identity not found", 404);
|
|
782
795
|
const body = await readJsonBody(request);
|
|
796
|
+
const profile = body.profile;
|
|
797
|
+
if (isCurrentTeamMember(identityId)) {
|
|
798
|
+
const changesName = body.displayName !== void 0 && body.displayName !== identity.displayName;
|
|
799
|
+
const changesEmail = profile !== void 0 && Object.hasOwn(profile, "email") && profile.email !== identity.profile.email;
|
|
800
|
+
if (changesName || changesEmail) return failure(TEAM_ACCOUNT_FIELDS_MESSAGE, 409);
|
|
801
|
+
}
|
|
783
802
|
if (body.displayName !== void 0) {
|
|
784
803
|
identity.displayName = body.displayName;
|
|
785
804
|
}
|
|
786
|
-
if (
|
|
787
|
-
|
|
805
|
+
if (profile !== void 0) {
|
|
806
|
+
const keptEmail = isCurrentTeamMember(identityId) && !Object.hasOwn(profile, "email") && Object.hasOwn(identity.profile, "email");
|
|
807
|
+
identity.profile = keptEmail ? { ...profile, email: identity.profile.email } : profile;
|
|
788
808
|
}
|
|
789
809
|
if (body.externalRef !== void 0) {
|
|
790
810
|
identity.externalRef = body.externalRef;
|
|
@@ -886,6 +906,36 @@ function identityMatchesQuery(identity, query) {
|
|
|
886
906
|
(link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
|
|
887
907
|
);
|
|
888
908
|
}
|
|
909
|
+
function parseSearchAttributes(raw) {
|
|
910
|
+
if (raw === null) return {};
|
|
911
|
+
let parsed;
|
|
912
|
+
try {
|
|
913
|
+
parsed = JSON.parse(raw);
|
|
914
|
+
} catch {
|
|
915
|
+
return "attributes: attributes must be a JSON object";
|
|
916
|
+
}
|
|
917
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
918
|
+
return "attributes: attributes must be a JSON object";
|
|
919
|
+
}
|
|
920
|
+
if (Object.hasOwn(parsed, "__proto__")) return "attributes: attribute keys may not be __proto__";
|
|
921
|
+
const entries = Object.entries(parsed);
|
|
922
|
+
if (entries.length > 10) return "attributes: attributes may filter on at most 10 keys";
|
|
923
|
+
for (const [key, value] of entries) {
|
|
924
|
+
if (!["string", "number", "boolean"].includes(typeof value)) {
|
|
925
|
+
return `attributes.${key}: attribute values must be a string, number or boolean`;
|
|
926
|
+
}
|
|
927
|
+
if (key.length < 1 || key.length > 100) {
|
|
928
|
+
return `attributes.${key}: attribute keys must be 1-100 characters`;
|
|
929
|
+
}
|
|
930
|
+
if (typeof value === "string" && value.length > 500) {
|
|
931
|
+
return `attributes.${key}: attribute values must be at most 500 characters`;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return parsed;
|
|
935
|
+
}
|
|
936
|
+
function identityMatchesAttributes(identity, attributes) {
|
|
937
|
+
return Object.entries(attributes).every(([key, value]) => identity.profile[key] === value);
|
|
938
|
+
}
|
|
889
939
|
function handleSearch(request) {
|
|
890
940
|
const url = new URL(request.url);
|
|
891
941
|
const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
|
|
@@ -898,10 +948,14 @@ function handleSearch(request) {
|
|
|
898
948
|
return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
|
|
899
949
|
}
|
|
900
950
|
}
|
|
951
|
+
const attributes = parseSearchAttributes(url.searchParams.get("attributes"));
|
|
952
|
+
if (typeof attributes === "string") {
|
|
953
|
+
return json({ success: false, error: "Invalid query parameters", details: attributes }, 400);
|
|
954
|
+
}
|
|
901
955
|
const rawCursor = url.searchParams.get("cursor");
|
|
902
956
|
const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
|
|
903
957
|
if (rawCursor && !cursor) return failure("cursor is invalid");
|
|
904
|
-
const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
|
|
958
|
+
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);
|
|
905
959
|
const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
|
|
906
960
|
const pageStart = start < 0 ? identities.length : start;
|
|
907
961
|
const items = identities.slice(pageStart, pageStart + limit);
|
|
@@ -977,15 +1031,92 @@ function validateClaimStaff(flow, staff) {
|
|
|
977
1031
|
if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
|
|
978
1032
|
return { staffActorRef: actorRef };
|
|
979
1033
|
}
|
|
1034
|
+
function parseReturnTo(raw) {
|
|
1035
|
+
if (raw === void 0) return { returnTo: null };
|
|
1036
|
+
if (typeof raw !== "string") return { error: "returnTo must be a string" };
|
|
1037
|
+
let parsed;
|
|
1038
|
+
try {
|
|
1039
|
+
parsed = new URL(raw);
|
|
1040
|
+
} catch {
|
|
1041
|
+
return { error: "returnTo must be an absolute URL" };
|
|
1042
|
+
}
|
|
1043
|
+
return { returnTo: state.identityAllowedReturnOrigins.has(parsed.origin) ? raw : null };
|
|
1044
|
+
}
|
|
1045
|
+
function claimableIdentity(identityId) {
|
|
1046
|
+
const identity = state.identities.get(identityId);
|
|
1047
|
+
if (!identity || identity.type !== "person" || identity.status !== "active") return null;
|
|
1048
|
+
return identity;
|
|
1049
|
+
}
|
|
1050
|
+
function mintClaim(params) {
|
|
1051
|
+
const claimId = crypto3.randomUUID();
|
|
1052
|
+
const handle = crypto3.randomBytes(32).toString("base64url");
|
|
1053
|
+
const claim = {
|
|
1054
|
+
claimId,
|
|
1055
|
+
flow: params.flow,
|
|
1056
|
+
identityId: params.identityId,
|
|
1057
|
+
kind: params.kind,
|
|
1058
|
+
externalId: params.externalId,
|
|
1059
|
+
returnTo: params.returnTo,
|
|
1060
|
+
staffActorRef: params.staffActorRef,
|
|
1061
|
+
state: "pending",
|
|
1062
|
+
refusalReason: null,
|
|
1063
|
+
completedIdentityId: null,
|
|
1064
|
+
claimantClerkUserId: null,
|
|
1065
|
+
claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
|
|
1066
|
+
delivered: params.deliver ? !state.identitySuppressClaimDelivery : null,
|
|
1067
|
+
expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
|
|
1068
|
+
createdAt: now()
|
|
1069
|
+
};
|
|
1070
|
+
state.identityClaims.set(claimId, claim);
|
|
1071
|
+
return success({
|
|
1072
|
+
claimId,
|
|
1073
|
+
claimUrl: claim.claimUrl,
|
|
1074
|
+
expiresAt: claim.expiresAt,
|
|
1075
|
+
// Absent unless asked for: an app delivering its own link must not read a
|
|
1076
|
+
// missing field as "nothing was sent".
|
|
1077
|
+
...params.deliver ? { delivered: claim.delivered } : {}
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
function createBearerClaim(body, returnTo) {
|
|
1081
|
+
for (const field of ["kind", "externalId", "deliver", "staff"]) {
|
|
1082
|
+
if (body[field] !== void 0) {
|
|
1083
|
+
return failure(`a bearer claim takes no ${field}: it is raised against no contact`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (!state.identityBearerLinksEnabled) {
|
|
1087
|
+
return failure(
|
|
1088
|
+
"profile invite links are not enabled for this project",
|
|
1089
|
+
403,
|
|
1090
|
+
BEARER_LINKS_NOT_ENABLED
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
const identity = claimableIdentity(body.identityId);
|
|
1094
|
+
if (!identity) return failure("identity not found", 404);
|
|
1095
|
+
if (hasRecordedContact(identity.id)) {
|
|
1096
|
+
return failure("identity has an email or phone on record; send an invite claim instead", 409);
|
|
1097
|
+
}
|
|
1098
|
+
return mintClaim({
|
|
1099
|
+
flow: "bearer",
|
|
1100
|
+
identityId: identity.id,
|
|
1101
|
+
kind: null,
|
|
1102
|
+
externalId: null,
|
|
1103
|
+
returnTo,
|
|
1104
|
+
staffActorRef: null,
|
|
1105
|
+
deliver: false
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
980
1108
|
async function handleCreateClaim(request) {
|
|
981
1109
|
const body = await readJsonBody(request);
|
|
982
1110
|
const flow = body.flow;
|
|
983
1111
|
if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
|
|
984
|
-
return failure("flow must be one of: invite, assist, transfer");
|
|
1112
|
+
return failure("flow must be one of: invite, assist, transfer, bearer");
|
|
985
1113
|
}
|
|
986
1114
|
if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
|
|
987
1115
|
return failure("identityId must be a uuid");
|
|
988
1116
|
}
|
|
1117
|
+
const parsedReturnTo = parseReturnTo(body.returnTo);
|
|
1118
|
+
if ("error" in parsedReturnTo) return failure(parsedReturnTo.error);
|
|
1119
|
+
if (flow === "bearer") return createBearerClaim(body, parsedReturnTo.returnTo);
|
|
989
1120
|
const normalized = normalizeLinkInput(
|
|
990
1121
|
body.kind,
|
|
991
1122
|
body.externalId,
|
|
@@ -993,17 +1124,6 @@ async function handleCreateClaim(request) {
|
|
|
993
1124
|
"kind must be one of: email, phone"
|
|
994
1125
|
);
|
|
995
1126
|
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
1127
|
if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
|
|
1008
1128
|
return failure("deliver must be a boolean");
|
|
1009
1129
|
}
|
|
@@ -1016,44 +1136,22 @@ async function handleCreateClaim(request) {
|
|
|
1016
1136
|
STAFF_ASSISTANCE_NOT_ENABLED
|
|
1017
1137
|
);
|
|
1018
1138
|
}
|
|
1019
|
-
const identity =
|
|
1020
|
-
if (!identity
|
|
1021
|
-
return failure("identity not found", 404);
|
|
1022
|
-
}
|
|
1139
|
+
const identity = claimableIdentity(body.identityId);
|
|
1140
|
+
if (!identity) return failure("identity not found", 404);
|
|
1023
1141
|
const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
|
|
1024
1142
|
(link) => link.identityId === identity.id
|
|
1025
1143
|
);
|
|
1026
1144
|
if (!holdsContact) {
|
|
1027
1145
|
return failure("identity does not hold that contact", 409);
|
|
1028
1146
|
}
|
|
1029
|
-
|
|
1030
|
-
const deliver = body.deliver === true;
|
|
1031
|
-
const handle = crypto3.randomBytes(32).toString("base64url");
|
|
1032
|
-
const claim = {
|
|
1033
|
-
claimId,
|
|
1147
|
+
return mintClaim({
|
|
1034
1148
|
flow,
|
|
1035
1149
|
identityId: identity.id,
|
|
1036
1150
|
kind: normalized.kind,
|
|
1037
1151
|
externalId: normalized.externalId,
|
|
1038
|
-
returnTo,
|
|
1152
|
+
returnTo: parsedReturnTo.returnTo,
|
|
1039
1153
|
staffActorRef: staff.staffActorRef,
|
|
1040
|
-
|
|
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 } : {}
|
|
1154
|
+
deliver: body.deliver === true
|
|
1057
1155
|
});
|
|
1058
1156
|
}
|
|
1059
1157
|
function handleGetClaim(claimId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "tsup src/index.ts src/config.ts src/setup.ts src/next/headers-shim.ts src/server-only-shim.ts --format cjs,esm --dts --no-splitting",
|
|
49
49
|
"dev": "tsup src/index.ts src/config.ts src/setup.ts src/next/headers-shim.ts src/server-only-shim.ts --format cjs,esm --dts --no-splitting --watch",
|
|
50
|
-
"format": "prettier --write . &&
|
|
50
|
+
"format": "prettier --write . && oxlint . --fix",
|
|
51
51
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
|
|
52
|
-
"lint": "
|
|
52
|
+
"lint": "oxlint src/",
|
|
53
53
|
"test": "vitest run",
|
|
54
54
|
"test:watch": "vitest"
|
|
55
55
|
},
|
|
@@ -94,7 +94,6 @@
|
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|
|
97
|
-
"@eslint/js": "^9.0.0",
|
|
98
97
|
"@neondatabase/serverless": "^1.0.0",
|
|
99
98
|
"@stardeck-customer-apps/data-store-sdk": "*",
|
|
100
99
|
"@stardeck-customer-apps/edge-sdk": "*",
|
|
@@ -109,7 +108,6 @@
|
|
|
109
108
|
"next": "^16.3.0",
|
|
110
109
|
"tsup": "^8.0.0",
|
|
111
110
|
"typescript": "^5.0.0",
|
|
112
|
-
"typescript-eslint": "^8.0.0",
|
|
113
111
|
"vitest": "^3.2.4",
|
|
114
112
|
"@stardeck-customer-apps/starlens": "*",
|
|
115
113
|
"zod": "^4.3.5"
|