@splitin/verification-adapter-plaid-idv 0.1.0-beta.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/dist/index.js ADDED
@@ -0,0 +1,727 @@
1
+ import { defineProviderManifest, VERIFICATION_ADAPTER_CONTRACT_VERSION, plainStringProperty, secretStringProperty, ProviderError, metadataContainsForbiddenIdentifier, isOpaqueSubjectReference, ProviderUnavailableError } from '@splitin/verification-adapter-sdk';
2
+ import { decodeProtectedHeader, importJWK, jwtVerify } from 'jose';
3
+
4
+ // src/adapter.ts
5
+ function createPlaidIdvConfiguration(values, environment) {
6
+ const secret = environment === "production" ? values.secret ?? values.PLAID_PRODUCTION_SECRET ?? values.PLAID_SECRET : values.secret ?? values.PLAID_SANDBOX_SECRET ?? values.PLAID_SECRET;
7
+ const templateId = environment === "production" ? values.templateId ?? values.PLAID_IDV_TEMPLATE_ID ?? values.PLAID_TEMPLATE_ID : values.templateId ?? values.PLAID_SANDBOX_TEMPLATE_ID ?? values.PLAID_IDV_TEMPLATE_ID ?? values.PLAID_TEMPLATE_ID;
8
+ return Object.freeze({
9
+ clientId: values.clientId ?? values.PLAID_CLIENT_ID ?? "",
10
+ secret: secret ?? "",
11
+ templateId: templateId ?? "",
12
+ clientName: values.clientName ?? values.PLAID_CLIENT_NAME ?? "",
13
+ webhookUrl: values.webhookUrl ?? values.PLAID_IDV_WEBHOOK_URL
14
+ });
15
+ }
16
+ function validatePlaidIdvConfiguration(config) {
17
+ if (!config.clientId.trim() || !config.secret.trim() || !config.templateId.trim() || !config.clientName.trim()) {
18
+ throw new ProviderError("INVALID_CONFIGURATION", "Plaid Identity Verification is not configured.", {
19
+ safeCode: "plaid_idv_not_configured"
20
+ });
21
+ }
22
+ if (config.webhookUrl) {
23
+ let url;
24
+ try {
25
+ url = new URL(config.webhookUrl);
26
+ } catch {
27
+ throw new ProviderError("INVALID_CONFIGURATION", "Plaid webhook URL is invalid.", {
28
+ safeCode: "plaid_webhook_url_invalid"
29
+ });
30
+ }
31
+ if (url.protocol !== "https:" || url.username || url.password) {
32
+ throw new ProviderError("INVALID_CONFIGURATION", "Plaid webhook URL is invalid.", {
33
+ safeCode: "plaid_webhook_url_invalid"
34
+ });
35
+ }
36
+ }
37
+ }
38
+
39
+ // src/constants.ts
40
+ var PLAID_SANDBOX_HOST = "sandbox.plaid.com";
41
+ var PLAID_PRODUCTION_HOST = "production.plaid.com";
42
+ var PLAID_HOSTED_HOST = "verify.plaid.com";
43
+ var PLAID_API_VERSION = "2020-09-14";
44
+ var PLAID_DISCLOSURE = "Powered by Plaid";
45
+ var PLAID_NORMALIZATION_VERSION = "plaid-idv-v1";
46
+ var PLAID_IDV_WEBHOOK_TYPE = "IDENTITY_VERIFICATION";
47
+ var PLAID_IDV_WEBHOOK_CODES = /* @__PURE__ */ new Set(["STATUS_UPDATED", "STEP_UPDATED", "RETRIED"]);
48
+ var plaidIdvProviderManifest = defineProviderManifest({
49
+ contractVersion: VERIFICATION_ADAPTER_CONTRACT_VERSION,
50
+ adapterVersion: "1.0.0",
51
+ engineCompatibility: "1.0.0",
52
+ provider: "plaid",
53
+ displayName: "Plaid Identity Verification",
54
+ description: "Plaid Identity Verification only. Auth, Identity (non-IDV), Monitor, and payments are out of scope. Redaction is not applicable; Plaid does not expose an IDV redaction API.",
55
+ supportedPackages: ["human_idv"],
56
+ supportedCountries: ["US"],
57
+ environments: ["sandbox", "production"],
58
+ capabilities: {
59
+ presentations: ["embedded", "hosted"],
60
+ canResume: true,
61
+ canRetry: true,
62
+ canCancel: false,
63
+ canRedact: false
64
+ },
65
+ launcherKeys: ["plaid_link", "hosted"],
66
+ launchPresentations: ["embedded", "hosted"],
67
+ configurationSchemaVersion: "urn:splitin:verification:config:plaid-idv:v1",
68
+ configurationSchema: {
69
+ $schema: "https://json-schema.org/draft/2020-12/schema",
70
+ type: "object",
71
+ additionalProperties: false,
72
+ required: ["clientId", "secret", "templateId", "clientName"],
73
+ properties: {
74
+ clientId: secretStringProperty(),
75
+ secret: secretStringProperty(),
76
+ templateId: plainStringProperty(),
77
+ clientName: plainStringProperty(),
78
+ webhookUrl: { type: "string", format: "uri", "x-secret": false }
79
+ }
80
+ },
81
+ webhook: {
82
+ protocol: "plaid_es256_jwk",
83
+ eventFamilies: ["IDENTITY_VERIFICATION"],
84
+ toleranceSeconds: 300
85
+ },
86
+ dataPolicy: {
87
+ classifications: ["provider_resource_id", "normalized_status", "reason_codes"],
88
+ prohibitedPersistence: ["raw_webhook", "launch_secret", "document", "selfie", "link_token"],
89
+ rawPayloadPersistence: false,
90
+ browserSecretPersistence: false,
91
+ governmentIdentifierPersistence: false
92
+ },
93
+ retry: { sameResourceWhenResumable: true, newAttemptAfterTerminal: true },
94
+ cancellation: { supported: false, terminal: true },
95
+ redaction: { supported: false, asynchronous: false, notApplicable: true },
96
+ apiHosts: [PLAID_SANDBOX_HOST, PLAID_PRODUCTION_HOST],
97
+ testedApiVersions: [PLAID_API_VERSION]
98
+ });
99
+
100
+ // src/status.ts
101
+ function normalizePlaidIdentityStatus(value) {
102
+ switch (String(value ?? "").trim().toLowerCase()) {
103
+ case "active":
104
+ case "pending":
105
+ return { status: "pending_user_input", reasonCodes: [] };
106
+ case "processing":
107
+ return { status: "processing", reasonCodes: [] };
108
+ case "success":
109
+ case "passed":
110
+ return { status: "verified", reasonCodes: [] };
111
+ case "pending_review":
112
+ case "review_needed":
113
+ return { status: "manual_review_required", reasonCodes: ["plaid_pending_review"] };
114
+ case "failed":
115
+ return { status: "failed", reasonCodes: ["plaid_verification_failed"] };
116
+ case "expired":
117
+ return { status: "expired", reasonCodes: ["plaid_attempt_expired"] };
118
+ case "canceled":
119
+ case "cancelled":
120
+ return { status: "canceled", reasonCodes: ["plaid_attempt_canceled"] };
121
+ default:
122
+ return { status: "manual_review_required", reasonCodes: ["plaid_status_ambiguous"] };
123
+ }
124
+ }
125
+
126
+ // src/webhook-crypto.ts
127
+ function constantTimeEqual(left, right) {
128
+ const maxLength = Math.max(left.length, right.length);
129
+ let difference = left.length ^ right.length;
130
+ for (let index = 0; index < maxLength; index += 1) {
131
+ difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
132
+ }
133
+ return difference === 0;
134
+ }
135
+ async function sha256Hex(crypto, value) {
136
+ const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
137
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes)));
138
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
139
+ }
140
+
141
+ // src/webhook-key-cache.ts
142
+ var PlaidWebhookVerificationError = class extends Error {
143
+ constructor(code, message) {
144
+ super(message);
145
+ this.code = code;
146
+ this.name = "PlaidWebhookVerificationError";
147
+ }
148
+ code;
149
+ };
150
+ var PlaidVerificationKeyCache = class {
151
+ entries = /* @__PURE__ */ new Map();
152
+ inFlight = /* @__PURE__ */ new Map();
153
+ ttlMs;
154
+ now;
155
+ constructor(options = {}) {
156
+ this.ttlMs = Math.min(Math.max(options.ttlMs ?? 15 * 6e4, 1e4), 60 * 6e4);
157
+ this.now = options.now ?? Date.now;
158
+ }
159
+ async get(keyId, loader) {
160
+ assertKeyId(keyId);
161
+ const nowMs = this.now();
162
+ const cached = this.entries.get(keyId);
163
+ if (cached && nowMs - cached.fetchedAtMs < this.ttlMs && !isExpired(cached.key, nowMs)) {
164
+ validatePlaidJwk(cached.key, keyId, nowMs);
165
+ return cached.key;
166
+ }
167
+ this.entries.delete(keyId);
168
+ const existing = this.inFlight.get(keyId);
169
+ if (existing) return existing;
170
+ const pending = (async () => {
171
+ const key = await loader(keyId);
172
+ validatePlaidJwk(key, keyId, this.now());
173
+ this.entries.set(keyId, { key, fetchedAtMs: this.now() });
174
+ return key;
175
+ })();
176
+ this.inFlight.set(keyId, pending);
177
+ try {
178
+ return await pending;
179
+ } finally {
180
+ this.inFlight.delete(keyId);
181
+ }
182
+ }
183
+ clear(keyId) {
184
+ if (keyId) this.entries.delete(keyId);
185
+ else this.entries.clear();
186
+ }
187
+ };
188
+ function assertKeyId(keyId) {
189
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(keyId)) {
190
+ throw new PlaidWebhookVerificationError("INVALID_KEY_ID", "Webhook key ID is invalid");
191
+ }
192
+ }
193
+ function validatePlaidJwk(key, keyId, nowMs) {
194
+ if (!key || key.kid !== keyId || key.kty !== "EC" || key.crv !== "P-256" || key.alg !== "ES256" || key.use !== "sig" || typeof key.x !== "string" || typeof key.y !== "string" || !/^[A-Za-z0-9_-]{40,64}$/.test(key.x) || !/^[A-Za-z0-9_-]{40,64}$/.test(key.y)) {
195
+ throw new PlaidWebhookVerificationError("INVALID_KEY", "Webhook verification key is invalid");
196
+ }
197
+ const nowSeconds = Math.floor(nowMs / 1e3);
198
+ const createdAt = timestampSeconds(key.created_at);
199
+ if (createdAt === null || createdAt > nowSeconds + 60) {
200
+ throw new PlaidWebhookVerificationError("INVALID_KEY", "Webhook verification key creation time is invalid");
201
+ }
202
+ if (isExpired(key, nowMs)) {
203
+ throw new PlaidWebhookVerificationError("EXPIRED_KEY", "Webhook verification key is expired");
204
+ }
205
+ }
206
+ function isExpired(key, nowMs) {
207
+ if (key.expired_at == null) return false;
208
+ const expiry = timestampSeconds(key.expired_at);
209
+ return expiry === null || expiry <= Math.floor(nowMs / 1e3);
210
+ }
211
+ function timestampSeconds(value) {
212
+ if (typeof value === "number") return Number.isInteger(value) && value > 0 ? value : null;
213
+ if (typeof value !== "string" || !value) return null;
214
+ const numeric = Number(value);
215
+ if (Number.isInteger(numeric) && numeric > 0) return numeric;
216
+ const parsed = Date.parse(value);
217
+ return Number.isFinite(parsed) ? Math.floor(parsed / 1e3) : null;
218
+ }
219
+ async function verifyPlaidWebhook(options) {
220
+ try {
221
+ return await verifyPlaidWebhookInner(options);
222
+ } catch (error) {
223
+ if (error instanceof ProviderError) throw error;
224
+ const code = error instanceof PlaidWebhookVerificationError ? error.code : "INVALID_SIGNATURE";
225
+ const retryable = code === "KEY_FETCH_FAILED";
226
+ throw new ProviderError(
227
+ retryable ? "RETRYABLE_PROVIDER_FAILURE" : "SIGNATURE_INVALID",
228
+ "Plaid webhook signature is invalid.",
229
+ {
230
+ retryable,
231
+ safeCode: `plaid_webhook_${code.toLowerCase()}`,
232
+ cause: error
233
+ }
234
+ );
235
+ }
236
+ }
237
+ async function verifyPlaidWebhookInner(options) {
238
+ const token = options.verificationHeader?.trim();
239
+ if (!token) {
240
+ throw new PlaidWebhookVerificationError("MISSING_HEADER", "Missing Plaid-Verification header");
241
+ }
242
+ const parts = token.split(".");
243
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
244
+ throw new PlaidWebhookVerificationError("MALFORMED_JWT", "Malformed webhook verification token");
245
+ }
246
+ let header;
247
+ try {
248
+ header = decodeProtectedHeader(token);
249
+ } catch {
250
+ throw new PlaidWebhookVerificationError("MALFORMED_JWT", "Malformed webhook verification token");
251
+ }
252
+ if (header.alg !== "ES256") {
253
+ throw new PlaidWebhookVerificationError("WRONG_ALGORITHM", "Webhook token must use ES256");
254
+ }
255
+ if (typeof header.kid !== "string" || !header.kid) {
256
+ throw new PlaidWebhookVerificationError("MISSING_KEY_ID", "Webhook token is missing its key ID");
257
+ }
258
+ assertKeyId(header.kid);
259
+ let jwk;
260
+ try {
261
+ jwk = await options.getKey(header.kid);
262
+ } catch (error) {
263
+ if (error instanceof PlaidWebhookVerificationError) throw error;
264
+ throw new PlaidWebhookVerificationError("KEY_FETCH_FAILED", "Unable to retrieve webhook verification key");
265
+ }
266
+ const nowMs = options.nowMs ?? Date.now();
267
+ validatePlaidJwk(jwk, header.kid, nowMs);
268
+ let payload;
269
+ try {
270
+ const key = await importJWK(jwk, "ES256");
271
+ const result = await jwtVerify(token, key, {
272
+ algorithms: ["ES256"],
273
+ currentDate: new Date(nowMs)
274
+ });
275
+ payload = result.payload;
276
+ } catch {
277
+ throw new PlaidWebhookVerificationError("INVALID_SIGNATURE", "Webhook signature is invalid");
278
+ }
279
+ if (!Number.isInteger(payload.iat)) {
280
+ throw new PlaidWebhookVerificationError("MISSING_ISSUED_AT", "Webhook token is missing a valid issued-at time");
281
+ }
282
+ const nowSeconds = Math.floor(nowMs / 1e3);
283
+ const maxAge = options.maxTokenAgeSeconds ?? 300;
284
+ const futureSkew = options.maxFutureSkewSeconds ?? 60;
285
+ if (nowSeconds - payload.iat > maxAge) {
286
+ throw new PlaidWebhookVerificationError("STALE_TOKEN", "Webhook token is older than five minutes");
287
+ }
288
+ if (payload.iat - nowSeconds > futureSkew) {
289
+ throw new PlaidWebhookVerificationError("FUTURE_TOKEN", "Webhook token issued-at time is in the future");
290
+ }
291
+ const claimedBodyHash = payload.request_body_sha256;
292
+ if (typeof claimedBodyHash !== "string" || !/^[a-f0-9]{64}$/i.test(claimedBodyHash)) {
293
+ throw new PlaidWebhookVerificationError("MISSING_BODY_HASH", "Webhook token is missing a valid body hash");
294
+ }
295
+ const bodySha256 = await sha256Hex(options.crypto, options.rawBody);
296
+ if (!constantTimeEqual(bodySha256.toLowerCase(), claimedBodyHash.toLowerCase())) {
297
+ throw new PlaidWebhookVerificationError("BODY_MISMATCH", "Webhook body does not match its signed digest");
298
+ }
299
+ return { bodySha256, issuedAt: payload.iat, keyId: header.kid };
300
+ }
301
+
302
+ // src/adapter.ts
303
+ var PlaidIdvVerificationAdapter = class {
304
+ contractVersion = VERIFICATION_ADAPTER_CONTRACT_VERSION;
305
+ manifest = plaidIdvProviderManifest;
306
+ provider = "plaid";
307
+ environment;
308
+ runtime;
309
+ verificationKeyCache;
310
+ constructor(runtime) {
311
+ this.runtime = runtime;
312
+ this.environment = runtime.environment;
313
+ this.verificationKeyCache = new PlaidVerificationKeyCache({
314
+ now: () => this.runtime.now().getTime()
315
+ });
316
+ this.validateConfiguration();
317
+ }
318
+ validateConfiguration() {
319
+ validatePlaidIdvConfiguration(this.runtime.configuration);
320
+ }
321
+ async createAttempt(command) {
322
+ this.assertCommand(command);
323
+ this.requireIdempotencyKey("create", command.attemptId, command.idempotencyKey);
324
+ const identity = await this.call("create", "/identity_verification/create", {
325
+ client_user_id: command.subjectReference,
326
+ template_id: this.runtime.configuration.templateId,
327
+ is_shareable: true,
328
+ gave_consent: false,
329
+ is_idempotent: true
330
+ });
331
+ return this.toAttemptResult(command.attemptId, identity, "create");
332
+ }
333
+ async resumeAttempt(command) {
334
+ const identity = await this.getIdentity(command.providerResourceId, "resume");
335
+ this.assertSubject(identity.client_user_id);
336
+ return this.createLaunch(command.attemptId, identity, "resume");
337
+ }
338
+ async retrieveAttempt(command) {
339
+ return this.normalizeSnapshot(await this.getIdentity(command.providerResourceId, "retrieve"));
340
+ }
341
+ async retryAttempt(command) {
342
+ this.assertCommand(command);
343
+ this.requireIdempotencyKey("retry", command.attemptId, command.idempotencyKey);
344
+ if (!command.previousProviderResourceId) {
345
+ return this.createAttempt(command);
346
+ }
347
+ const previous = await this.getIdentity(command.previousProviderResourceId, "retry");
348
+ this.assertSubject(previous.client_user_id);
349
+ if (previous.client_user_id !== command.subjectReference) {
350
+ throw new ProviderError("TERMINAL_INPUT_FAILURE", "The prior Plaid attempt does not match this subject.", {
351
+ safeCode: "plaid_subject_mismatch"
352
+ });
353
+ }
354
+ const normalized = normalizePlaidIdentityStatus(previous.status);
355
+ if (normalized.status === "pending_user_input" || normalized.status === "created") {
356
+ return this.toAttemptResult(command.attemptId, previous, "retry");
357
+ }
358
+ if (normalized.status === "processing") {
359
+ throw new ProviderError("RETRYABLE_PROVIDER_FAILURE", "Plaid is still processing this attempt.", {
360
+ retryable: true,
361
+ retryAfterSeconds: 15,
362
+ safeCode: "plaid_attempt_processing"
363
+ });
364
+ }
365
+ if (normalized.status === "manual_review_required" || normalized.status === "verified") {
366
+ throw new ProviderError("TERMINAL_INPUT_FAILURE", "Plaid review state cannot be retried.", {
367
+ safeCode: normalized.status === "verified" ? "plaid_attempt_already_verified" : "plaid_manual_review_pending"
368
+ });
369
+ }
370
+ const identity = await this.call("retry", "/identity_verification/retry", {
371
+ client_user_id: command.subjectReference,
372
+ template_id: this.runtime.configuration.templateId,
373
+ strategy: "reset"
374
+ });
375
+ const result = await this.toAttemptResult(command.attemptId, identity, "retry");
376
+ result.linkedResources = [{
377
+ resourceType: "identity_verification",
378
+ resourceId: requiredProviderId(previous.id),
379
+ relationshipCode: "retried_from",
380
+ providerStatus: requiredStatus(previous.status),
381
+ occurredAt: safeDate(previous.created_at, this.runtime.now())
382
+ }];
383
+ return result;
384
+ }
385
+ async cancelAttempt(_command) {
386
+ throw new ProviderError("UNSUPPORTED_CAPABILITY", "Plaid Identity Verification does not expose a cancel API.", {
387
+ safeCode: "plaid_cancel_unsupported"
388
+ });
389
+ }
390
+ async redactSubject(_command) {
391
+ return { completed: true, retryable: false, disposition: "not_applicable" };
392
+ }
393
+ async verifyWebhook(request) {
394
+ const rawBody = new Uint8Array(await request.arrayBuffer());
395
+ if (rawBody.byteLength === 0 || rawBody.byteLength > 1048576) {
396
+ throw new ProviderError("SIGNATURE_INVALID", "Plaid webhook body size is invalid.", {
397
+ safeCode: "plaid_webhook_body_invalid"
398
+ });
399
+ }
400
+ const verified = await verifyPlaidWebhook({
401
+ rawBody,
402
+ verificationHeader: request.headers.get("Plaid-Verification"),
403
+ crypto: this.runtime.crypto,
404
+ nowMs: this.runtime.now().getTime(),
405
+ getKey: async (keyId) => this.verificationKeyCache.get(
406
+ keyId,
407
+ async (uncachedKeyId) => this.getVerificationKey(uncachedKeyId)
408
+ )
409
+ });
410
+ return {
411
+ providerEventKey: `plaid_${verified.bodySha256}`,
412
+ receivedAt: this.runtime.now().toISOString(),
413
+ bodySha256: verified.bodySha256,
414
+ signatureIssuedAt: new Date(verified.issuedAt * 1e3).toISOString(),
415
+ opaquePayload: rawBody
416
+ };
417
+ }
418
+ async normalizeWebhook(input) {
419
+ let webhook;
420
+ try {
421
+ webhook = JSON.parse(new TextDecoder().decode(input.opaquePayload));
422
+ } catch {
423
+ throw new ProviderError("TERMINAL_INPUT_FAILURE", "Plaid webhook JSON is invalid.", {
424
+ safeCode: "plaid_webhook_payload_invalid"
425
+ });
426
+ }
427
+ const webhookType = requiredText(webhook.webhook_type, "webhook_type");
428
+ const webhookCode = requiredText(webhook.webhook_code, "webhook_code");
429
+ if (webhookType !== PLAID_IDV_WEBHOOK_TYPE || !PLAID_IDV_WEBHOOK_CODES.has(webhookCode)) {
430
+ throw new ProviderError("TERMINAL_INPUT_FAILURE", "Plaid webhook is outside Identity Verification scope.", {
431
+ safeCode: "plaid_webhook_scope_denied"
432
+ });
433
+ }
434
+ const providerResourceId = requiredProviderId(webhook.identity_verification_id);
435
+ const webhookEnvironment = requiredText(webhook.environment, "environment");
436
+ if (webhookEnvironment !== this.environment) {
437
+ throw new ProviderError("TERMINAL_INPUT_FAILURE", "Plaid webhook environment does not match the configured adapter.", {
438
+ safeCode: "plaid_environment_mismatch"
439
+ });
440
+ }
441
+ const status = typeof webhook.status === "string" ? normalizePlaidIdentityStatus(webhook.status) : null;
442
+ const providerEventKey = `plaid_${await sha256Hex(this.runtime.crypto, [
443
+ this.environment,
444
+ webhookType,
445
+ webhookCode,
446
+ providerResourceId,
447
+ input.bodySha256
448
+ ].join(":"))}`;
449
+ return {
450
+ providerEventKey,
451
+ providerResourceId,
452
+ eventType: status ? `verification.provider_event.${status.status}` : "verification.provider_event.processing",
453
+ providerEventType: `${webhookType}.${webhookCode}`,
454
+ canonicalStatus: status?.status,
455
+ occurredAt: input.receivedAt,
456
+ normalizedReasonCodes: status?.reasonCodes ?? ["plaid_webhook_requires_reconciliation"],
457
+ safeMetadata: {
458
+ adapter_version: this.manifest.adapterVersion,
459
+ normalization_version: PLAID_NORMALIZATION_VERSION,
460
+ provider_event_category: webhookCode.toLowerCase(),
461
+ provider_environment: this.environment
462
+ }
463
+ };
464
+ }
465
+ assertCommand(command) {
466
+ if (command.packageCode !== "human_idv") {
467
+ throw new ProviderError("UNSUPPORTED_CAPABILITY", "Plaid Identity Verification does not support this package.", {
468
+ safeCode: "unsupported_package"
469
+ });
470
+ }
471
+ this.assertSubject(command.subjectReference);
472
+ if (metadataContainsForbiddenIdentifier(command.metadata)) {
473
+ throw new ProviderError("TERMINAL_INPUT_FAILURE", "Attempt metadata contains a forbidden identifier.", {
474
+ safeCode: "forbidden_identifier"
475
+ });
476
+ }
477
+ }
478
+ assertSubject(value) {
479
+ if (!isOpaqueSubjectReference(value)) {
480
+ throw new ProviderError("TERMINAL_INPUT_FAILURE", "The subject reference is not an opaque identifier.", {
481
+ safeCode: "subject_reference_invalid"
482
+ });
483
+ }
484
+ }
485
+ async getIdentity(providerResourceId, operation) {
486
+ return this.call(operation, "/identity_verification/get", {
487
+ identity_verification_id: requiredProviderId(providerResourceId)
488
+ });
489
+ }
490
+ async toAttemptResult(attemptId, identity, operation) {
491
+ const normalized = normalizePlaidIdentityStatus(identity.status);
492
+ return {
493
+ attemptId,
494
+ providerResourceId: requiredProviderId(identity.id),
495
+ providerStatus: requiredStatus(identity.status),
496
+ canonicalStatus: normalized.status,
497
+ launch: await this.createLaunch(attemptId, identity, operation)
498
+ };
499
+ }
500
+ normalizeSnapshot(identity) {
501
+ const normalized = normalizePlaidIdentityStatus(identity.status);
502
+ const unknownStatus = normalized.reasonCodes.includes("plaid_status_ambiguous");
503
+ if (unknownStatus) {
504
+ this.runtime.logger.error("verification_provider_status_unknown", {
505
+ provider: this.provider,
506
+ environment: this.environment,
507
+ safe_code: "PLAID_STATUS_UNRECOGNIZED"
508
+ });
509
+ }
510
+ return {
511
+ providerResourceId: requiredProviderId(identity.id),
512
+ providerStatus: requiredStatus(identity.status),
513
+ canonicalStatus: normalized.status,
514
+ occurredAt: safeDate(identity.completed_at ?? identity.created_at, this.runtime.now()),
515
+ normalizedReasonCodes: normalized.reasonCodes,
516
+ safeMetadata: {
517
+ source: "retrieve",
518
+ adapter_version: this.manifest.adapterVersion,
519
+ normalization_version: PLAID_NORMALIZATION_VERSION,
520
+ ...unknownStatus ? { unknown_reason: "provider_status_not_allowlisted" } : {}
521
+ }
522
+ };
523
+ }
524
+ async createLaunch(attemptId, identity, operation) {
525
+ this.assertSubject(identity.client_user_id);
526
+ const payload = {
527
+ user: { client_user_id: identity.client_user_id },
528
+ client_name: this.runtime.configuration.clientName,
529
+ products: ["identity_verification"],
530
+ country_codes: ["US"],
531
+ language: "en",
532
+ identity_verification: { template_id: this.runtime.configuration.templateId, gave_consent: false }
533
+ };
534
+ if (this.runtime.configuration.webhookUrl) payload.webhook = this.runtime.configuration.webhookUrl;
535
+ const result = await this.call(operation, "/link/token/create", payload);
536
+ if (typeof result.link_token !== "string" || result.link_token.length < 8) {
537
+ throw new ProviderUnavailableError("Plaid did not return launch material.", {
538
+ safeCode: "plaid_launch_material_missing"
539
+ });
540
+ }
541
+ const canonical = normalizePlaidIdentityStatus(identity.status).status;
542
+ const hostedUrl = safeHttpsUrl(identity.shareable_url, PLAID_HOSTED_HOST);
543
+ return {
544
+ attemptId,
545
+ canonicalStatus: canonical,
546
+ presentation: "embedded",
547
+ launcherKey: "plaid_link",
548
+ providerDisclosure: PLAID_DISCLOSURE,
549
+ transientSecret: result.link_token,
550
+ transientSecretExpiresAt: safeDate(result.expiration, new Date(this.runtime.now().getTime() + 30 * 6e4)),
551
+ hostedUrl,
552
+ hostedFallbackExpiresAt: hostedUrl ? safeDate(result.expiration, new Date(this.runtime.now().getTime() + 30 * 6e4)) : void 0,
553
+ continuationReference: requiredProviderId(identity.id)
554
+ };
555
+ }
556
+ async getVerificationKey(keyId) {
557
+ const response = await this.call(
558
+ "webhook_verify",
559
+ "/webhook_verification_key/get",
560
+ { key_id: keyId }
561
+ );
562
+ if (!response.key || response.key.kid !== keyId) {
563
+ throw new ProviderError("SIGNATURE_INVALID", "Plaid returned an unknown webhook key.", {
564
+ safeCode: "plaid_webhook_invalid_key"
565
+ });
566
+ }
567
+ return response.key;
568
+ }
569
+ async call(operation, endpoint, body) {
570
+ if (this.runtime.rateBudget) {
571
+ const budget = await this.runtime.rateBudget.acquire(operation);
572
+ if (!budget.allowed) {
573
+ throw new ProviderError("RATE_LIMITED", "Plaid rate budget is exhausted.", {
574
+ retryable: true,
575
+ retryAfterSeconds: budget.retryAfterSeconds,
576
+ safeCode: "plaid_rate_limited"
577
+ });
578
+ }
579
+ }
580
+ const startedAt = this.runtime.now().getTime();
581
+ const controller = new AbortController();
582
+ const timeoutId = setTimeout(() => controller.abort(), 1e4);
583
+ const host = this.environment === "production" ? PLAID_PRODUCTION_HOST : PLAID_SANDBOX_HOST;
584
+ try {
585
+ const response = await this.runtime.http.fetch(`https://${host}${endpoint}`, {
586
+ method: "POST",
587
+ headers: {
588
+ "Content-Type": "application/json",
589
+ "PLAID-CLIENT-ID": this.runtime.configuration.clientId,
590
+ "PLAID-SECRET": this.runtime.configuration.secret,
591
+ "Plaid-Version": PLAID_API_VERSION
592
+ },
593
+ body: JSON.stringify(body),
594
+ signal: controller.signal
595
+ });
596
+ if (!response.ok) {
597
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
598
+ const code = response.status === 401 || response.status === 403 ? "AUTHENTICATION_FAILED" : response.status === 429 ? "RATE_LIMITED" : response.status === 408 || response.status === 504 ? "TIMEOUT" : response.status >= 500 ? "RETRYABLE_PROVIDER_FAILURE" : "TERMINAL_INPUT_FAILURE";
599
+ throw new ProviderError(code, "Plaid request failed.", {
600
+ retryable: response.status === 408 || response.status === 429 || response.status >= 500,
601
+ retryAfterSeconds: retryAfter,
602
+ safeCode: code === "AUTHENTICATION_FAILED" ? "plaid_authentication_failed" : code === "RATE_LIMITED" ? "plaid_rate_limited" : code === "TIMEOUT" ? "plaid_timeout" : code === "RETRYABLE_PROVIDER_FAILURE" ? "plaid_provider_failure" : "plaid_terminal_input_failure"
603
+ });
604
+ }
605
+ const result = await response.json().catch(() => {
606
+ throw new ProviderError("UNKNOWN_PROVIDER_STATE", "Plaid returned a malformed response.", {
607
+ safeCode: "malformed_provider_response"
608
+ });
609
+ });
610
+ const unknown = isRecordWithStringStatus(result) && normalizePlaidIdentityStatus(result.status).reasonCodes.includes("plaid_status_ambiguous");
611
+ await this.recordObservation(
612
+ operation,
613
+ unknown ? "unknown_status" : "success",
614
+ unknown ? "plaid_unknown_status" : `plaid_${operation}_ok`,
615
+ startedAt
616
+ );
617
+ return result;
618
+ } catch (error) {
619
+ const failure = error instanceof ProviderError ? error : error instanceof DOMException && error.name === "AbortError" ? new ProviderError("TIMEOUT", "Plaid request timed out.", {
620
+ retryable: true,
621
+ safeCode: "plaid_timeout"
622
+ }) : new ProviderError("RETRYABLE_PROVIDER_FAILURE", "Plaid Identity Verification is temporarily unavailable.", {
623
+ retryable: true,
624
+ safeCode: "plaid_provider_failure",
625
+ cause: error
626
+ });
627
+ await this.recordObservation(
628
+ operation,
629
+ failure.retryable ? "retryable_failure" : "terminal_failure",
630
+ failure.safeCode,
631
+ startedAt
632
+ );
633
+ throw failure;
634
+ } finally {
635
+ clearTimeout(timeoutId);
636
+ }
637
+ }
638
+ requireIdempotencyKey(operation, attemptId, suppliedKey) {
639
+ const key = this.runtime.idempotency.keyFor(operation, attemptId, suppliedKey);
640
+ if (!key || key.length > 255) {
641
+ throw new ProviderError("INVALID_CONFIGURATION", "Plaid idempotency configuration is invalid.", {
642
+ safeCode: "plaid_idempotency_invalid"
643
+ });
644
+ }
645
+ return key;
646
+ }
647
+ async recordObservation(operation, outcome, safeCode, startedAt) {
648
+ const observedAt = this.runtime.now();
649
+ const latencyMs = Math.max(0, observedAt.getTime() - startedAt);
650
+ const metadata = {
651
+ provider: this.provider,
652
+ environment: this.environment,
653
+ operation,
654
+ outcome,
655
+ safe_code: safeCode,
656
+ latency_ms: latencyMs
657
+ };
658
+ try {
659
+ await this.runtime.recordHealth({
660
+ operation,
661
+ outcome,
662
+ safeCode,
663
+ observedAt: observedAt.toISOString(),
664
+ latencyMs
665
+ });
666
+ if (outcome === "success") this.runtime.logger.info("verification_provider_operation", metadata);
667
+ else this.runtime.logger.warn("verification_provider_operation", metadata);
668
+ } catch {
669
+ this.runtime.logger.warn("verification_provider_health_record_failed", {
670
+ provider: this.provider,
671
+ environment: this.environment,
672
+ operation
673
+ });
674
+ }
675
+ }
676
+ };
677
+ function requiredProviderId(value) {
678
+ if (typeof value !== "string" || !/^[A-Za-z0-9_.:-]{3,256}$/.test(value)) {
679
+ throw new ProviderError("UNKNOWN_PROVIDER_STATE", "Plaid resource identifier is invalid.", {
680
+ safeCode: "malformed_provider_response"
681
+ });
682
+ }
683
+ return value;
684
+ }
685
+ function requiredStatus(value) {
686
+ if (typeof value !== "string" || value.length < 1 || value.length > 128) {
687
+ throw new ProviderError("UNKNOWN_PROVIDER_STATE", "Plaid status is invalid.", {
688
+ safeCode: "malformed_provider_response"
689
+ });
690
+ }
691
+ return value;
692
+ }
693
+ function requiredText(value, field) {
694
+ if (typeof value !== "string" || value.length < 1 || value.length > 128) {
695
+ throw new ProviderError("TERMINAL_INPUT_FAILURE", `Plaid ${field} is invalid.`, {
696
+ safeCode: "plaid_webhook_payload_invalid"
697
+ });
698
+ }
699
+ return value;
700
+ }
701
+ function safeDate(value, fallback) {
702
+ if (typeof value === "string") {
703
+ const parsed = Date.parse(value);
704
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
705
+ }
706
+ return fallback.toISOString();
707
+ }
708
+ function safeHttpsUrl(value, hostedHost) {
709
+ if (typeof value !== "string") return void 0;
710
+ try {
711
+ const url = new URL(value);
712
+ return url.protocol === "https:" && url.hostname === hostedHost && !url.username && !url.password ? url.toString() : void 0;
713
+ } catch {
714
+ return void 0;
715
+ }
716
+ }
717
+ function parseRetryAfter(value) {
718
+ if (!value || !/^\d{1,6}$/.test(value)) return void 0;
719
+ return Math.min(Number(value), 3600);
720
+ }
721
+ function isRecordWithStringStatus(value) {
722
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value) && typeof value.status === "string";
723
+ }
724
+
725
+ export { PlaidIdvVerificationAdapter, PlaidVerificationKeyCache, createPlaidIdvConfiguration, normalizePlaidIdentityStatus, plaidIdvProviderManifest, verifyPlaidWebhook };
726
+ //# sourceMappingURL=index.js.map
727
+ //# sourceMappingURL=index.js.map