@stardeck-customer-apps/testing 0.15.0 → 0.16.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 +40 -4
- package/dist/index.d.mts +63 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +242 -44
- package/dist/index.mjs +242 -44
- package/dist/next/headers-shim.js +9 -0
- package/dist/next/headers-shim.mjs +9 -0
- package/dist/setup.js +163 -26
- package/dist/setup.mjs +163 -26
- package/package.json +2 -2
package/SKILL.md
CHANGED
|
@@ -341,6 +341,37 @@ externalId })` helper models a trusted platform/channel link, and
|
|
|
341
341
|
`.merge(sourceId, canonicalId)` models governed merge redirects. They are
|
|
342
342
|
setup helpers only; deployed app code cannot create verified links or merge
|
|
343
343
|
identities through the integrations SDK.
|
|
344
|
+
- **Profile connections** (`client.identities.claims`) are driven from the same
|
|
345
|
+
accessor: `.claims()` and `.claim(claimId)` inspect what the app opened;
|
|
346
|
+
`.enableStaffAssist()` turns on the org opt-in that `assist`/`transfer` claims
|
|
347
|
+
need (off by default, so an un-opted project gets the real 403);
|
|
348
|
+
`.completeClaim(claimId, { clerkUserId })` models the customer finishing the
|
|
349
|
+
platform-hosted flow; `.refuseClaim(claimId, reason)` models reception or the
|
|
350
|
+
platform turning one down. `completeClaim` flips the claimed contact to
|
|
351
|
+
`verified`, or records `invalidated` when the contact is no longer on that
|
|
352
|
+
profile and `refused` / `contact_owned` when someone else already proved it.
|
|
353
|
+
Completing a finished claim replays the recorded result. It does not attach a
|
|
354
|
+
login or move history — the platform's merge core is not modelled.
|
|
355
|
+
`.allowReturnOrigin(origin)` widens the allowed `returnTo` origins (the test
|
|
356
|
+
app's own origin is allowed by default); a `returnTo` anywhere else is dropped
|
|
357
|
+
silently, exactly as the platform drops it. `claimUrl` carries a one-time
|
|
358
|
+
opaque handle, so never assert it contains the claim id, and `deliver: true`
|
|
359
|
+
comes back as `delivered`. `.suppressClaimDelivery()` models the platform
|
|
360
|
+
sending nothing (a suppressed quota, a bounced address): `deliver: true` then
|
|
361
|
+
returns `delivered: false` with the `claimUrl` still valid, so the app's own
|
|
362
|
+
fallback delivery can be tested.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
const claim = await integrations.identities.claims.create({
|
|
366
|
+
flow: "invite",
|
|
367
|
+
identityId: person.id,
|
|
368
|
+
kind: "phone",
|
|
369
|
+
externalId: "+15550100",
|
|
370
|
+
});
|
|
371
|
+
app.identities.completeClaim(claim.claimId, { clerkUserId: "user_1" });
|
|
372
|
+
expect((await integrations.identities.claims.get(claim.claimId)).state).toBe("completed");
|
|
373
|
+
```
|
|
374
|
+
|
|
344
375
|
- `app.payments` — checkouts created through payments-sdk:
|
|
345
376
|
`.checkouts`, `.latest()`, `.setProducts()`, `.markPaid(id)`,
|
|
346
377
|
`.setSessionStatus(id, status)`, `.setPaymentLinkStatus(id, status)`,
|
|
@@ -409,11 +440,16 @@ describe the code, and the owner can't tell from them what is or isn't covered.
|
|
|
409
440
|
A–Z→a–z; Turkish İ is left unchanged), requires E.164 phone values, and
|
|
410
441
|
always writes an unverified link. A guest lookup of a verified link returns
|
|
411
442
|
`{ identityId: null, reason: "verified_conflict" }`; staff provenance may
|
|
412
|
-
resolve either verified or unverified links. `attachLink`
|
|
413
|
-
channel
|
|
414
|
-
|
|
443
|
+
resolve either verified or unverified links. `attachLink` records a contact or
|
|
444
|
+
channel id (line/facebook/instagram/email/phone); login keys must be seeded
|
|
445
|
+
via `app.identities.seedVerifiedLink`. `attachLink(..., { verified:
|
|
415
446
|
true })` remains source-compatible but the simulator (like the control plane)
|
|
416
|
-
ignores that flag and returns `verified: false`.
|
|
447
|
+
ignores that flag and returns `verified: false`. An **unverified** email or
|
|
448
|
+
phone may be recorded on several identities (a household sharing one number);
|
|
449
|
+
a verified holder makes a further _verified_ attach conflict, but unverified
|
|
450
|
+
copies still succeed, and `resolveOrCreate` prefers the verified owner and
|
|
451
|
+
otherwise the earliest unverified holder. Channel and login kinds stay
|
|
452
|
+
single-owner and 409 on a second holder. `update` replaces the
|
|
417
453
|
`profile` object (not a merge), like the control plane. Inspect via
|
|
418
454
|
`app.identities`; merge/archive are privileged setup operations, with only
|
|
419
455
|
test-only merge modeling exposed above.
|
package/dist/index.d.mts
CHANGED
|
@@ -105,6 +105,32 @@ interface CapturedIdentityLink {
|
|
|
105
105
|
* the control plane that `client.identities` talks to in tests. Identities and
|
|
106
106
|
* links created by app code under test land here; assert on them like `inbox`.
|
|
107
107
|
*/
|
|
108
|
+
interface CapturedIdentityClaim {
|
|
109
|
+
claimId: string;
|
|
110
|
+
flow: "invite" | "assist" | "transfer";
|
|
111
|
+
/** The profile the claim connects the customer to. */
|
|
112
|
+
identityId: string;
|
|
113
|
+
kind: "email" | "phone";
|
|
114
|
+
externalId: string;
|
|
115
|
+
returnTo: string | null;
|
|
116
|
+
/** The app's reference for the staff member, for assist/transfer claims. */
|
|
117
|
+
staffActorRef: string | null;
|
|
118
|
+
state: "pending" | "completed" | "expired" | "invalidated" | "refused";
|
|
119
|
+
refusalReason: string | null;
|
|
120
|
+
completedIdentityId: string | null;
|
|
121
|
+
/** Set by completeClaim; models the Clerk account that proved the contact. */
|
|
122
|
+
claimantClerkUserId: string | null;
|
|
123
|
+
/**
|
|
124
|
+
* Hosted claim link. Its path carries a one-time opaque handle, NOT the claim
|
|
125
|
+
* id — the platform never makes the customer's page derivable from an id an
|
|
126
|
+
* app already holds.
|
|
127
|
+
*/
|
|
128
|
+
claimUrl: string;
|
|
129
|
+
/** Whether the platform sent the link itself (only when `deliver` was asked). */
|
|
130
|
+
delivered: boolean | null;
|
|
131
|
+
expiresAt: string;
|
|
132
|
+
createdAt: string;
|
|
133
|
+
}
|
|
108
134
|
interface TestDirectory {
|
|
109
135
|
/** All identities, oldest first. */
|
|
110
136
|
all(): CapturedIdentity[];
|
|
@@ -126,6 +152,43 @@ interface TestDirectory {
|
|
|
126
152
|
* identity. The production integrations SDK does not expose merge operations.
|
|
127
153
|
*/
|
|
128
154
|
merge(sourceIdentityId: string, canonicalIdentityId: string): void;
|
|
155
|
+
/** Every profile-connection claim the app has opened, oldest first. */
|
|
156
|
+
claims(): CapturedIdentityClaim[];
|
|
157
|
+
/** A single claim by id, or undefined. */
|
|
158
|
+
claim(claimId: string): CapturedIdentityClaim | undefined;
|
|
159
|
+
/**
|
|
160
|
+
* Test setup only: turn on the org's staff-assistance opt-in. Without it,
|
|
161
|
+
* `claims.create` refuses `assist` and `transfer` with 403, like the platform.
|
|
162
|
+
*/
|
|
163
|
+
enableStaffAssist(enabled?: boolean): void;
|
|
164
|
+
/**
|
|
165
|
+
* Test setup only: model the platform failing to send a claim link it was
|
|
166
|
+
* asked to deliver (a suppressed quota, a bounced address). `deliver: true`
|
|
167
|
+
* then comes back `delivered: false` with the `claimUrl` still valid, so an
|
|
168
|
+
* app's own fallback delivery can be tested.
|
|
169
|
+
*/
|
|
170
|
+
suppressClaimDelivery(enabled?: boolean): void;
|
|
171
|
+
/**
|
|
172
|
+
* Test setup only: allow a claim `returnTo` on this origin. The harness app's
|
|
173
|
+
* own origin is allowed by default; anything else is dropped silently, like
|
|
174
|
+
* the platform's allowed-origins check.
|
|
175
|
+
*/
|
|
176
|
+
allowReturnOrigin(origin: string): void;
|
|
177
|
+
/**
|
|
178
|
+
* Test setup only: model the customer finishing the platform-hosted claim —
|
|
179
|
+
* Clerk proof plus their confirmation. Flips the claimed contact to verified,
|
|
180
|
+
* or records `invalidated` (the contact was removed from that profile) or
|
|
181
|
+
* `refused` with `contact_owned` (someone else already proved it). Replaying a
|
|
182
|
+
* finished claim returns the recorded result unchanged.
|
|
183
|
+
*
|
|
184
|
+
* It does NOT attach a login link or move history: the platform's merge core
|
|
185
|
+
* is not modelled here, only the ownership decision the app can observe.
|
|
186
|
+
*/
|
|
187
|
+
completeClaim(claimId: string, params: {
|
|
188
|
+
clerkUserId: string;
|
|
189
|
+
}): CapturedIdentityClaim;
|
|
190
|
+
/** Test setup only: model reception or the platform refusing a claim. */
|
|
191
|
+
refuseClaim(claimId: string, reason: string): CapturedIdentityClaim;
|
|
129
192
|
clear(): void;
|
|
130
193
|
get count(): number;
|
|
131
194
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -105,6 +105,32 @@ interface CapturedIdentityLink {
|
|
|
105
105
|
* the control plane that `client.identities` talks to in tests. Identities and
|
|
106
106
|
* links created by app code under test land here; assert on them like `inbox`.
|
|
107
107
|
*/
|
|
108
|
+
interface CapturedIdentityClaim {
|
|
109
|
+
claimId: string;
|
|
110
|
+
flow: "invite" | "assist" | "transfer";
|
|
111
|
+
/** The profile the claim connects the customer to. */
|
|
112
|
+
identityId: string;
|
|
113
|
+
kind: "email" | "phone";
|
|
114
|
+
externalId: string;
|
|
115
|
+
returnTo: string | null;
|
|
116
|
+
/** The app's reference for the staff member, for assist/transfer claims. */
|
|
117
|
+
staffActorRef: string | null;
|
|
118
|
+
state: "pending" | "completed" | "expired" | "invalidated" | "refused";
|
|
119
|
+
refusalReason: string | null;
|
|
120
|
+
completedIdentityId: string | null;
|
|
121
|
+
/** Set by completeClaim; models the Clerk account that proved the contact. */
|
|
122
|
+
claimantClerkUserId: string | null;
|
|
123
|
+
/**
|
|
124
|
+
* Hosted claim link. Its path carries a one-time opaque handle, NOT the claim
|
|
125
|
+
* id — the platform never makes the customer's page derivable from an id an
|
|
126
|
+
* app already holds.
|
|
127
|
+
*/
|
|
128
|
+
claimUrl: string;
|
|
129
|
+
/** Whether the platform sent the link itself (only when `deliver` was asked). */
|
|
130
|
+
delivered: boolean | null;
|
|
131
|
+
expiresAt: string;
|
|
132
|
+
createdAt: string;
|
|
133
|
+
}
|
|
108
134
|
interface TestDirectory {
|
|
109
135
|
/** All identities, oldest first. */
|
|
110
136
|
all(): CapturedIdentity[];
|
|
@@ -126,6 +152,43 @@ interface TestDirectory {
|
|
|
126
152
|
* identity. The production integrations SDK does not expose merge operations.
|
|
127
153
|
*/
|
|
128
154
|
merge(sourceIdentityId: string, canonicalIdentityId: string): void;
|
|
155
|
+
/** Every profile-connection claim the app has opened, oldest first. */
|
|
156
|
+
claims(): CapturedIdentityClaim[];
|
|
157
|
+
/** A single claim by id, or undefined. */
|
|
158
|
+
claim(claimId: string): CapturedIdentityClaim | undefined;
|
|
159
|
+
/**
|
|
160
|
+
* Test setup only: turn on the org's staff-assistance opt-in. Without it,
|
|
161
|
+
* `claims.create` refuses `assist` and `transfer` with 403, like the platform.
|
|
162
|
+
*/
|
|
163
|
+
enableStaffAssist(enabled?: boolean): void;
|
|
164
|
+
/**
|
|
165
|
+
* Test setup only: model the platform failing to send a claim link it was
|
|
166
|
+
* asked to deliver (a suppressed quota, a bounced address). `deliver: true`
|
|
167
|
+
* then comes back `delivered: false` with the `claimUrl` still valid, so an
|
|
168
|
+
* app's own fallback delivery can be tested.
|
|
169
|
+
*/
|
|
170
|
+
suppressClaimDelivery(enabled?: boolean): void;
|
|
171
|
+
/**
|
|
172
|
+
* Test setup only: allow a claim `returnTo` on this origin. The harness app's
|
|
173
|
+
* own origin is allowed by default; anything else is dropped silently, like
|
|
174
|
+
* the platform's allowed-origins check.
|
|
175
|
+
*/
|
|
176
|
+
allowReturnOrigin(origin: string): void;
|
|
177
|
+
/**
|
|
178
|
+
* Test setup only: model the customer finishing the platform-hosted claim —
|
|
179
|
+
* Clerk proof plus their confirmation. Flips the claimed contact to verified,
|
|
180
|
+
* or records `invalidated` (the contact was removed from that profile) or
|
|
181
|
+
* `refused` with `contact_owned` (someone else already proved it). Replaying a
|
|
182
|
+
* finished claim returns the recorded result unchanged.
|
|
183
|
+
*
|
|
184
|
+
* It does NOT attach a login link or move history: the platform's merge core
|
|
185
|
+
* is not modelled here, only the ownership decision the app can observe.
|
|
186
|
+
*/
|
|
187
|
+
completeClaim(claimId: string, params: {
|
|
188
|
+
clerkUserId: string;
|
|
189
|
+
}): CapturedIdentityClaim;
|
|
190
|
+
/** Test setup only: model reception or the platform refusing a claim. */
|
|
191
|
+
refuseClaim(claimId: string, reason: string): CapturedIdentityClaim;
|
|
129
192
|
clear(): void;
|
|
130
193
|
get count(): number;
|
|
131
194
|
}
|
package/dist/index.js
CHANGED
|
@@ -106,6 +106,31 @@ function globalSingleton(key, create) {
|
|
|
106
106
|
return holder[symbol];
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// src/constants.ts
|
|
110
|
+
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
111
|
+
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
112
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
113
|
+
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
114
|
+
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
115
|
+
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
116
|
+
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
117
|
+
var TEST_ENV_DEFAULTS = {
|
|
118
|
+
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
119
|
+
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
120
|
+
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
121
|
+
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
122
|
+
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
123
|
+
STORAGE_URL: STORAGE_TEST_URL
|
|
124
|
+
};
|
|
125
|
+
var DEFAULT_TEST_USER = {
|
|
126
|
+
id: "test-user-1",
|
|
127
|
+
email: "test-user@example.com",
|
|
128
|
+
name: "Test User",
|
|
129
|
+
role: "member",
|
|
130
|
+
permissions: []
|
|
131
|
+
};
|
|
132
|
+
var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
133
|
+
|
|
109
134
|
// src/state.ts
|
|
110
135
|
var state = globalSingleton("state", () => ({
|
|
111
136
|
db: null,
|
|
@@ -117,6 +142,10 @@ var state = globalSingleton("state", () => ({
|
|
|
117
142
|
emailCounter: 0,
|
|
118
143
|
identities: /* @__PURE__ */ new Map(),
|
|
119
144
|
identityLinks: [],
|
|
145
|
+
identityClaims: /* @__PURE__ */ new Map(),
|
|
146
|
+
identityStaffAssistEnabled: false,
|
|
147
|
+
identitySuppressClaimDelivery: false,
|
|
148
|
+
identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
|
|
120
149
|
checkouts: [],
|
|
121
150
|
checkoutCounter: 0,
|
|
122
151
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
@@ -158,30 +187,6 @@ function requireDb() {
|
|
|
158
187
|
return state.db;
|
|
159
188
|
}
|
|
160
189
|
|
|
161
|
-
// src/constants.ts
|
|
162
|
-
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
163
|
-
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
164
|
-
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
165
|
-
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
166
|
-
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
167
|
-
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
168
|
-
var TEST_ENV_DEFAULTS = {
|
|
169
|
-
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
170
|
-
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
171
|
-
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
172
|
-
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
173
|
-
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
174
|
-
STORAGE_URL: STORAGE_TEST_URL
|
|
175
|
-
};
|
|
176
|
-
var DEFAULT_TEST_USER = {
|
|
177
|
-
id: "test-user-1",
|
|
178
|
-
email: "test-user@example.com",
|
|
179
|
-
name: "Test User",
|
|
180
|
-
role: "member",
|
|
181
|
-
permissions: []
|
|
182
|
-
};
|
|
183
|
-
var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
184
|
-
|
|
185
190
|
// src/simulator/hmac.ts
|
|
186
191
|
var import_node_crypto = __toESM(require("crypto"));
|
|
187
192
|
var TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
@@ -232,8 +237,8 @@ function json(body, status = 200) {
|
|
|
232
237
|
function success(data) {
|
|
233
238
|
return json({ success: true, data });
|
|
234
239
|
}
|
|
235
|
-
function failure(error, status = 400) {
|
|
236
|
-
return json({ success: false, error }, status);
|
|
240
|
+
function failure(error, status = 400, code) {
|
|
241
|
+
return json({ success: false, error, ...code ? { code } : {} }, status);
|
|
237
242
|
}
|
|
238
243
|
async function readJsonBody(request) {
|
|
239
244
|
try {
|
|
@@ -721,6 +726,12 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
|
|
|
721
726
|
"dashboard_user"
|
|
722
727
|
]);
|
|
723
728
|
var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
|
|
729
|
+
var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
730
|
+
var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
|
|
731
|
+
var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
732
|
+
var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
|
|
733
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
734
|
+
var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
|
|
724
735
|
var E164_PHONE = /^\+[1-9]\d{1,14}$/;
|
|
725
736
|
var SEARCH_DEFAULT_LIMIT = 50;
|
|
726
737
|
var SEARCH_MAX_LIMIT = 100;
|
|
@@ -803,6 +814,11 @@ function matchingLinks(kind, externalId) {
|
|
|
803
814
|
(link) => link.kind === kind && comparableExternalId(link.kind, link.externalId) === comparable
|
|
804
815
|
);
|
|
805
816
|
}
|
|
817
|
+
function sortedMatchingLinks(kind, externalId) {
|
|
818
|
+
return matchingLinks(kind, externalId).sort(
|
|
819
|
+
(a, b) => Number(b.verified) - Number(a.verified) || a.createdAt.localeCompare(b.createdAt) || state.identityLinks.indexOf(a) - state.identityLinks.indexOf(b)
|
|
820
|
+
);
|
|
821
|
+
}
|
|
806
822
|
function normalizedLinkKey(link) {
|
|
807
823
|
return `${link.kind}:${comparableExternalId(link.kind, link.externalId)}`;
|
|
808
824
|
}
|
|
@@ -916,9 +932,10 @@ async function handleAttachLink(identityId, request) {
|
|
|
916
932
|
);
|
|
917
933
|
if ("error" in normalized) return failure(normalized.error);
|
|
918
934
|
const { kind, externalId } = normalized;
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
935
|
+
const matches = matchingLinks(kind, externalId);
|
|
936
|
+
const ownRow = matches.find((link2) => link2.identityId === identityId);
|
|
937
|
+
if (ownRow) return success({ link: ownRow });
|
|
938
|
+
if (!DUPLICABLE_UNVERIFIED_KINDS.has(kind) && matches.length > 0) {
|
|
922
939
|
return failure("identifier already linked to another identity", 409);
|
|
923
940
|
}
|
|
924
941
|
const link = createUnverifiedLink(identityId, kind, externalId);
|
|
@@ -935,7 +952,7 @@ async function handleResolve(request) {
|
|
|
935
952
|
if (body.displayName !== void 0 && typeof body.displayName !== "string") {
|
|
936
953
|
return failure("displayName must be a string");
|
|
937
954
|
}
|
|
938
|
-
const links =
|
|
955
|
+
const links = sortedMatchingLinks(normalized.kind, normalized.externalId);
|
|
939
956
|
if (links.length > 0) {
|
|
940
957
|
const canonicalIds = /* @__PURE__ */ new Set();
|
|
941
958
|
for (const link of links) {
|
|
@@ -946,12 +963,15 @@ async function handleResolve(request) {
|
|
|
946
963
|
return failure(`cannot resolve identity link: ${message}`, 409);
|
|
947
964
|
}
|
|
948
965
|
}
|
|
949
|
-
|
|
966
|
+
const verifiedOwners = new Set(
|
|
967
|
+
links.filter((link) => link.verified).map((link) => link.identityId)
|
|
968
|
+
);
|
|
969
|
+
if (verifiedOwners.size > 1 || !DUPLICABLE_UNVERIFIED_KINDS.has(normalized.kind) && canonicalIds.size !== 1) {
|
|
950
970
|
return failure("identifier is linked to multiple identities", 409);
|
|
951
971
|
}
|
|
952
|
-
const
|
|
953
|
-
const
|
|
954
|
-
if (provenance === "guest" &&
|
|
972
|
+
const [chosen] = links;
|
|
973
|
+
const canonicalId = canonicalIdentity(chosen.identityId).id;
|
|
974
|
+
if (provenance === "guest" && chosen.verified) {
|
|
955
975
|
return success({ identityId: null, reason: "verified_conflict" });
|
|
956
976
|
}
|
|
957
977
|
return success({ identityId: canonicalId, created: false });
|
|
@@ -1053,6 +1073,116 @@ async function handleAliases(request) {
|
|
|
1053
1073
|
}
|
|
1054
1074
|
return success({ aliases });
|
|
1055
1075
|
}
|
|
1076
|
+
function claimView(claim) {
|
|
1077
|
+
const state_ = claim.state === "pending" && Date.parse(claim.expiresAt) <= Date.now() ? "expired" : claim.state;
|
|
1078
|
+
return {
|
|
1079
|
+
claimId: claim.claimId,
|
|
1080
|
+
flow: claim.flow,
|
|
1081
|
+
state: state_,
|
|
1082
|
+
refusalReason: claim.refusalReason,
|
|
1083
|
+
completedIdentityId: claim.completedIdentityId,
|
|
1084
|
+
expiresAt: claim.expiresAt
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
function validateClaimStaff(flow, staff) {
|
|
1088
|
+
if (flow === "invite") {
|
|
1089
|
+
if (staff !== void 0) return { error: "staff is only accepted for assist or transfer" };
|
|
1090
|
+
return { staffActorRef: null };
|
|
1091
|
+
}
|
|
1092
|
+
if (!staff || typeof staff !== "object") {
|
|
1093
|
+
return { error: `staff is required for a ${flow} claim` };
|
|
1094
|
+
}
|
|
1095
|
+
const { actorRef, grant } = staff;
|
|
1096
|
+
if (typeof actorRef !== "string" || actorRef.length === 0) {
|
|
1097
|
+
return { error: "staff.actorRef is required" };
|
|
1098
|
+
}
|
|
1099
|
+
if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
|
|
1100
|
+
return { staffActorRef: actorRef };
|
|
1101
|
+
}
|
|
1102
|
+
async function handleCreateClaim(request) {
|
|
1103
|
+
const body = await readJsonBody(request);
|
|
1104
|
+
const flow = body.flow;
|
|
1105
|
+
if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
|
|
1106
|
+
return failure("flow must be one of: invite, assist, transfer");
|
|
1107
|
+
}
|
|
1108
|
+
if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
|
|
1109
|
+
return failure("identityId must be a uuid");
|
|
1110
|
+
}
|
|
1111
|
+
const normalized = normalizeLinkInput(
|
|
1112
|
+
body.kind,
|
|
1113
|
+
body.externalId,
|
|
1114
|
+
CLAIM_KINDS,
|
|
1115
|
+
"kind must be one of: email, phone"
|
|
1116
|
+
);
|
|
1117
|
+
if ("error" in normalized) return failure(normalized.error);
|
|
1118
|
+
let returnTo = null;
|
|
1119
|
+
if (body.returnTo !== void 0) {
|
|
1120
|
+
if (typeof body.returnTo !== "string") return failure("returnTo must be a string");
|
|
1121
|
+
let parsed;
|
|
1122
|
+
try {
|
|
1123
|
+
parsed = new URL(body.returnTo);
|
|
1124
|
+
} catch {
|
|
1125
|
+
return failure("returnTo must be an absolute URL");
|
|
1126
|
+
}
|
|
1127
|
+
returnTo = state.identityAllowedReturnOrigins.has(parsed.origin) ? body.returnTo : null;
|
|
1128
|
+
}
|
|
1129
|
+
if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
|
|
1130
|
+
return failure("deliver must be a boolean");
|
|
1131
|
+
}
|
|
1132
|
+
const staff = validateClaimStaff(flow, body.staff);
|
|
1133
|
+
if ("error" in staff) return failure(staff.error);
|
|
1134
|
+
if (flow !== "invite" && !state.identityStaffAssistEnabled) {
|
|
1135
|
+
return failure(
|
|
1136
|
+
"staff-assisted profile claiming is not enabled for this project",
|
|
1137
|
+
403,
|
|
1138
|
+
STAFF_ASSISTANCE_NOT_ENABLED
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
const identity = state.identities.get(body.identityId);
|
|
1142
|
+
if (!identity || identity.type !== "person" || identity.status !== "active") {
|
|
1143
|
+
return failure("identity not found", 404);
|
|
1144
|
+
}
|
|
1145
|
+
const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
|
|
1146
|
+
(link) => link.identityId === identity.id
|
|
1147
|
+
);
|
|
1148
|
+
if (!holdsContact) {
|
|
1149
|
+
return failure("identity does not hold that contact", 409);
|
|
1150
|
+
}
|
|
1151
|
+
const claimId = import_node_crypto3.default.randomUUID();
|
|
1152
|
+
const deliver = body.deliver === true;
|
|
1153
|
+
const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
|
|
1154
|
+
const claim = {
|
|
1155
|
+
claimId,
|
|
1156
|
+
flow,
|
|
1157
|
+
identityId: identity.id,
|
|
1158
|
+
kind: normalized.kind,
|
|
1159
|
+
externalId: normalized.externalId,
|
|
1160
|
+
returnTo,
|
|
1161
|
+
staffActorRef: staff.staffActorRef,
|
|
1162
|
+
state: "pending",
|
|
1163
|
+
refusalReason: null,
|
|
1164
|
+
completedIdentityId: null,
|
|
1165
|
+
claimantClerkUserId: null,
|
|
1166
|
+
claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
|
|
1167
|
+
delivered: deliver ? !state.identitySuppressClaimDelivery : null,
|
|
1168
|
+
expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
|
|
1169
|
+
createdAt: now()
|
|
1170
|
+
};
|
|
1171
|
+
state.identityClaims.set(claimId, claim);
|
|
1172
|
+
return success({
|
|
1173
|
+
claimId,
|
|
1174
|
+
claimUrl: claim.claimUrl,
|
|
1175
|
+
expiresAt: claim.expiresAt,
|
|
1176
|
+
// Absent unless asked for: an app delivering its own link must not read a
|
|
1177
|
+
// missing field as "nothing was sent".
|
|
1178
|
+
...deliver ? { delivered: claim.delivered } : {}
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
function handleGetClaim(claimId) {
|
|
1182
|
+
const claim = state.identityClaims.get(claimId);
|
|
1183
|
+
if (!claim) return failure("claim not found", 404);
|
|
1184
|
+
return success(claimView(claim));
|
|
1185
|
+
}
|
|
1056
1186
|
async function handleIdentitiesRequest(request, subPath) {
|
|
1057
1187
|
const method = request.method;
|
|
1058
1188
|
if (subPath === "" || subPath === "/") {
|
|
@@ -1068,6 +1198,13 @@ async function handleIdentitiesRequest(request, subPath) {
|
|
|
1068
1198
|
if (subPath === "/search" && method === "GET") {
|
|
1069
1199
|
return handleSearch(request);
|
|
1070
1200
|
}
|
|
1201
|
+
if (subPath === "/claims" && method === "POST") {
|
|
1202
|
+
return handleCreateClaim(request);
|
|
1203
|
+
}
|
|
1204
|
+
const claimMatch = subPath.match(/^\/claims\/([^/]+)$/);
|
|
1205
|
+
if (claimMatch && method === "GET") {
|
|
1206
|
+
return handleGetClaim(decodeURIComponent(claimMatch[1]));
|
|
1207
|
+
}
|
|
1071
1208
|
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
1072
1209
|
if (linksMatch && method === "POST") {
|
|
1073
1210
|
return handleAttachLink(linksMatch[1], request);
|
|
@@ -1092,11 +1229,13 @@ function seedVerifiedLink(identityId, params) {
|
|
|
1092
1229
|
);
|
|
1093
1230
|
if ("error" in normalized) throw new Error(normalized.error);
|
|
1094
1231
|
const { kind, externalId } = normalized;
|
|
1095
|
-
const
|
|
1232
|
+
const matches = matchingLinks(kind, externalId);
|
|
1233
|
+
const blocker = matches.find(
|
|
1234
|
+
(link2) => link2.identityId !== identityId && (link2.verified || !DUPLICABLE_UNVERIFIED_KINDS.has(link2.kind))
|
|
1235
|
+
);
|
|
1236
|
+
if (blocker) throw new Error("identifier already linked to another identity");
|
|
1237
|
+
const existing = matches.find((link2) => link2.identityId === identityId);
|
|
1096
1238
|
if (existing) {
|
|
1097
|
-
if (existing.identityId !== identityId) {
|
|
1098
|
-
throw new Error("identifier already linked to another identity");
|
|
1099
|
-
}
|
|
1100
1239
|
existing.verified = true;
|
|
1101
1240
|
return existing;
|
|
1102
1241
|
}
|
|
@@ -1126,7 +1265,12 @@ function merge(sourceIdentityId, canonicalIdentityId) {
|
|
|
1126
1265
|
);
|
|
1127
1266
|
for (const sourceLink of sourceLinks) {
|
|
1128
1267
|
const key = normalizedLinkKey(sourceLink);
|
|
1129
|
-
|
|
1268
|
+
const conflicting = otherLinks.some(
|
|
1269
|
+
(otherLink) => normalizedLinkKey(otherLink) === key && // Same partial rule as the index: a third identity's UNVERIFIED copy of a
|
|
1270
|
+
// shared email/phone is allowed to coexist and must not block the merge.
|
|
1271
|
+
(!DUPLICABLE_UNVERIFIED_KINDS.has(key.split(":")[0]) || otherLink.verified && sourceLink.verified)
|
|
1272
|
+
);
|
|
1273
|
+
if (conflicting) {
|
|
1130
1274
|
throw new Error("merge would conflict with a link owned by another identity");
|
|
1131
1275
|
}
|
|
1132
1276
|
}
|
|
@@ -1153,6 +1297,45 @@ function merge(sourceIdentityId, canonicalIdentityId) {
|
|
|
1153
1297
|
source.mergedIntoId = canonical.id;
|
|
1154
1298
|
source.updatedAt = now();
|
|
1155
1299
|
}
|
|
1300
|
+
function requireClaim(claimId) {
|
|
1301
|
+
const claim = state.identityClaims.get(claimId);
|
|
1302
|
+
if (!claim) throw new Error("claim not found");
|
|
1303
|
+
return claim;
|
|
1304
|
+
}
|
|
1305
|
+
function completeClaim(claimId, params) {
|
|
1306
|
+
const claim = requireClaim(claimId);
|
|
1307
|
+
if (claim.state !== "pending") return claim;
|
|
1308
|
+
if (Date.parse(claim.expiresAt) <= Date.now()) {
|
|
1309
|
+
claim.state = "expired";
|
|
1310
|
+
return claim;
|
|
1311
|
+
}
|
|
1312
|
+
if (!params.clerkUserId) throw new Error("clerkUserId is required");
|
|
1313
|
+
const rows = matchingLinks(claim.kind, claim.externalId);
|
|
1314
|
+
const target = rows.find((link) => link.identityId === claim.identityId);
|
|
1315
|
+
if (!target) {
|
|
1316
|
+
claim.state = "invalidated";
|
|
1317
|
+
return claim;
|
|
1318
|
+
}
|
|
1319
|
+
const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
|
|
1320
|
+
if (otherVerified) {
|
|
1321
|
+
claim.state = "refused";
|
|
1322
|
+
claim.refusalReason = "contact_owned";
|
|
1323
|
+
return claim;
|
|
1324
|
+
}
|
|
1325
|
+
target.verified = true;
|
|
1326
|
+
claim.state = "completed";
|
|
1327
|
+
claim.completedIdentityId = claim.identityId;
|
|
1328
|
+
claim.claimantClerkUserId = params.clerkUserId;
|
|
1329
|
+
return claim;
|
|
1330
|
+
}
|
|
1331
|
+
function refuseClaim(claimId, reason) {
|
|
1332
|
+
const claim = requireClaim(claimId);
|
|
1333
|
+
if (claim.state !== "pending") return claim;
|
|
1334
|
+
if (!reason) throw new Error("a refusal reason is required");
|
|
1335
|
+
claim.state = "refused";
|
|
1336
|
+
claim.refusalReason = reason;
|
|
1337
|
+
return claim;
|
|
1338
|
+
}
|
|
1156
1339
|
function createDirectory() {
|
|
1157
1340
|
return {
|
|
1158
1341
|
all: () => [...state.identities.values()],
|
|
@@ -1160,9 +1343,26 @@ function createDirectory() {
|
|
|
1160
1343
|
links: (identityId) => linksFor(identityId),
|
|
1161
1344
|
seedVerifiedLink,
|
|
1162
1345
|
merge,
|
|
1346
|
+
claims: () => [...state.identityClaims.values()],
|
|
1347
|
+
claim: (claimId) => state.identityClaims.get(claimId),
|
|
1348
|
+
enableStaffAssist: (enabled = true) => {
|
|
1349
|
+
state.identityStaffAssistEnabled = enabled;
|
|
1350
|
+
},
|
|
1351
|
+
suppressClaimDelivery: (enabled = true) => {
|
|
1352
|
+
state.identitySuppressClaimDelivery = enabled;
|
|
1353
|
+
},
|
|
1354
|
+
allowReturnOrigin: (origin) => {
|
|
1355
|
+
state.identityAllowedReturnOrigins.add(new URL(origin).origin);
|
|
1356
|
+
},
|
|
1357
|
+
completeClaim,
|
|
1358
|
+
refuseClaim,
|
|
1163
1359
|
clear: () => {
|
|
1164
1360
|
state.identities.clear();
|
|
1165
1361
|
state.identityLinks = [];
|
|
1362
|
+
state.identityClaims.clear();
|
|
1363
|
+
state.identityStaffAssistEnabled = false;
|
|
1364
|
+
state.identitySuppressClaimDelivery = false;
|
|
1365
|
+
state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
|
|
1166
1366
|
},
|
|
1167
1367
|
get count() {
|
|
1168
1368
|
return state.identities.size;
|
|
@@ -1201,7 +1401,7 @@ async function importNextServer() {
|
|
|
1201
1401
|
async function callRoute(handler, options = {}) {
|
|
1202
1402
|
const { NextRequest } = await importNextServer();
|
|
1203
1403
|
const path = options.path ?? "/api/test-route";
|
|
1204
|
-
const url = new URL(
|
|
1404
|
+
const url = new URL(`${APP_TEST_ORIGIN}${path}`);
|
|
1205
1405
|
for (const [key, value] of Object.entries(options.searchParams ?? {})) {
|
|
1206
1406
|
url.searchParams.set(key, value);
|
|
1207
1407
|
}
|
|
@@ -2976,8 +3176,7 @@ async function createTestApp(options = {}) {
|
|
|
2976
3176
|
state.refreshSessions.clear();
|
|
2977
3177
|
state.emails = [];
|
|
2978
3178
|
state.emailCounter = 0;
|
|
2979
|
-
|
|
2980
|
-
state.identityLinks = [];
|
|
3179
|
+
directory.clear();
|
|
2981
3180
|
payments.clear();
|
|
2982
3181
|
storage.clear();
|
|
2983
3182
|
messages.clear();
|
|
@@ -2991,8 +3190,7 @@ async function createTestApp(options = {}) {
|
|
|
2991
3190
|
state.refreshSessions.clear();
|
|
2992
3191
|
state.emails = [];
|
|
2993
3192
|
state.emailCounter = 0;
|
|
2994
|
-
|
|
2995
|
-
state.identityLinks = [];
|
|
3193
|
+
directory.clear();
|
|
2996
3194
|
payments.clear();
|
|
2997
3195
|
storage.clear();
|
|
2998
3196
|
messages.clear();
|