@sekyro/node 0.1.0-rc.5 → 0.1.0-rc.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @sekyro/node
2
2
 
3
+ ## 0.1.0-rc.7
4
+
5
+ ### Minor Changes
6
+
7
+ - 1e450dc: Connect: the `@sekyro/node/connect` subpath (claim contract, `mergeClaim`, `evaluateGate`, `createConnector`) with Express and Next.js gates; `verifyResultToken` accepts several app ids and returns `appId` and `reuseMode`; `sessions.list({ externalRef, status })`; `Subject.level`, `levelAt`, `levelAssurance`, `sessions[].level`; `externalRefs` on subject events; `reuseMode` and `link.state` on session events; the optional `ProviderAdapter.update` for an atomic read-merge-write, and writes for one user serialised in process.
8
+
9
+ ### Patch Changes
10
+
11
+ - 3ea3d33: Docs: `listFlows()` now documents that an invalid API key answers `401 unauthorized` (every key belongs to an app; the `key_not_bound` error is gone).
12
+
13
+ ## 0.1.0-rc.6
14
+
15
+ ### Minor Changes
16
+
17
+ - 8bc8320: `subject.reuse` accepts an identity document: `{ document: { type, issuingCountry, number } }` (`SubjectReuseDocument`; `type` is `national_id`, `passport` or `residence_permit`, `issuingCountry` ISO alpha-2 or alpha-3). Still exactly one target per session.
18
+
19
+ ### Patch Changes
20
+
21
+ - 82c4898: `CreateSessionInput.assistedBy` and `Session.assistedBy`: your own reference for the agent filming the person in an assisted-capture flow.
22
+
3
23
  ## 0.1.0-rc.5
4
24
 
5
25
  ### Minor Changes
package/README.md CHANGED
@@ -1,15 +1,17 @@
1
1
  # @sekyro/node
2
2
 
3
- Server SDK for [Sekyro AI](https://sekyro.ai) identity verification. Zero runtime
4
- dependencies, Node.js ≥ 20, ESM + CommonJS, typed.
3
+ Server SDK for [Sekyro AI](https://sekyro.ai) identity verification. One runtime
4
+ dependency (`jose`, for result tokens), Node.js ≥ 20, ESM + CommonJS, typed.
5
5
 
6
- Two jobs, nothing else:
6
+ Three jobs, nothing else:
7
7
 
8
8
  1. **Sessions**: create a verification session from your backend and read its
9
9
  result. The API key never leaves your server.
10
10
  2. **Webhooks**: verify the `x-sekyro-signature` header on deliveries (the
11
11
  legacy `x-ibtikar-signature` name is still sent) and get a typed envelope
12
12
  back.
13
+ 3. **Result tokens**: verify the signed result token of a finished session
14
+ (`verifyResultToken`) and read its level, assurance and outcome.
13
15
 
14
16
  The SDK does **not** orchestrate checks or retry for you; the partner owns the
15
17
  retry policy (`SekyroApiError.retryable` tells you when a retry is sensible).
@@ -108,7 +110,8 @@ always widens. The changed paths come back on `session.overriddenFields`.
108
110
  > `overrides`. `flow` is accepted again, now as the app's flow slug or id.
109
111
 
110
112
  The session view also carries `failureReason` (a policy stop such as
111
- `location_not_allowed` or `underage`), `overriddenFields`, `riskSignals`,
113
+ `location_not_allowed` or `underage`), `failureDetail` (why, when that reason
114
+ is `camera_unavailable`: `denied` or `no_camera`), `overriddenFields`, `riskSignals`,
112
115
  `subjectId` / `externalRef`, `verifiedContacts`, `eid`, and the end user's
113
116
  `network` and `device`. A step with `handled: true` failed, but the flow's
114
117
  fallback took over.
@@ -233,6 +236,9 @@ subject.status; // 'active' | 'revoked'
233
236
  subject.reference; // { source: 'registry' | 'doc_registry' | 'chip' | 'document', capturedAt } | null
234
237
  subject.assurance; // 'registry_biometric' | 'chip_biometric' | 'document_biometric' | 'registry_only' | 'document_only' | null
235
238
  subject.returningReady; // a returning-user session can be created now
239
+ subject.level; // the highest level proved: 0 | 1 | 2 | 3 (0 once revoked), null before any, absent on an older API
240
+
241
+ const recent = await sekyro.sessions.list({ externalRef: userId, status: "completed" }); // newest first, at most 100; filters re-applied client-side
236
242
 
237
243
  const [byRef] = await sekyro.subjects.list({ externalRef: userId });
238
244
 
@@ -240,6 +246,11 @@ const [byRef] = await sekyro.subjects.list({ externalRef: userId });
240
246
  // Name exactly one of subjectId, externalRef or nni.
241
247
  await sekyro.sessions.create({ subject: { reuse: { subjectId } } });
242
248
 
249
+ // The same in one call, with a mode: `recheck` (verify the same person again)
250
+ // or `step_up` (raise a level 1 subject to identity level). A 24-hex string is
251
+ // a subject id; anything else is your external reference.
252
+ await sekyro.subjects.createSession(userId, { mode: "step_up", flow: "identity" });
253
+
243
254
  await sekyro.subjects.revoke(subjectId, { reason: "account closed" }); // idempotent
244
255
  await sekyro.subjects.delete(subjectId); // photos, face templates and identifiers; cannot be undone
245
256
  ```
@@ -301,7 +312,123 @@ app.post("/webhooks/sekyro", express.raw({ type: "application/json" }), (req, re
301
312
  `stale`; pass `{ now }` to inject a clock in tests.
302
313
  - Retried deliveries carry the same envelope `id`; de-duplicate on it.
303
314
 
315
+ ## Result tokens
316
+
317
+ A finished session carries a short-lived ES256 JWT: `resultToken` on the
318
+ `session.completed` and `session.failed` webhooks, or on demand with
319
+ `sekyro.sessions.resultToken(id)`. Verify it against the public key set before
320
+ trusting it:
321
+
322
+ ```ts
323
+ import { verifyResultToken, SekyroResultTokenError } from "@sekyro/node";
324
+
325
+ const result = await verifyResultToken(token, { appId: process.env.SEKYRO_APP_ID!, env: "live" });
326
+ result.level; // 0 | 1 | 2 | 3 | null (1 human, 2 identity, 3 corroborated)
327
+ result.assurance; // 'live_only' | 'document_biometric' | 'registry_biometric' | ...
328
+ result.outcome; // 'approved' | 'rejected' | ...
329
+ result.subjectId; // the person, when known
330
+ ```
331
+
332
+ - Keys come from `https://api.sekyro.ai/.well-known/sekyro/jwks.json` and are
333
+ cached for 10 minutes; an unknown key id fetches the set again.
334
+ - `aud` must be your app id (pass an array to accept several; `result.appId`
335
+ says which), and the token expires 10 minutes after signing.
336
+ - Sandbox tokens are signed with sandbox keys (`sbx-` key ids); pass
337
+ `env: "live"` to refuse them in production.
338
+ - Failures throw `SekyroResultTokenError` with a `code`: `invalid_token`,
339
+ `expired`, `wrong_audience`, `wrong_issuer`, `wrong_env`,
340
+ `key_env_mismatch`, `jwks_unavailable` or `invalid_options`.
341
+
342
+ ## Connect
343
+
344
+ `@sekyro/node/connect` carries the Sekyro proof into your auth provider as one
345
+ small claim, `sekyro`, on the provider's user, and gates routes on it. A
346
+ provider package supplies the adapter that reads and writes the claim
347
+ (`@sekyro/connect-zitadel`, `@sekyro/connect-supabase`, `@sekyro/connect-clerk`,
348
+ `@sekyro/connect-auth0`, `@sekyro/connect-firebase`); any object with the
349
+ `ProviderAdapter` shape works too. Nothing else to install: the subpaths ship
350
+ inside `@sekyro/node`.
351
+
352
+ ```ts
353
+ import { Sekyro } from "@sekyro/node";
354
+ import { createConnector } from "@sekyro/node/connect";
355
+
356
+ const connector = createConnector({
357
+ sekyro: new Sekyro({ apiKey: process.env.SEKYRO_API_KEY! }),
358
+ appId: process.env.SEKYRO_APP_ID!, // or an array of app ids
359
+ env: "live",
360
+ webhookSecret: process.env.SEKYRO_WEBHOOK_SECRET!,
361
+ provider, // from a provider package
362
+ onUserDeleted: "ignore", // 'ignore' | 'revoke' | 'delete'
363
+ });
364
+
365
+ // Start a verification for your user: their id becomes the session's externalRef.
366
+ const session = await connector.start(userId, { returnUrl, state });
367
+ // Store session.id with the state: the return must name this session.
368
+ // Later: connector.recheck(userId) or connector.stepUp(userId, { flow: "identity" }).
369
+ ```
370
+
371
+ The webhook route writes the claim. Mount it on the raw body:
372
+
373
+ ```ts
374
+ import express from "express";
375
+ import { SekyroResultTokenError, SekyroWebhookError } from "@sekyro/node";
376
+ import { SekyroConnectError } from "@sekyro/node/connect";
377
+
378
+ app.post("/webhooks/sekyro", express.raw({ type: "application/json" }), async (req, res) => {
379
+ try {
380
+ await connector.webhook((req.body as Buffer).toString("utf8"), req.headers);
381
+ res.sendStatus(200);
382
+ } catch (err) {
383
+ if (err instanceof SekyroWebhookError || err instanceof SekyroResultTokenError || err instanceof SekyroConnectError) {
384
+ // `unavailable`: the key set is unreachable, a 503 makes Sekyro retry. Anything else is refused: 400.
385
+ res.status(err instanceof SekyroConnectError && err.code === "unavailable" ? 503 : 400).send(err.code);
386
+ return;
387
+ }
388
+ throw err;
389
+ }
390
+ });
391
+
392
+ // The return URL: check `state` and the session id against the values you stored, then
393
+ // sync reads the session and its signed result token server-side (a session of another
394
+ // app, or created without one, is refused).
395
+ await connector.sync(userId, sessionIdFromQuery);
396
+ ```
397
+
398
+ Gate a route on the claim in the session token (no network call):
399
+
400
+ ```ts
401
+ import { requireLevel } from "@sekyro/node/connect/express";
402
+
403
+ app.get(
404
+ "/payouts",
405
+ requireLevel(connector, { level: 2, maxAgeDays: 365 }, { claims: (req) => (req as { auth?: Record<string, unknown> }).auth }),
406
+ handler,
407
+ );
408
+ // Fails with 403 { error: "sekyro_gate", reason, action }; action is verify | step_up | recheck | block.
409
+ // Next.js: sekyroGate() and gateRoute() from "@sekyro/node/connect/next".
410
+ ```
411
+
412
+ The claim (unix seconds; never a name, a document number, a photo or a score):
413
+
414
+ ```json
415
+ { "v": 1, "level": 2, "assurance": "document_biometric", "verified_at": 1790000000, "rechecked_at": 1790003600,
416
+ "session_id": "<session id>", "subject_id": "<subject id>", "env": "live", "flags": ["face_mismatch"] }
417
+ ```
418
+
419
+ Trust rules:
420
+
421
+ - A webhook writes only after its HMAC signature verified; retries are de-duplicated on the envelope id, which is never left recorded for a delivery whose handling failed (see the `store` contract in the guide).
422
+ - A key set outage while checking a result token throws `SekyroConnectError` with code `unavailable`: answer `503` so Sekyro retries, and `400` for every other refusal.
423
+ - When the webhook carries a result token it must verify too (audience, environment, same session, same user), and it is the source of the level, the assurance and the time.
424
+ - The return URL is a hint: store the session id with your `state` at start and compare both on return; `sync` then reads the session and its signed result token with your API key, verifies the token (your app ids, your environment) and refuses a session of another user, of another app or created without one.
425
+ - Every input goes through one merge: a failed attempt never erases a proof, a weaker proof never lowers the level, an older result never overwrites a newer one, and a result from the other environment is refused.
426
+ - Writes for one user are serialised: each one reads the stored claim, merges and writes it back through the adapter's optional `update` when it has one (the Firebase adapter does), otherwise under a lock per user inside the connector. That lock is per process: two server instances can still interleave for one user, and a re-check result read just before a revocation could write over it. When that matters, send the webhook route to one instance or queue its deliveries per user id.
427
+ - A revocation or deletion of the subject sets the level to 0 with the `revoked` flag (a claim stored without `subject_id` too, when the event's `externalRefs` include the user's id); the gate blocks `revoked` and `face_mismatch` by default. A revoked claim stays revoked: a later result of the same subject, or one without a subject id, is `revoked_ignored`; only an approved session of another subject writes a level again.
428
+
429
+ The full guide, with the Zitadel and Supabase setups, is in [Auth connectors](https://docs.sekyro.ai/en/connectors/overview).
430
+
304
431
  ## Compatibility
305
432
 
306
- Only `node:crypto` and the global `fetch` are used. You can inject a `fetch`
433
+ Only `node:crypto`, the global `fetch` and `jose` (result tokens) are used. You can inject a `fetch`
307
434
  implementation via `new Sekyro({ fetch })` for tests or custom agents.
@@ -0,0 +1 @@
1
+ var CLAIM_VERSION=1,KNOWN_FLAGS=["face_mismatch","duplicate","revoked"];function isRecord(v){return typeof v=="object"&&v!==null&&!Array.isArray(v)}function isFiniteNumber(v){return typeof v=="number"&&Number.isFinite(v)}function build(c){let out={v:1,level:c.level,assurance:c.assurance,verified_at:c.verified_at};c.rechecked_at!==void 0&&(out.rechecked_at=c.rechecked_at),out.session_id=c.session_id,c.subject_id!==void 0&&(out.subject_id=c.subject_id),out.env=c.env;let flags=new Set;for(let f of c.flags??[])KNOWN_FLAGS.includes(f)&&flags.add(f);return flags.size>0&&(out.flags=[...flags].sort()),out}function parseClaim(value){let v=value;if(typeof v=="string")try{v=JSON.parse(v)}catch{return null}if(!isRecord(v)||!isFiniteNumber(v.v))return null;if(v.v>1)return v;if(v.v!==1)return null;let level=v.level;return level!==0&&level!==1&&level!==2&&level!==3||typeof v.assurance!="string"||!isFiniteNumber(v.verified_at)||typeof v.session_id!="string"||v.env!=="live"&&v.env!=="sandbox"?null:build({level,assurance:v.assurance,verified_at:v.verified_at,rechecked_at:isFiniteNumber(v.rechecked_at)?v.rechecked_at:void 0,session_id:v.session_id,subject_id:typeof v.subject_id=="string"?v.subject_id:void 0,env:v.env,flags:Array.isArray(v.flags)?v.flags:void 0})}function toUnix(isoTime,now){let ms=Date.parse(isoTime);return Math.floor((Number.isFinite(ms)?ms:now())/1e3)}var same=(claim,rule)=>({claim,changed:!1,rule}),wrote=(claim,rule)=>({claim,changed:!0,rule});function mergeClaim(previous,event,opts){let now=opts.now??Date.now,prev=parseClaim(previous);if(prev&&prev.v>1)return same(prev,"unsupported_version");if(event.kind==="revoked"||event.kind==="deleted"){if(!prev)return same(null,"no_change");if(!(prev.subject_id===void 0?event.userInExternalRefs===!0:prev.subject_id===event.subjectId))return same(prev,"subject_mismatch");if(prev.level===0&&prev.flags?.includes("revoked"))return same(prev,"no_change");let{rechecked_at:_cleared,...kept}=prev;return wrote(build({...kept,level:0,subject_id:event.subjectId,flags:["revoked"]}),"revoked")}let r=event.result;if(r.env!==opts.env)return same(prev,"env_mismatch");let t=toUnix(r.verifiedAt,now);if(prev&&t<prev.verified_at)return same(prev,"stale_ignored");let revokedPrev=prev?.flags?.includes("revoked")===!0;if(prev&&revokedPrev&&(r.subjectId===void 0||r.subjectId===prev.subject_id))return same(prev,"revoked_ignored");if(r.outcome==="rejected"&&r.subjectConflict===!0)return!prev||prev.flags?.includes("face_mismatch")?same(prev,"no_change"):wrote(build({...prev,flags:[...prev.flags??[],"face_mismatch"]}),"face_mismatch");if(r.outcome!=="approved"||r.level===null||r.level===0)return same(prev,"not_approved");let level=r.level,assurance=r.assurance??"none",duplicate=r.duplicates===!0?["duplicate"]:[];if(!prev)return wrote(build({level,assurance,verified_at:t,session_id:r.sessionId,subject_id:r.subjectId,env:r.env,flags:duplicate}),"created");if(!revokedPrev&&(r.reuseMode==="recheck"||r.returning===!0&&level<=prev.level))return prev.rechecked_at!==void 0&&t<=prev.rechecked_at?same(prev,t<prev.rechecked_at?"stale_ignored":"no_change"):wrote(build({...prev,rechecked_at:t,subject_id:prev.subject_id??r.subjectId}),"rechecked");if(level>prev.level){let kept=(prev.flags??[]).filter(f=>f!=="face_mismatch"&&f!=="revoked");return wrote(build({...prev,level,assurance,verified_at:t,session_id:r.sessionId,subject_id:r.subjectId??prev.subject_id,flags:[...kept,...duplicate]}),"raised")}return level===prev.level&&t>prev.verified_at?wrote(build({...prev,assurance,verified_at:t,session_id:r.sessionId,subject_id:prev.subject_id??r.subjectId,flags:[...prev.flags??[],...duplicate]}),"renewed"):same(prev,"no_change")}var DEFAULT_DENY_FLAGS=["revoked","face_mismatch"],fail=(reason,action,claim)=>({ok:!1,reason,action,claim});function evaluateGate(claim,policy,nowSec=Math.floor(Date.now()/1e3)){let parsed=parseClaim(claim);if(!parsed)return fail("no_claim","verify",null);if(parsed.v>1)return fail("unsupported_version","verify",parsed);if(policy.env!==void 0&&parsed.env!==policy.env)return fail("wrong_env","verify",parsed);let deny=policy.denyFlags??DEFAULT_DENY_FLAGS;return parsed.flags?.some(f=>deny.includes(f))?fail("flagged","block",parsed):parsed.level<policy.level?fail("level_too_low",parsed.level>=1?"step_up":"verify",parsed):policy.maxAgeDays!==void 0&&nowSec-parsed.verified_at>policy.maxAgeDays*86400?fail("stale","recheck",parsed):policy.maxRecheckAgeMinutes!==void 0&&nowSec-(parsed.rechecked_at??parsed.verified_at)>policy.maxRecheckAgeMinutes*60?fail("recheck_stale","recheck",parsed):policy.assurance&&!policy.assurance.includes(parsed.assurance)?fail("assurance_not_allowed","step_up",parsed):{ok:!0,claim:parsed}}export{CLAIM_VERSION,parseClaim,mergeClaim,evaluateGate};
@@ -0,0 +1 @@
1
+ import{evaluateGate}from"./chunk-O5GJ3YJV.js";function sekyroGate(source,tokenClaims,policy){let claims=tokenClaims??{};if("gate"in source)return source.gate(claims,policy);let effective=source.env!==void 0&&policy.env===void 0?{...policy,env:source.env}:policy;return evaluateGate(source.provider.readClaimFromToken(claims),effective)}function gateRoute(result,routes){if(result.ok)return null;switch(result.action){case"verify":return routes.verify;case"step_up":return routes.stepUp;case"recheck":return routes.recheck;case"block":return routes.block}}export{sekyroGate,gateRoute};