@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/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 (body.profile !== void 0) {
787
- identity.profile = body.profile;
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 = state.identities.get(body.identityId);
1020
- if (!identity || identity.type !== "person" || identity.status !== "active") {
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
- const claimId = crypto3.randomUUID();
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
- 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 } : {}
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.16.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 . && eslint . --fix",
50
+ "format": "prettier --write . && oxlint . --fix",
51
51
  "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
52
- "lint": "eslint src/",
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"