@stardeck-customer-apps/testing 0.5.1 → 0.6.1

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