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