@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 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,27 @@ 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` applies the production email contract (trim + ASCII-only
219
+ A–Z→a–z; Turkish İ is left unchanged), requires E.164 phone values, and
220
+ always writes an unverified link. A guest lookup of a verified link returns
221
+ `{ identityId: null, reason: "verified_conflict" }`; staff provenance may
222
+ resolve either verified or unverified links. `attachLink` accepts only
223
+ channel kinds (line/facebook/instagram/email/phone); login keys must be
224
+ seeded via `app.identities.seedVerifiedLink`. `attachLink(..., { verified:
225
+ true })` remains source-compatible but the simulator (like the control plane)
226
+ ignores that flag and returns `verified: false`. `update` replaces the
227
+ `profile` object (not a merge), like the control plane. Inspect via
228
+ `app.identities`; merge/archive are privileged setup operations, with only
229
+ test-only merge modeling exposed above.
230
+ - `session.user.identityId` is the platform-provisioned cross-app customer key.
231
+ `session.user.id` is the platform login key; it is not the key for customer
232
+ data. The simulator round-trips `identityId` through issued sessions while
233
+ preserving older users that omit it or set it to `null`.
234
+ - For merge-correct reads, request aliases in batches and include every id in
235
+ the returned canonical set. Page directory UIs through `search({ query,
236
+ cursor, limit })`; `list()` intentionally keeps its previous array shape.
186
237
  - `PaymentsServerClient` (Stripe checkout + Beam payment links, product list) —
187
238
  captured in `app.payments`; fulfill via `markPaid` (poll) or
188
239
  `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
@@ -671,7 +679,8 @@ function createInbox() {
671
679
 
672
680
  // src/simulator/identities.ts
673
681
  var import_node_crypto3 = __toESM(require("crypto"));
674
- var LINK_KINDS = /* @__PURE__ */ new Set([
682
+ var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
683
+ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
675
684
  "line",
676
685
  "facebook",
677
686
  "instagram",
@@ -680,16 +689,130 @@ var LINK_KINDS = /* @__PURE__ */ new Set([
680
689
  "project_auth_user",
681
690
  "dashboard_user"
682
691
  ]);
692
+ var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
693
+ var E164_PHONE = /^\+[1-9]\d{1,14}$/;
694
+ var SEARCH_DEFAULT_LIMIT = 50;
695
+ var SEARCH_MAX_LIMIT = 100;
696
+ function asciiLowercaseTrim(value) {
697
+ const trimmed = value.trim();
698
+ let out = "";
699
+ for (let i = 0; i < trimmed.length; i++) {
700
+ const code = trimmed.charCodeAt(i);
701
+ out += code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : trimmed[i];
702
+ }
703
+ return out;
704
+ }
705
+ var IdentityGraphError = class extends Error {
706
+ };
683
707
  function now() {
684
708
  return (/* @__PURE__ */ new Date()).toISOString();
685
709
  }
686
710
  function linksFor(identityId) {
687
711
  return state.identityLinks.filter((l) => l.identityId === identityId);
688
712
  }
713
+ function comparableExternalId(kind, externalId) {
714
+ if (kind === "email") return asciiLowercaseTrim(externalId);
715
+ return externalId;
716
+ }
717
+ function normalizeExternalId(kind, externalId) {
718
+ if (kind === "email") {
719
+ const normalized = asciiLowercaseTrim(externalId);
720
+ return normalized.length > 0 ? { externalId: normalized } : { error: "externalId is required" };
721
+ }
722
+ if (kind === "phone" && !E164_PHONE.test(externalId)) {
723
+ return { error: "phone externalId must be an E.164 number" };
724
+ }
725
+ return { externalId };
726
+ }
727
+ function normalizeLinkInput(kind, externalId, allowedKinds, kindError) {
728
+ if (typeof kind !== "string" || !allowedKinds.has(kind)) {
729
+ return { error: kindError };
730
+ }
731
+ if (typeof externalId !== "string" || externalId.length === 0) {
732
+ return { error: "externalId is required" };
733
+ }
734
+ const normalized = normalizeExternalId(kind, externalId);
735
+ if ("error" in normalized) return normalized;
736
+ return { kind, externalId: normalized.externalId };
737
+ }
738
+ function normalizeResolveInput(kind, externalId) {
739
+ const normalized = normalizeLinkInput(
740
+ kind,
741
+ externalId,
742
+ RESOLVE_KINDS,
743
+ "kind must be one of: phone, email, line"
744
+ );
745
+ if ("error" in normalized) return normalized;
746
+ return { kind: normalized.kind, externalId: normalized.externalId };
747
+ }
748
+ function canonicalIdentity(identityId) {
749
+ const visited = /* @__PURE__ */ new Set();
750
+ let identity = state.identities.get(identityId);
751
+ while (identity) {
752
+ if (visited.has(identity.id)) {
753
+ throw new IdentityGraphError("identity merge chain contains a cycle");
754
+ }
755
+ visited.add(identity.id);
756
+ if (identity.status === "archived") {
757
+ throw new IdentityGraphError("identity is archived");
758
+ }
759
+ if (identity.status === "active" && !identity.mergedIntoId) {
760
+ return identity;
761
+ }
762
+ if (!identity.mergedIntoId) {
763
+ throw new IdentityGraphError("identity merge chain has no active canonical target");
764
+ }
765
+ identity = state.identities.get(identity.mergedIntoId);
766
+ }
767
+ throw new IdentityGraphError("identity merge chain references a missing identity");
768
+ }
769
+ function matchingLinks(kind, externalId) {
770
+ const comparable = comparableExternalId(kind, externalId);
771
+ return state.identityLinks.filter(
772
+ (link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
773
+ );
774
+ }
775
+ function normalizedLinkKey(link) {
776
+ return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
777
+ }
778
+ function createPerson(displayName) {
779
+ const createdAt = now();
780
+ const identity = {
781
+ id: import_node_crypto3.default.randomUUID(),
782
+ type: "person",
783
+ parentId: null,
784
+ displayName,
785
+ profile: {},
786
+ status: "active",
787
+ mergedIntoId: null,
788
+ externalRef: null,
789
+ createdAt,
790
+ updatedAt: createdAt
791
+ };
792
+ state.identities.set(identity.id, identity);
793
+ return identity;
794
+ }
795
+ function createUnverifiedLink(identityId, kind, externalId) {
796
+ const link = {
797
+ id: import_node_crypto3.default.randomUUID(),
798
+ identityId,
799
+ kind,
800
+ externalId,
801
+ // Deployment credentials can attest provenance, but cannot manufacture a
802
+ // platform-verified fact. Trusted channel/control-plane setup uses the
803
+ // directory's test-only seedVerifiedLink helper instead.
804
+ verified: false,
805
+ createdAt: now()
806
+ };
807
+ state.identityLinks.push(link);
808
+ return link;
809
+ }
689
810
  function handleList(request) {
690
811
  const typeParam = new URL(request.url).searchParams.get("type");
691
812
  const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
692
- const identities = [...state.identities.values()];
813
+ const identities = [...state.identities.values()].filter(
814
+ (identity) => identity.status !== "merged"
815
+ );
693
816
  return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
694
817
  }
695
818
  async function handleCreate(request) {
@@ -707,6 +830,7 @@ async function handleCreate(request) {
707
830
  }
708
831
  parentId = parent.id;
709
832
  }
833
+ const createdAt = now();
710
834
  const identity = {
711
835
  id: import_node_crypto3.default.randomUUID(),
712
836
  type,
@@ -716,8 +840,8 @@ async function handleCreate(request) {
716
840
  status: "active",
717
841
  mergedIntoId: null,
718
842
  externalRef: body.externalRef ?? null,
719
- createdAt: now(),
720
- updatedAt: now()
843
+ createdAt,
844
+ updatedAt: createdAt
721
845
  };
722
846
  state.identities.set(identity.id, identity);
723
847
  return success({ identity });
@@ -753,36 +877,166 @@ async function handleAttachLink(identityId, request) {
753
877
  return failure("links attach only to active persons");
754
878
  }
755
879
  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);
880
+ const normalized = normalizeLinkInput(
881
+ body.kind,
882
+ body.externalId,
883
+ ATTACH_LINK_KINDS,
884
+ `kind must be one of: ${[...ATTACH_LINK_KINDS].join(", ")}`
885
+ );
886
+ if ("error" in normalized) return failure(normalized.error);
887
+ const { kind, externalId } = normalized;
888
+ const [existing] = matchingLinks(kind, externalId);
765
889
  if (existing) {
766
890
  if (existing.identityId === identityId) return success({ link: existing });
767
891
  return failure("identifier already linked to another identity", 409);
768
892
  }
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);
893
+ const link = createUnverifiedLink(identityId, kind, externalId);
778
894
  return success({ link });
779
895
  }
896
+ async function handleResolve(request) {
897
+ const body = await readJsonBody(request);
898
+ const normalized = normalizeResolveInput(body.kind, body.externalId);
899
+ if ("error" in normalized) return failure(normalized.error);
900
+ const provenance = body.provenance;
901
+ if (provenance !== "guest" && provenance !== "staff") {
902
+ return failure("provenance must be 'guest' or 'staff'");
903
+ }
904
+ if (body.displayName !== void 0 && typeof body.displayName !== "string") {
905
+ return failure("displayName must be a string");
906
+ }
907
+ const links = matchingLinks(normalized.kind, normalized.externalId);
908
+ if (links.length > 0) {
909
+ const canonicalIds = /* @__PURE__ */ new Set();
910
+ for (const link of links) {
911
+ try {
912
+ canonicalIds.add(canonicalIdentity(link.identityId).id);
913
+ } catch (error) {
914
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
915
+ return failure(`cannot resolve identity link: ${message}`, 409);
916
+ }
917
+ }
918
+ if (canonicalIds.size !== 1) {
919
+ return failure("identifier is linked to multiple identities", 409);
920
+ }
921
+ const canonicalId = [...canonicalIds][0];
922
+ const hasVerifiedLink = links.some((link) => link.verified);
923
+ if (provenance === "guest" && hasVerifiedLink) {
924
+ return success({ identityId: null, reason: "verified_conflict" });
925
+ }
926
+ return success({ identityId: canonicalId, created: false });
927
+ }
928
+ const displayName = body.displayName ?? null;
929
+ const identity = createPerson(displayName);
930
+ createUnverifiedLink(identity.id, normalized.kind, normalized.externalId);
931
+ return success({ identityId: identity.id, created: true });
932
+ }
933
+ function encodeSearchCursor(cursor) {
934
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
935
+ }
936
+ function decodeSearchCursor(raw, query) {
937
+ try {
938
+ const rawParsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
939
+ if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null;
940
+ const parsed = rawParsed;
941
+ if (typeof parsed.createdAt !== "string" || typeof parsed.id !== "string" || typeof parsed.query !== "string" || parsed.query !== query) {
942
+ return null;
943
+ }
944
+ return { createdAt: parsed.createdAt, id: parsed.id, query: parsed.query };
945
+ } catch {
946
+ return null;
947
+ }
948
+ }
949
+ function compareIdentityOrder(left, right) {
950
+ const byCreatedAt = left.createdAt.localeCompare(right.createdAt);
951
+ return byCreatedAt !== 0 ? byCreatedAt : left.id.localeCompare(right.id);
952
+ }
953
+ function identityMatchesQuery(identity, query) {
954
+ if (!query) return true;
955
+ if (identity.displayName?.toLowerCase().includes(query)) return true;
956
+ return state.identityLinks.some(
957
+ (link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
958
+ );
959
+ }
960
+ function handleSearch(request) {
961
+ const url = new URL(request.url);
962
+ const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
963
+ const rawLimit = url.searchParams.get("limit");
964
+ let limit = SEARCH_DEFAULT_LIMIT;
965
+ if (rawLimit !== null) {
966
+ if (!/^\d+$/.test(rawLimit)) return failure("limit must be a positive integer");
967
+ limit = Number(rawLimit);
968
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SEARCH_MAX_LIMIT) {
969
+ return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
970
+ }
971
+ }
972
+ const rawCursor = url.searchParams.get("cursor");
973
+ const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
974
+ if (rawCursor && !cursor) return failure("cursor is invalid");
975
+ const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
976
+ const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
977
+ const pageStart = start < 0 ? identities.length : start;
978
+ const items = identities.slice(pageStart, pageStart + limit);
979
+ const hasMore = pageStart + items.length < identities.length;
980
+ const nextCursor = hasMore ? encodeSearchCursor({
981
+ createdAt: items[items.length - 1].createdAt,
982
+ id: items[items.length - 1].id,
983
+ query
984
+ }) : null;
985
+ return success({ items, nextCursor });
986
+ }
987
+ function aliasesForCanonical(canonicalId) {
988
+ const aliases = [canonicalId];
989
+ for (const identity of state.identities.values()) {
990
+ if (identity.id === canonicalId || identity.status !== "merged") continue;
991
+ try {
992
+ if (canonicalIdentity(identity.id).id === canonicalId) aliases.push(identity.id);
993
+ } catch {
994
+ }
995
+ }
996
+ return aliases;
997
+ }
998
+ async function handleAliases(request) {
999
+ const body = await readJsonBody(request);
1000
+ if (!Array.isArray(body.identityIds)) {
1001
+ return failure("identityIds must be an array");
1002
+ }
1003
+ if (body.identityIds.length === 0) {
1004
+ return failure("identityIds must not be empty");
1005
+ }
1006
+ if (body.identityIds.length > 200) {
1007
+ return failure("A maximum of 200 identity ids may be requested");
1008
+ }
1009
+ if (body.identityIds.some((identityId) => typeof identityId !== "string" || identityId.length === 0)) {
1010
+ return failure("identityIds must contain non-empty strings");
1011
+ }
1012
+ const aliases = {};
1013
+ for (const identityId of body.identityIds) {
1014
+ let canonical;
1015
+ try {
1016
+ canonical = canonicalIdentity(identityId);
1017
+ } catch (error) {
1018
+ const message = error instanceof Error ? error.message : "invalid identity merge chain";
1019
+ return failure(`cannot expand identity aliases: ${message}`, 409);
1020
+ }
1021
+ aliases[canonical.id] ??= aliasesForCanonical(canonical.id);
1022
+ }
1023
+ return success({ aliases });
1024
+ }
780
1025
  async function handleIdentitiesRequest(request, subPath) {
781
1026
  const method = request.method;
782
1027
  if (subPath === "" || subPath === "/") {
783
1028
  if (method === "GET") return handleList(request);
784
1029
  if (method === "POST") return handleCreate(request);
785
1030
  }
1031
+ if (subPath === "/resolve" && method === "POST") {
1032
+ return handleResolve(request);
1033
+ }
1034
+ if (subPath === "/aliases" && method === "POST") {
1035
+ return handleAliases(request);
1036
+ }
1037
+ if (subPath === "/search" && method === "GET") {
1038
+ return handleSearch(request);
1039
+ }
786
1040
  const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
787
1041
  if (linksMatch && method === "POST") {
788
1042
  return handleAttachLink(linksMatch[1], request);
@@ -794,11 +1048,87 @@ async function handleIdentitiesRequest(request, subPath) {
794
1048
  }
795
1049
  return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
796
1050
  }
1051
+ function seedVerifiedLink(identityId, params) {
1052
+ const identity = state.identities.get(identityId);
1053
+ if (!identity) throw new Error("identity not found");
1054
+ if (identity.type !== "person") throw new Error("verified links attach only to persons");
1055
+ if (identity.status !== "active") throw new Error("verified links attach only to active persons");
1056
+ const normalized = normalizeLinkInput(
1057
+ params.kind,
1058
+ params.externalId,
1059
+ TRUSTED_LINK_KINDS,
1060
+ "unknown identity link kind"
1061
+ );
1062
+ if ("error" in normalized) throw new Error(normalized.error);
1063
+ const { kind, externalId } = normalized;
1064
+ const [existing] = matchingLinks(kind, externalId);
1065
+ if (existing) {
1066
+ if (existing.identityId !== identityId) {
1067
+ throw new Error("identifier already linked to another identity");
1068
+ }
1069
+ existing.verified = true;
1070
+ return existing;
1071
+ }
1072
+ const link = createUnverifiedLink(identityId, kind, externalId);
1073
+ link.verified = true;
1074
+ return link;
1075
+ }
1076
+ function merge(sourceIdentityId, canonicalIdentityId) {
1077
+ const source = state.identities.get(sourceIdentityId);
1078
+ if (!source) throw new Error("source identity not found");
1079
+ if (source.status !== "active" || source.mergedIntoId !== null) {
1080
+ throw new Error("source identity must be active and unmerged");
1081
+ }
1082
+ const canonical = state.identities.get(canonicalIdentityId);
1083
+ if (!canonical) throw new Error("canonical identity not found");
1084
+ if (canonical.status !== "active" || canonical.mergedIntoId !== null) {
1085
+ throw new Error("canonical identity must be active and unmerged");
1086
+ }
1087
+ if (source.type !== canonical.type) {
1088
+ throw new Error("identities must have the same type");
1089
+ }
1090
+ if (source.id === canonical.id) throw new Error("an identity cannot merge into itself");
1091
+ const sourceLinks = state.identityLinks.filter((link) => link.identityId === source.id);
1092
+ const canonicalLinks = state.identityLinks.filter((link) => link.identityId === canonical.id);
1093
+ const otherLinks = state.identityLinks.filter(
1094
+ (link) => link.identityId !== source.id && link.identityId !== canonical.id
1095
+ );
1096
+ for (const sourceLink of sourceLinks) {
1097
+ const key = normalizedLinkKey(sourceLink);
1098
+ if (otherLinks.some((otherLink) => normalizedLinkKey(otherLink) === key)) {
1099
+ throw new Error("merge would conflict with a link owned by another identity");
1100
+ }
1101
+ }
1102
+ const linksByKey = /* @__PURE__ */ new Map();
1103
+ for (const canonicalLink of canonicalLinks) {
1104
+ linksByKey.set(normalizedLinkKey(canonicalLink), canonicalLink);
1105
+ }
1106
+ const droppedSourceLinks = /* @__PURE__ */ new Set();
1107
+ for (const sourceLink of sourceLinks) {
1108
+ const key = normalizedLinkKey(sourceLink);
1109
+ const existing = linksByKey.get(key);
1110
+ if (existing) {
1111
+ existing.verified = existing.verified || sourceLink.verified;
1112
+ droppedSourceLinks.add(sourceLink);
1113
+ continue;
1114
+ }
1115
+ sourceLink.identityId = canonical.id;
1116
+ linksByKey.set(key, sourceLink);
1117
+ }
1118
+ if (droppedSourceLinks.size > 0) {
1119
+ state.identityLinks = state.identityLinks.filter((link) => !droppedSourceLinks.has(link));
1120
+ }
1121
+ source.status = "merged";
1122
+ source.mergedIntoId = canonical.id;
1123
+ source.updatedAt = now();
1124
+ }
797
1125
  function createDirectory() {
798
1126
  return {
799
1127
  all: () => [...state.identities.values()],
800
1128
  get: (id) => state.identities.get(id),
801
1129
  links: (identityId) => linksFor(identityId),
1130
+ seedVerifiedLink,
1131
+ merge,
802
1132
  clear: () => {
803
1133
  state.identities.clear();
804
1134
  state.identityLinks = [];
@@ -1723,13 +2053,24 @@ function requiresDeploymentHmac(request, url) {
1723
2053
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
1724
2054
  return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
1725
2055
  }
2056
+ function deploymentIdFromPath(url) {
2057
+ const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
2058
+ if (!match) return null;
2059
+ try {
2060
+ return decodeURIComponent(match[1]);
2061
+ } catch {
2062
+ return match[1];
2063
+ }
2064
+ }
1726
2065
  async function handleSimulatedRequest(request, url) {
1727
2066
  if (url.pathname === "/sql") {
1728
2067
  return handleNeonSql(requireDb(), request);
1729
2068
  }
1730
- const authMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/auth\/(verify|refresh)$/);
2069
+ const authMatch = url.pathname.match(
2070
+ /^\/api\/deployments\/[^/]+\/auth\/(v2\/verify|verify|refresh)$/
2071
+ );
1731
2072
  if (authMatch) {
1732
- return authMatch[1] === "verify" ? handleAuthVerify(request) : handleAuthRefresh(request);
2073
+ return authMatch[1].endsWith("verify") ? handleAuthVerify(request) : handleAuthRefresh(request);
1733
2074
  }
1734
2075
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
1735
2076
  const isEmail = url.pathname === "/api/email/send";
@@ -1746,7 +2087,9 @@ async function handleSimulatedRequest(request, url) {
1746
2087
  return failure("Missing authentication header", 401);
1747
2088
  }
1748
2089
  const secret = process.env.DEPLOYMENT_SECRET;
1749
- if (!secret || !verifyDeploymentAuthHeader(secret, authHeader)) {
2090
+ const authPayload = secret ? verifyDeploymentAuthHeader(secret, authHeader) : null;
2091
+ const pathDeploymentId = deploymentIdFromPath(url);
2092
+ if (!authPayload || pathDeploymentId !== null && authPayload.deploymentId !== pathDeploymentId) {
1750
2093
  return failure("Invalid authentication", 401);
1751
2094
  }
1752
2095
  }