@labyrinthe/otp-widget 0.0.0-stage → 1.2.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/README.md CHANGED
@@ -1,3 +1,575 @@
1
- # Temporary Holding Version
1
+ # @labyrinthe/otp-widget
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@labyrinthe/otp-widget.svg)](https://www.npmjs.com/package/@labyrinthe/otp-widget)
4
+ [![CI](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/actions/workflows/widget-otp.yml/badge.svg)](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/actions/workflows/widget-otp.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ ![min+gzip](https://img.shields.io/badge/min%2Bgzip-8.1%20kB-008374)
7
+
8
+ Drop-in phone verification for [Labyrinthe Connect](https://labyrinthe-rdc.com), the SMS and OTP platform for the
9
+ DR Congo and Central Africa: a `<labyrinthe-otp>` web component, a React wrapper, and the two server endpoints it
10
+ needs (Express, Next.js and any Fetch API runtime).
11
+
12
+ *Version française : [README.fr.md](README.fr.md).*
13
+
14
+ - **No API key in the browser.** The widget only calls *your* server; your server calls Labyrinthe with
15
+ [`@labyrinthe/connect`](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/tree/main/node). `otp_id` and the
16
+ code never reach the browser.
17
+ - Phone step (DR Congo `+243` by default, same normalisation as the SDK), separate code boxes, paste,
18
+ `autocomplete="one-time-code"`, Web OTP API on Android, auto-submit, resend countdown, attempts left, every error
19
+ explained.
20
+ - Accessible (labels, `role="alert"`, live region, keyboard, focus management), responsive, light / dark, themable
21
+ with CSS custom properties and `::part()`, French (default) and English, every text overridable.
22
+ - Zero dependencies, Shadow DOM, **8.1 kB min+gzip** (standalone script), SSR-safe.
23
+
24
+ Version **1.2.0**, built against the API specification **1.3.0** and `@labyrinthe/connect` 1.2. What changed:
25
+ [CHANGELOG](CHANGELOG.md).
26
+
27
+ ## Contents
28
+
29
+ 1. [How it works](#how-it-works)
30
+ 2. [Requirements](#requirements)
31
+ 3. [Installation](#installation)
32
+ 4. [Quick start](#quick-start)
33
+ 5. [Server handlers](#server-handlers) (Express, Next.js, Hono, Bun, Deno, other languages)
34
+ 6. [Server options](#server-options)
35
+ 7. [The element](#the-element) (script tag, bundler, attributes, properties, events)
36
+ 8. [React](#react)
37
+ 9. [Styling and theming](#styling-and-theming)
38
+ 10. [Texts and languages](#texts-and-languages)
39
+ 11. [Accessibility](#accessibility)
40
+ 12. [Errors](#errors) and [maintenance](#maintenance-503-service_unavailable)
41
+ 13. [Security](#security)
42
+ 14. [Test mode](#test-mode) · [Size](#size)
43
+ 15. [Upgrading](#upgrading) · [Contributing](#contributing) · [License](#license)
44
+
45
+ ## How it works
46
+
47
+ A Labyrinthe API key (`lsk_live_…`) gives full access to your account: whoever holds it can send SMS on your credits.
48
+ It must stay on your server; `@labyrinthe/connect` even refuses to run in a browser. So the widget never talks to
49
+ Labyrinthe: it talks to two endpoints on **your** server, which this package also provides.
50
+
51
+ ```
52
+ Browser Your server Labyrinthe API
53
+ <labyrinthe-otp> @labyrinthe/otp-widget/server (lsk_live_… / lsk_test_…)
54
+ │ POST /api/otp/send │ │
55
+ │ { phone, locale, send_id } │ │
56
+ │────────────────────────────────▶│ beforeSend() (your anti-abuse checks) │
57
+ │ │ client.otp.send({ phone, … }) ─────────▶│ SMS
58
+ │ │◀──────────── { otp_id, expires_at, … } │
59
+ │◀── { sent, phone_masked, … } ───│ Set-Cookie: encrypted { otp_id, phone } │
60
+ │ (no otp_id, no code) │ (HttpOnly, SameSite=Strict, Secure) │
61
+ │ POST /api/otp/verify { code } │ │
62
+ │────────────────────────────────▶│ decrypt cookie → otp_id │
63
+ │ │ client.otp.verify({ otpId, code }) ────▶│
64
+ │ │◀───── { verified, reason, attempts… } │
65
+ │ │ onVerified({ phone }) → your session │
66
+ │◀── { verified: true, data } ────│ cookie cleared │
67
+ ```
68
+
69
+ The browser only ever sees a masked number, the expiry, the attempts left and the resend delay.
70
+
71
+ ## Requirements
72
+
73
+ | Part | Needs |
74
+ |---|---|
75
+ | `<labyrinthe-otp>` | A browser with Custom Elements and Shadow DOM: Chrome / Edge 84+, Firefox 90+, Safari 15+ |
76
+ | `@labyrinthe/otp-widget/react` | React ≥ 18 (18 and 19, Next.js App Router) |
77
+ | `@labyrinthe/otp-widget/server` | Node.js ≥ 18 (`engines`), or any runtime with Fetch and Web Crypto (edge, Deno, Bun), and `@labyrinthe/connect` ^1.2.0 |
78
+
79
+ `@labyrinthe/connect` and `react` are optional peer dependencies: install them only where you use the server helper
80
+ or the React wrapper.
81
+
82
+ ## Installation
83
+
84
+ ```bash
85
+ npm install @labyrinthe/otp-widget @labyrinthe/connect
86
+ # or
87
+ pnpm add @labyrinthe/otp-widget @labyrinthe/connect
88
+ # or
89
+ yarn add @labyrinthe/otp-widget @labyrinthe/connect
90
+ ```
91
+
92
+ Without a bundler, load the standalone script from a CDN (pin the version):
93
+
94
+ ```html
95
+ <script src="https://cdn.jsdelivr.net/npm/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js" defer></script>
96
+ <!-- or https://unpkg.com/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js -->
97
+ ```
98
+
99
+ To self-host it, the file is exported as `@labyrinthe/otp-widget/cdn` (see
100
+ [`examples/express/server.ts`](examples/express/server.ts)).
101
+
102
+ ## Quick start
103
+
104
+ **1. Server.** Two endpoints that keep the key and `otp_id` (Express here; other frameworks
105
+ [below](#server-handlers)):
106
+
107
+ ```ts
108
+ import express from 'express';
109
+ import { Labyrinthe } from '@labyrinthe/connect';
110
+ import { createOtpHandlers } from '@labyrinthe/otp-widget/server';
111
+
112
+ const otp = createOtpHandlers({
113
+ client: new Labyrinthe(), // LABYRINTHE_API_KEY=lsk_test_… (sends nothing, bills nothing)
114
+ secret: process.env.LABYRINTHE_OTP_SECRET, // ≥ 32 random characters: openssl rand -hex 32
115
+ onVerified: async ({ phone, request }) => {
116
+ // The ONLY place where the number is trusted: log the user in here.
117
+ return { redirect: '/dashboard' }; // sent to the browser as `data`
118
+ },
119
+ });
120
+
121
+ const app = express();
122
+ app.use(express.json());
123
+ app.post('/api/otp/send', otp.express.send);
124
+ app.post('/api/otp/verify', otp.express.verify);
125
+ app.listen(3000);
126
+ ```
127
+
128
+ **2. Browser.**
129
+
130
+ ```html
131
+ <script src="https://cdn.jsdelivr.net/npm/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js" defer></script>
132
+
133
+ <labyrinthe-otp send-url="/api/otp/send" verify-url="/api/otp/verify"></labyrinthe-otp>
134
+ <script>
135
+ document.querySelector('labyrinthe-otp')
136
+ .addEventListener('labyrinthe-otp:verified', (e) => location.assign(e.detail.data.redirect));
137
+ </script>
138
+ ```
139
+
140
+ Complete examples: [`examples/express`](examples/express) (static page + Express server) and
141
+ [`examples/nextjs`](examples/nextjs) (route handlers + client component).
142
+
143
+ ## Server handlers
144
+
145
+ `createOtpHandlers(options)` returns:
146
+
147
+ | Member | Type | For |
148
+ |---|---|---|
149
+ | `send`, `verify` | `(request: Request) => Promise<Response>` | Next.js App Router, Hono, Bun, Deno, edge runtimes |
150
+ | `express.send`, `express.verify` | `(req, res, next) => Promise<void>` | Express, Connect, plain `node:http` (with or without `express.json()`) |
151
+
152
+ Create the handlers **once** and share them between the two routes.
153
+
154
+ ### Express
155
+
156
+ ```ts
157
+ app.use(express.json({ limit: '4kb' })); // optional: the handlers also read the raw stream
158
+ app.post('/api/otp/send', otp.express.send);
159
+ app.post('/api/otp/verify', otp.express.verify);
160
+ ```
161
+
162
+ ### Next.js (App Router)
163
+
164
+ ```ts
165
+ // lib/otp.ts — server only
166
+ import { Labyrinthe } from '@labyrinthe/connect';
167
+ import { createOtpHandlers } from '@labyrinthe/otp-widget/server';
168
+
169
+ export const otp = createOtpHandlers({
170
+ client: () => new Labyrinthe(), // created on first use, not at build time
171
+ secret: process.env.LABYRINTHE_OTP_SECRET,
172
+ cookie: { path: '/api/otp' }, // the cookie only goes to the two OTP routes
173
+ onVerified: async ({ phone, setCookie }) => {
174
+ const token = await createAppSession(phone); // your auth system
175
+ setCookie(`app_session=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000`);
176
+ return { redirect: '/dashboard' };
177
+ },
178
+ });
179
+ ```
180
+
181
+ ```ts
182
+ // app/api/otp/send/route.ts
183
+ import { otp } from '../../../../lib/otp';
184
+ export const POST = otp.send;
185
+
186
+ // app/api/otp/verify/route.ts
187
+ import { otp } from '../../../../lib/otp';
188
+ export const POST = otp.verify;
189
+ ```
190
+
191
+ Keep `LABYRINTHE_API_KEY` and `LABYRINTHE_OTP_SECRET` in `.env.local`, never in a `NEXT_PUBLIC_…` variable. The
192
+ handlers run on the Node.js runtime and on the Edge runtime.
193
+
194
+ ### Hono
195
+
196
+ ```ts
197
+ import { Hono } from 'hono';
198
+
199
+ const app = new Hono();
200
+ app.post('/api/otp/send', (c) => otp.send(c.req.raw));
201
+ app.post('/api/otp/verify', (c) => otp.verify(c.req.raw));
202
+ ```
203
+
204
+ ### Bun and Deno
205
+
206
+ ```ts
207
+ const handle = (request: Request): Promise<Response> | Response => {
208
+ const { pathname } = new URL(request.url);
209
+ if (pathname === '/api/otp/send') return otp.send(request);
210
+ if (pathname === '/api/otp/verify') return otp.verify(request);
211
+ return new Response('Not found', { status: 404 });
212
+ };
213
+
214
+ Bun.serve({ port: 3000, fetch: handle }); // Bun
215
+ // Deno.serve({ port: 3000 }, handle); // Deno
216
+ ```
217
+
218
+ ### Another backend language
219
+
220
+ The widget only needs two JSON endpoints, so any backend can serve it (for example with the PHP, Python, .NET or Java
221
+ SDK). The protocol (`ErrorBody`, `SendSuccessBody`, `VerifySuccessBody`, `VerifyFailureBody` types are exported):
222
+
223
+ | Request (from the widget) | Success response (200) |
224
+ |---|---|
225
+ | `POST {send-url}` `{ "phone": "+243812345678", "locale": "fr", "send_id": "<32 hex>" }` | `{ "sent": true, "phone_masked": "+243 81•••••78", "expires_at": "…", "max_attempts": 5, "resend_after_seconds": 60, "code_length"?: 6, "code_type"?: "numeric" }` |
226
+ | `POST {verify-url}` `{ "code": "482913" }` | `{ "verified": true, "data"?: … }` or `{ "verified": false, "reason": "invalid_code", "attempts_left": 2 }` |
227
+
228
+ Errors: any status with `{ "error": { "code", "message", "retry_after_seconds"?, "admin_message"?, "until"? } }` and
229
+ the codes of [Errors](#errors). Requests are sent with `Content-Type: application/json`, the cookies of your origin
230
+ (`credentials`) and your `requestHeaders`. On your side: keep `otp_id` in the server session, derive the
231
+ `Idempotency-Key` of `otp/send` from `send_id` (the helper uses an HMAC with its secret, so the browser never chooses
232
+ the key), and trust the number only after `verified: true`.
233
+
234
+ ## Server options
235
+
236
+ | Option | Default | Description |
237
+ |---|---|---|
238
+ | `client` | — | **Required.** A `Labyrinthe` instance, or a function returning one (called on first use). |
239
+ | `secret` | `LABYRINTHE_OTP_SECRET` | **Required**, ≥ 32 characters. Encrypts the cookie and derives idempotency keys. Not your API key. |
240
+ | `onVerified(ctx)` | — | **Required.** Called once the code is verified: `ctx.phone` (international, no `+`), `ctx.request` (Fetch `Request` or Express `req`), `ctx.setCookie(cookie)`, `ctx.responseHeaders`. Its return value is sent to the browser as `data` (JSON, non-secret). |
241
+ | `beforeSend(ctx)` | — | Your anti-abuse checks (`ctx.phone`, `ctx.request`): return `false` to refuse (403 `forbidden`). |
242
+ | `onError(error, { action })` | — | Upstream and unexpected errors (`action`: `'send'` or `'verify'`), for your logs. Never contains the key, the code or `otp_id`. |
243
+ | `store` | — (state in the cookie) | `{ get, set, delete }` (Redis, database…): the cookie then only carries a random session id. `memoryStore()` for development. |
244
+ | `cookie` | `{ name: 'labyrinthe_otp', path: '/', sameSite: 'Strict' }` | Also `domain` and `secure` (default: on HTTPS requests and when `NODE_ENV=production`). |
245
+ | `defaultCountryCode` | `'243'` | Added to local numbers. |
246
+ | `template` | account settings | SMS template, must contain `{code}`. |
247
+ | `locale` | widget's `locale`, then account settings | SMS language, `'fr'` or `'en'`. |
248
+ | `webOtpDomain` | — | Your site's host (`app.example.com`): the SMS ends with `@app.example.com #CODE` for the Web OTP API. Validated at start-up. |
249
+ | `stripAccents` | `false` | Remove accents from the SMS (GSM-7, often half the cost). |
250
+ | `resendAfterSeconds` | from the API, then 60 | Override of the countdown before *Resend* (the API still enforces the real delay). |
251
+ | `codeLength`, `codeType` | from the API | Overrides of the code shape. Only needed with an API older than 1.2.0. |
252
+ | `exposeTestCode` | `false` | Development only: sends the `test_code` of a `lsk_test_` key to the browser. Refused when `NODE_ENV=production`. |
253
+ | `allowedOrigins` | the request's own host | Origins allowed to call the endpoints (`Origin` / `Sec-Fetch-Site` checks). |
254
+
255
+ | Environment variable | Read by |
256
+ |---|---|
257
+ | `LABYRINTHE_API_KEY`, `LABYRINTHE_BASE_URL` | `new Labyrinthe()` (`@labyrinthe/connect`) |
258
+ | `LABYRINTHE_OTP_SECRET` | `createOtpHandlers()` when `secret` is not given |
259
+ | `NODE_ENV` | `production` refuses `exposeTestCode` and makes the cookie `Secure` |
260
+
261
+ `createOtpHandlers()` throws at start-up for a missing or short `secret`, a missing `onVerified`, a `template` without
262
+ `{code}`, an invalid `webOtpDomain` or `defaultCountryCode`, and `exposeTestCode` in production.
263
+
264
+ ## The element
265
+
266
+ ### Script tag or bundler
267
+
268
+ ```html
269
+ <!-- Standalone script (CDN or self-hosted): defines <labyrinthe-otp> and the global LabyrintheOtp -->
270
+ <script src="https://cdn.jsdelivr.net/npm/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js" defer></script>
271
+ ```
272
+
273
+ ```ts
274
+ // With a bundler (Vite, webpack, Next.js…): importing the package defines the element.
275
+ import '@labyrinthe/otp-widget';
276
+ // Importing it on a server (SSR) does nothing.
277
+
278
+ import { defineLabyrintheOtp } from '@labyrinthe/otp-widget';
279
+ defineLabyrintheOtp('my-otp'); // optional: register another tag name (idempotent)
280
+ ```
281
+
282
+ ```html
283
+ <labyrinthe-otp
284
+ send-url="/api/otp/send"
285
+ verify-url="/api/otp/verify"
286
+ locale="en"
287
+ country-code="243"
288
+ theme="dark"
289
+ ></labyrinthe-otp>
290
+ ```
291
+
292
+ ### Attributes
293
+
294
+ | Attribute | Default | Description |
295
+ |---|---|---|
296
+ | `send-url`, `verify-url` | `/api/otp/send`, `/api/otp/verify` | Your endpoints. |
297
+ | `locale` | `fr` | `fr` or `en`: texts of the widget, and SMS language unless the server sets one. |
298
+ | `country-code` | `243` | Default country code, editable by the user. |
299
+ | `phone` | — | Pre-filled number. |
300
+ | `code-length`, `code-type` | `6`, `numeric` | Code shape until the server says otherwise (it always does with the API ≥ 1.2.0). `code-type`: `numeric` or `alphanumeric`. |
301
+ | `theme` | automatic | `light` or `dark`; otherwise follows `prefers-color-scheme`. |
302
+ | `heading` | the `title` text | Title above the form; `""` hides it. |
303
+ | `credentials` | `same-origin` | `include` when your endpoints are on another origin (then `cookie: { sameSite: 'None', secure: true }` and `allowedOrigins` on the server). |
304
+ | `no-auto-submit` | — | Do not submit as soon as every box is filled. |
305
+ | `no-web-otp` | — | Do not use the Web OTP API. |
306
+
307
+ ### Properties and methods
308
+
309
+ | Member | Description |
310
+ |---|---|
311
+ | `step` | Read-only: `'phone'`, `'code'` or `'success'`. |
312
+ | `texts` | Text overrides (object, merged with the locale defaults); `null` resets them. |
313
+ | `requestHeaders` | Extra headers sent to your endpoints (e.g. a CSRF token). |
314
+ | `reset()` | Back to the phone step, clearing the code. |
315
+ | `focus()` | Focuses the first focusable control inside (the Shadow DOM delegates focus). |
316
+
317
+ ```js
318
+ const widget = document.querySelector('labyrinthe-otp');
319
+ widget.requestHeaders = { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content };
320
+ ```
321
+
322
+ ### Events
323
+
324
+ Every event bubbles and crosses the Shadow DOM (`bubbles`, `composed`); its `detail` is typed (`OtpSentDetail`,
325
+ `OtpVerifiedDetail`, `OtpErrorDetail`). Constants: `EVENT_SENT`, `EVENT_VERIFIED`, `EVENT_ERROR`.
326
+
327
+ | Event | `detail` |
328
+ |---|---|
329
+ | `labyrinthe-otp:sent` | `{ phone, phoneMasked, expiresAt: Date, maxAttempts, resendAfterSeconds, testCode? }` — first send and every resend |
330
+ | `labyrinthe-otp:verified` | `{ phone, data }` — `data` is what your `onVerified()` returned |
331
+ | `labyrinthe-otp:error` | `{ code, message, status, retryAfterSeconds?, attemptsLeft?, adminMessage?, until?: Date }` — `message` is the text shown to the user, `status` `null` for local and network errors |
332
+
333
+ ```js
334
+ widget.addEventListener('labyrinthe-otp:error', (e) => analytics.track('otp_error', { code: e.detail.code }));
335
+ widget.addEventListener('labyrinthe-otp:verified', (e) => location.assign(e.detail.data.redirect));
336
+ ```
337
+
338
+ The `verified` event is for the user experience only (redirect, message): the server already trusted the number in
339
+ `onVerified()`. Never trust the browser event.
340
+
341
+ ### Web OTP API (Android Chrome)
342
+
343
+ For the browser to fill the code automatically, the last line of the SMS must be `@your-domain.com #CODE`, where the
344
+ domain is exactly the host of the page showing the widget (top-level page, HTTPS). Set the server option
345
+ `webOtpDomain: 'shop.example.com'`: the API adds that line itself (do not put it in your `template`). The widget then
346
+ calls `navigator.credentials.get({ otp: { transport: ['sms'] } })` while the code boxes are shown (disable with
347
+ `no-web-otp`); typing, pasting and `autocomplete="one-time-code"` keep working everywhere.
348
+
349
+ ## React
350
+
351
+ ```tsx
352
+ 'use client';
353
+ import { useRouter } from 'next/navigation';
354
+ import { LabyrintheOtp } from '@labyrinthe/otp-widget/react';
355
+
356
+ export function VerifyPhone() {
357
+ const router = useRouter();
358
+ return (
359
+ <LabyrintheOtp
360
+ sendUrl="/api/otp/send"
361
+ verifyUrl="/api/otp/verify"
362
+ locale="en"
363
+ texts={{ send: 'Text me a code' }}
364
+ onError={(error) => console.warn(error.code)}
365
+ onVerified={({ data }) => router.push((data as { redirect: string }).redirect)}
366
+ />
367
+ );
368
+ }
369
+ ```
370
+
371
+ | Prop | Same as |
372
+ |---|---|
373
+ | `sendUrl`, `verifyUrl`, `locale`, `countryCode`, `phone`, `codeLength`, `codeType`, `theme`, `heading`, `credentials` | the attributes above |
374
+ | `autoSubmit` (default `true`), `webOtp` (default `true`) | `no-auto-submit`, `no-web-otp` when `false` |
375
+ | `texts`, `requestHeaders` | the properties above |
376
+ | `onSent`, `onVerified`, `onError` | the events above (receive the `detail`) |
377
+ | `className`, `style`, `id` | on the host element |
378
+
379
+ The component is a client component (`'use client'`), SSR-safe, works with React 18 and 19, and forwards a `ref` to
380
+ the `<labyrinthe-otp>` element (`ref.current.reset()`). Callbacks always use the latest props without re-subscribing.
381
+
382
+ ## Styling and theming
383
+
384
+ The widget renders in a Shadow DOM: style it with CSS custom properties on the host and with `::part()`.
385
+
386
+ ```css
387
+ labyrinthe-otp {
388
+ --lab-otp-primary: #008374; /* buttons, links, focus ring */
389
+ --lab-otp-primary-contrast: #fff; /* text on primary */
390
+ --lab-otp-accent: #F85A40; /* test-mode hint */
391
+ --lab-otp-error: #c2381f;
392
+ --lab-otp-success: #008374;
393
+ --lab-otp-bg: #fff;
394
+ --lab-otp-fg: #19201e;
395
+ --lab-otp-muted: #5b6763;
396
+ --lab-otp-border: #d5dcd9;
397
+ --lab-otp-field: #f8faf9; /* input background */
398
+ --lab-otp-radius: 10px;
399
+ --lab-otp-font: Inter, ui-sans-serif, system-ui, sans-serif;
400
+ --lab-otp-cell-size: 44px; /* code boxes (36px under 360px wide) */
401
+ --lab-otp-gap: 8px;
402
+ }
403
+ labyrinthe-otp::part(root) { border: 0; box-shadow: 0 8px 30px rgb(0 0 0 / 0.08); }
404
+ labyrinthe-otp::part(cell) { font-size: 24px; }
405
+ ```
406
+
407
+ The values above are the light defaults (teal `#008374`, coral `#F85A40`); `theme="dark"`, or the system dark mode
408
+ without `theme="light"`, switches to a dark palette. The host is `display: block` with `max-width: 420px`.
409
+
410
+ Parts: `root`, `title`, `phone-form`, `country-code`, `phone-input`, `button`, `send-button`, `code-form`, `cells`,
411
+ `cell`, `verify-button`, `link`, `change-button`, `resend-button`, `test-code`, `success`, `error`.
412
+
413
+ ## Texts and languages
414
+
415
+ `locale="fr"` (default) or `locale="en"`. Override any text with the `texts` property (or React prop): top-level keys
416
+ and `errors` entries are merged with the locale defaults.
417
+
418
+ ```js
419
+ widget.texts = {
420
+ send: 'Text me a code',
421
+ errors: { invalid_code: 'Nope, try again.' },
422
+ };
423
+ ```
424
+
425
+ | Keys | Placeholders |
426
+ |---|---|
427
+ | `title`, `phoneLabel`, `countryCodeLabel`, `phonePlaceholder`, `phoneHint`, `send`, `sending`, `codeLabel`, `verify`, `verifying`, `changePhone`, `resend`, `resendAvailable`, `attemptsLeftOne`, `success` | — |
428
+ | `codeSentTo` | `{phone}` |
429
+ | `cellLabel` (accessible name of each box) | `{index}`, `{total}` |
430
+ | `resendIn`, `retryIn` | `{time}` (`1:15`, or `1:30:00` beyond an hour) |
431
+ | `attemptsLeftOther` | `{count}` |
432
+ | `testMode` | `{code}` |
433
+ | `errors.<code>` (see [Errors](#errors)) | `{time}` in `resend_too_soon`, `{count}` in `incomplete_code` |
434
+
435
+ `LOCALES` (the built-in French and English texts), `resolveTexts(locale, override)` and the `OtpWidgetTexts` /
436
+ `OtpWidgetTextsOverride` types are exported, for example to build a third language from the English texts.
437
+
438
+ ## Accessibility
439
+
440
+ - Every field has a label; the code boxes are a `role="group"` with an accessible name per box (`cellLabel`).
441
+ - Errors are announced (`role="alert"`, re-announced when the same error repeats); progress (code sent, verified) goes
442
+ to a polite live region.
443
+ - Full keyboard use: arrows, Home, End and Backspace move between boxes, paste fills every box,
444
+ `enterkeyhint="done"`.
445
+ - Focus moves to the first empty box after a send, to *Resend* when a new code is needed, to the success message at
446
+ the end.
447
+ - Visible focus ring, `aria-invalid` on a refused number, `aria-busy` while waiting, transitions disabled with
448
+ `prefers-reduced-motion`.
449
+
450
+ ## Errors
451
+
452
+ Error codes are stable, lower case, and shown with the localised text of `errors.<code>`. Upstream (Labyrinthe)
453
+ messages are never forwarded to the browser, except the maintenance message.
454
+
455
+ | `code` | HTTP from your server | When | Labyrinthe API error |
456
+ |---|---|---|---|
457
+ | `invalid_phone` | 422 | The number is invalid, blacklisted or opted out | `INVALID_PHONE`, `RECIPIENT_NOT_SENDABLE`, local validation |
458
+ | `resend_too_soon` | 429 | A code was sent too recently: countdown of `retry_after_seconds` | `OTP_RESEND_TOO_SOON` |
459
+ | `rate_limited` | 429 | Too many requests (number, account, key) or a send still in progress | `OTP_RATE_LIMITED`, `TOO_MANY_REQUESTS`, `IDEMPOTENCY_REQUEST_IN_PROGRESS` |
460
+ | `no_pending_verification` | 400 | No pending code for this browser (cookie missing, expired or tampered with) | `NOT_FOUND` on verify |
461
+ | `forbidden` | 403 | Refused by `beforeSend()` or by the origin check | — |
462
+ | `invalid_request` | 400, 405, 413, 415, 422 | Malformed request (not `POST`, not JSON, too large, bad `send_id` or code) | — |
463
+ | `service_unavailable` | 503 | Labyrinthe unreachable or misconfigured (key, credits, Sender ID), or [maintenance](#maintenance-503-service_unavailable) | `FEATURE_DISABLED`, `OTP_SERVICE_DISABLED`, others |
464
+ | `account_suspended` | 503 | Your Labyrinthe account is suspended | `ACCOUNT_SUSPENDED` |
465
+ | `send_failed` | 502 | The send failed unexpectedly on the Labyrinthe side, a code may have been sent | `IDEMPOTENCY_KEY_UNRESOLVED` |
466
+ | `server_error` | 500 | Unexpected error in your server (e.g. `onVerified()` threw) | — |
467
+
468
+ Refusals of a code are answered `200 { verified: false, reason }`: `invalid_code` (attempts left shown),
469
+ `expired`, `already_used`, `max_attempts_reached` (the last three ask for a new code). Widget-side codes, with
470
+ `status: null`: `network_error` (your server is unreachable) and `incomplete_code`.
471
+
472
+ `onError()` receives every upstream error except the ones caused by the user (`invalid_phone`, `resend_too_soon`,
473
+ `rate_limited`), plus unexpected ones: log the `requestId` of `@labyrinthe/connect` errors.
474
+
475
+ ### Maintenance (503 `service_unavailable`)
476
+
477
+ When the Labyrinthe platform temporarily shuts down the API or the OTP functions (`503 FEATURE_DISABLED` /
478
+ `OTP_SERVICE_DISABLED`), nothing is sent and the helper answers:
479
+
480
+ ```json
481
+ { "error": { "code": "service_unavailable", "message": "The verification service is temporarily unavailable.",
482
+ "admin_message": "Maintenance until 14:00.", "until": "2026-11-06T14:00:00+00:00", "retry_after_seconds": 5400 } }
483
+ ```
484
+
485
+ - `admin_message`: the platform administrator's message, written for end users (trimmed, at most 500 characters),
486
+ when there is one — the only upstream text forwarded;
487
+ - `until` (ISO 8601) and `retry_after_seconds` + a `Retry-After` header: only when the end is scheduled;
488
+ - `onError()` is called.
489
+
490
+ The widget shows the administrator's message (as plain text) or `errors.service_unavailable`, followed by `retryIn`
491
+ ("Try again in 1:30:00.") when the time left is known. The `labyrinthe-otp:error` event carries `adminMessage` and
492
+ `until` (`Date`). Nothing is retried automatically; the user can try again later.
493
+
494
+ ## Security
495
+
496
+ - **The API key stays on your server.** The browser never sees it, nor `otp_id`, nor the code.
497
+ - `otp_id` and the phone number travel in an **AES-256-GCM encrypted and authenticated** cookie (key derived from
498
+ `secret` with HKDF-SHA-256), `HttpOnly`, `SameSite=Strict`, `Secure` on HTTPS. The browser can neither read nor
499
+ modify it. With `store`, the cookie only carries a random session id (encrypted too).
500
+ - **Trust the number in `onVerified()`**, on the server, never in the browser event.
501
+ - Endpoints accept `POST` + `application/json` only (a cross-site form cannot send it without a CORS preflight), bodies
502
+ up to 4 kB, and reject other origins (`Origin` / `Sec-Fetch-Site`; widen with `allowedOrigins`). Add a CSRF token
503
+ with `requestHeaders` if your app uses one. Responses are `Cache-Control: no-store`.
504
+ - **Every send is a billed SMS**: use `beforeSend()` for rate limits per IP or account, a captcha or an allow-list. The
505
+ API also limits sends per number and per account (`resend_too_soon`, `rate_limited`).
506
+ - **No double SMS.** Each send carries a random `send_id`, kept by the widget until the send succeeds: after a lost
507
+ response, the next click reuses it. The helper turns it into the `Idempotency-Key` with an HMAC of `secret` (the
508
+ browser never chooses the key sent to Labyrinthe), so the API replays the first send instead of sending a new SMS.
509
+ The SDK also retries network errors and 5xx with the same key.
510
+ - **Outcome unknown (`send_failed`).** After `409 IDEMPOTENCY_KEY_UNRESOLVED`, nothing is retried automatically; the
511
+ widget forgets its `send_id`, so a new code requested by the user is sent with a new key.
512
+ - The code is never returned to the browser, except `test_code` with a test key when you explicitly enable
513
+ `exposeTestCode` in development (refused in production; the API never returns it with a live key).
514
+ - The administrator's maintenance message is inserted as text, never as HTML.
515
+
516
+ ## Test mode
517
+
518
+ With a `lsk_test_…` key nothing is sent and nothing is billed. In development, `exposeTestCode: true` shows the code
519
+ inside the widget ("Test mode: the code is …") and in the `sent` event, so the whole flow can be tried without a phone:
520
+
521
+ ```ts
522
+ createOtpHandlers({
523
+ client: new Labyrinthe({ apiKey: 'lsk_test_…' }),
524
+ secret: process.env.LABYRINTHE_OTP_SECRET,
525
+ exposeTestCode: process.env.NODE_ENV === 'development',
526
+ onVerified: () => ({ ok: true }),
527
+ });
528
+ ```
529
+
530
+ ## Size
531
+
532
+ | File | gzip | |
533
+ |---|---|---|
534
+ | `dist/labyrinthe-otp.min.js` | 8.1 kB minified (7.2 kB brotli) | standalone `<script>`; CI budget 10 kB min+gzip (`npm run size`) |
535
+ | `dist/index.js` | 9.6 kB unminified | ESM for bundlers (minified by your build) |
536
+ | `dist/react.js` | 0.7 kB | React wrapper, without the element |
537
+ | `dist/server.js` | 6.8 kB unminified | server only, never shipped to the browser |
538
+
539
+ No runtime dependency. `sideEffects` is declared so bundlers keep the element definition and tree-shake the rest.
540
+
541
+ ## Upgrading
542
+
543
+ - **1.1.x → 1.2.0** (API 1.3.0): update the peer dependency `@labyrinthe/connect` to `^1.2.0`. No code change.
544
+ Maintenance is answered as `service_unavailable` with `admin_message`, `until` and `Retry-After`; the error event
545
+ gains `adminMessage` and `until`; new text `retryIn` (add it if you pass a complete `OtpWidgetTexts` object);
546
+ durations of an hour or more are shown as `h:mm:ss`.
547
+ - **1.1.0 → 1.1.1**: requires `@labyrinthe/connect` ≥ 1.1.1 (short foreign numbers keep their country code). New error
548
+ code `send_failed`.
549
+ - **1.0.x → 1.1.0** (API 1.2.0): code length, alphabet and resend delay come from the API: remove `codeLength`,
550
+ `codeType` and `resendAfterSeconds` from the server options unless you need overrides. Real attempts left, new
551
+ `webOtpDomain` and `stripAccents` options, new error code `account_suspended`, and the send request gains `send_id`
552
+ (custom servers must accept it).
553
+
554
+ Details: [CHANGELOG](CHANGELOG.md).
555
+
556
+ ## Contributing
557
+
558
+ Issues and pull requests go to the monorepo
559
+ [Labyrinthe-Team/labyrinthe-connect-sdks](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks)
560
+ (see [CONTRIBUTING.md](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/blob/main/CONTRIBUTING.md)). In
561
+ `widget-otp/`:
562
+
563
+ ```bash
564
+ npm ci
565
+ npm run check # lint + typecheck + tests (happy-dom, React, server) + build + check:dist + size + publint + attw
566
+
567
+ # integration tests against a local backend, with a TEST key only
568
+ LABYRINTHE_API_KEY=lsk_test_… LABYRINTHE_BASE_URL=http://localhost:8000 npm run test:integration
569
+ ```
570
+
571
+ Security issues: email connect@labyrinthe-rdc.com rather than opening a public issue.
572
+
573
+ ## License
574
+
575
+ MIT — see [LICENSE](LICENSE).