@stardeck-customer-apps/testing 0.16.0 → 0.17.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 +34 -4
- package/dist/index.d.mts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +190 -44
- package/dist/index.mjs +190 -44
- package/dist/next/headers-shim.js +1 -0
- package/dist/next/headers-shim.mjs +1 -0
- package/dist/setup.js +142 -44
- package/dist/setup.mjs +142 -44
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -350,8 +350,9 @@ externalId })` helper models a trusted platform/channel link, and
|
|
|
350
350
|
platform turning one down. `completeClaim` flips the claimed contact to
|
|
351
351
|
`verified`, or records `invalidated` when the contact is no longer on that
|
|
352
352
|
profile and `refused` / `contact_owned` when someone else already proved it.
|
|
353
|
-
Completing a finished claim replays the recorded result.
|
|
354
|
-
login
|
|
353
|
+
Completing a finished claim replays the recorded result. Completion attaches
|
|
354
|
+
the claimant's login to the profile (moving it off any profile it was on), but
|
|
355
|
+
does not move history — the platform's merge core is not modelled.
|
|
355
356
|
`.allowReturnOrigin(origin)` widens the allowed `returnTo` origins (the test
|
|
356
357
|
app's own origin is allowed by default); a `returnTo` anywhere else is dropped
|
|
357
358
|
silently, exactly as the platform drops it. `claimUrl` carries a one-time
|
|
@@ -359,7 +360,22 @@ externalId })` helper models a trusted platform/channel link, and
|
|
|
359
360
|
comes back as `delivered`. `.suppressClaimDelivery()` models the platform
|
|
360
361
|
sending nothing (a suppressed quota, a bounced address): `deliver: true` then
|
|
361
362
|
returns `delivered: false` with the `claimUrl` still valid, so the app's own
|
|
362
|
-
fallback delivery can be tested.
|
|
363
|
+
fallback delivery can be tested. Bearer links are on by default, like the platform;
|
|
364
|
+
`.enableBearerLinks(false)` models a project whose admin turned them off. A bearer claim is raised against a
|
|
365
|
+
profile with no contact at all, so it takes no `kind`/`externalId`/`deliver`
|
|
366
|
+
(sending one is refused, not ignored) and `completeClaim` connects whoever
|
|
367
|
+
completes it — unless another login already holds that profile, which comes
|
|
368
|
+
back `refused` / `owned_elsewhere`. That refusal is the one a leaked bearer
|
|
369
|
+
link runs into, so test it. A profile that holds an email or phone is refused
|
|
370
|
+
at `create` with a 409 (send an `invite`); one that gains a contact before
|
|
371
|
+
the link is used completes as `refused` / `protected`, and a link still
|
|
372
|
+
outstanding when `.enableBearerLinks(false)` runs completes as `refused` /
|
|
373
|
+
`expired`. People seeded with only a `dashboard_user` link (team members) are
|
|
374
|
+
left out of `identities.list()` and `search()`, as on the platform.
|
|
375
|
+
`search({ attributes })` matches profile attributes exactly and by JSON type
|
|
376
|
+
(`5` never matches `"5"`) and rejects nested, array, `null` or more than 10
|
|
377
|
+
keys (or a `__proto__` key) with a `ValidationError` whose message matches the
|
|
378
|
+
platform's, like the platform.
|
|
363
379
|
|
|
364
380
|
```ts
|
|
365
381
|
const claim = await integrations.identities.claims.create({
|
|
@@ -372,6 +388,17 @@ externalId })` helper models a trusted platform/channel link, and
|
|
|
372
388
|
expect((await integrations.identities.claims.get(claim.claimId)).state).toBe("completed");
|
|
373
389
|
```
|
|
374
390
|
|
|
391
|
+
```ts
|
|
392
|
+
// A name on a card, no email or phone: the link IS the authorisation.
|
|
393
|
+
const bearer = await integrations.identities.claims.create({
|
|
394
|
+
flow: "bearer",
|
|
395
|
+
identityId: walkIn.id,
|
|
396
|
+
});
|
|
397
|
+
expect(app.identities.completeClaim(bearer.claimId, { clerkUserId: "user_2" }).state).toBe(
|
|
398
|
+
"completed"
|
|
399
|
+
);
|
|
400
|
+
```
|
|
401
|
+
|
|
375
402
|
- `app.payments` — checkouts created through payments-sdk:
|
|
376
403
|
`.checkouts`, `.latest()`, `.setProducts()`, `.markPaid(id)`,
|
|
377
404
|
`.setSessionStatus(id, status)`, `.setPaymentLinkStatus(id, status)`,
|
|
@@ -450,7 +477,10 @@ true })` remains source-compatible but the simulator (like the control plane)
|
|
|
450
477
|
copies still succeed, and `resolveOrCreate` prefers the verified owner and
|
|
451
478
|
otherwise the earliest unverified holder. Channel and login kinds stay
|
|
452
479
|
single-owner and 409 on a second holder. `update` replaces the
|
|
453
|
-
`profile` object (not a merge), like the control plane.
|
|
480
|
+
`profile` object (not a merge), like the control plane. A person with a
|
|
481
|
+
seeded `dashboard_user` link is a team member: changing their `displayName`
|
|
482
|
+
or `profile.email` is refused with a 409 `ValidationError`, and a new
|
|
483
|
+
`profile` without `email` keeps the current one, as on the platform. Inspect via
|
|
454
484
|
`app.identities`; merge/archive are privileged setup operations, with only
|
|
455
485
|
test-only merge modeling exposed above.
|
|
456
486
|
- `session.user.identityId` is the platform-provisioned cross-app customer key.
|
package/dist/index.d.mts
CHANGED
|
@@ -107,11 +107,12 @@ interface CapturedIdentityLink {
|
|
|
107
107
|
*/
|
|
108
108
|
interface CapturedIdentityClaim {
|
|
109
109
|
claimId: string;
|
|
110
|
-
flow: "invite" | "assist" | "transfer";
|
|
110
|
+
flow: "invite" | "assist" | "transfer" | "bearer";
|
|
111
111
|
/** The profile the claim connects the customer to. */
|
|
112
112
|
identityId: string;
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
/** The recorded contact this claim was raised against. Null for `bearer` only. */
|
|
114
|
+
kind: "email" | "phone" | null;
|
|
115
|
+
externalId: string | null;
|
|
115
116
|
returnTo: string | null;
|
|
116
117
|
/** The app's reference for the staff member, for assist/transfer claims. */
|
|
117
118
|
staffActorRef: string | null;
|
|
@@ -161,6 +162,12 @@ interface TestDirectory {
|
|
|
161
162
|
* `claims.create` refuses `assist` and `transfer` with 403, like the platform.
|
|
162
163
|
*/
|
|
163
164
|
enableStaffAssist(enabled?: boolean): void;
|
|
165
|
+
/**
|
|
166
|
+
* Test setup only: set the project's bearer-link opt-in. It is on by default,
|
|
167
|
+
* like the platform; pass `false` to model an org that switched it off, and
|
|
168
|
+
* `claims.create({ flow: "bearer" })` is then refused with 403.
|
|
169
|
+
*/
|
|
170
|
+
enableBearerLinks(enabled?: boolean): void;
|
|
164
171
|
/**
|
|
165
172
|
* Test setup only: model the platform failing to send a claim link it was
|
|
166
173
|
* asked to deliver (a suppressed quota, a bounced address). `deliver: true`
|
package/dist/index.d.ts
CHANGED
|
@@ -107,11 +107,12 @@ interface CapturedIdentityLink {
|
|
|
107
107
|
*/
|
|
108
108
|
interface CapturedIdentityClaim {
|
|
109
109
|
claimId: string;
|
|
110
|
-
flow: "invite" | "assist" | "transfer";
|
|
110
|
+
flow: "invite" | "assist" | "transfer" | "bearer";
|
|
111
111
|
/** The profile the claim connects the customer to. */
|
|
112
112
|
identityId: string;
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
/** The recorded contact this claim was raised against. Null for `bearer` only. */
|
|
114
|
+
kind: "email" | "phone" | null;
|
|
115
|
+
externalId: string | null;
|
|
115
116
|
returnTo: string | null;
|
|
116
117
|
/** The app's reference for the staff member, for assist/transfer claims. */
|
|
117
118
|
staffActorRef: string | null;
|
|
@@ -161,6 +162,12 @@ interface TestDirectory {
|
|
|
161
162
|
* `claims.create` refuses `assist` and `transfer` with 403, like the platform.
|
|
162
163
|
*/
|
|
163
164
|
enableStaffAssist(enabled?: boolean): void;
|
|
165
|
+
/**
|
|
166
|
+
* Test setup only: set the project's bearer-link opt-in. It is on by default,
|
|
167
|
+
* like the platform; pass `false` to model an org that switched it off, and
|
|
168
|
+
* `claims.create({ flow: "bearer" })` is then refused with 403.
|
|
169
|
+
*/
|
|
170
|
+
enableBearerLinks(enabled?: boolean): void;
|
|
164
171
|
/**
|
|
165
172
|
* Test setup only: model the platform failing to send a claim link it was
|
|
166
173
|
* asked to deliver (a suppressed quota, a bounced address). `deliver: true`
|
package/dist/index.js
CHANGED
|
@@ -144,6 +144,7 @@ var state = globalSingleton("state", () => ({
|
|
|
144
144
|
identityLinks: [],
|
|
145
145
|
identityClaims: /* @__PURE__ */ new Map(),
|
|
146
146
|
identityStaffAssistEnabled: false,
|
|
147
|
+
identityBearerLinksEnabled: true,
|
|
147
148
|
identitySuppressClaimDelivery: false,
|
|
148
149
|
identityAllowedReturnOrigins: /* @__PURE__ */ new Set([APP_TEST_ORIGIN]),
|
|
149
150
|
checkouts: [],
|
|
@@ -727,14 +728,16 @@ var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
|
|
|
727
728
|
]);
|
|
728
729
|
var RESOLVE_KINDS = /* @__PURE__ */ new Set(["phone", "email", "line"]);
|
|
729
730
|
var CLAIM_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
730
|
-
var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer"]);
|
|
731
|
+
var CLAIM_FLOWS = /* @__PURE__ */ new Set(["invite", "assist", "transfer", "bearer"]);
|
|
731
732
|
var DUPLICABLE_UNVERIFIED_KINDS = /* @__PURE__ */ new Set(["email", "phone"]);
|
|
732
733
|
var CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
|
|
733
734
|
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
734
735
|
var STAFF_ASSISTANCE_NOT_ENABLED = "STAFF_ASSISTANCE_NOT_ENABLED";
|
|
736
|
+
var BEARER_LINKS_NOT_ENABLED = "BEARER_LINKS_NOT_ENABLED";
|
|
735
737
|
var E164_PHONE = /^\+[1-9]\d{1,14}$/;
|
|
736
738
|
var SEARCH_DEFAULT_LIMIT = 50;
|
|
737
739
|
var SEARCH_MAX_LIMIT = 100;
|
|
740
|
+
var TEAM_ACCOUNT_FIELDS_MESSAGE = "This person's name and email come from their team account. Change them in Members settings.";
|
|
738
741
|
function asciiLowercaseTrim(value) {
|
|
739
742
|
const trimmed = value.trim();
|
|
740
743
|
let out = "";
|
|
@@ -854,11 +857,21 @@ function createUnverifiedLink(identityId, kind, externalId) {
|
|
|
854
857
|
state.identityLinks.push(link);
|
|
855
858
|
return link;
|
|
856
859
|
}
|
|
860
|
+
function isTeamOnly(identityId) {
|
|
861
|
+
const kinds = linksFor(identityId).map((link) => link.kind);
|
|
862
|
+
return kinds.includes("dashboard_user") && !kinds.includes("project_auth_user");
|
|
863
|
+
}
|
|
864
|
+
function isCurrentTeamMember(identityId) {
|
|
865
|
+
return linksFor(identityId).some((link) => link.kind === "dashboard_user" && link.verified);
|
|
866
|
+
}
|
|
867
|
+
function hasRecordedContact(identityId) {
|
|
868
|
+
return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
|
|
869
|
+
}
|
|
857
870
|
function handleList(request) {
|
|
858
871
|
const typeParam = new URL(request.url).searchParams.get("type");
|
|
859
872
|
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
860
873
|
const identities = [...state.identities.values()].filter(
|
|
861
|
-
(identity) => identity.status !== "merged"
|
|
874
|
+
(identity) => identity.status !== "merged" && !isTeamOnly(identity.id)
|
|
862
875
|
);
|
|
863
876
|
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
864
877
|
}
|
|
@@ -902,11 +915,18 @@ async function handleUpdate(identityId, request) {
|
|
|
902
915
|
const identity = state.identities.get(identityId);
|
|
903
916
|
if (!identity) return failure("identity not found", 404);
|
|
904
917
|
const body = await readJsonBody(request);
|
|
918
|
+
const profile = body.profile;
|
|
919
|
+
if (isCurrentTeamMember(identityId)) {
|
|
920
|
+
const changesName = body.displayName !== void 0 && body.displayName !== identity.displayName;
|
|
921
|
+
const changesEmail = profile !== void 0 && Object.hasOwn(profile, "email") && profile.email !== identity.profile.email;
|
|
922
|
+
if (changesName || changesEmail) return failure(TEAM_ACCOUNT_FIELDS_MESSAGE, 409);
|
|
923
|
+
}
|
|
905
924
|
if (body.displayName !== void 0) {
|
|
906
925
|
identity.displayName = body.displayName;
|
|
907
926
|
}
|
|
908
|
-
if (
|
|
909
|
-
|
|
927
|
+
if (profile !== void 0) {
|
|
928
|
+
const keptEmail = isCurrentTeamMember(identityId) && !Object.hasOwn(profile, "email") && Object.hasOwn(identity.profile, "email");
|
|
929
|
+
identity.profile = keptEmail ? { ...profile, email: identity.profile.email } : profile;
|
|
910
930
|
}
|
|
911
931
|
if (body.externalRef !== void 0) {
|
|
912
932
|
identity.externalRef = body.externalRef;
|
|
@@ -1008,6 +1028,36 @@ function identityMatchesQuery(identity, query) {
|
|
|
1008
1028
|
(link) => link.identityId === identity.id && comparableExternalId(link.kind, link.externalId).toLowerCase().includes(query)
|
|
1009
1029
|
);
|
|
1010
1030
|
}
|
|
1031
|
+
function parseSearchAttributes(raw) {
|
|
1032
|
+
if (raw === null) return {};
|
|
1033
|
+
let parsed;
|
|
1034
|
+
try {
|
|
1035
|
+
parsed = JSON.parse(raw);
|
|
1036
|
+
} catch {
|
|
1037
|
+
return "attributes: attributes must be a JSON object";
|
|
1038
|
+
}
|
|
1039
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1040
|
+
return "attributes: attributes must be a JSON object";
|
|
1041
|
+
}
|
|
1042
|
+
if (Object.hasOwn(parsed, "__proto__")) return "attributes: attribute keys may not be __proto__";
|
|
1043
|
+
const entries = Object.entries(parsed);
|
|
1044
|
+
if (entries.length > 10) return "attributes: attributes may filter on at most 10 keys";
|
|
1045
|
+
for (const [key, value] of entries) {
|
|
1046
|
+
if (!["string", "number", "boolean"].includes(typeof value)) {
|
|
1047
|
+
return `attributes.${key}: attribute values must be a string, number or boolean`;
|
|
1048
|
+
}
|
|
1049
|
+
if (key.length < 1 || key.length > 100) {
|
|
1050
|
+
return `attributes.${key}: attribute keys must be 1-100 characters`;
|
|
1051
|
+
}
|
|
1052
|
+
if (typeof value === "string" && value.length > 500) {
|
|
1053
|
+
return `attributes.${key}: attribute values must be at most 500 characters`;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return parsed;
|
|
1057
|
+
}
|
|
1058
|
+
function identityMatchesAttributes(identity, attributes) {
|
|
1059
|
+
return Object.entries(attributes).every(([key, value]) => identity.profile[key] === value);
|
|
1060
|
+
}
|
|
1011
1061
|
function handleSearch(request) {
|
|
1012
1062
|
const url = new URL(request.url);
|
|
1013
1063
|
const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
|
|
@@ -1020,10 +1070,14 @@ function handleSearch(request) {
|
|
|
1020
1070
|
return failure(`limit must be between 1 and ${SEARCH_MAX_LIMIT}`);
|
|
1021
1071
|
}
|
|
1022
1072
|
}
|
|
1073
|
+
const attributes = parseSearchAttributes(url.searchParams.get("attributes"));
|
|
1074
|
+
if (typeof attributes === "string") {
|
|
1075
|
+
return json({ success: false, error: "Invalid query parameters", details: attributes }, 400);
|
|
1076
|
+
}
|
|
1023
1077
|
const rawCursor = url.searchParams.get("cursor");
|
|
1024
1078
|
const cursor = rawCursor ? decodeSearchCursor(rawCursor, query) : null;
|
|
1025
1079
|
if (rawCursor && !cursor) return failure("cursor is invalid");
|
|
1026
|
-
const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => identityMatchesQuery(identity, query)).sort(compareIdentityOrder);
|
|
1080
|
+
const identities = [...state.identities.values()].filter((identity) => identity.status === "active" && identity.mergedIntoId === null).filter((identity) => !isTeamOnly(identity.id)).filter((identity) => identityMatchesQuery(identity, query)).filter((identity) => identityMatchesAttributes(identity, attributes)).sort(compareIdentityOrder);
|
|
1027
1081
|
const start = cursor ? identities.findIndex((identity) => compareIdentityOrder(identity, cursor) > 0) : 0;
|
|
1028
1082
|
const pageStart = start < 0 ? identities.length : start;
|
|
1029
1083
|
const items = identities.slice(pageStart, pageStart + limit);
|
|
@@ -1099,15 +1153,92 @@ function validateClaimStaff(flow, staff) {
|
|
|
1099
1153
|
if (grant !== flow) return { error: `staff.grant must be '${flow}' for a ${flow} claim` };
|
|
1100
1154
|
return { staffActorRef: actorRef };
|
|
1101
1155
|
}
|
|
1156
|
+
function parseReturnTo(raw) {
|
|
1157
|
+
if (raw === void 0) return { returnTo: null };
|
|
1158
|
+
if (typeof raw !== "string") return { error: "returnTo must be a string" };
|
|
1159
|
+
let parsed;
|
|
1160
|
+
try {
|
|
1161
|
+
parsed = new URL(raw);
|
|
1162
|
+
} catch {
|
|
1163
|
+
return { error: "returnTo must be an absolute URL" };
|
|
1164
|
+
}
|
|
1165
|
+
return { returnTo: state.identityAllowedReturnOrigins.has(parsed.origin) ? raw : null };
|
|
1166
|
+
}
|
|
1167
|
+
function claimableIdentity(identityId) {
|
|
1168
|
+
const identity = state.identities.get(identityId);
|
|
1169
|
+
if (!identity || identity.type !== "person" || identity.status !== "active") return null;
|
|
1170
|
+
return identity;
|
|
1171
|
+
}
|
|
1172
|
+
function mintClaim(params) {
|
|
1173
|
+
const claimId = import_node_crypto3.default.randomUUID();
|
|
1174
|
+
const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
|
|
1175
|
+
const claim = {
|
|
1176
|
+
claimId,
|
|
1177
|
+
flow: params.flow,
|
|
1178
|
+
identityId: params.identityId,
|
|
1179
|
+
kind: params.kind,
|
|
1180
|
+
externalId: params.externalId,
|
|
1181
|
+
returnTo: params.returnTo,
|
|
1182
|
+
staffActorRef: params.staffActorRef,
|
|
1183
|
+
state: "pending",
|
|
1184
|
+
refusalReason: null,
|
|
1185
|
+
completedIdentityId: null,
|
|
1186
|
+
claimantClerkUserId: null,
|
|
1187
|
+
claimUrl: `${CONTROL_PLANE_TEST_URL}/claim/${handle}`,
|
|
1188
|
+
delivered: params.deliver ? !state.identitySuppressClaimDelivery : null,
|
|
1189
|
+
expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(),
|
|
1190
|
+
createdAt: now()
|
|
1191
|
+
};
|
|
1192
|
+
state.identityClaims.set(claimId, claim);
|
|
1193
|
+
return success({
|
|
1194
|
+
claimId,
|
|
1195
|
+
claimUrl: claim.claimUrl,
|
|
1196
|
+
expiresAt: claim.expiresAt,
|
|
1197
|
+
// Absent unless asked for: an app delivering its own link must not read a
|
|
1198
|
+
// missing field as "nothing was sent".
|
|
1199
|
+
...params.deliver ? { delivered: claim.delivered } : {}
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
function createBearerClaim(body, returnTo) {
|
|
1203
|
+
for (const field of ["kind", "externalId", "deliver", "staff"]) {
|
|
1204
|
+
if (body[field] !== void 0) {
|
|
1205
|
+
return failure(`a bearer claim takes no ${field}: it is raised against no contact`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
if (!state.identityBearerLinksEnabled) {
|
|
1209
|
+
return failure(
|
|
1210
|
+
"profile invite links are not enabled for this project",
|
|
1211
|
+
403,
|
|
1212
|
+
BEARER_LINKS_NOT_ENABLED
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
const identity = claimableIdentity(body.identityId);
|
|
1216
|
+
if (!identity) return failure("identity not found", 404);
|
|
1217
|
+
if (hasRecordedContact(identity.id)) {
|
|
1218
|
+
return failure("identity has an email or phone on record; send an invite claim instead", 409);
|
|
1219
|
+
}
|
|
1220
|
+
return mintClaim({
|
|
1221
|
+
flow: "bearer",
|
|
1222
|
+
identityId: identity.id,
|
|
1223
|
+
kind: null,
|
|
1224
|
+
externalId: null,
|
|
1225
|
+
returnTo,
|
|
1226
|
+
staffActorRef: null,
|
|
1227
|
+
deliver: false
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1102
1230
|
async function handleCreateClaim(request) {
|
|
1103
1231
|
const body = await readJsonBody(request);
|
|
1104
1232
|
const flow = body.flow;
|
|
1105
1233
|
if (typeof flow !== "string" || !CLAIM_FLOWS.has(flow)) {
|
|
1106
|
-
return failure("flow must be one of: invite, assist, transfer");
|
|
1234
|
+
return failure("flow must be one of: invite, assist, transfer, bearer");
|
|
1107
1235
|
}
|
|
1108
1236
|
if (typeof body.identityId !== "string" || !UUID.test(body.identityId)) {
|
|
1109
1237
|
return failure("identityId must be a uuid");
|
|
1110
1238
|
}
|
|
1239
|
+
const parsedReturnTo = parseReturnTo(body.returnTo);
|
|
1240
|
+
if ("error" in parsedReturnTo) return failure(parsedReturnTo.error);
|
|
1241
|
+
if (flow === "bearer") return createBearerClaim(body, parsedReturnTo.returnTo);
|
|
1111
1242
|
const normalized = normalizeLinkInput(
|
|
1112
1243
|
body.kind,
|
|
1113
1244
|
body.externalId,
|
|
@@ -1115,17 +1246,6 @@ async function handleCreateClaim(request) {
|
|
|
1115
1246
|
"kind must be one of: email, phone"
|
|
1116
1247
|
);
|
|
1117
1248
|
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
1249
|
if (body.deliver !== void 0 && typeof body.deliver !== "boolean") {
|
|
1130
1250
|
return failure("deliver must be a boolean");
|
|
1131
1251
|
}
|
|
@@ -1138,44 +1258,22 @@ async function handleCreateClaim(request) {
|
|
|
1138
1258
|
STAFF_ASSISTANCE_NOT_ENABLED
|
|
1139
1259
|
);
|
|
1140
1260
|
}
|
|
1141
|
-
const identity =
|
|
1142
|
-
if (!identity
|
|
1143
|
-
return failure("identity not found", 404);
|
|
1144
|
-
}
|
|
1261
|
+
const identity = claimableIdentity(body.identityId);
|
|
1262
|
+
if (!identity) return failure("identity not found", 404);
|
|
1145
1263
|
const holdsContact = matchingLinks(normalized.kind, normalized.externalId).some(
|
|
1146
1264
|
(link) => link.identityId === identity.id
|
|
1147
1265
|
);
|
|
1148
1266
|
if (!holdsContact) {
|
|
1149
1267
|
return failure("identity does not hold that contact", 409);
|
|
1150
1268
|
}
|
|
1151
|
-
|
|
1152
|
-
const deliver = body.deliver === true;
|
|
1153
|
-
const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
|
|
1154
|
-
const claim = {
|
|
1155
|
-
claimId,
|
|
1269
|
+
return mintClaim({
|
|
1156
1270
|
flow,
|
|
1157
1271
|
identityId: identity.id,
|
|
1158
1272
|
kind: normalized.kind,
|
|
1159
1273
|
externalId: normalized.externalId,
|
|
1160
|
-
returnTo,
|
|
1274
|
+
returnTo: parsedReturnTo.returnTo,
|
|
1161
1275
|
staffActorRef: staff.staffActorRef,
|
|
1162
|
-
|
|
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 } : {}
|
|
1276
|
+
deliver: body.deliver === true
|
|
1179
1277
|
});
|
|
1180
1278
|
}
|
|
1181
1279
|
function handleGetClaim(claimId) {
|
|
@@ -1310,6 +1408,7 @@ function completeClaim(claimId, params) {
|
|
|
1310
1408
|
return claim;
|
|
1311
1409
|
}
|
|
1312
1410
|
if (!params.clerkUserId) throw new Error("clerkUserId is required");
|
|
1411
|
+
if (claim.flow === "bearer") return completeBearerClaim(claim, params.clerkUserId);
|
|
1313
1412
|
const rows = matchingLinks(claim.kind, claim.externalId);
|
|
1314
1413
|
const target = rows.find((link) => link.identityId === claim.identityId);
|
|
1315
1414
|
if (!target) {
|
|
@@ -1322,12 +1421,55 @@ function completeClaim(claimId, params) {
|
|
|
1322
1421
|
claim.refusalReason = "contact_owned";
|
|
1323
1422
|
return claim;
|
|
1324
1423
|
}
|
|
1424
|
+
if (claim.flow !== "transfer" && ownedByAnotherLogin(claim.identityId, params.clerkUserId)) {
|
|
1425
|
+
claim.state = "refused";
|
|
1426
|
+
claim.refusalReason = "owned_elsewhere";
|
|
1427
|
+
return claim;
|
|
1428
|
+
}
|
|
1325
1429
|
target.verified = true;
|
|
1430
|
+
connectLogin(claim, params.clerkUserId);
|
|
1326
1431
|
claim.state = "completed";
|
|
1327
1432
|
claim.completedIdentityId = claim.identityId;
|
|
1328
1433
|
claim.claimantClerkUserId = params.clerkUserId;
|
|
1329
1434
|
return claim;
|
|
1330
1435
|
}
|
|
1436
|
+
function ownedByAnotherLogin(identityId, clerkUserId) {
|
|
1437
|
+
return linksFor(identityId).some(
|
|
1438
|
+
(link) => link.kind === "project_auth_user" && link.externalId !== clerkUserId
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
function connectLogin(claim, clerkUserId) {
|
|
1442
|
+
const isLogin = (link) => link.identityId === claim.identityId && link.kind === "project_auth_user";
|
|
1443
|
+
if (claim.flow === "transfer") {
|
|
1444
|
+
state.identityLinks = state.identityLinks.filter(
|
|
1445
|
+
(link) => !isLogin(link) || link.externalId === clerkUserId
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
state.identityLinks = state.identityLinks.filter(
|
|
1449
|
+
(link) => link.kind !== "project_auth_user" || link.externalId !== clerkUserId || link.identityId === claim.identityId
|
|
1450
|
+
);
|
|
1451
|
+
if (!state.identityLinks.some((link) => isLogin(link) && link.externalId === clerkUserId)) {
|
|
1452
|
+
createUnverifiedLink(claim.identityId, "project_auth_user", clerkUserId).verified = true;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
function completeBearerClaim(claim, clerkUserId) {
|
|
1456
|
+
const identity = state.identities.get(claim.identityId);
|
|
1457
|
+
if (!identity || identity.type !== "person" || identity.status !== "active") {
|
|
1458
|
+
claim.state = "invalidated";
|
|
1459
|
+
return claim;
|
|
1460
|
+
}
|
|
1461
|
+
const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
|
|
1462
|
+
if (refusal) {
|
|
1463
|
+
claim.state = "refused";
|
|
1464
|
+
claim.refusalReason = refusal;
|
|
1465
|
+
return claim;
|
|
1466
|
+
}
|
|
1467
|
+
connectLogin(claim, clerkUserId);
|
|
1468
|
+
claim.state = "completed";
|
|
1469
|
+
claim.completedIdentityId = claim.identityId;
|
|
1470
|
+
claim.claimantClerkUserId = clerkUserId;
|
|
1471
|
+
return claim;
|
|
1472
|
+
}
|
|
1331
1473
|
function refuseClaim(claimId, reason) {
|
|
1332
1474
|
const claim = requireClaim(claimId);
|
|
1333
1475
|
if (claim.state !== "pending") return claim;
|
|
@@ -1348,6 +1490,9 @@ function createDirectory() {
|
|
|
1348
1490
|
enableStaffAssist: (enabled = true) => {
|
|
1349
1491
|
state.identityStaffAssistEnabled = enabled;
|
|
1350
1492
|
},
|
|
1493
|
+
enableBearerLinks: (enabled = true) => {
|
|
1494
|
+
state.identityBearerLinksEnabled = enabled;
|
|
1495
|
+
},
|
|
1351
1496
|
suppressClaimDelivery: (enabled = true) => {
|
|
1352
1497
|
state.identitySuppressClaimDelivery = enabled;
|
|
1353
1498
|
},
|
|
@@ -1361,6 +1506,7 @@ function createDirectory() {
|
|
|
1361
1506
|
state.identityLinks = [];
|
|
1362
1507
|
state.identityClaims.clear();
|
|
1363
1508
|
state.identityStaffAssistEnabled = false;
|
|
1509
|
+
state.identityBearerLinksEnabled = true;
|
|
1364
1510
|
state.identitySuppressClaimDelivery = false;
|
|
1365
1511
|
state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
|
|
1366
1512
|
},
|