@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/SKILL.md +57 -6
- package/dist/index.d.mts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +373 -30
- package/dist/index.mjs +373 -30
- package/dist/setup.js +293 -38
- package/dist/setup.mjs +293 -38
- package/package.json +1 -1
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
|
|
137
|
+
let parsed;
|
|
130
138
|
try {
|
|
131
|
-
|
|
139
|
+
parsed = JSON.parse(payloadJson);
|
|
132
140
|
} catch {
|
|
133
141
|
return null;
|
|
134
142
|
}
|
|
135
|
-
if (
|
|
143
|
+
if (!isDeploymentAuthPayload(parsed)) return null;
|
|
136
144
|
const now3 = Math.floor(Date.now() / 1e3);
|
|
137
|
-
if (Math.abs(now3 -
|
|
138
|
-
return
|
|
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
|
|
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
|
|
674
|
-
updatedAt:
|
|
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
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}
|
|
718
|
-
const existing =
|
|
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(
|
|
2023
|
+
const authMatch = url.pathname.match(
|
|
2024
|
+
/^\/api\/deployments\/[^/]+\/auth\/(v2\/verify|verify|refresh)$/
|
|
2025
|
+
);
|
|
1685
2026
|
if (authMatch) {
|
|
1686
|
-
return authMatch[1]
|
|
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
|
-
|
|
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
|
}
|