@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/SKILL.md +54 -6
- package/dist/index.d.mts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +362 -29
- package/dist/index.mjs +362 -29
- package/dist/setup.js +283 -29
- package/dist/setup.mjs +283 -29
- 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
|
|
@@ -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
|
|
674
|
-
updatedAt:
|
|
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
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}
|
|
718
|
-
const existing =
|
|
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(
|
|
2013
|
+
const authMatch = url.pathname.match(
|
|
2014
|
+
/^\/api\/deployments\/[^/]+\/auth\/(v2\/verify|verify|refresh)$/
|
|
2015
|
+
);
|
|
1685
2016
|
if (authMatch) {
|
|
1686
|
-
return authMatch[1]
|
|
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
|
-
|
|
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
|
}
|