@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 CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  Vitest harness for Stardeck apps. Tests run against an **in-process Postgres
4
4
  (PGlite)** and a **simulated control plane**, so the real Stardeck SDKs
5
- (`data-store-sdk`, `email-sdk`, `project-auth`) execute their production code
6
- paths with no network, no mocks to write, and full determinism.
5
+ (`data-store-sdk`, `email-sdk`, `project-auth`, and `integrations-sdk`) execute
6
+ their production code paths with no network, no mocks to write, and full
7
+ determinism.
7
8
 
8
9
  ## Setup (once per project)
9
10
 
@@ -102,6 +103,34 @@ const integrations = createIntegrationsClient();
102
103
  await integrations.line.push("U123", { type: "text", text: "Your order shipped" });
103
104
  expect(app.messages.channel("line").to("U123")[0].body.text).toMatch(/shipped/i);
104
105
 
106
+ // Guest/counter identity resolution. `provenance` is selected by this trusted
107
+ // server route; never copy it from a browser form or request body.
108
+ const identity = await integrations.identities.resolveOrCreate({
109
+ kind: "email",
110
+ externalId: "guest@example.com",
111
+ provenance: "guest",
112
+ displayName: "Guest",
113
+ });
114
+ if (identity.identityId) {
115
+ const identityId = identity.identityId;
116
+ const aliases = await integrations.identities.getAliases(identityId);
117
+ const aliasSet = Object.values(aliases).find((ids) => ids.includes(identityId)) ?? [identityId];
118
+ expect(aliasSet).toContain(identityId);
119
+ }
120
+
121
+ // A staff POS route derives `staff` only after checking an authenticated staff
122
+ // session and permission. A guest lookup against a verified link returns the
123
+ // safe conflict branch instead of attributing activity to that customer.
124
+ const staffIdentity = await integrations.identities.resolveOrCreate({
125
+ kind: "phone",
126
+ externalId: "+15550100",
127
+ provenance: "staff",
128
+ });
129
+ if (staffIdentity.identityId) {
130
+ const page = await integrations.identities.search({ query: "15550100", limit: 10 });
131
+ expect(page.items.map((item) => item.id)).toContain(staffIdentity.identityId);
132
+ }
133
+
105
134
  // Receipt print + display
106
135
  import { createEdgeClient } from "@stardeck-customer-apps/edge-sdk/server";
107
136
 
@@ -132,7 +161,11 @@ expect(app.edge.latestDisplay()?.action).toBe("show");
132
161
  `.to(addr)`, `.all()`, `.count`, `.clear()`.
133
162
  - `app.identities` — the platform-identity directory created through
134
163
  integrations-sdk `client.identities`: `.get(id)`, `.links(id)`, `.all()`,
135
- `.count`, `.clear()`.
164
+ `.count`, `.clear()`. The test-only `.seedVerifiedLink(id, { kind,
165
+ externalId })` helper models a trusted platform/channel link, and
166
+ `.merge(sourceId, canonicalId)` models governed merge redirects. They are
167
+ setup helpers only; deployed app code cannot create verified links or merge
168
+ identities through the integrations SDK.
136
169
  - `app.payments` — checkouts created through payments-sdk:
137
170
  `.checkouts`, `.latest()`, `.setProducts()`, `.markPaid(id)`,
138
171
  `.deliverStripeEvent(handler, event)`, `.deliverBeamEvent(handler, event)`,
@@ -180,9 +213,24 @@ describe the code, and the owner can't tell from them what is or isn't covered.
180
213
  `asUser(...)` user (header fast path) or issued session cookies.
181
214
  - `EmailClient.send()` — captured in `app.inbox`, never delivered.
182
215
  - `client.identities` from integrations-sdk (create/get/update/list accounts &
183
- persons, attach channel links) served offline by the simulated directory;
184
- `update` replaces the `profile` object (not a merge), like the control plane.
185
- Inspect via `app.identities`. Merge/archive are dashboard-only not simulated.
216
+ persons, attach channel links, guest/staff `resolveOrCreate`, paginated
217
+ `search`, and batch `getAliases`) served offline by the simulated directory.
218
+ `resolveOrCreate` lowercases/trims email, requires E.164 phone values, and
219
+ always writes an unverified link. A guest lookup of a verified link returns
220
+ `{ identityId: null, reason: "verified_conflict" }`; staff provenance may
221
+ resolve either verified or unverified links. `attachLink(..., { verified:
222
+ true })` remains source-compatible but the simulator (like the control plane)
223
+ ignores that flag and returns `verified: false`. `update` replaces the
224
+ `profile` object (not a merge), like the control plane. Inspect via
225
+ `app.identities`; merge/archive are privileged setup operations, with only
226
+ test-only merge modeling exposed above.
227
+ - `session.user.identityId` is the platform-provisioned cross-app customer key.
228
+ `session.user.id` is the platform login key; it is not the key for customer
229
+ data. The simulator round-trips `identityId` through issued sessions while
230
+ preserving older users that omit it or set it to `null`.
231
+ - For merge-correct reads, request aliases in batches and include every id in
232
+ the returned canonical set. Page directory UIs through `search({ query,
233
+ cursor, limit })`; `list()` intentionally keeps its previous array shape.
186
234
  - `PaymentsServerClient` (Stripe checkout + Beam payment links, product list) —
187
235
  captured in `app.payments`; fulfill via `markPaid` (poll) or
188
236
  `deliverStripeEvent` / `deliverBeamEvent` (webhook push).
package/dist/index.d.mts CHANGED
@@ -34,6 +34,8 @@ interface TestUser {
34
34
  permissions?: string[];
35
35
  organizationId?: string | null;
36
36
  projectId?: string | null;
37
+ /** Cross-app customer ownership key returned by the platform identity resolver. */
38
+ identityId?: string | null;
37
39
  }
38
40
  interface CapturedEmail {
39
41
  resendId: string;
@@ -103,6 +105,20 @@ interface TestDirectory {
103
105
  get(id: string): CapturedIdentity | undefined;
104
106
  /** Channel/login links attached to an identity. */
105
107
  links(identityId: string): CapturedIdentityLink[];
108
+ /**
109
+ * Test setup only: seed a platform-verified link. Deployment SDK writes are
110
+ * intentionally unverified; this helper models a trusted control-plane or
111
+ * channel source so guest conflict and adoption tests can be deterministic.
112
+ */
113
+ seedVerifiedLink(identityId: string, params: {
114
+ kind: string;
115
+ externalId: string;
116
+ }): CapturedIdentityLink;
117
+ /**
118
+ * Test setup only: govern a merge from one identity into an active canonical
119
+ * identity. The production integrations SDK does not expose merge operations.
120
+ */
121
+ merge(sourceIdentityId: string, canonicalIdentityId: string): void;
106
122
  clear(): void;
107
123
  get count(): number;
108
124
  }
package/dist/index.d.ts CHANGED
@@ -34,6 +34,8 @@ interface TestUser {
34
34
  permissions?: string[];
35
35
  organizationId?: string | null;
36
36
  projectId?: string | null;
37
+ /** Cross-app customer ownership key returned by the platform identity resolver. */
38
+ identityId?: string | null;
37
39
  }
38
40
  interface CapturedEmail {
39
41
  resendId: string;
@@ -103,6 +105,20 @@ interface TestDirectory {
103
105
  get(id: string): CapturedIdentity | undefined;
104
106
  /** Channel/login links attached to an identity. */
105
107
  links(identityId: string): CapturedIdentityLink[];
108
+ /**
109
+ * Test setup only: seed a platform-verified link. Deployment SDK writes are
110
+ * intentionally unverified; this helper models a trusted control-plane or
111
+ * channel source so guest conflict and adoption tests can be deterministic.
112
+ */
113
+ seedVerifiedLink(identityId: string, params: {
114
+ kind: string;
115
+ externalId: string;
116
+ }): CapturedIdentityLink;
117
+ /**
118
+ * Test setup only: govern a merge from one identity into an active canonical
119
+ * identity. The production integrations SDK does not expose merge operations.
120
+ */
121
+ merge(sourceIdentityId: string, canonicalIdentityId: string): void;
106
122
  clear(): void;
107
123
  get count(): number;
108
124
  }
package/dist/index.js CHANGED
@@ -155,6 +155,14 @@ var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
155
155
  // src/simulator/hmac.ts
156
156
  var import_node_crypto = __toESM(require("crypto"));
157
157
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
158
+ function isNonEmptyString(value) {
159
+ return typeof value === "string" && value.length > 0;
160
+ }
161
+ function isDeploymentAuthPayload(value) {
162
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
163
+ const payload = value;
164
+ 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);
165
+ }
158
166
  function verifyDeploymentAuthHeader(secret, header) {
159
167
  const dotIndex = header.lastIndexOf(".");
160
168
  if (dotIndex === -1) return null;
@@ -172,16 +180,16 @@ function verifyDeploymentAuthHeader(secret, header) {
172
180
  if (expectedBuf.length !== actualBuf.length || !import_node_crypto.default.timingSafeEqual(expectedBuf, actualBuf)) {
173
181
  return null;
174
182
  }
175
- let payload;
183
+ let parsed;
176
184
  try {
177
- payload = JSON.parse(payloadJson);
185
+ parsed = JSON.parse(payloadJson);
178
186
  } catch {
179
187
  return null;
180
188
  }
181
- if (payload.type !== "deployment-request") return null;
189
+ if (!isDeploymentAuthPayload(parsed)) return null;
182
190
  const now3 = Math.floor(Date.now() / 1e3);
183
- if (Math.abs(now3 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
184
- return payload;
191
+ if (Math.abs(now3 - parsed.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
192
+ return parsed;
185
193
  }
186
194
 
187
195
  // src/simulator/http.ts
@@ -680,16 +688,121 @@ var LINK_KINDS = /* @__PURE__ */ new Set([
680
688
  "project_auth_user",
681
689
  "dashboard_user"
682
690
  ]);
691
+ var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
692
+ var E164_PHONE = /^\+[1-9]\d{1,14}$/;
693
+ var SEARCH_DEFAULT_LIMIT = 50;
694
+ var SEARCH_MAX_LIMIT = 100;
695
+ var IdentityGraphError = class extends Error {
696
+ };
683
697
  function now() {
684
698
  return (/* @__PURE__ */ new Date()).toISOString();
685
699
  }
686
700
  function linksFor(identityId) {
687
701
  return state.identityLinks.filter((l) => l.identityId === identityId);
688
702
  }
703
+ function comparableExternalId(kind, externalId) {
704
+ if (kind === "email") return externalId.trim().toLowerCase();
705
+ return externalId;
706
+ }
707
+ function normalizeExternalId(kind, externalId) {
708
+ if (kind === "email") {
709
+ const normalized = externalId.trim().toLowerCase();
710
+ return normalized.length > 0 ? { externalId: normalized } : { error: "externalId is required" };
711
+ }
712
+ if (kind === "phone" && !E164_PHONE.test(externalId)) {
713
+ return { error: "phone externalId must be an E.164 number" };
714
+ }
715
+ return { externalId };
716
+ }
717
+ function normalizeLinkInput(kind, externalId, allowedKinds, kindError) {
718
+ if (typeof kind !== "string" || !allowedKinds.has(kind)) {
719
+ return { error: kindError };
720
+ }
721
+ if (typeof externalId !== "string" || externalId.length === 0) {
722
+ return { error: "externalId is required" };
723
+ }
724
+ const normalized = normalizeExternalId(kind, externalId);
725
+ if ("error" in normalized) return normalized;
726
+ return { kind, externalId: normalized.externalId };
727
+ }
728
+ function normalizeResolveInput(kind, externalId) {
729
+ const normalized = normalizeLinkInput(
730
+ kind,
731
+ externalId,
732
+ RESOLVE_KINDS,
733
+ "kind must be one of: phone, email, line"
734
+ );
735
+ if ("error" in normalized) return normalized;
736
+ return { kind: normalized.kind, externalId: normalized.externalId };
737
+ }
738
+ function canonicalIdentity(identityId) {
739
+ const visited = /* @__PURE__ */ new Set();
740
+ let identity = state.identities.get(identityId);
741
+ while (identity) {
742
+ if (visited.has(identity.id)) {
743
+ throw new IdentityGraphError("identity merge chain contains a cycle");
744
+ }
745
+ visited.add(identity.id);
746
+ if (identity.status === "archived") {
747
+ throw new IdentityGraphError("identity is archived");
748
+ }
749
+ if (identity.status === "active" && !identity.mergedIntoId) {
750
+ return identity;
751
+ }
752
+ if (!identity.mergedIntoId) {
753
+ throw new IdentityGraphError("identity merge chain has no active canonical target");
754
+ }
755
+ identity = state.identities.get(identity.mergedIntoId);
756
+ }
757
+ throw new IdentityGraphError("identity merge chain references a missing identity");
758
+ }
759
+ function matchingLinks(kind, externalId) {
760
+ const comparable = comparableExternalId(kind, externalId);
761
+ return state.identityLinks.filter(
762
+ (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
763
+ );
764
+ }
765
+ function normalizedLinkKey(link) {
766
+ return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
767
+ }
768
+ function createPerson(displayName) {
769
+ const createdAt = now();
770
+ const identity = {
771
+ id: import_node_crypto3.default.randomUUID(),
772
+ type: "person",
773
+ parentId: null,
774
+ displayName,
775
+ profile: {},
776
+ status: "active",
777
+ mergedIntoId: null,
778
+ externalRef: null,
779
+ createdAt,
780
+ updatedAt: createdAt
781
+ };
782
+ state.identities.set(identity.id, identity);
783
+ return identity;
784
+ }
785
+ function createUnverifiedLink(identityId, kind, externalId) {
786
+ const link = {
787
+ id: import_node_crypto3.default.randomUUID(),
788
+ identityId,
789
+ kind,
790
+ externalId,
791
+ // Deployment credentials can attest provenance, but cannot manufacture a
792
+ // platform-verified fact. Trusted channel/control-plane setup uses the
793
+ // directory's test-only seedVerifiedLink helper instead.
794
+ verified: false,
795
+ createdAt: now()
796
+ };
797
+ state.identityLinks.push(link);
798
+ return link;
799
+ }
689
800
  function handleList(request) {
690
801
  const typeParam = new URL(request.url).searchParams.get("type");
691
802
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
692
- const identities = [...state.identities.values()];
803
+ const identities = [...state.identities.values()].filter(
804
+ (identity) => identity.status !== "merged"
805
+ );
693
806
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
694
807
  }
695
808
  async function handleCreate(request) {
@@ -707,6 +820,7 @@ async function handleCreate(request) {
707
820
  }
708
821
  parentId = parent.id;
709
822
  }
823
+ const createdAt = now();
710
824
  const identity = {
711
825
  id: import_node_crypto3.default.randomUUID(),
712
826
  type,
@@ -716,8 +830,8 @@ async function handleCreate(request) {
716
830
  status: "active",
717
831
  mergedIntoId: null,
718
832
  externalRef: body.externalRef ?? null,
719
- createdAt: now(),
720
- updatedAt: now()
833
+ createdAt,
834
+ updatedAt: createdAt
721
835
  };
722
836
  state.identities.set(identity.id, identity);
723
837
  return success({ identity });
@@ -753,36 +867,166 @@ async function handleAttachLink(identityId, request) {
753
867
  return failure("links attach only to active persons");
754
868
  }
755
869
  const body = await readJsonBody(request);
756
- const kind = body.kind;
757
- const externalId = body.externalId;
758
- if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
759
- return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
760
- }
761
- if (typeof externalId !== "string" || !externalId) {
762
- return failure("externalId is required");
763
- }
764
- const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
870
+ const normalized = normalizeLinkInput(
871
+ body.kind,
872
+ body.externalId,
873
+ LINK_KINDS,
874
+ `kind must be one of: ${[...LINK_KINDS].join(", ")}`
875
+ );
876
+ if ("error" in normalized) return failure(normalized.error);
877
+ const { kind, externalId } = normalized;
878
+ const [existing] = matchingLinks(kind, externalId);
765
879
  if (existing) {
766
880
  if (existing.identityId === identityId) return success({ link: existing });
767
881
  return failure("identifier already linked to another identity", 409);
768
882
  }
769
- const link = {
770
- id: import_node_crypto3.default.randomUUID(),
771
- identityId,
772
- kind,
773
- externalId,
774
- verified: body.verified === true,
775
- createdAt: now()
776
- };
777
- state.identityLinks.push(link);
883
+ const link = createUnverifiedLink(identityId, kind, externalId);
778
884
  return success({ link });
779
885
  }
886
+ async function handleResolve(request) {
887
+ const body = await readJsonBody(request);
888
+ const normalized = normalizeResolveInput(body.kind, body.externalId);
889
+ if ("error" in normalized) return failure(normalized.error);
890
+ const provenance = body.provenance;
891
+ if (provenance !== "guest" && provenance !== "staff") {
892
+ return failure("provenance must be 'guest' or 'staff'");
893
+ }
894
+ if (body.displayName !== void 0 && typeof body.displayName !== "string") {
895
+ return failure("displayName must be a string");
896
+ }
897
+ const links = matchingLinks(normalized.kind, normalized.externalId);
898
+ if (links.length > 0) {
899
+ const canonicalIds = /* @__PURE__ */ new Set();
900
+ for (const link of links) {
901
+ try {
902
+ canonicalIds.add(canonicalIdentity(link.identityId).id);
903
+ } catch (error) {
904
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
905
+ return failure(`cannot resolve identity link: ${message}`, 409);
906
+ }
907
+ }
908
+ if (canonicalIds.size !== 1) {
909
+ return failure("identifier is linked to multiple identities", 409);
910
+ }
911
+ const canonicalId = [...canonicalIds][0];
912
+ const hasVerifiedLink = links.some((link) => link.verified);
913
+ if (provenance === "guest" && hasVerifiedLink) {
914
+ return success({ identityId: null, reason: "verified_conflict" });
915
+ }
916
+ return success({ identityId: canonicalId, created: false });
917
+ }
918
+ const displayName = body.displayName ?? null;
919
+ const identity = createPerson(displayName);
920
+ createUnverifiedLink(identity.id, normalized.kind, normalized.externalId);
921
+ return success({ identityId: identity.id, created: true });
922
+ }
923
+ function encodeSearchCursor(cursor) {
924
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
925
+ }
926
+ function decodeSearchCursor(raw, query) {
927
+ try {
928
+ const rawParsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
929
+ if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null;
930
+ const parsed = rawParsed;
931
+ if (typeof parsed.createdAt !== "string" || typeof parsed.id !== "string" || typeof parsed.query !== "string" || parsed.query !== query) {
932
+ return null;
933
+ }
934
+ return { createdAt: parsed.createdAt, id: parsed.id, query: parsed.query };
935
+ } catch {
936
+ return null;
937
+ }
938
+ }
939
+ function compareIdentityOrder(left, right) {
940
+ const byCreatedAt = left.createdAt.localeCompare(right.createdAt);
941
+ return byCreatedAt !== 0 ? byCreatedAt : left.id.localeCompare(right.id);
942
+ }
943
+ function identityMatchesQuery(identity, query) {
944
+ if (!query) return true;
945
+ if (identity.displayName?.toLowerCase().includes(query)) return true;
946
+ return state.identityLinks.some(
947
+ (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
948
+ );
949
+ }
950
+ function handleSearch(request) {
951
+ const url = new URL(request.url);
952
+ const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
953
+ const rawLimit = url.searchParams.get("limit");
954
+ let limit = SEARCH_DEFAULT_LIMIT;
955
+ if (rawLimit !== null) {
956
+ if (!/^\d+$/.test(rawLimit)) return failure("limit must be a positive integer");
957
+ limit = Number(rawLimit);
958
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SEARCH_MAX_LIMIT) {
959
+ return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
960
+ }
961
+ }
962
+ const rawCursor = url.searchParams.get("cursor");
963
+ const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
964
+ if (rawCursor && !cursor) return failure("cursor is invalid");
965
+ const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
966
+ const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
967
+ const pageStart = start < 0 ? identities.length : start;
968
+ const items = identities.slice(pageStart, pageStart + limit);
969
+ const hasMore = pageStart + items.length < identities.length;
970
+ const nextCursor = hasMore ? encodeSearchCursor({
971
+ createdAt: items[items.length - 1].createdAt,
972
+ id: items[items.length - 1].id,
973
+ query
974
+ }) : null;
975
+ return success({ items, nextCursor });
976
+ }
977
+ function aliasesForCanonical(canonicalId) {
978
+ const aliases = [canonicalId];
979
+ for (const identity of state.identities.values()) {
980
+ if (identity.id === canonicalId || identity.status !== "merged") continue;
981
+ try {
982
+ if (canonicalIdentity(identity.id).id === canonicalId) aliases.push(identity.id);
983
+ } catch {
984
+ }
985
+ }
986
+ return aliases;
987
+ }
988
+ async function handleAliases(request) {
989
+ const body = await readJsonBody(request);
990
+ if (!Array.isArray(body.identityIds)) {
991
+ return failure("identityIds must be an array");
992
+ }
993
+ if (body.identityIds.length === 0) {
994
+ return failure("identityIds must not be empty");
995
+ }
996
+ if (body.identityIds.length > 200) {
997
+ return failure("A maximum of 200 identity ids may be requested");
998
+ }
999
+ if (body.identityIds.some((identityId) => typeof identityId !== "string" || identityId.length === 0)) {
1000
+ return failure("identityIds must contain non-empty strings");
1001
+ }
1002
+ const aliases = {};
1003
+ for (const identityId of body.identityIds) {
1004
+ let canonical;
1005
+ try {
1006
+ canonical = canonicalIdentity(identityId);
1007
+ } catch (error) {
1008
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
1009
+ return failure(`cannot expand identity aliases: ${message}`, 409);
1010
+ }
1011
+ aliases[canonical.id] ??= aliasesForCanonical(canonical.id);
1012
+ }
1013
+ return success({ aliases });
1014
+ }
780
1015
  async function handleIdentitiesRequest(request, subPath) {
781
1016
  const method = request.method;
782
1017
  if (subPath === "" || subPath === "/") {
783
1018
  if (method === "GET") return handleList(request);
784
1019
  if (method === "POST") return handleCreate(request);
785
1020
  }
1021
+ if (subPath === "/resolve" && method === "POST") {
1022
+ return handleResolve(request);
1023
+ }
1024
+ if (subPath === "/aliases" && method === "POST") {
1025
+ return handleAliases(request);
1026
+ }
1027
+ if (subPath === "/search" && method === "GET") {
1028
+ return handleSearch(request);
1029
+ }
786
1030
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
787
1031
  if (linksMatch && method === "POST") {
788
1032
  return handleAttachLink(linksMatch[1], request);
@@ -794,11 +1038,87 @@ async function handleIdentitiesRequest(request, subPath) {
794
1038
  }
795
1039
  return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
796
1040
  }
1041
+ function seedVerifiedLink(identityId, params) {
1042
+ const identity = state.identities.get(identityId);
1043
+ if (!identity) throw new Error("identity not found");
1044
+ if (identity.type !== "person") throw new Error("verified links attach only to persons");
1045
+ if (identity.status !== "active") throw new Error("verified links attach only to active persons");
1046
+ const normalized = normalizeLinkInput(
1047
+ params.kind,
1048
+ params.externalId,
1049
+ LINK_KINDS,
1050
+ "unknown identity link kind"
1051
+ );
1052
+ if ("error" in normalized) throw new Error(normalized.error);
1053
+ const { kind, externalId } = normalized;
1054
+ const [existing] = matchingLinks(kind, externalId);
1055
+ if (existing) {
1056
+ if (existing.identityId !== identityId) {
1057
+ throw new Error("identifier already linked to another identity");
1058
+ }
1059
+ existing.verified = true;
1060
+ return existing;
1061
+ }
1062
+ const link = createUnverifiedLink(identityId, kind, externalId);
1063
+ link.verified = true;
1064
+ return link;
1065
+ }
1066
+ function merge(sourceIdentityId, canonicalIdentityId) {
1067
+ const source = state.identities.get(sourceIdentityId);
1068
+ if (!source) throw new Error("source identity not found");
1069
+ if (source.status !== "active" || source.mergedIntoId !== null) {
1070
+ throw new Error("source identity must be active and unmerged");
1071
+ }
1072
+ const canonical = state.identities.get(canonicalIdentityId);
1073
+ if (!canonical) throw new Error("canonical identity not found");
1074
+ if (canonical.status !== "active" || canonical.mergedIntoId !== null) {
1075
+ throw new Error("canonical identity must be active and unmerged");
1076
+ }
1077
+ if (source.type !== canonical.type) {
1078
+ throw new Error("identities must have the same type");
1079
+ }
1080
+ if (source.id === canonical.id) throw new Error("an identity cannot merge into itself");
1081
+ const sourceLinks = state.identityLinks.filter((link) => link.identityId === source.id);
1082
+ const canonicalLinks = state.identityLinks.filter((link) => link.identityId === canonical.id);
1083
+ const otherLinks = state.identityLinks.filter(
1084
+ (link) => link.identityId !== source.id && link.identityId !== canonical.id
1085
+ );
1086
+ for (const sourceLink of sourceLinks) {
1087
+ const key = normalizedLinkKey(sourceLink);
1088
+ if (otherLinks.some((otherLink) => normalizedLinkKey(otherLink) === key)) {
1089
+ throw new Error("merge would conflict with a link owned by another identity");
1090
+ }
1091
+ }
1092
+ const linksByKey = /* @__PURE__ */ new Map();
1093
+ for (const canonicalLink of canonicalLinks) {
1094
+ linksByKey.set(normalizedLinkKey(canonicalLink), canonicalLink);
1095
+ }
1096
+ const droppedSourceLinks = /* @__PURE__ */ new Set();
1097
+ for (const sourceLink of sourceLinks) {
1098
+ const key = normalizedLinkKey(sourceLink);
1099
+ const existing = linksByKey.get(key);
1100
+ if (existing) {
1101
+ existing.verified = existing.verified || sourceLink.verified;
1102
+ droppedSourceLinks.add(sourceLink);
1103
+ continue;
1104
+ }
1105
+ sourceLink.identityId = canonical.id;
1106
+ linksByKey.set(key, sourceLink);
1107
+ }
1108
+ if (droppedSourceLinks.size > 0) {
1109
+ state.identityLinks = state.identityLinks.filter((link) => !droppedSourceLinks.has(link));
1110
+ }
1111
+ source.status = "merged";
1112
+ source.mergedIntoId = canonical.id;
1113
+ source.updatedAt = now();
1114
+ }
797
1115
  function createDirectory() {
798
1116
  return {
799
1117
  all: () => [...state.identities.values()],
800
1118
  get: (id) => state.identities.get(id),
801
1119
  links: (identityId) => linksFor(identityId),
1120
+ seedVerifiedLink,
1121
+ merge,
802
1122
  clear: () => {
803
1123
  state.identities.clear();
804
1124
  state.identityLinks = [];
@@ -1723,13 +2043,24 @@ function requiresDeploymentHmac(request, url) {
1723
2043
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
1724
2044
  return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
1725
2045
  }
2046
+ function deploymentIdFromPath(url) {
2047
+ const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
2048
+ if (!match) return null;
2049
+ try {
2050
+ return decodeURIComponent(match[1]);
2051
+ } catch {
2052
+ return match[1];
2053
+ }
2054
+ }
1726
2055
  async function handleSimulatedRequest(request, url) {
1727
2056
  if (url.pathname === "/sql") {
1728
2057
  return handleNeonSql(requireDb(), request);
1729
2058
  }
1730
- const authMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/auth\/(verify|refresh)$/);
2059
+ const authMatch = url.pathname.match(
2060
+ /^\/api\/deployments\/[^/]+\/auth\/(v2\/verify|verify|refresh)$/
2061
+ );
1731
2062
  if (authMatch) {
1732
- return authMatch[1] === "verify" ? handleAuthVerify(request) : handleAuthRefresh(request);
2063
+ return authMatch[1].endsWith("verify") ? handleAuthVerify(request) : handleAuthRefresh(request);
1733
2064
  }
1734
2065
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
1735
2066
  const isEmail = url.pathname === "/api/email/send";
@@ -1746,7 +2077,9 @@ async function handleSimulatedRequest(request, url) {
1746
2077
  return failure("Missing authentication header", 401);
1747
2078
  }
1748
2079
  const secret = process.env.DEPLOYMENT_SECRET;
1749
- if (!secret || !verifyDeploymentAuthHeader(secret, authHeader)) {
2080
+ const authPayload = secret ? verifyDeploymentAuthHeader(secret, authHeader) : null;
2081
+ const pathDeploymentId = deploymentIdFromPath(url);
2082
+ if (!authPayload || pathDeploymentId !== null && authPayload.deploymentId !== pathDeploymentId) {
1750
2083
  return failure("Invalid authentication", 401);
1751
2084
  }
1752
2085
  }