@stardeck-customer-apps/testing 0.5.0 → 0.6.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.js CHANGED
@@ -155,6 +155,14 @@ var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
155
155
  // src/simulator/hmac.ts
156
156
  var import_node_crypto = __toESM(require("crypto"));
157
157
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
158
+ function isNonEmptyString(value) {
159
+ return typeof value === "string" && value.length > 0;
160
+ }
161
+ function isDeploymentAuthPayload(value) {
162
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
163
+ const payload = value;
164
+ return payload.type === "deployment-request" && isNonEmptyString(payload.organizationId) && isNonEmptyString(payload.projectId) && isNonEmptyString(payload.deploymentId) && typeof payload.timestamp === "number" && Number.isSafeInteger(payload.timestamp) && isNonEmptyString(payload.nonce);
165
+ }
158
166
  function verifyDeploymentAuthHeader(secret, header) {
159
167
  const dotIndex = header.lastIndexOf(".");
160
168
  if (dotIndex === -1) return null;
@@ -172,16 +180,16 @@ function verifyDeploymentAuthHeader(secret, header) {
172
180
  if (expectedBuf.length !== actualBuf.length || !import_node_crypto.default.timingSafeEqual(expectedBuf, actualBuf)) {
173
181
  return null;
174
182
  }
175
- let payload;
183
+ let parsed;
176
184
  try {
177
- payload = JSON.parse(payloadJson);
185
+ parsed = JSON.parse(payloadJson);
178
186
  } catch {
179
187
  return null;
180
188
  }
181
- if (payload.type !== "deployment-request") return null;
189
+ if (!isDeploymentAuthPayload(parsed)) return null;
182
190
  const now3 = Math.floor(Date.now() / 1e3);
183
- if (Math.abs(now3 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
184
- return payload;
191
+ if (Math.abs(now3 - parsed.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
192
+ return parsed;
185
193
  }
186
194
 
187
195
  // src/simulator/http.ts
@@ -680,16 +688,121 @@ var LINK_KINDS = /* @__PURE__ */ new Set([
680
688
  "project_auth_user",
681
689
  "dashboard_user"
682
690
  ]);
691
+ var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
692
+ var E164_PHONE = /^\+[1-9]\d{1,14}$/;
693
+ var SEARCH_DEFAULT_LIMIT = 50;
694
+ var SEARCH_MAX_LIMIT = 100;
695
+ var IdentityGraphError = class extends Error {
696
+ };
683
697
  function now() {
684
698
  return (/* @__PURE__ */ new Date()).toISOString();
685
699
  }
686
700
  function linksFor(identityId) {
687
701
  return state.identityLinks.filter((l) => l.identityId === identityId);
688
702
  }
703
+ function comparableExternalId(kind, externalId) {
704
+ if (kind === "email") return externalId.trim().toLowerCase();
705
+ return externalId;
706
+ }
707
+ function normalizeExternalId(kind, externalId) {
708
+ if (kind === "email") {
709
+ const normalized = externalId.trim().toLowerCase();
710
+ return normalized.length > 0 ? { externalId: normalized } : { error: "externalId is required" };
711
+ }
712
+ if (kind === "phone" && !E164_PHONE.test(externalId)) {
713
+ return { error: "phone externalId must be an E.164 number" };
714
+ }
715
+ return { externalId };
716
+ }
717
+ function normalizeLinkInput(kind, externalId, allowedKinds, kindError) {
718
+ if (typeof kind !== "string" || !allowedKinds.has(kind)) {
719
+ return { error: kindError };
720
+ }
721
+ if (typeof externalId !== "string" || externalId.length === 0) {
722
+ return { error: "externalId is required" };
723
+ }
724
+ const normalized = normalizeExternalId(kind, externalId);
725
+ if ("error" in normalized) return normalized;
726
+ return { kind, externalId: normalized.externalId };
727
+ }
728
+ function normalizeResolveInput(kind, externalId) {
729
+ const normalized = normalizeLinkInput(
730
+ kind,
731
+ externalId,
732
+ RESOLVE_KINDS,
733
+ "kind must be one of: phone, email, line"
734
+ );
735
+ if ("error" in normalized) return normalized;
736
+ return { kind: normalized.kind, externalId: normalized.externalId };
737
+ }
738
+ function canonicalIdentity(identityId) {
739
+ const visited = /* @__PURE__ */ new Set();
740
+ let identity = state.identities.get(identityId);
741
+ while (identity) {
742
+ if (visited.has(identity.id)) {
743
+ throw new IdentityGraphError("identity merge chain contains a cycle");
744
+ }
745
+ visited.add(identity.id);
746
+ if (identity.status === "archived") {
747
+ throw new IdentityGraphError("identity is archived");
748
+ }
749
+ if (identity.status === "active" && !identity.mergedIntoId) {
750
+ return identity;
751
+ }
752
+ if (!identity.mergedIntoId) {
753
+ throw new IdentityGraphError("identity merge chain has no active canonical target");
754
+ }
755
+ identity = state.identities.get(identity.mergedIntoId);
756
+ }
757
+ throw new IdentityGraphError("identity merge chain references a missing identity");
758
+ }
759
+ function matchingLinks(kind, externalId) {
760
+ const comparable = comparableExternalId(kind, externalId);
761
+ return state.identityLinks.filter(
762
+ (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
763
+ );
764
+ }
765
+ function normalizedLinkKey(link) {
766
+ return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
767
+ }
768
+ function createPerson(displayName) {
769
+ const createdAt = now();
770
+ const identity = {
771
+ id: import_node_crypto3.default.randomUUID(),
772
+ type: "person",
773
+ parentId: null,
774
+ displayName,
775
+ profile: {},
776
+ status: "active",
777
+ mergedIntoId: null,
778
+ externalRef: null,
779
+ createdAt,
780
+ updatedAt: createdAt
781
+ };
782
+ state.identities.set(identity.id, identity);
783
+ return identity;
784
+ }
785
+ function createUnverifiedLink(identityId, kind, externalId) {
786
+ const link = {
787
+ id: import_node_crypto3.default.randomUUID(),
788
+ identityId,
789
+ kind,
790
+ externalId,
791
+ // Deployment credentials can attest provenance, but cannot manufacture a
792
+ // platform-verified fact. Trusted channel/control-plane setup uses the
793
+ // directory's test-only seedVerifiedLink helper instead.
794
+ verified: false,
795
+ createdAt: now()
796
+ };
797
+ state.identityLinks.push(link);
798
+ return link;
799
+ }
689
800
  function handleList(request) {
690
801
  const typeParam = new URL(request.url).searchParams.get("type");
691
802
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
692
- const identities = [...state.identities.values()];
803
+ const identities = [...state.identities.values()].filter(
804
+ (identity) => identity.status !== "merged"
805
+ );
693
806
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
694
807
  }
695
808
  async function handleCreate(request) {
@@ -707,6 +820,7 @@ async function handleCreate(request) {
707
820
  }
708
821
  parentId = parent.id;
709
822
  }
823
+ const createdAt = now();
710
824
  const identity = {
711
825
  id: import_node_crypto3.default.randomUUID(),
712
826
  type,
@@ -716,8 +830,8 @@ async function handleCreate(request) {
716
830
  status: "active",
717
831
  mergedIntoId: null,
718
832
  externalRef: body.externalRef ?? null,
719
- createdAt: now(),
720
- updatedAt: now()
833
+ createdAt,
834
+ updatedAt: createdAt
721
835
  };
722
836
  state.identities.set(identity.id, identity);
723
837
  return success({ identity });
@@ -753,36 +867,166 @@ async function handleAttachLink(identityId, request) {
753
867
  return failure("links attach only to active persons");
754
868
  }
755
869
  const body = await readJsonBody(request);
756
- const kind = body.kind;
757
- const externalId = body.externalId;
758
- if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
759
- return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
760
- }
761
- if (typeof externalId !== "string" || !externalId) {
762
- return failure("externalId is required");
763
- }
764
- const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
870
+ const normalized = normalizeLinkInput(
871
+ body.kind,
872
+ body.externalId,
873
+ LINK_KINDS,
874
+ `kind must be one of: ${[...LINK_KINDS].join(", ")}`
875
+ );
876
+ if ("error" in normalized) return failure(normalized.error);
877
+ const { kind, externalId } = normalized;
878
+ const [existing] = matchingLinks(kind, externalId);
765
879
  if (existing) {
766
880
  if (existing.identityId === identityId) return success({ link: existing });
767
881
  return failure("identifier already linked to another identity", 409);
768
882
  }
769
- const link = {
770
- id: import_node_crypto3.default.randomUUID(),
771
- identityId,
772
- kind,
773
- externalId,
774
- verified: body.verified === true,
775
- createdAt: now()
776
- };
777
- state.identityLinks.push(link);
883
+ const link = createUnverifiedLink(identityId, kind, externalId);
778
884
  return success({ link });
779
885
  }
886
+ async function handleResolve(request) {
887
+ const body = await readJsonBody(request);
888
+ const normalized = normalizeResolveInput(body.kind, body.externalId);
889
+ if ("error" in normalized) return failure(normalized.error);
890
+ const provenance = body.provenance;
891
+ if (provenance !== "guest" && provenance !== "staff") {
892
+ return failure("provenance must be 'guest' or 'staff'");
893
+ }
894
+ if (body.displayName !== void 0 && typeof body.displayName !== "string") {
895
+ return failure("displayName must be a string");
896
+ }
897
+ const links = matchingLinks(normalized.kind, normalized.externalId);
898
+ if (links.length > 0) {
899
+ const canonicalIds = /* @__PURE__ */ new Set();
900
+ for (const link of links) {
901
+ try {
902
+ canonicalIds.add(canonicalIdentity(link.identityId).id);
903
+ } catch (error) {
904
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
905
+ return failure(`cannot resolve identity link: ${message}`, 409);
906
+ }
907
+ }
908
+ if (canonicalIds.size !== 1) {
909
+ return failure("identifier is linked to multiple identities", 409);
910
+ }
911
+ const canonicalId = [...canonicalIds][0];
912
+ const hasVerifiedLink = links.some((link) => link.verified);
913
+ if (provenance === "guest" && hasVerifiedLink) {
914
+ return success({ identityId: null, reason: "verified_conflict" });
915
+ }
916
+ return success({ identityId: canonicalId, created: false });
917
+ }
918
+ const displayName = body.displayName ?? null;
919
+ const identity = createPerson(displayName);
920
+ createUnverifiedLink(identity.id, normalized.kind, normalized.externalId);
921
+ return success({ identityId: identity.id, created: true });
922
+ }
923
+ function encodeSearchCursor(cursor) {
924
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
925
+ }
926
+ function decodeSearchCursor(raw, query) {
927
+ try {
928
+ const rawParsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
929
+ if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null;
930
+ const parsed = rawParsed;
931
+ if (typeof parsed.createdAt !== "string" || typeof parsed.id !== "string" || typeof parsed.query !== "string" || parsed.query !== query) {
932
+ return null;
933
+ }
934
+ return { createdAt: parsed.createdAt, id: parsed.id, query: parsed.query };
935
+ } catch {
936
+ return null;
937
+ }
938
+ }
939
+ function compareIdentityOrder(left, right) {
940
+ const byCreatedAt = left.createdAt.localeCompare(right.createdAt);
941
+ return byCreatedAt !== 0 ? byCreatedAt : left.id.localeCompare(right.id);
942
+ }
943
+ function identityMatchesQuery(identity, query) {
944
+ if (!query) return true;
945
+ if (identity.displayName?.toLowerCase().includes(query)) return true;
946
+ return state.identityLinks.some(
947
+ (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
948
+ );
949
+ }
950
+ function handleSearch(request) {
951
+ const url = new URL(request.url);
952
+ const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
953
+ const rawLimit = url.searchParams.get("limit");
954
+ let limit = SEARCH_DEFAULT_LIMIT;
955
+ if (rawLimit !== null) {
956
+ if (!/^\d+$/.test(rawLimit)) return failure("limit must be a positive integer");
957
+ limit = Number(rawLimit);
958
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SEARCH_MAX_LIMIT) {
959
+ return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
960
+ }
961
+ }
962
+ const rawCursor = url.searchParams.get("cursor");
963
+ const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
964
+ if (rawCursor && !cursor) return failure("cursor is invalid");
965
+ const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
966
+ const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
967
+ const pageStart = start < 0 ? identities.length : start;
968
+ const items = identities.slice(pageStart, pageStart + limit);
969
+ const hasMore = pageStart + items.length < identities.length;
970
+ const nextCursor = hasMore ? encodeSearchCursor({
971
+ createdAt: items[items.length - 1].createdAt,
972
+ id: items[items.length - 1].id,
973
+ query
974
+ }) : null;
975
+ return success({ items, nextCursor });
976
+ }
977
+ function aliasesForCanonical(canonicalId) {
978
+ const aliases = [canonicalId];
979
+ for (const identity of state.identities.values()) {
980
+ if (identity.id === canonicalId || identity.status !== "merged") continue;
981
+ try {
982
+ if (canonicalIdentity(identity.id).id === canonicalId) aliases.push(identity.id);
983
+ } catch {
984
+ }
985
+ }
986
+ return aliases;
987
+ }
988
+ async function handleAliases(request) {
989
+ const body = await readJsonBody(request);
990
+ if (!Array.isArray(body.identityIds)) {
991
+ return failure("identityIds must be an array");
992
+ }
993
+ if (body.identityIds.length === 0) {
994
+ return failure("identityIds must not be empty");
995
+ }
996
+ if (body.identityIds.length > 200) {
997
+ return failure("A maximum of 200 identity ids may be requested");
998
+ }
999
+ if (body.identityIds.some((identityId) => typeof identityId !== "string" || identityId.length === 0)) {
1000
+ return failure("identityIds must contain non-empty strings");
1001
+ }
1002
+ const aliases = {};
1003
+ for (const identityId of body.identityIds) {
1004
+ let canonical;
1005
+ try {
1006
+ canonical = canonicalIdentity(identityId);
1007
+ } catch (error) {
1008
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
1009
+ return failure(`cannot expand identity aliases: ${message}`, 409);
1010
+ }
1011
+ aliases[canonical.id] ??= aliasesForCanonical(canonical.id);
1012
+ }
1013
+ return success({ aliases });
1014
+ }
780
1015
  async function handleIdentitiesRequest(request, subPath) {
781
1016
  const method = request.method;
782
1017
  if (subPath === "" || subPath === "/") {
783
1018
  if (method === "GET") return handleList(request);
784
1019
  if (method === "POST") return handleCreate(request);
785
1020
  }
1021
+ if (subPath === "/resolve" && method === "POST") {
1022
+ return handleResolve(request);
1023
+ }
1024
+ if (subPath === "/aliases" && method === "POST") {
1025
+ return handleAliases(request);
1026
+ }
1027
+ if (subPath === "/search" && method === "GET") {
1028
+ return handleSearch(request);
1029
+ }
786
1030
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
787
1031
  if (linksMatch && method === "POST") {
788
1032
  return handleAttachLink(linksMatch[1], request);
@@ -794,11 +1038,87 @@ async function handleIdentitiesRequest(request, subPath) {
794
1038
  }
795
1039
  return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
796
1040
  }
1041
+ function seedVerifiedLink(identityId, params) {
1042
+ const identity = state.identities.get(identityId);
1043
+ if (!identity) throw new Error("identity not found");
1044
+ if (identity.type !== "person") throw new Error("verified links attach only to persons");
1045
+ if (identity.status !== "active") throw new Error("verified links attach only to active persons");
1046
+ const normalized = normalizeLinkInput(
1047
+ params.kind,
1048
+ params.externalId,
1049
+ LINK_KINDS,
1050
+ "unknown identity link kind"
1051
+ );
1052
+ if ("error" in normalized) throw new Error(normalized.error);
1053
+ const { kind, externalId } = normalized;
1054
+ const [existing] = matchingLinks(kind, externalId);
1055
+ if (existing) {
1056
+ if (existing.identityId !== identityId) {
1057
+ throw new Error("identifier already linked to another identity");
1058
+ }
1059
+ existing.verified = true;
1060
+ return existing;
1061
+ }
1062
+ const link = createUnverifiedLink(identityId, kind, externalId);
1063
+ link.verified = true;
1064
+ return link;
1065
+ }
1066
+ function merge(sourceIdentityId, canonicalIdentityId) {
1067
+ const source = state.identities.get(sourceIdentityId);
1068
+ if (!source) throw new Error("source identity not found");
1069
+ if (source.status !== "active" || source.mergedIntoId !== null) {
1070
+ throw new Error("source identity must be active and unmerged");
1071
+ }
1072
+ const canonical = state.identities.get(canonicalIdentityId);
1073
+ if (!canonical) throw new Error("canonical identity not found");
1074
+ if (canonical.status !== "active" || canonical.mergedIntoId !== null) {
1075
+ throw new Error("canonical identity must be active and unmerged");
1076
+ }
1077
+ if (source.type !== canonical.type) {
1078
+ throw new Error("identities must have the same type");
1079
+ }
1080
+ if (source.id === canonical.id) throw new Error("an identity cannot merge into itself");
1081
+ const sourceLinks = state.identityLinks.filter((link) => link.identityId === source.id);
1082
+ const canonicalLinks = state.identityLinks.filter((link) => link.identityId === canonical.id);
1083
+ const otherLinks = state.identityLinks.filter(
1084
+ (link) => link.identityId !== source.id && link.identityId !== canonical.id
1085
+ );
1086
+ for (const sourceLink of sourceLinks) {
1087
+ const key = normalizedLinkKey(sourceLink);
1088
+ if (otherLinks.some((otherLink) => normalizedLinkKey(otherLink) === key)) {
1089
+ throw new Error("merge would conflict with a link owned by another identity");
1090
+ }
1091
+ }
1092
+ const linksByKey = /* @__PURE__ */ new Map();
1093
+ for (const canonicalLink of canonicalLinks) {
1094
+ linksByKey.set(normalizedLinkKey(canonicalLink), canonicalLink);
1095
+ }
1096
+ const droppedSourceLinks = /* @__PURE__ */ new Set();
1097
+ for (const sourceLink of sourceLinks) {
1098
+ const key = normalizedLinkKey(sourceLink);
1099
+ const existing = linksByKey.get(key);
1100
+ if (existing) {
1101
+ existing.verified = existing.verified || sourceLink.verified;
1102
+ droppedSourceLinks.add(sourceLink);
1103
+ continue;
1104
+ }
1105
+ sourceLink.identityId = canonical.id;
1106
+ linksByKey.set(key, sourceLink);
1107
+ }
1108
+ if (droppedSourceLinks.size > 0) {
1109
+ state.identityLinks = state.identityLinks.filter((link) => !droppedSourceLinks.has(link));
1110
+ }
1111
+ source.status = "merged";
1112
+ source.mergedIntoId = canonical.id;
1113
+ source.updatedAt = now();
1114
+ }
797
1115
  function createDirectory() {
798
1116
  return {
799
1117
  all: () => [...state.identities.values()],
800
1118
  get: (id) => state.identities.get(id),
801
1119
  links: (identityId) => linksFor(identityId),
1120
+ seedVerifiedLink,
1121
+ merge,
802
1122
  clear: () => {
803
1123
  state.identities.clear();
804
1124
  state.identityLinks = [];
@@ -1723,13 +2043,24 @@ function requiresDeploymentHmac(request, url) {
1723
2043
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
1724
2044
  return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
1725
2045
  }
2046
+ function deploymentIdFromPath(url) {
2047
+ const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
2048
+ if (!match) return null;
2049
+ try {
2050
+ return decodeURIComponent(match[1]);
2051
+ } catch {
2052
+ return match[1];
2053
+ }
2054
+ }
1726
2055
  async function handleSimulatedRequest(request, url) {
1727
2056
  if (url.pathname === "/sql") {
1728
2057
  return handleNeonSql(requireDb(), request);
1729
2058
  }
1730
- const authMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/auth\/(verify|refresh)$/);
2059
+ const authMatch = url.pathname.match(
2060
+ /^\/api\/deployments\/[^/]+\/auth\/(v2\/verify|verify|refresh)$/
2061
+ );
1731
2062
  if (authMatch) {
1732
- return authMatch[1] === "verify" ? handleAuthVerify(request) : handleAuthRefresh(request);
2063
+ return authMatch[1].endsWith("verify") ? handleAuthVerify(request) : handleAuthRefresh(request);
1733
2064
  }
1734
2065
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
1735
2066
  const isEmail = url.pathname === "/api/email/send";
@@ -1746,7 +2077,9 @@ async function handleSimulatedRequest(request, url) {
1746
2077
  return failure("Missing authentication header", 401);
1747
2078
  }
1748
2079
  const secret = process.env.DEPLOYMENT_SECRET;
1749
- if (!secret || !verifyDeploymentAuthHeader(secret, authHeader)) {
2080
+ const authPayload = secret ? verifyDeploymentAuthHeader(secret, authHeader) : null;
2081
+ const pathDeploymentId = deploymentIdFromPath(url);
2082
+ if (!authPayload || pathDeploymentId !== null && authPayload.deploymentId !== pathDeploymentId) {
1750
2083
  return failure("Invalid authentication", 401);
1751
2084
  }
1752
2085
  }