@stardeck-customer-apps/testing 0.16.0 → 0.18.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 +37 -4
- package/dist/index.d.mts +13 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +196 -45
- package/dist/index.mjs +196 -45
- 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 +3 -5
package/SKILL.md
CHANGED
|
@@ -350,8 +350,12 @@ 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
|
-
|
|
354
|
-
|
|
353
|
+
A contact the claimant already proved on their own login profile is not
|
|
354
|
+
"someone else": the claim completes and the one verified copy ends up on the
|
|
355
|
+
claimed profile.
|
|
356
|
+
Completing a finished claim replays the recorded result. Completion attaches
|
|
357
|
+
the claimant's login to the profile (moving it off any profile it was on), but
|
|
358
|
+
does not move history — the platform's merge core is not modelled.
|
|
355
359
|
`.allowReturnOrigin(origin)` widens the allowed `returnTo` origins (the test
|
|
356
360
|
app's own origin is allowed by default); a `returnTo` anywhere else is dropped
|
|
357
361
|
silently, exactly as the platform drops it. `claimUrl` carries a one-time
|
|
@@ -359,7 +363,22 @@ externalId })` helper models a trusted platform/channel link, and
|
|
|
359
363
|
comes back as `delivered`. `.suppressClaimDelivery()` models the platform
|
|
360
364
|
sending nothing (a suppressed quota, a bounced address): `deliver: true` then
|
|
361
365
|
returns `delivered: false` with the `claimUrl` still valid, so the app's own
|
|
362
|
-
fallback delivery can be tested.
|
|
366
|
+
fallback delivery can be tested. Bearer links are on by default, like the platform;
|
|
367
|
+
`.enableBearerLinks(false)` models a project whose admin turned them off. A bearer claim is raised against a
|
|
368
|
+
profile with no contact at all, so it takes no `kind`/`externalId`/`deliver`
|
|
369
|
+
(sending one is refused, not ignored) and `completeClaim` connects whoever
|
|
370
|
+
completes it — unless another login already holds that profile, which comes
|
|
371
|
+
back `refused` / `owned_elsewhere`. That refusal is the one a leaked bearer
|
|
372
|
+
link runs into, so test it. A profile that holds an email or phone is refused
|
|
373
|
+
at `create` with a 409 (send an `invite`); one that gains a contact before
|
|
374
|
+
the link is used completes as `refused` / `protected`, and a link still
|
|
375
|
+
outstanding when `.enableBearerLinks(false)` runs completes as `refused` /
|
|
376
|
+
`expired`. People seeded with only a `dashboard_user` link (team members) are
|
|
377
|
+
left out of `identities.list()` and `search()`, as on the platform.
|
|
378
|
+
`search({ attributes })` matches profile attributes exactly and by JSON type
|
|
379
|
+
(`5` never matches `"5"`) and rejects nested, array, `null` or more than 10
|
|
380
|
+
keys (or a `__proto__` key) with a `ValidationError` whose message matches the
|
|
381
|
+
platform's, like the platform.
|
|
363
382
|
|
|
364
383
|
```ts
|
|
365
384
|
const claim = await integrations.identities.claims.create({
|
|
@@ -372,6 +391,17 @@ externalId })` helper models a trusted platform/channel link, and
|
|
|
372
391
|
expect((await integrations.identities.claims.get(claim.claimId)).state).toBe("completed");
|
|
373
392
|
```
|
|
374
393
|
|
|
394
|
+
```ts
|
|
395
|
+
// A name on a card, no email or phone: the link IS the authorisation.
|
|
396
|
+
const bearer = await integrations.identities.claims.create({
|
|
397
|
+
flow: "bearer",
|
|
398
|
+
identityId: walkIn.id,
|
|
399
|
+
});
|
|
400
|
+
expect(app.identities.completeClaim(bearer.claimId, { clerkUserId: "user_2" }).state).toBe(
|
|
401
|
+
"completed"
|
|
402
|
+
);
|
|
403
|
+
```
|
|
404
|
+
|
|
375
405
|
- `app.payments` — checkouts created through payments-sdk:
|
|
376
406
|
`.checkouts`, `.latest()`, `.setProducts()`, `.markPaid(id)`,
|
|
377
407
|
`.setSessionStatus(id, status)`, `.setPaymentLinkStatus(id, status)`,
|
|
@@ -450,7 +480,10 @@ true })` remains source-compatible but the simulator (like the control plane)
|
|
|
450
480
|
copies still succeed, and `resolveOrCreate` prefers the verified owner and
|
|
451
481
|
otherwise the earliest unverified holder. Channel and login kinds stay
|
|
452
482
|
single-owner and 409 on a second holder. `update` replaces the
|
|
453
|
-
`profile` object (not a merge), like the control plane.
|
|
483
|
+
`profile` object (not a merge), like the control plane. A person with a
|
|
484
|
+
seeded `dashboard_user` link is a team member: changing their `displayName`
|
|
485
|
+
or `profile.email` is refused with a 409 `ValidationError`, and a new
|
|
486
|
+
`profile` without `email` keeps the current one, as on the platform. Inspect via
|
|
454
487
|
`app.identities`; merge/archive are privileged setup operations, with only
|
|
455
488
|
test-only merge modeling exposed above.
|
|
456
489
|
- `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`
|
|
@@ -178,7 +185,9 @@ interface TestDirectory {
|
|
|
178
185
|
* Test setup only: model the customer finishing the platform-hosted claim —
|
|
179
186
|
* Clerk proof plus their confirmation. Flips the claimed contact to verified,
|
|
180
187
|
* or records `invalidated` (the contact was removed from that profile) or
|
|
181
|
-
* `refused` with `contact_owned` (someone else already proved it
|
|
188
|
+
* `refused` with `contact_owned` (someone else already proved it — the
|
|
189
|
+
* claimant's own login profile does not count; its verified copy is dropped
|
|
190
|
+
* so the claimed profile holds the only one). Replaying a
|
|
182
191
|
* finished claim returns the recorded result unchanged.
|
|
183
192
|
*
|
|
184
193
|
* It does NOT attach a login link or move history: the platform's merge core
|
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`
|
|
@@ -178,7 +185,9 @@ interface TestDirectory {
|
|
|
178
185
|
* Test setup only: model the customer finishing the platform-hosted claim —
|
|
179
186
|
* Clerk proof plus their confirmation. Flips the claimed contact to verified,
|
|
180
187
|
* or records `invalidated` (the contact was removed from that profile) or
|
|
181
|
-
* `refused` with `contact_owned` (someone else already proved it
|
|
188
|
+
* `refused` with `contact_owned` (someone else already proved it — the
|
|
189
|
+
* claimant's own login profile does not count; its verified copy is dropped
|
|
190
|
+
* so the claimed profile holds the only one). Replaying a
|
|
182
191
|
* finished claim returns the recorded result unchanged.
|
|
183
192
|
*
|
|
184
193
|
* It does NOT attach a login link or move history: the platform's merge core
|
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,24 +1408,73 @@ 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) {
|
|
1316
1415
|
claim.state = "invalidated";
|
|
1317
1416
|
return claim;
|
|
1318
1417
|
}
|
|
1418
|
+
const claimantIdentityId = state.identityLinks.find(
|
|
1419
|
+
(link) => link.kind === "project_auth_user" && link.externalId === params.clerkUserId
|
|
1420
|
+
)?.identityId;
|
|
1319
1421
|
const otherVerified = rows.find((link) => link.verified && link.identityId !== claim.identityId);
|
|
1320
|
-
|
|
1422
|
+
const ownProof = otherVerified !== void 0 && otherVerified.identityId === claimantIdentityId;
|
|
1423
|
+
if (otherVerified && !ownProof) {
|
|
1321
1424
|
claim.state = "refused";
|
|
1322
1425
|
claim.refusalReason = "contact_owned";
|
|
1323
1426
|
return claim;
|
|
1324
1427
|
}
|
|
1428
|
+
if (claim.flow !== "transfer" && ownedByAnotherLogin(claim.identityId, params.clerkUserId)) {
|
|
1429
|
+
claim.state = "refused";
|
|
1430
|
+
claim.refusalReason = "owned_elsewhere";
|
|
1431
|
+
return claim;
|
|
1432
|
+
}
|
|
1433
|
+
if (ownProof) state.identityLinks = state.identityLinks.filter((link) => link !== otherVerified);
|
|
1325
1434
|
target.verified = true;
|
|
1435
|
+
connectLogin(claim, params.clerkUserId);
|
|
1326
1436
|
claim.state = "completed";
|
|
1327
1437
|
claim.completedIdentityId = claim.identityId;
|
|
1328
1438
|
claim.claimantClerkUserId = params.clerkUserId;
|
|
1329
1439
|
return claim;
|
|
1330
1440
|
}
|
|
1441
|
+
function ownedByAnotherLogin(identityId, clerkUserId) {
|
|
1442
|
+
return linksFor(identityId).some(
|
|
1443
|
+
(link) => link.kind === "project_auth_user" && link.externalId !== clerkUserId
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
function connectLogin(claim, clerkUserId) {
|
|
1447
|
+
const isLogin = (link) => link.identityId === claim.identityId && link.kind === "project_auth_user";
|
|
1448
|
+
if (claim.flow === "transfer") {
|
|
1449
|
+
state.identityLinks = state.identityLinks.filter(
|
|
1450
|
+
(link) => !isLogin(link) || link.externalId === clerkUserId
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
state.identityLinks = state.identityLinks.filter(
|
|
1454
|
+
(link) => link.kind !== "project_auth_user" || link.externalId !== clerkUserId || link.identityId === claim.identityId
|
|
1455
|
+
);
|
|
1456
|
+
if (!state.identityLinks.some((link) => isLogin(link) && link.externalId === clerkUserId)) {
|
|
1457
|
+
createUnverifiedLink(claim.identityId, "project_auth_user", clerkUserId).verified = true;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
function completeBearerClaim(claim, clerkUserId) {
|
|
1461
|
+
const identity = state.identities.get(claim.identityId);
|
|
1462
|
+
if (!identity || identity.type !== "person" || identity.status !== "active") {
|
|
1463
|
+
claim.state = "invalidated";
|
|
1464
|
+
return claim;
|
|
1465
|
+
}
|
|
1466
|
+
const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
|
|
1467
|
+
if (refusal) {
|
|
1468
|
+
claim.state = "refused";
|
|
1469
|
+
claim.refusalReason = refusal;
|
|
1470
|
+
return claim;
|
|
1471
|
+
}
|
|
1472
|
+
connectLogin(claim, clerkUserId);
|
|
1473
|
+
claim.state = "completed";
|
|
1474
|
+
claim.completedIdentityId = claim.identityId;
|
|
1475
|
+
claim.claimantClerkUserId = clerkUserId;
|
|
1476
|
+
return claim;
|
|
1477
|
+
}
|
|
1331
1478
|
function refuseClaim(claimId, reason) {
|
|
1332
1479
|
const claim = requireClaim(claimId);
|
|
1333
1480
|
if (claim.state !== "pending") return claim;
|
|
@@ -1348,6 +1495,9 @@ function createDirectory() {
|
|
|
1348
1495
|
enableStaffAssist: (enabled = true) => {
|
|
1349
1496
|
state.identityStaffAssistEnabled = enabled;
|
|
1350
1497
|
},
|
|
1498
|
+
enableBearerLinks: (enabled = true) => {
|
|
1499
|
+
state.identityBearerLinksEnabled = enabled;
|
|
1500
|
+
},
|
|
1351
1501
|
suppressClaimDelivery: (enabled = true) => {
|
|
1352
1502
|
state.identitySuppressClaimDelivery = enabled;
|
|
1353
1503
|
},
|
|
@@ -1361,6 +1511,7 @@ function createDirectory() {
|
|
|
1361
1511
|
state.identityLinks = [];
|
|
1362
1512
|
state.identityClaims.clear();
|
|
1363
1513
|
state.identityStaffAssistEnabled = false;
|
|
1514
|
+
state.identityBearerLinksEnabled = true;
|
|
1364
1515
|
state.identitySuppressClaimDelivery = false;
|
|
1365
1516
|
state.identityAllowedReturnOrigins = /* @__PURE__ */ new Set([APP_TEST_ORIGIN]);
|
|
1366
1517
|
},
|