@stardeck-customer-apps/testing 0.5.1 → 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.mjs CHANGED
@@ -109,6 +109,14 @@ var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
109
109
  // src/simulator/hmac.ts
110
110
  import crypto from "crypto";
111
111
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
112
+ function isNonEmptyString(value) {
113
+ return typeof value === "string" && value.length > 0;
114
+ }
115
+ function isDeploymentAuthPayload(value) {
116
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
117
+ const payload = value;
118
+ 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);
119
+ }
112
120
  function verifyDeploymentAuthHeader(secret, header) {
113
121
  const dotIndex = header.lastIndexOf(".");
114
122
  if (dotIndex === -1) return null;
@@ -126,16 +134,16 @@ function verifyDeploymentAuthHeader(secret, header) {
126
134
  if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
127
135
  return null;
128
136
  }
129
- let payload;
137
+ let parsed;
130
138
  try {
131
- payload = JSON.parse(payloadJson);
139
+ parsed = JSON.parse(payloadJson);
132
140
  } catch {
133
141
  return null;
134
142
  }
135
- if (payload.type !== "deployment-request") return null;
143
+ if (!isDeploymentAuthPayload(parsed)) return null;
136
144
  const now3 = Math.floor(Date.now() / 1e3);
137
- if (Math.abs(now3 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
138
- return payload;
145
+ if (Math.abs(now3 - parsed.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
146
+ return parsed;
139
147
  }
140
148
 
141
149
  // src/simulator/http.ts
@@ -634,16 +642,121 @@ var LINK_KINDS = /* @__PURE__ */ new Set([
634
642
  "project_auth_user",
635
643
  "dashboard_user"
636
644
  ]);
645
+ var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
646
+ var E164_PHONE = /^\+[1-9]\d{1,14}$/;
647
+ var SEARCH_DEFAULT_LIMIT = 50;
648
+ var SEARCH_MAX_LIMIT = 100;
649
+ var IdentityGraphError = class extends Error {
650
+ };
637
651
  function now() {
638
652
  return (/* @__PURE__ */ new Date()).toISOString();
639
653
  }
640
654
  function linksFor(identityId) {
641
655
  return state.identityLinks.filter((l) => l.identityId === identityId);
642
656
  }
657
+ function comparableExternalId(kind, externalId) {
658
+ if (kind === "email") return externalId.trim().toLowerCase();
659
+ return externalId;
660
+ }
661
+ function normalizeExternalId(kind, externalId) {
662
+ if (kind === "email") {
663
+ const normalized = externalId.trim().toLowerCase();
664
+ return normalized.length > 0 ? { externalId: normalized } : { error: "externalId is required" };
665
+ }
666
+ if (kind === "phone" && !E164_PHONE.test(externalId)) {
667
+ return { error: "phone externalId must be an E.164 number" };
668
+ }
669
+ return { externalId };
670
+ }
671
+ function normalizeLinkInput(kind, externalId, allowedKinds, kindError) {
672
+ if (typeof kind !== "string" || !allowedKinds.has(kind)) {
673
+ return { error: kindError };
674
+ }
675
+ if (typeof externalId !== "string" || externalId.length === 0) {
676
+ return { error: "externalId is required" };
677
+ }
678
+ const normalized = normalizeExternalId(kind, externalId);
679
+ if ("error" in normalized) return normalized;
680
+ return { kind, externalId: normalized.externalId };
681
+ }
682
+ function normalizeResolveInput(kind, externalId) {
683
+ const normalized = normalizeLinkInput(
684
+ kind,
685
+ externalId,
686
+ RESOLVE_KINDS,
687
+ "kind must be one of: phone, email, line"
688
+ );
689
+ if ("error" in normalized) return normalized;
690
+ return { kind: normalized.kind, externalId: normalized.externalId };
691
+ }
692
+ function canonicalIdentity(identityId) {
693
+ const visited = /* @__PURE__ */ new Set();
694
+ let identity = state.identities.get(identityId);
695
+ while (identity) {
696
+ if (visited.has(identity.id)) {
697
+ throw new IdentityGraphError("identity merge chain contains a cycle");
698
+ }
699
+ visited.add(identity.id);
700
+ if (identity.status === "archived") {
701
+ throw new IdentityGraphError("identity is archived");
702
+ }
703
+ if (identity.status === "active" && !identity.mergedIntoId) {
704
+ return identity;
705
+ }
706
+ if (!identity.mergedIntoId) {
707
+ throw new IdentityGraphError("identity merge chain has no active canonical target");
708
+ }
709
+ identity = state.identities.get(identity.mergedIntoId);
710
+ }
711
+ throw new IdentityGraphError("identity merge chain references a missing identity");
712
+ }
713
+ function matchingLinks(kind, externalId) {
714
+ const comparable = comparableExternalId(kind, externalId);
715
+ return state.identityLinks.filter(
716
+ (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
717
+ );
718
+ }
719
+ function normalizedLinkKey(link) {
720
+ return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
721
+ }
722
+ function createPerson(displayName) {
723
+ const createdAt = now();
724
+ const identity = {
725
+ id: crypto3.randomUUID(),
726
+ type: "person",
727
+ parentId: null,
728
+ displayName,
729
+ profile: {},
730
+ status: "active",
731
+ mergedIntoId: null,
732
+ externalRef: null,
733
+ createdAt,
734
+ updatedAt: createdAt
735
+ };
736
+ state.identities.set(identity.id, identity);
737
+ return identity;
738
+ }
739
+ function createUnverifiedLink(identityId, kind, externalId) {
740
+ const link = {
741
+ id: crypto3.randomUUID(),
742
+ identityId,
743
+ kind,
744
+ externalId,
745
+ // Deployment credentials can attest provenance, but cannot manufacture a
746
+ // platform-verified fact. Trusted channel/control-plane setup uses the
747
+ // directory's test-only seedVerifiedLink helper instead.
748
+ verified: false,
749
+ createdAt: now()
750
+ };
751
+ state.identityLinks.push(link);
752
+ return link;
753
+ }
643
754
  function handleList(request) {
644
755
  const typeParam = new URL(request.url).searchParams.get("type");
645
756
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
646
- const identities = [...state.identities.values()];
757
+ const identities = [...state.identities.values()].filter(
758
+ (identity) => identity.status !== "merged"
759
+ );
647
760
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
648
761
  }
649
762
  async function handleCreate(request) {
@@ -661,6 +774,7 @@ async function handleCreate(request) {
661
774
  }
662
775
  parentId = parent.id;
663
776
  }
777
+ const createdAt = now();
664
778
  const identity = {
665
779
  id: crypto3.randomUUID(),
666
780
  type,
@@ -670,8 +784,8 @@ async function handleCreate(request) {
670
784
  status: "active",
671
785
  mergedIntoId: null,
672
786
  externalRef: body.externalRef ?? null,
673
- createdAt: now(),
674
- updatedAt: now()
787
+ createdAt,
788
+ updatedAt: createdAt
675
789
  };
676
790
  state.identities.set(identity.id, identity);
677
791
  return success({ identity });
@@ -707,36 +821,166 @@ async function handleAttachLink(identityId, request) {
707
821
  return failure("links attach only to active persons");
708
822
  }
709
823
  const body = await readJsonBody(request);
710
- const kind = body.kind;
711
- const externalId = body.externalId;
712
- if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
713
- return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
714
- }
715
- if (typeof externalId !== "string" || !externalId) {
716
- return failure("externalId is required");
717
- }
718
- const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
824
+ const normalized = normalizeLinkInput(
825
+ body.kind,
826
+ body.externalId,
827
+ LINK_KINDS,
828
+ `kind must be one of: ${[...LINK_KINDS].join(", ")}`
829
+ );
830
+ if ("error" in normalized) return failure(normalized.error);
831
+ const { kind, externalId } = normalized;
832
+ const [existing] = matchingLinks(kind, externalId);
719
833
  if (existing) {
720
834
  if (existing.identityId === identityId) return success({ link: existing });
721
835
  return failure("identifier already linked to another identity", 409);
722
836
  }
723
- const link = {
724
- id: crypto3.randomUUID(),
725
- identityId,
726
- kind,
727
- externalId,
728
- verified: body.verified === true,
729
- createdAt: now()
730
- };
731
- state.identityLinks.push(link);
837
+ const link = createUnverifiedLink(identityId, kind, externalId);
732
838
  return success({ link });
733
839
  }
840
+ async function handleResolve(request) {
841
+ const body = await readJsonBody(request);
842
+ const normalized = normalizeResolveInput(body.kind, body.externalId);
843
+ if ("error" in normalized) return failure(normalized.error);
844
+ const provenance = body.provenance;
845
+ if (provenance !== "guest" && provenance !== "staff") {
846
+ return failure("provenance must be 'guest' or 'staff'");
847
+ }
848
+ if (body.displayName !== void 0 && typeof body.displayName !== "string") {
849
+ return failure("displayName must be a string");
850
+ }
851
+ const links = matchingLinks(normalized.kind, normalized.externalId);
852
+ if (links.length > 0) {
853
+ const canonicalIds = /* @__PURE__ */ new Set();
854
+ for (const link of links) {
855
+ try {
856
+ canonicalIds.add(canonicalIdentity(link.identityId).id);
857
+ } catch (error) {
858
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
859
+ return failure(`cannot resolve identity link: ${message}`, 409);
860
+ }
861
+ }
862
+ if (canonicalIds.size !== 1) {
863
+ return failure("identifier is linked to multiple identities", 409);
864
+ }
865
+ const canonicalId = [...canonicalIds][0];
866
+ const hasVerifiedLink = links.some((link) => link.verified);
867
+ if (provenance === "guest" && hasVerifiedLink) {
868
+ return success({ identityId: null, reason: "verified_conflict" });
869
+ }
870
+ return success({ identityId: canonicalId, created: false });
871
+ }
872
+ const displayName = body.displayName ?? null;
873
+ const identity = createPerson(displayName);
874
+ createUnverifiedLink(identity.id, normalized.kind, normalized.externalId);
875
+ return success({ identityId: identity.id, created: true });
876
+ }
877
+ function encodeSearchCursor(cursor) {
878
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
879
+ }
880
+ function decodeSearchCursor(raw, query) {
881
+ try {
882
+ const rawParsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
883
+ if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null;
884
+ const parsed = rawParsed;
885
+ if (typeof parsed.createdAt !== "string" || typeof parsed.id !== "string" || typeof parsed.query !== "string" || parsed.query !== query) {
886
+ return null;
887
+ }
888
+ return { createdAt: parsed.createdAt, id: parsed.id, query: parsed.query };
889
+ } catch {
890
+ return null;
891
+ }
892
+ }
893
+ function compareIdentityOrder(left, right) {
894
+ const byCreatedAt = left.createdAt.localeCompare(right.createdAt);
895
+ return byCreatedAt !== 0 ? byCreatedAt : left.id.localeCompare(right.id);
896
+ }
897
+ function identityMatchesQuery(identity, query) {
898
+ if (!query) return true;
899
+ if (identity.displayName?.toLowerCase().includes(query)) return true;
900
+ return state.identityLinks.some(
901
+ (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
902
+ );
903
+ }
904
+ function handleSearch(request) {
905
+ const url = new URL(request.url);
906
+ const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
907
+ const rawLimit = url.searchParams.get("limit");
908
+ let limit = SEARCH_DEFAULT_LIMIT;
909
+ if (rawLimit !== null) {
910
+ if (!/^\d+$/.test(rawLimit)) return failure("limit must be a positive integer");
911
+ limit = Number(rawLimit);
912
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SEARCH_MAX_LIMIT) {
913
+ return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
914
+ }
915
+ }
916
+ const rawCursor = url.searchParams.get("cursor");
917
+ const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
918
+ if (rawCursor && !cursor) return failure("cursor is invalid");
919
+ const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
920
+ const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
921
+ const pageStart = start < 0 ? identities.length : start;
922
+ const items = identities.slice(pageStart, pageStart + limit);
923
+ const hasMore = pageStart + items.length < identities.length;
924
+ const nextCursor = hasMore ? encodeSearchCursor({
925
+ createdAt: items[items.length - 1].createdAt,
926
+ id: items[items.length - 1].id,
927
+ query
928
+ }) : null;
929
+ return success({ items, nextCursor });
930
+ }
931
+ function aliasesForCanonical(canonicalId) {
932
+ const aliases = [canonicalId];
933
+ for (const identity of state.identities.values()) {
934
+ if (identity.id === canonicalId || identity.status !== "merged") continue;
935
+ try {
936
+ if (canonicalIdentity(identity.id).id === canonicalId) aliases.push(identity.id);
937
+ } catch {
938
+ }
939
+ }
940
+ return aliases;
941
+ }
942
+ async function handleAliases(request) {
943
+ const body = await readJsonBody(request);
944
+ if (!Array.isArray(body.identityIds)) {
945
+ return failure("identityIds must be an array");
946
+ }
947
+ if (body.identityIds.length === 0) {
948
+ return failure("identityIds must not be empty");
949
+ }
950
+ if (body.identityIds.length > 200) {
951
+ return failure("A maximum of 200 identity ids may be requested");
952
+ }
953
+ if (body.identityIds.some((identityId) => typeof identityId !== "string" || identityId.length === 0)) {
954
+ return failure("identityIds must contain non-empty strings");
955
+ }
956
+ const aliases = {};
957
+ for (const identityId of body.identityIds) {
958
+ let canonical;
959
+ try {
960
+ canonical = canonicalIdentity(identityId);
961
+ } catch (error) {
962
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
963
+ return failure(`cannot expand identity aliases: ${message}`, 409);
964
+ }
965
+ aliases[canonical.id] ??= aliasesForCanonical(canonical.id);
966
+ }
967
+ return success({ aliases });
968
+ }
734
969
  async function handleIdentitiesRequest(request, subPath) {
735
970
  const method = request.method;
736
971
  if (subPath === "" || subPath === "/") {
737
972
  if (method === "GET") return handleList(request);
738
973
  if (method === "POST") return handleCreate(request);
739
974
  }
975
+ if (subPath === "/resolve" && method === "POST") {
976
+ return handleResolve(request);
977
+ }
978
+ if (subPath === "/aliases" && method === "POST") {
979
+ return handleAliases(request);
980
+ }
981
+ if (subPath === "/search" && method === "GET") {
982
+ return handleSearch(request);
983
+ }
740
984
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
741
985
  if (linksMatch && method === "POST") {
742
986
  return handleAttachLink(linksMatch[1], request);
@@ -748,11 +992,87 @@ async function handleIdentitiesRequest(request, subPath) {
748
992
  }
749
993
  return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
750
994
  }
995
+ function seedVerifiedLink(identityId, params) {
996
+ const identity = state.identities.get(identityId);
997
+ if (!identity) throw new Error("identity not found");
998
+ if (identity.type !== "person") throw new Error("verified links attach only to persons");
999
+ if (identity.status !== "active") throw new Error("verified links attach only to active persons");
1000
+ const normalized = normalizeLinkInput(
1001
+ params.kind,
1002
+ params.externalId,
1003
+ LINK_KINDS,
1004
+ "unknown identity link kind"
1005
+ );
1006
+ if ("error" in normalized) throw new Error(normalized.error);
1007
+ const { kind, externalId } = normalized;
1008
+ const [existing] = matchingLinks(kind, externalId);
1009
+ if (existing) {
1010
+ if (existing.identityId !== identityId) {
1011
+ throw new Error("identifier already linked to another identity");
1012
+ }
1013
+ existing.verified = true;
1014
+ return existing;
1015
+ }
1016
+ const link = createUnverifiedLink(identityId, kind, externalId);
1017
+ link.verified = true;
1018
+ return link;
1019
+ }
1020
+ function merge(sourceIdentityId, canonicalIdentityId) {
1021
+ const source = state.identities.get(sourceIdentityId);
1022
+ if (!source) throw new Error("source identity not found");
1023
+ if (source.status !== "active" || source.mergedIntoId !== null) {
1024
+ throw new Error("source identity must be active and unmerged");
1025
+ }
1026
+ const canonical = state.identities.get(canonicalIdentityId);
1027
+ if (!canonical) throw new Error("canonical identity not found");
1028
+ if (canonical.status !== "active" || canonical.mergedIntoId !== null) {
1029
+ throw new Error("canonical identity must be active and unmerged");
1030
+ }
1031
+ if (source.type !== canonical.type) {
1032
+ throw new Error("identities must have the same type");
1033
+ }
1034
+ if (source.id === canonical.id) throw new Error("an identity cannot merge into itself");
1035
+ const sourceLinks = state.identityLinks.filter((link) => link.identityId === source.id);
1036
+ const canonicalLinks = state.identityLinks.filter((link) => link.identityId === canonical.id);
1037
+ const otherLinks = state.identityLinks.filter(
1038
+ (link) => link.identityId !== source.id && link.identityId !== canonical.id
1039
+ );
1040
+ for (const sourceLink of sourceLinks) {
1041
+ const key = normalizedLinkKey(sourceLink);
1042
+ if (otherLinks.some((otherLink) => normalizedLinkKey(otherLink) === key)) {
1043
+ throw new Error("merge would conflict with a link owned by another identity");
1044
+ }
1045
+ }
1046
+ const linksByKey = /* @__PURE__ */ new Map();
1047
+ for (const canonicalLink of canonicalLinks) {
1048
+ linksByKey.set(normalizedLinkKey(canonicalLink), canonicalLink);
1049
+ }
1050
+ const droppedSourceLinks = /* @__PURE__ */ new Set();
1051
+ for (const sourceLink of sourceLinks) {
1052
+ const key = normalizedLinkKey(sourceLink);
1053
+ const existing = linksByKey.get(key);
1054
+ if (existing) {
1055
+ existing.verified = existing.verified || sourceLink.verified;
1056
+ droppedSourceLinks.add(sourceLink);
1057
+ continue;
1058
+ }
1059
+ sourceLink.identityId = canonical.id;
1060
+ linksByKey.set(key, sourceLink);
1061
+ }
1062
+ if (droppedSourceLinks.size > 0) {
1063
+ state.identityLinks = state.identityLinks.filter((link) => !droppedSourceLinks.has(link));
1064
+ }
1065
+ source.status = "merged";
1066
+ source.mergedIntoId = canonical.id;
1067
+ source.updatedAt = now();
1068
+ }
751
1069
  function createDirectory() {
752
1070
  return {
753
1071
  all: () => [...state.identities.values()],
754
1072
  get: (id) => state.identities.get(id),
755
1073
  links: (identityId) => linksFor(identityId),
1074
+ seedVerifiedLink,
1075
+ merge,
756
1076
  clear: () => {
757
1077
  state.identities.clear();
758
1078
  state.identityLinks = [];
@@ -1677,13 +1997,24 @@ function requiresDeploymentHmac(request, url) {
1677
1997
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
1678
1998
  return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
1679
1999
  }
2000
+ function deploymentIdFromPath(url) {
2001
+ const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
2002
+ if (!match) return null;
2003
+ try {
2004
+ return decodeURIComponent(match[1]);
2005
+ } catch {
2006
+ return match[1];
2007
+ }
2008
+ }
1680
2009
  async function handleSimulatedRequest(request, url) {
1681
2010
  if (url.pathname === "/sql") {
1682
2011
  return handleNeonSql(requireDb(), request);
1683
2012
  }
1684
- const authMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/auth\/(verify|refresh)$/);
2013
+ const authMatch = url.pathname.match(
2014
+ /^\/api\/deployments\/[^/]+\/auth\/(v2\/verify|verify|refresh)$/
2015
+ );
1685
2016
  if (authMatch) {
1686
- return authMatch[1] === "verify" ? handleAuthVerify(request) : handleAuthRefresh(request);
2017
+ return authMatch[1].endsWith("verify") ? handleAuthVerify(request) : handleAuthRefresh(request);
1687
2018
  }
1688
2019
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
1689
2020
  const isEmail = url.pathname === "/api/email/send";
@@ -1700,7 +2031,9 @@ async function handleSimulatedRequest(request, url) {
1700
2031
  return failure("Missing authentication header", 401);
1701
2032
  }
1702
2033
  const secret = process.env.DEPLOYMENT_SECRET;
1703
- if (!secret || !verifyDeploymentAuthHeader(secret, authHeader)) {
2034
+ const authPayload = secret ? verifyDeploymentAuthHeader(secret, authHeader) : null;
2035
+ const pathDeploymentId = deploymentIdFromPath(url);
2036
+ if (!authPayload || pathDeploymentId !== null && authPayload.deploymentId !== pathDeploymentId) {
1704
2037
  return failure("Invalid authentication", 401);
1705
2038
  }
1706
2039
  }