@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/setup.js CHANGED
@@ -87,6 +87,14 @@ var TEST_ENV_DEFAULTS = {
87
87
  // src/simulator/hmac.ts
88
88
  var import_node_crypto = __toESM(require("crypto"));
89
89
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
90
+ function isNonEmptyString(value) {
91
+ return typeof value === "string" && value.length > 0;
92
+ }
93
+ function isDeploymentAuthPayload(value) {
94
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
95
+ const payload = value;
96
+ 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);
97
+ }
90
98
  function verifyDeploymentAuthHeader(secret, header) {
91
99
  const dotIndex = header.lastIndexOf(".");
92
100
  if (dotIndex === -1) return null;
@@ -104,16 +112,16 @@ function verifyDeploymentAuthHeader(secret, header) {
104
112
  if (expectedBuf.length !== actualBuf.length || !import_node_crypto.default.timingSafeEqual(expectedBuf, actualBuf)) {
105
113
  return null;
106
114
  }
107
- let payload;
115
+ let parsed;
108
116
  try {
109
- payload = JSON.parse(payloadJson);
117
+ parsed = JSON.parse(payloadJson);
110
118
  } catch {
111
119
  return null;
112
120
  }
113
- if (payload.type !== "deployment-request") return null;
121
+ if (!isDeploymentAuthPayload(parsed)) return null;
114
122
  const now3 = Math.floor(Date.now() / 1e3);
115
- if (Math.abs(now3 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
116
- return payload;
123
+ if (Math.abs(now3 - parsed.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
124
+ return parsed;
117
125
  }
118
126
 
119
127
  // src/simulator/http.ts
@@ -610,16 +618,118 @@ var LINK_KINDS = /* @__PURE__ */ new Set([
610
618
  "project_auth_user",
611
619
  "dashboard_user"
612
620
  ]);
621
+ var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
622
+ var E164_PHONE = /^\+[1-9]\d{1,14}$/;
623
+ var SEARCH_DEFAULT_LIMIT = 50;
624
+ var SEARCH_MAX_LIMIT = 100;
625
+ var IdentityGraphError = class extends Error {
626
+ };
613
627
  function now() {
614
628
  return (/* @__PURE__ */ new Date()).toISOString();
615
629
  }
616
630
  function linksFor(identityId) {
617
631
  return state.identityLinks.filter((l) => l.identityId === identityId);
618
632
  }
633
+ function comparableExternalId(kind, externalId) {
634
+ if (kind === "email") return externalId.trim().toLowerCase();
635
+ return externalId;
636
+ }
637
+ function normalizeExternalId(kind, externalId) {
638
+ if (kind === "email") {
639
+ const normalized = externalId.trim().toLowerCase();
640
+ return normalized.length > 0 ? { externalId: normalized } : { error: "externalId is required" };
641
+ }
642
+ if (kind === "phone" && !E164_PHONE.test(externalId)) {
643
+ return { error: "phone externalId must be an E.164 number" };
644
+ }
645
+ return { externalId };
646
+ }
647
+ function normalizeLinkInput(kind, externalId, allowedKinds, kindError) {
648
+ if (typeof kind !== "string" || !allowedKinds.has(kind)) {
649
+ return { error: kindError };
650
+ }
651
+ if (typeof externalId !== "string" || externalId.length === 0) {
652
+ return { error: "externalId is required" };
653
+ }
654
+ const normalized = normalizeExternalId(kind, externalId);
655
+ if ("error" in normalized) return normalized;
656
+ return { kind, externalId: normalized.externalId };
657
+ }
658
+ function normalizeResolveInput(kind, externalId) {
659
+ const normalized = normalizeLinkInput(
660
+ kind,
661
+ externalId,
662
+ RESOLVE_KINDS,
663
+ "kind must be one of: phone, email, line"
664
+ );
665
+ if ("error" in normalized) return normalized;
666
+ return { kind: normalized.kind, externalId: normalized.externalId };
667
+ }
668
+ function canonicalIdentity(identityId) {
669
+ const visited = /* @__PURE__ */ new Set();
670
+ let identity = state.identities.get(identityId);
671
+ while (identity) {
672
+ if (visited.has(identity.id)) {
673
+ throw new IdentityGraphError("identity merge chain contains a cycle");
674
+ }
675
+ visited.add(identity.id);
676
+ if (identity.status === "archived") {
677
+ throw new IdentityGraphError("identity is archived");
678
+ }
679
+ if (identity.status === "active" && !identity.mergedIntoId) {
680
+ return identity;
681
+ }
682
+ if (!identity.mergedIntoId) {
683
+ throw new IdentityGraphError("identity merge chain has no active canonical target");
684
+ }
685
+ identity = state.identities.get(identity.mergedIntoId);
686
+ }
687
+ throw new IdentityGraphError("identity merge chain references a missing identity");
688
+ }
689
+ function matchingLinks(kind, externalId) {
690
+ const comparable = comparableExternalId(kind, externalId);
691
+ return state.identityLinks.filter(
692
+ (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
693
+ );
694
+ }
695
+ function createPerson(displayName) {
696
+ const createdAt = now();
697
+ const identity = {
698
+ id: import_node_crypto3.default.randomUUID(),
699
+ type: "person",
700
+ parentId: null,
701
+ displayName,
702
+ profile: {},
703
+ status: "active",
704
+ mergedIntoId: null,
705
+ externalRef: null,
706
+ createdAt,
707
+ updatedAt: createdAt
708
+ };
709
+ state.identities.set(identity.id, identity);
710
+ return identity;
711
+ }
712
+ function createUnverifiedLink(identityId, kind, externalId) {
713
+ const link = {
714
+ id: import_node_crypto3.default.randomUUID(),
715
+ identityId,
716
+ kind,
717
+ externalId,
718
+ // Deployment credentials can attest provenance, but cannot manufacture a
719
+ // platform-verified fact. Trusted channel/control-plane setup uses the
720
+ // directory's test-only seedVerifiedLink helper instead.
721
+ verified: false,
722
+ createdAt: now()
723
+ };
724
+ state.identityLinks.push(link);
725
+ return link;
726
+ }
619
727
  function handleList(request) {
620
728
  const typeParam = new URL(request.url).searchParams.get("type");
621
729
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
622
- const identities = [...state.identities.values()];
730
+ const identities = [...state.identities.values()].filter(
731
+ (identity) => identity.status !== "merged"
732
+ );
623
733
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
624
734
  }
625
735
  async function handleCreate(request) {
@@ -637,6 +747,7 @@ async function handleCreate(request) {
637
747
  }
638
748
  parentId = parent.id;
639
749
  }
750
+ const createdAt = now();
640
751
  const identity = {
641
752
  id: import_node_crypto3.default.randomUUID(),
642
753
  type,
@@ -646,8 +757,8 @@ async function handleCreate(request) {
646
757
  status: "active",
647
758
  mergedIntoId: null,
648
759
  externalRef: body.externalRef ?? null,
649
- createdAt: now(),
650
- updatedAt: now()
760
+ createdAt,
761
+ updatedAt: createdAt
651
762
  };
652
763
  state.identities.set(identity.id, identity);
653
764
  return success({ identity });
@@ -683,36 +794,166 @@ async function handleAttachLink(identityId, request) {
683
794
  return failure("links attach only to active persons");
684
795
  }
685
796
  const body = await readJsonBody(request);
686
- const kind = body.kind;
687
- const externalId = body.externalId;
688
- if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
689
- return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
690
- }
691
- if (typeof externalId !== "string" || !externalId) {
692
- return failure("externalId is required");
693
- }
694
- const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
797
+ const normalized = normalizeLinkInput(
798
+ body.kind,
799
+ body.externalId,
800
+ LINK_KINDS,
801
+ `kind must be one of: ${[...LINK_KINDS].join(", ")}`
802
+ );
803
+ if ("error" in normalized) return failure(normalized.error);
804
+ const { kind, externalId } = normalized;
805
+ const [existing] = matchingLinks(kind, externalId);
695
806
  if (existing) {
696
807
  if (existing.identityId === identityId) return success({ link: existing });
697
808
  return failure("identifier already linked to another identity", 409);
698
809
  }
699
- const link = {
700
- id: import_node_crypto3.default.randomUUID(),
701
- identityId,
702
- kind,
703
- externalId,
704
- verified: body.verified === true,
705
- createdAt: now()
706
- };
707
- state.identityLinks.push(link);
810
+ const link = createUnverifiedLink(identityId, kind, externalId);
708
811
  return success({ link });
709
812
  }
813
+ async function handleResolve(request) {
814
+ const body = await readJsonBody(request);
815
+ const normalized = normalizeResolveInput(body.kind, body.externalId);
816
+ if ("error" in normalized) return failure(normalized.error);
817
+ const provenance = body.provenance;
818
+ if (provenance !== "guest" && provenance !== "staff") {
819
+ return failure("provenance must be 'guest' or 'staff'");
820
+ }
821
+ if (body.displayName !== void 0 && typeof body.displayName !== "string") {
822
+ return failure("displayName must be a string");
823
+ }
824
+ const links = matchingLinks(normalized.kind, normalized.externalId);
825
+ if (links.length > 0) {
826
+ const canonicalIds = /* @__PURE__ */ new Set();
827
+ for (const link of links) {
828
+ try {
829
+ canonicalIds.add(canonicalIdentity(link.identityId).id);
830
+ } catch (error) {
831
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
832
+ return failure(`cannot resolve identity link: ${message}`, 409);
833
+ }
834
+ }
835
+ if (canonicalIds.size !== 1) {
836
+ return failure("identifier is linked to multiple identities", 409);
837
+ }
838
+ const canonicalId = [...canonicalIds][0];
839
+ const hasVerifiedLink = links.some((link) => link.verified);
840
+ if (provenance === "guest" && hasVerifiedLink) {
841
+ return success({ identityId: null, reason: "verified_conflict" });
842
+ }
843
+ return success({ identityId: canonicalId, created: false });
844
+ }
845
+ const displayName = body.displayName ?? null;
846
+ const identity = createPerson(displayName);
847
+ createUnverifiedLink(identity.id, normalized.kind, normalized.externalId);
848
+ return success({ identityId: identity.id, created: true });
849
+ }
850
+ function encodeSearchCursor(cursor) {
851
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
852
+ }
853
+ function decodeSearchCursor(raw, query) {
854
+ try {
855
+ const rawParsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
856
+ if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null;
857
+ const parsed = rawParsed;
858
+ if (typeof parsed.createdAt !== "string" || typeof parsed.id !== "string" || typeof parsed.query !== "string" || parsed.query !== query) {
859
+ return null;
860
+ }
861
+ return { createdAt: parsed.createdAt, id: parsed.id, query: parsed.query };
862
+ } catch {
863
+ return null;
864
+ }
865
+ }
866
+ function compareIdentityOrder(left, right) {
867
+ const byCreatedAt = left.createdAt.localeCompare(right.createdAt);
868
+ return byCreatedAt !== 0 ? byCreatedAt : left.id.localeCompare(right.id);
869
+ }
870
+ function identityMatchesQuery(identity, query) {
871
+ if (!query) return true;
872
+ if (identity.displayName?.toLowerCase().includes(query)) return true;
873
+ return state.identityLinks.some(
874
+ (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
875
+ );
876
+ }
877
+ function handleSearch(request) {
878
+ const url = new URL(request.url);
879
+ const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
880
+ const rawLimit = url.searchParams.get("limit");
881
+ let limit = SEARCH_DEFAULT_LIMIT;
882
+ if (rawLimit !== null) {
883
+ if (!/^\d+$/.test(rawLimit)) return failure("limit must be a positive integer");
884
+ limit = Number(rawLimit);
885
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SEARCH_MAX_LIMIT) {
886
+ return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
887
+ }
888
+ }
889
+ const rawCursor = url.searchParams.get("cursor");
890
+ const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
891
+ if (rawCursor && !cursor) return failure("cursor is invalid");
892
+ const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
893
+ const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
894
+ const pageStart = start < 0 ? identities.length : start;
895
+ const items = identities.slice(pageStart, pageStart + limit);
896
+ const hasMore = pageStart + items.length < identities.length;
897
+ const nextCursor = hasMore ? encodeSearchCursor({
898
+ createdAt: items[items.length - 1].createdAt,
899
+ id: items[items.length - 1].id,
900
+ query
901
+ }) : null;
902
+ return success({ items, nextCursor });
903
+ }
904
+ function aliasesForCanonical(canonicalId) {
905
+ const aliases = [canonicalId];
906
+ for (const identity of state.identities.values()) {
907
+ if (identity.id === canonicalId || identity.status !== "merged") continue;
908
+ try {
909
+ if (canonicalIdentity(identity.id).id === canonicalId) aliases.push(identity.id);
910
+ } catch {
911
+ }
912
+ }
913
+ return aliases;
914
+ }
915
+ async function handleAliases(request) {
916
+ const body = await readJsonBody(request);
917
+ if (!Array.isArray(body.identityIds)) {
918
+ return failure("identityIds must be an array");
919
+ }
920
+ if (body.identityIds.length === 0) {
921
+ return failure("identityIds must not be empty");
922
+ }
923
+ if (body.identityIds.length > 200) {
924
+ return failure("A maximum of 200 identity ids may be requested");
925
+ }
926
+ if (body.identityIds.some((identityId) => typeof identityId !== "string" || identityId.length === 0)) {
927
+ return failure("identityIds must contain non-empty strings");
928
+ }
929
+ const aliases = {};
930
+ for (const identityId of body.identityIds) {
931
+ let canonical;
932
+ try {
933
+ canonical = canonicalIdentity(identityId);
934
+ } catch (error) {
935
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
936
+ return failure(`cannot expand identity aliases: ${message}`, 409);
937
+ }
938
+ aliases[canonical.id] ??= aliasesForCanonical(canonical.id);
939
+ }
940
+ return success({ aliases });
941
+ }
710
942
  async function handleIdentitiesRequest(request, subPath) {
711
943
  const method = request.method;
712
944
  if (subPath === "" || subPath === "/") {
713
945
  if (method === "GET") return handleList(request);
714
946
  if (method === "POST") return handleCreate(request);
715
947
  }
948
+ if (subPath === "/resolve" && method === "POST") {
949
+ return handleResolve(request);
950
+ }
951
+ if (subPath === "/aliases" && method === "POST") {
952
+ return handleAliases(request);
953
+ }
954
+ if (subPath === "/search" && method === "GET") {
955
+ return handleSearch(request);
956
+ }
716
957
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
717
958
  if (linksMatch && method === "POST") {
718
959
  return handleAttachLink(linksMatch[1], request);
@@ -1361,13 +1602,24 @@ function requiresDeploymentHmac(request, url) {
1361
1602
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
1362
1603
  return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
1363
1604
  }
1605
+ function deploymentIdFromPath(url) {
1606
+ const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
1607
+ if (!match) return null;
1608
+ try {
1609
+ return decodeURIComponent(match[1]);
1610
+ } catch {
1611
+ return match[1];
1612
+ }
1613
+ }
1364
1614
  async function handleSimulatedRequest(request, url) {
1365
1615
  if (url.pathname === "/sql") {
1366
1616
  return handleNeonSql(requireDb(), request);
1367
1617
  }
1368
- const authMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/auth\/(verify|refresh)$/);
1618
+ const authMatch = url.pathname.match(
1619
+ /^\/api\/deployments\/[^/]+\/auth\/(v2\/verify|verify|refresh)$/
1620
+ );
1369
1621
  if (authMatch) {
1370
- return authMatch[1] === "verify" ? handleAuthVerify(request) : handleAuthRefresh(request);
1622
+ return authMatch[1].endsWith("verify") ? handleAuthVerify(request) : handleAuthRefresh(request);
1371
1623
  }
1372
1624
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
1373
1625
  const isEmail = url.pathname === "/api/email/send";
@@ -1384,7 +1636,9 @@ async function handleSimulatedRequest(request, url) {
1384
1636
  return failure("Missing authentication header", 401);
1385
1637
  }
1386
1638
  const secret = process.env.DEPLOYMENT_SECRET;
1387
- if (!secret || !verifyDeploymentAuthHeader(secret, authHeader)) {
1639
+ const authPayload = secret ? verifyDeploymentAuthHeader(secret, authHeader) : null;
1640
+ const pathDeploymentId = deploymentIdFromPath(url);
1641
+ if (!authPayload || pathDeploymentId !== null && authPayload.deploymentId !== pathDeploymentId) {
1388
1642
  return failure("Invalid authentication", 401);
1389
1643
  }
1390
1644
  }