@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/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
|
|
6
|
-
paths with no network, no mocks to write, and full
|
|
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
|
|
184
|
-
`
|
|
185
|
-
|
|
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
|
|
183
|
+
let parsed;
|
|
176
184
|
try {
|
|
177
|
-
|
|
185
|
+
parsed = JSON.parse(payloadJson);
|
|
178
186
|
} catch {
|
|
179
187
|
return null;
|
|
180
188
|
}
|
|
181
|
-
if (
|
|
189
|
+
if (!isDeploymentAuthPayload(parsed)) return null;
|
|
182
190
|
const now3 = Math.floor(Date.now() / 1e3);
|
|
183
|
-
if (Math.abs(now3 -
|
|
184
|
-
return
|
|
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
|
|
720
|
-
updatedAt:
|
|
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
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
}
|
|
764
|
-
const existing =
|
|
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(
|
|
2059
|
+
const authMatch = url.pathname.match(
|
|
2060
|
+
/^\/api\/deployments\/[^/]+\/auth\/(v2\/verify|verify|refresh)$/
|
|
2061
|
+
);
|
|
1731
2062
|
if (authMatch) {
|
|
1732
|
-
return authMatch[1]
|
|
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
|
-
|
|
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
|
}
|