@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/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,10 @@ 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
+ identitySuppressClaimDelivery: false,
99
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
71
100
  checkouts: [],
72
101
  checkoutCounter: 0,
73
102
  sessionStatuses: /* @__PURE__ */ new Map(),
@@ -109,30 +138,6 @@ function requireDb() {
109
138
  return state.db;
110
139
  }
111
140
 
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
141
  // src/simulator/hmac.ts
137
142
  import crypto from "crypto";
138
143
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
@@ -183,8 +188,8 @@ function json(body, status = 200) {
183
188
  function success(data) {
184
189
  return json({ success: true, data });
185
190
  }
186
- function failure(error, status = 400) {
187
- return json({ success: false, error }, status);
191
+ function failure(error, status = 400, code) {
192
+ return json({ success: false, error, ...code ? { code } : {} }, status);
188
193
  }
189
194
  async function readJsonBody(request) {
190
195
  try {
@@ -672,6 +677,12 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
672
677
  "dashboard_user"
673
678
  ]);
674
679
  var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
680
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
681
+ var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
682
+ var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
683
+ var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
684
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
685
+ var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
675
686
  var E164_PHONE = /^\+[1-9]\d{1,14}$/;
676
687
  var SEARCH_DEFAULT_LIMIT = 50;
677
688
  var SEARCH_MAX_LIMIT = 100;
@@ -754,6 +765,11 @@ function matchingLinks(kind, externalId) {
754
765
  (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
755
766
  );
756
767
  }
768
+ function sortedMatchingLinks(kind, externalId) {
769
+ return matchingLinks(kind, externalId).sort(
770
+ (a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
771
+ );
772
+ }
757
773
  function normalizedLinkKey(link) {
758
774
  return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
759
775
  }
@@ -867,9 +883,10 @@ async function handleAttachLink(identityId, request) {
867
883
  );
868
884
  if ("error" in normalized) return failure(normalized.error);
869
885
  const { kind, externalId } = normalized;
870
- const [existing] = matchingLinks(kind, externalId);
871
- if (existing) {
872
- if (existing.identityId === identityId) return success({ link: existing });
886
+ const matches = matchingLinks(kind, externalId);
887
+ const ownRow = matches.find((link2) => link2.identityId === identityId);
888
+ if (ownRow) return success({ link: ownRow });
889
+ if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
873
890
  return failure("identifier already linked to another identity", 409);
874
891
  }
875
892
  const link = createUnverifiedLink(identityId, kind, externalId);
@@ -886,7 +903,7 @@ async function handleResolve(request) {
886
903
  if (body.displayName !== void 0 && typeof body.displayName !== "string") {
887
904
  return failure("displayName must be a string");
888
905
  }
889
- const links = matchingLinks(normalized.kind, normalized.externalId);
906
+ const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
890
907
  if (links.length > 0) {
891
908
  const canonicalIds = /* @__PURE__ */ new Set();
892
909
  for (const link of links) {
@@ -897,12 +914,15 @@ async function handleResolve(request) {
897
914
  return failure(`cannot resolve identity link: ${message}`, 409);
898
915
  }
899
916
  }
900
- if (canonicalIds.size !== 1) {
917
+ const verifiedOwners = new Set(
918
+ links.filter((link) => link.verified).map((link) => link.identityId)
919
+ );
920
+ if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
901
921
  return failure("identifier is linked to multiple identities", 409);
902
922
  }
903
- const canonicalId = [...canonicalIds][0];
904
- const hasVerifiedLink = links.some((link) => link.verified);
905
- if (provenance === "guest" && hasVerifiedLink) {
923
+ const [chosen] = links;
924
+ const canonicalId = canonicalIdentity(chosen.identityId).id;
925
+ if (provenance === "guest" && chosen.verified) {
906
926
  return success({ identityId: null, reason: "verified_conflict" });
907
927
  }
908
928
  return success({ identityId: canonicalId, created: false });
@@ -1004,6 +1024,116 @@ async function handleAliases(request) {
1004
1024
  }
1005
1025
  return success({ aliases });
1006
1026
  }
1027
+ function claimView(claim) {
1028
+ const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
1029
+ return {
1030
+ claimId: claim.claimId,
1031
+ flow: claim.flow,
1032
+ state: state_,
1033
+ refusalReason: claim.refusalReason,
1034
+ completedIdentityId: claim.completedIdentityId,
1035
+ expiresAt: claim.expiresAt
1036
+ };
1037
+ }
1038
+ function validateClaimStaff(flow, staff) {
1039
+ if (flow === "invite") {
1040
+ if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
1041
+ return { staffActorRef: null };
1042
+ }
1043
+ if (!staff || typeof staff !== "object") {
1044
+ return { error: `staff is required for a ${flow} claim` };
1045
+ }
1046
+ const { actorRef, grant } = staff;
1047
+ if (typeof actorRef !== "string" || actorRef.length === 0) {
1048
+ return { error: "staff.actorRef is required" };
1049
+ }
1050
+ if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
1051
+ return { staffActorRef: actorRef };
1052
+ }
1053
+ async function handleCreateClaim(request) {
1054
+ const body = await readJsonBody(request);
1055
+ const flow = body.flow;
1056
+ if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
1057
+ return failure("flow must be one of: invite, assist, transfer");
1058
+ }
1059
+ if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
1060
+ return failure("identityId must be a uuid");
1061
+ }
1062
+ const normalized = normalizeLinkInput(
1063
+ body.kind,
1064
+ body.externalId,
1065
+ CLAIM_KINDS,
1066
+ "kind must be one of: email, phone"
1067
+ );
1068
+ if ("error" in normalized) return failure(normalized.error);
1069
+ let returnTo = null;
1070
+ if (body.returnTo !== void 0) {
1071
+ if (typeof body.returnTo !== "string") return failure("returnTo must be a string");
1072
+ let parsed;
1073
+ try {
1074
+ parsed = new URL(body.returnTo);
1075
+ } catch {
1076
+ return failure("returnTo must be an absolute URL");
1077
+ }
1078
+ returnTo = state.identityAllowedReturnOrigins.has(parsed.origin) ? body.returnTo : null;
1079
+ }
1080
+ if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
1081
+ return failure("deliver must be a boolean");
1082
+ }
1083
+ const staff = validateClaimStaff(flow, body.staff);
1084
+ if ("error" in staff) return failure(staff.error);
1085
+ if (flow !== "invite" && !state.identityStaffAssistEnabled) {
1086
+ return failure(
1087
+ "staff-assisted profile claiming is not enabled for this project",
1088
+ 403,
1089
+ STAFF_ASSISTANCE_NOT_ENABLED
1090
+ );
1091
+ }
1092
+ const identity = state.identities.get(body.identityId);
1093
+ if (!identity || identity.type !== "person" || identity.status !== "active") {
1094
+ return failure("identity not found", 404);
1095
+ }
1096
+ const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
1097
+ (link) => link.identityId === identity.id
1098
+ );
1099
+ if (!holdsContact) {
1100
+ return failure("identity does not hold that contact", 409);
1101
+ }
1102
+ const claimId = crypto3.randomUUID();
1103
+ const deliver = body.deliver === true;
1104
+ const handle = crypto3.randomBytes(32).toString("base64url");
1105
+ const claim = {
1106
+ claimId,
1107
+ flow,
1108
+ identityId: identity.id,
1109
+ kind: normalized.kind,
1110
+ externalId: normalized.externalId,
1111
+ returnTo,
1112
+ staffActorRef: staff.staffActorRef,
1113
+ state: "pending",
1114
+ refusalReason: null,
1115
+ completedIdentityId: null,
1116
+ claimantClerkUserId: null,
1117
+ claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
1118
+ delivered: deliver ? !state.identitySuppressClaimDelivery : null,
1119
+ expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
1120
+ createdAt: now()
1121
+ };
1122
+ state.identityClaims.set(claimId, claim);
1123
+ return success({
1124
+ claimId,
1125
+ claimUrl: claim.claimUrl,
1126
+ expiresAt: claim.expiresAt,
1127
+ // Absent unless asked for: an app delivering its own link must not read a
1128
+ // missing field as "nothing was sent".
1129
+ ...deliver ? { delivered: claim.delivered } : {}
1130
+ });
1131
+ }
1132
+ function handleGetClaim(claimId) {
1133
+ const claim = state.identityClaims.get(claimId);
1134
+ if (!claim) return failure("claim not found", 404);
1135
+ return success(claimView(claim));
1136
+ }
1007
1137
  async function handleIdentitiesRequest(request, subPath) {
1008
1138
  const method = request.method;
1009
1139
  if (subPath === "" || subPath === "/") {
@@ -1019,6 +1149,13 @@ async function handleIdentitiesRequest(request, subPath) {
1019
1149
  if (subPath === "/search" && method === "GET") {
1020
1150
  return handleSearch(request);
1021
1151
  }
1152
+ if (subPath === "/claims" && method === "POST") {
1153
+ return handleCreateClaim(request);
1154
+ }
1155
+ const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
1156
+ if (claimMatch && method === "GET") {
1157
+ return handleGetClaim(decodeURIComponent(claimMatch[1]));
1158
+ }
1022
1159
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
1023
1160
  if (linksMatch && method === "POST") {
1024
1161
  return handleAttachLink(linksMatch[1], request);
@@ -1043,11 +1180,13 @@ function seedVerifiedLink(identityId, params) {
1043
1180
  );
1044
1181
  if ("error" in normalized) throw new Error(normalized.error);
1045
1182
  const { kind, externalId } = normalized;
1046
- const [existing] = matchingLinks(kind, externalId);
1183
+ const matches = matchingLinks(kind, externalId);
1184
+ const blocker = matches.find(
1185
+ (link2) => link2.identityId !== identityId && (link2.verified || !DUPLICABLE_UNVERIFIED_KINDS.has(link2.kind))
1186
+ );
1187
+ if (blocker) throw new Error("identifier already linked to another identity");
1188
+ const existing = matches.find((link2) => link2.identityId === identityId);
1047
1189
  if (existing) {
1048
- if (existing.identityId !== identityId) {
1049
- throw new Error("identifier already linked to another identity");
1050
- }
1051
1190
  existing.verified = true;
1052
1191
  return existing;
1053
1192
  }
@@ -1077,7 +1216,12 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1077
1216
  );
1078
1217
  for (const sourceLink of sourceLinks) {
1079
1218
  const key = normalizedLinkKey(sourceLink);
1080
- if (otherLinks.some((otherLink) => normalizedLinkKey(otherLink) === key)) {
1219
+ const conflicting = otherLinks.some(
1220
+ (otherLink) => normalizedLinkKey(otherLink) === key && // Same partial rule as the index: a third identity's UNVERIFIED copy of a
1221
+ // shared email/phone is allowed to coexist and must not block the merge.
1222
+ (!DUPLICABLE_UNVERIFIED_KINDS.has(key.split(":")[0]) || otherLink.verified && sourceLink.verified)
1223
+ );
1224
+ if (conflicting) {
1081
1225
  throw new Error("merge would conflict with a link owned by another identity");
1082
1226
  }
1083
1227
  }
@@ -1104,6 +1248,45 @@ function merge(sourceIdentityId, canonicalIdentityId) {
1104
1248
  source.mergedIntoId = canonical.id;
1105
1249
  source.updatedAt = now();
1106
1250
  }
1251
+ function requireClaim(claimId) {
1252
+ const claim = state.identityClaims.get(claimId);
1253
+ if (!claim) throw new Error("claim not found");
1254
+ return claim;
1255
+ }
1256
+ function completeClaim(claimId, params) {
1257
+ const claim = requireClaim(claimId);
1258
+ if (claim.state !== "pending") return claim;
1259
+ if (Date.parse(claim.expiresAt) <= Date.now()) {
1260
+ claim.state = "expired";
1261
+ return claim;
1262
+ }
1263
+ if (!params.clerkUserId) throw new Error("clerkUserId is required");
1264
+ const rows = matchingLinks(claim.kind, claim.externalId);
1265
+ const target = rows.find((link) => link.identityId === claim.identityId);
1266
+ if (!target) {
1267
+ claim.state = "invalidated";
1268
+ return claim;
1269
+ }
1270
+ const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
1271
+ if (otherVerified) {
1272
+ claim.state = "refused";
1273
+ claim.refusalReason = "contact_owned";
1274
+ return claim;
1275
+ }
1276
+ target.verified = true;
1277
+ claim.state = "completed";
1278
+ claim.completedIdentityId = claim.identityId;
1279
+ claim.claimantClerkUserId = params.clerkUserId;
1280
+ return claim;
1281
+ }
1282
+ function refuseClaim(claimId, reason) {
1283
+ const claim = requireClaim(claimId);
1284
+ if (claim.state !== "pending") return claim;
1285
+ if (!reason) throw new Error("a refusal reason is required");
1286
+ claim.state = "refused";
1287
+ claim.refusalReason = reason;
1288
+ return claim;
1289
+ }
1107
1290
  function createDirectory() {
1108
1291
  return {
1109
1292
  all: () => [...state.identities.values()],
@@ -1111,9 +1294,26 @@ function createDirectory() {
1111
1294
  links: (identityId) => linksFor(identityId),
1112
1295
  seedVerifiedLink,
1113
1296
  merge,
1297
+ claims: () => [...state.identityClaims.values()],
1298
+ claim: (claimId) => state.identityClaims.get(claimId),
1299
+ enableStaffAssist: (enabled = true) => {
1300
+ state.identityStaffAssistEnabled = enabled;
1301
+ },
1302
+ suppressClaimDelivery: (enabled = true) => {
1303
+ state.identitySuppressClaimDelivery = enabled;
1304
+ },
1305
+ allowReturnOrigin: (origin) => {
1306
+ state.identityAllowedReturnOrigins.add(new URL(origin).origin);
1307
+ },
1308
+ completeClaim,
1309
+ refuseClaim,
1114
1310
  clear: () => {
1115
1311
  state.identities.clear();
1116
1312
  state.identityLinks = [];
1313
+ state.identityClaims.clear();
1314
+ state.identityStaffAssistEnabled = false;
1315
+ state.identitySuppressClaimDelivery = false;
1316
+ state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
1117
1317
  },
1118
1318
  get count() {
1119
1319
  return state.identities.size;
@@ -1152,7 +1352,7 @@ async function importNextServer() {
1152
1352
  async function callRoute(handler, options = {}) {
1153
1353
  const { NextRequest } = await importNextServer();
1154
1354
  const path = options.path ?? "/api/test-route";
1155
- const url = new URL(`http://localhost:3333${path}`);
1355
+ const url = new URL(`${APP_TEST_ORIGIN}${path}`);
1156
1356
  for (const [key, value] of Object.entries(options.searchParams ?? {})) {
1157
1357
  url.searchParams.set(key, value);
1158
1358
  }
@@ -2927,8 +3127,7 @@ async function createTestApp(options = {}) {
2927
3127
  state.refreshSessions.clear();
2928
3128
  state.emails = [];
2929
3129
  state.emailCounter = 0;
2930
- state.identities.clear();
2931
- state.identityLinks = [];
3130
+ directory.clear();
2932
3131
  payments.clear();
2933
3132
  storage.clear();
2934
3133
  messages.clear();
@@ -2942,8 +3141,7 @@ async function createTestApp(options = {}) {
2942
3141
  state.refreshSessions.clear();
2943
3142
  state.emails = [];
2944
3143
  state.emailCounter = 0;
2945
- state.identities.clear();
2946
- state.identityLinks = [];
3144
+ directory.clear();
2947
3145
  payments.clear();
2948
3146
  storage.clear();
2949
3147
  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,10 @@ 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
+ identitySuppressClaimDelivery: false,
58
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
50
59
  checkouts: [],
51
60
  checkoutCounter: 0,
52
61
  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,10 @@ 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
+ identitySuppressClaimDelivery: false,
31
+ identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
23
32
  checkouts: [],
24
33
  checkoutCounter: 0,
25
34
  sessionStatuses: /* @__PURE__ */ new Map(),