@saeris/hanko 0.0.0 → 0.2.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +346 -0
  4. package/dist/approve/index.d.mts +318 -0
  5. package/dist/approve/index.d.mts.map +1 -0
  6. package/dist/approve/index.mjs +393 -0
  7. package/dist/approve/index.mjs.map +1 -0
  8. package/dist/client/index.d.mts +101 -0
  9. package/dist/client/index.d.mts.map +1 -0
  10. package/dist/client/index.mjs +215 -0
  11. package/dist/client/index.mjs.map +1 -0
  12. package/dist/codes-Ba_qYH6u.mjs +93 -0
  13. package/dist/codes-Ba_qYH6u.mjs.map +1 -0
  14. package/dist/handlers.d.mts +113 -0
  15. package/dist/handlers.d.mts.map +1 -0
  16. package/dist/handlers.mjs +194 -0
  17. package/dist/handlers.mjs.map +1 -0
  18. package/dist/index.d.mts +5 -0
  19. package/dist/index.mjs +345 -0
  20. package/dist/index.mjs.map +1 -0
  21. package/dist/linking-DcQSMgem.mjs +177 -0
  22. package/dist/linking-DcQSMgem.mjs.map +1 -0
  23. package/dist/linking-nKoayyHf.d.mts +133 -0
  24. package/dist/linking-nKoayyHf.d.mts.map +1 -0
  25. package/dist/machine-CRHKjtoP.d.mts +223 -0
  26. package/dist/machine-CRHKjtoP.d.mts.map +1 -0
  27. package/dist/machine-D_5DAFxi.mjs +155 -0
  28. package/dist/machine-D_5DAFxi.mjs.map +1 -0
  29. package/dist/qr.d.mts +58 -0
  30. package/dist/qr.d.mts.map +1 -0
  31. package/dist/qr.mjs +27 -0
  32. package/dist/qr.mjs.map +1 -0
  33. package/dist/scan/index.d.mts +384 -0
  34. package/dist/scan/index.d.mts.map +1 -0
  35. package/dist/scan/index.mjs +409 -0
  36. package/dist/scan/index.mjs.map +1 -0
  37. package/dist/scan/worker.d.mts +2 -0
  38. package/dist/scan/worker.mjs +2 -0
  39. package/dist/server-BhoYRkCm.d.mts +257 -0
  40. package/dist/server-BhoYRkCm.d.mts.map +1 -0
  41. package/dist/stores/kv.d.mts +64 -0
  42. package/dist/stores/kv.d.mts.map +1 -0
  43. package/dist/stores/kv.mjs +87 -0
  44. package/dist/stores/kv.mjs.map +1 -0
  45. package/dist/stores/memory.d.mts +22 -0
  46. package/dist/stores/memory.d.mts.map +1 -0
  47. package/dist/stores/memory.mjs +42 -0
  48. package/dist/stores/memory.mjs.map +1 -0
  49. package/dist/types-BvBIFPH6.mjs +7 -0
  50. package/dist/types-BvBIFPH6.mjs.map +1 -0
  51. package/dist/types-C82lb-zX.d.mts +82 -0
  52. package/dist/types-C82lb-zX.d.mts.map +1 -0
  53. package/dist/worker-BdwaK1uX.mjs +5291 -0
  54. package/dist/worker-BdwaK1uX.mjs.map +1 -0
  55. package/dist/worker-DxbdBA2z.d.mts +164 -0
  56. package/dist/worker-DxbdBA2z.d.mts.map +1 -0
  57. package/package.json +116 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+
4
+ ## 0.2.1
5
+ <sub>2026-09-02</sub>
6
+
7
+ - Refresh the README, and give the example real navigation.
8
+
9
+ ## 0.2.0
10
+ <sub>2026-09-02</sub>
11
+
12
+ - Add a zero-dependency QR decoder, and scan with it.
13
+ The library previously delegated scanning to a third-party dependency. It now
14
+ ships its own decoder: pixels in, string out, no camera and no DOM, so the same
15
+ code runs in a browser, on a worker, on a server, or in a test.
16
+ Recognition is measured against the 718-image BoofCV benchmark, where it reads
17
+ **74.4%**. Published results on the same corpus are BoofCV 60.69%, ZBar 38.95%,
18
+ ZXing 31.87% and jsQR 24.8%.
19
+ - `@saeris/hanko/scan` — `createQrDecoder` for a synchronous decode, and
20
+ `createProgressiveScanner` for a camera, which spreads the retry ladder
21
+ across frames rather than cramming it into one.
22
+ - `@saeris/hanko/scan/worker` — `serveDecoder`, to run a decode off the main
23
+ thread. A viewfinder needs this: the ladder deliberately outruns its own time
24
+ budget, so a synchronous decode stalls the preview on exactly the frames
25
+ someone is lining up.
26
+ Reed-Solomon correction also accepts erasures — damage whose position is known
27
+ but whose value is not — which is worth double the capacity when the imaging
28
+ layer can say where the damage is.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Drake Costa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,346 @@
1
+ <div align="center">
2
+
3
+ # 💮 hanko「判子」
4
+
5
+ [![CI status][ci_badge]][ci] [![npm][npm_badge]][npm] [![License][license_badge]][license]
6
+
7
+ QR-assisted device sign-in for screens without a keyboard, with a QR decoder of its own.
8
+
9
+ </div>
10
+
11
+ ---
12
+
13
+ **判子** (_hanko_) is the personal seal used in Japan in place of a signature —
14
+ pressed once to authorize something on your behalf. Which is the whole flow: a
15
+ TV asks, you approve it from your phone, the TV is signed in.
16
+
17
+ ## 🎯 What it does
18
+
19
+ An implementation of [RFC 8628][rfc], the OAuth 2.0 Device Authorization Grant
20
+ — the flow behind Plex and Steam's TV sign-in, and Discord's device
21
+ authorization. A device that is awkward to type on shows a short code and a QR;
22
+ the user authorizes on a phone they are already signed in on; the device polls
23
+ until it hears back.
24
+
25
+ ```mermaid
26
+ sequenceDiagram
27
+ participant TV as 📺 Device
28
+ participant API as ⚙️ Your API
29
+ participant Phone as 📱 Phone
30
+
31
+ TV->>API: POST /device/authorize
32
+ API-->>TV: user_code, device_code, verification_uri
33
+ Note over TV: shows WDJB-MJHT<br/>and a QR of the link
34
+
35
+ loop until approved or expired
36
+ TV->>API: POST /device/token
37
+ API-->>TV: authorization_pending
38
+ end
39
+
40
+ Phone->>Phone: scans the QR
41
+ Phone->>API: POST /link (approve)
42
+ API-->>Phone: approved
43
+
44
+ TV->>API: POST /device/token
45
+ API-->>TV: access_token
46
+ ```
47
+
48
+ Three entry points, one per device in the flow. No UI components for any of
49
+ them — state and lifecycle hooks instead, so React, Vue, Svelte, Solid, Angular
50
+ and React Native each bind it with their own conventions.
51
+
52
+ | Entry | Runs on | Gives you |
53
+ | ----------------------------- | ---------------------- | ------------------------------------------ |
54
+ | `@saeris/hanko` + `/handlers` | your API | grant lifecycle, `Request`→`Response` glue |
55
+ | `@saeris/hanko/client` | the device signing in | poll loop, QR rendering |
56
+ | `@saeris/hanko/approve` | the device granting it | QR reading, confirmation challenges |
57
+
58
+ ## 📷 The QR decoder
59
+
60
+ `@saeris/hanko/scan` reads QR codes. Pixels in, string out — no camera, no DOM,
61
+ no dependencies — so the same code runs in a browser, in a worker, on a server,
62
+ or in a test.
63
+
64
+ It exists because every JavaScript alternative is unmaintained: jsQR's last code
65
+ commit was August 2021, qr-scanner's November 2022, and `BarcodeDetector` is
66
+ still a WICG incubation that Safari has never shipped.
67
+
68
+ Measured against the 718-image [BoofCV benchmark][boofcv] — photographs, not
69
+ renders:
70
+
71
+ | Decoder | Recognition |
72
+ | --------- | ----------- |
73
+ | **hanko** | **74.4%** |
74
+ | BoofCV | 60.69% |
75
+ | ZBar | 38.95% |
76
+ | ZXing | 31.87% |
77
+ | jsQR | 24.8% |
78
+
79
+ ### Decoding one image
80
+
81
+ ```ts
82
+ import { createQrDecoder, toGray } from "@saeris/hanko/scan";
83
+
84
+ const decoder = createQrDecoder();
85
+ const symbol = decoder.decode(toGray(rgba, width, height));
86
+
87
+ symbol?.value; // the payload, or null if nothing was found
88
+ ```
89
+
90
+ ### Scanning with a camera
91
+
92
+ Three pieces: a worker holding the decoder, something to turn a camera into
93
+ greyscale frames, and a loop between them.
94
+
95
+ The worker is not optional. The retry ladder deliberately outruns its own time
96
+ budget — the budget is checked between attempts, not inside them — so a
97
+ synchronous decode stalls the preview on exactly the frames someone is lining
98
+ up.
99
+
100
+ ```ts
101
+ // decoder.worker.ts
102
+ import { serveDecoder } from "@saeris/hanko/scan/worker";
103
+
104
+ // No time budget: a worker has no preview to block, so the ladder may run to
105
+ // exhaustion. Worth roughly twenty points of recognition over the 120ms a
106
+ // synchronous decode has to respect.
107
+ serveDecoder(self, { timeBudgetMs: 0 });
108
+ ```
109
+
110
+ ```ts
111
+ // camera.ts
112
+ export const openCamera = async (video: HTMLVideoElement) => {
113
+ const stream = await navigator.mediaDevices.getUserMedia({
114
+ // A preference, not a guarantee — a laptop with one camera ignores it.
115
+ video: { facingMode: { ideal: `environment` } },
116
+ audio: false
117
+ });
118
+
119
+ video.srcObject = stream;
120
+ // Without this iOS opens the system player full-screen instead of playing
121
+ // inline, and there is no preview to aim with.
122
+ video.setAttribute(`playsinline`, ``);
123
+ await video.play();
124
+
125
+ const canvas = document.createElement(`canvas`);
126
+ // `willReadFrequently` matters: without it the browser keeps the canvas on
127
+ // the GPU and every `getImageData` is a synchronous readback, which is the
128
+ // most expensive thing in this loop.
129
+ const context = canvas.getContext(`2d`, { willReadFrequently: true })!;
130
+
131
+ return {
132
+ grab: () => {
133
+ const width = video.videoWidth;
134
+ const height = video.videoHeight;
135
+ // Zero before the first paint, and on iOS while the element is paused.
136
+ if (width === 0 || height === 0) return null;
137
+
138
+ canvas.width = width;
139
+ canvas.height = height;
140
+ context.drawImage(video, 0, 0);
141
+ const { data } = context.getImageData(0, 0, width, height);
142
+
143
+ // Converted here rather than in the worker: it quarters the bytes
144
+ // transferred per frame.
145
+ const grey = new Uint8ClampedArray(width * height);
146
+ for (let i = 0, p = 0; i < grey.length; i++, p += 4) {
147
+ grey[i] = (data[p] * 77 + data[p + 1] * 150 + data[p + 2] * 29) >> 8;
148
+ }
149
+
150
+ return { data: grey, width, height };
151
+ },
152
+
153
+ stop: () => {
154
+ for (const track of stream.getTracks()) track.stop();
155
+ video.srcObject = null;
156
+ }
157
+ };
158
+ };
159
+ ```
160
+
161
+ ```ts
162
+ // the loop
163
+ import { createWorkerScanner } from "@saeris/hanko/scan";
164
+ import { openCamera } from "./camera";
165
+
166
+ const camera = await openCamera(document.querySelector(`video`)!);
167
+ const scanner = createWorkerScanner(
168
+ new Worker(new URL("./decoder.worker.ts", import.meta.url), {
169
+ type: `module`
170
+ })
171
+ );
172
+
173
+ let scanning = true;
174
+
175
+ while (scanning) {
176
+ const frame = camera.grab();
177
+
178
+ if (frame !== null) {
179
+ // Awaited one at a time on purpose. A camera produces frames faster than
180
+ // they decode, and `scan` drops anything offered while a decode is in
181
+ // flight rather than queueing it — the next frame is always a better
182
+ // input than a backlogged one.
183
+ const symbol = await scanner.scan(frame);
184
+
185
+ if (symbol !== null) {
186
+ console.log(symbol.value);
187
+ scanning = false;
188
+ break;
189
+ }
190
+ }
191
+
192
+ // Yield to the compositor. The decode happens on another thread, but this
193
+ // loop still runs on the main one.
194
+ await new Promise((resolve) => requestAnimationFrame(resolve));
195
+ }
196
+
197
+ scanner.close();
198
+ camera.stop();
199
+ ```
200
+
201
+ A scanned payload is untrusted input. If you turn one into a link, restrict it
202
+ to `http:` and `https:` and show the raw text beside it — `new URL()` happily
203
+ accepts `javascript:`.
204
+
205
+ For a camera that should keep trying across frames rather than exhausting the
206
+ ladder on one, `createProgressiveScanner` spends a small budget per frame and
207
+ raises it while a symbol is in view.
208
+
209
+ Coverage per condition, and the negative results behind it, live in
210
+ [plan/qr-coverage.md](plan/qr-coverage.md). Both move often.
211
+
212
+ ## 🏗️ How it works
213
+
214
+ hanko owns the **grant lifecycle** and nothing else. It never mints sessions or
215
+ touches your user table: it tells you a grant was approved and by whom, and
216
+ issuing a token from that is your application's decision.
217
+
218
+ ```mermaid
219
+ flowchart LR
220
+ subgraph device["📺 Device"]
221
+ client["@saeris/hanko/client"]
222
+ end
223
+
224
+ subgraph api["⚙️ Your API"]
225
+ server["@saeris/hanko + /handlers"]
226
+ store[("DeviceGrantStore")]
227
+ server <--> store
228
+ end
229
+
230
+ subgraph phone["📱 Phone"]
231
+ approve["@saeris/hanko/approve"]
232
+ scan["@saeris/hanko/scan"]
233
+ approve --> scan
234
+ end
235
+
236
+ client -->|authorize, then poll| server
237
+ approve -->|approve| server
238
+ ```
239
+
240
+ ### Decisions worth knowing
241
+
242
+ - **Zero runtime dependencies.** The grant lifecycle is the product; rendering a
243
+ QR is a different class of problem, so it sits behind `@saeris/hanko/qr` and
244
+ defers to [`etiket`][etiket] as an optional peer. Reading one had no
245
+ maintained option at all, which is why `/scan` exists.
246
+ - **WinterTC primitives only.** `Request`, `Response`, `crypto.getRandomValues` —
247
+ so one build runs on Node, Deno, Bun, Cloudflare Workers and in a browser,
248
+ with no adapter per runtime.
249
+ - **A short code is a small keyspace.** That is the deliberate trade for
250
+ readability, and [RFC 8628 §5.1][rfc-security] **requires you to rate-limit
251
+ attempts**. hanko does not do it for you: that belongs at your HTTP boundary,
252
+ where you can see IPs.
253
+ - **Approval is two steps, not one.** Scanning a code that says "approve this"
254
+ and approving it are separate, because a QR is a link anyone can point a
255
+ camera at. `/approve` ships the confirmation challenge that closes that gap.
256
+ - **The store is four methods.** `create`, `findByDeviceCode`, `findByUserCode`
257
+ and `update`, plus an optional `prune` — small enough that an adapter for your
258
+ database is a short file. The bundled memory store is for development only.
259
+
260
+ ## 📦 Install
261
+
262
+ ```sh
263
+ yarn add @saeris/hanko
264
+ ```
265
+
266
+ Add `etiket` only if you render the device screen:
267
+
268
+ ```sh
269
+ yarn add etiket
270
+ ```
271
+
272
+ Codes follow the spec's worked example: 8 characters from a 20-consonant
273
+ alphabet, shown as `WDJB-MJHT`. No vowels, so a code cannot spell a word; no
274
+ digits, so there is no `0`/`O` or `1`/`l`/`I` to misread.
275
+
276
+ ## 🔧 Examples
277
+
278
+ The whole sign-in flow, wired end to end, lives in
279
+ [`examples/astro`](examples/astro) — three surfaces, a store, rate limiting and
280
+ the confirmation challenge, with the framework-specific parts explained in
281
+ [its README](examples/astro/README.md). That is the reference for building the
282
+ flow; this README covers the pieces rather than the assembly.
283
+
284
+ More are planned — Next.js first, mirroring the Astro one closely enough to
285
+ diff, then other frameworks and UI stacks.
286
+
287
+ | Route | What it is |
288
+ | ---------------- | -------------------------------------------------------------------- |
289
+ | `/` | the map — what to open where, and whether the URL reaches a phone |
290
+ | `/tv` | the device screen — a short code and a QR for a pending grant |
291
+ | `/signin` | the phone's own sign-in; this identity is what the device inherits |
292
+ | `/account` | approved devices, and revoking them |
293
+ | `/link` | the approval page — scans that QR, or takes a typed code |
294
+ | `/scanner` | a bare scanner, showing the payload raw and as a link |
295
+ | `/scanner-debug` | the same, reporting each stage on screen for a phone with no console |
296
+
297
+ ```bash
298
+ yarn demo # builds the library, then serves the example
299
+ yarn demo:share # the same, over TLS so a phone can reach it
300
+ ```
301
+
302
+ A camera needs a secure context, so a bare LAN IP will not do — `demo:share`
303
+ puts a TLS proxy in front, which is what makes the phone half testable at all.
304
+
305
+ ## 🧪 Checks
306
+
307
+ | What | Command | Notes |
308
+ | -------------- | ------------------- | ----------------------------------------------- |
309
+ | Everything | `vp run ci` | `vp pack && vp check && vp test`, what CI runs. |
310
+ | Tests | `vp test` | Pure TypeScript; never imports react-native. |
311
+ | Recognition | `yarn bench:corpus` | The 718-image corpus, sharded across workers. |
312
+ | Decode profile | `yarn bench` | A still image, for `deoptkit`. |
313
+
314
+ `vp pack` has to run before `vp check`: the example resolves `@saeris/hanko`
315
+ through the `exports` map, which points at `dist/`, so a clean checkout cannot
316
+ typecheck it until the library is built.
317
+
318
+ ## 🚀 Releasing
319
+
320
+ Driven by [bumpy][bumpy]. Every change carries a **bump file** in `.bumpy/`
321
+ saying what moved and how far, so the changelog cannot fall behind the code.
322
+
323
+ ```bash
324
+ yarn bumpy add
325
+ ```
326
+
327
+ Merging that opens a **Version PR**; merging _that_ tags the release and
328
+ publishes to npm over OIDC trusted publishing, so no token is stored. The
329
+ example deploys to Vercel on push, independently of releases.
330
+
331
+ ## 🥂 License
332
+
333
+ [MIT][license] © [Drake Costa][personal-website]
334
+
335
+ [rfc]: https://datatracker.ietf.org/doc/html/rfc8628
336
+ [rfc-security]: https://datatracker.ietf.org/doc/html/rfc8628#section-5
337
+ [etiket]: https://github.com/productdevbook/etiket
338
+ [boofcv]: https://boofcv.org/index.php?title=Performance:QrCode
339
+ [bumpy]: https://github.com/dmno-dev/bumpy
340
+ [ci_badge]: https://github.com/Saeris/hanko/actions/workflows/ci.yml/badge.svg
341
+ [ci]: https://github.com/Saeris/hanko/actions/workflows/ci.yml
342
+ [npm_badge]: https://img.shields.io/npm/v/@saeris/hanko.svg
343
+ [npm]: https://www.npmjs.com/package/@saeris/hanko
344
+ [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
345
+ [license]: https://github.com/Saeris/hanko/blob/main/LICENSE.md
346
+ [personal-website]: https://saeris.gg