@saeris/hanko 0.0.0 → 0.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +605 -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 +381 -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
@@ -0,0 +1,133 @@
1
+ //#region src/linking.d.ts
2
+ /**
3
+ * App-opening for scanned QR codes.
4
+ *
5
+ * The goal: one QR payload that opens a native app when it is installed, and a
6
+ * web page when it is not — without the device ever showing an error.
7
+ *
8
+ * That rules out custom schemes as the primary target. A `myapp://` QR read by
9
+ * the OS camera on a phone without the app fails silently and
10
+ * unrecoverably — the user sees "cannot open" and has nowhere to go. Universal
11
+ * Links (iOS) and App Links (Android) solve this by making the payload an
12
+ * ordinary `https://` URL that the OS *routes* to the app when the domain and
13
+ * app are associated, and to the browser when they are not.
14
+ *
15
+ * So the QR keeps encoding `verification_uri_complete` exactly as before. The
16
+ * routing lives in association files served from the same origin, not in a
17
+ * different payload. What this module provides is the association files, the
18
+ * URL parsing on the receiving end, and a custom-scheme fallback for the cases
19
+ * that genuinely need one.
20
+ */
21
+ /** Where an inbound approval link came from. */
22
+ type LinkSource =
23
+ /** A Universal Link / App Link that opened the native app. */
24
+ `app-link` |
25
+ /** A custom scheme (`myapp://`). Only reached when explicitly used. */
26
+ `custom-scheme` |
27
+ /** An ordinary web navigation — the app was not installed, or this is a PWA. */
28
+ `web`;
29
+ interface ParsedApprovalLink {
30
+ userCode: string;
31
+ source: LinkSource;
32
+ /** The full URL, for logging or to hand to a router. */
33
+ href: string;
34
+ }
35
+ interface LinkConfig {
36
+ /**
37
+ * Origin serving the approval page, e.g. `https://example.com`.
38
+ * Must be HTTPS: both Apple and Google refuse to associate a plain-HTTP domain.
39
+ */
40
+ origin: string;
41
+ /** Path of the approval page. Also the path the association files claim. */
42
+ path?: string;
43
+ /** Query parameter carrying the code. */
44
+ codeParam?: string;
45
+ /**
46
+ * Custom scheme, e.g. `beerjournal`. Optional and NOT the primary path.
47
+ *
48
+ * Worth registering anyway: it is the only way to reach the app from
49
+ * contexts that refuse to follow universal links — some in-app browsers, and
50
+ * a few QR readers that strip them.
51
+ */
52
+ scheme?: string;
53
+ }
54
+ /**
55
+ * Build the URL a QR should encode.
56
+ *
57
+ * Deliberately an `https://` URL rather than a scheme: this is the same string
58
+ * `verification_uri_complete` already carries, which is what lets one QR serve
59
+ * a phone with the app, a phone without it, and a laptop.
60
+ */
61
+ declare const buildApprovalUrl: (userCode: string, { origin, path, codeParam }: LinkConfig) => string;
62
+ /**
63
+ * Build the custom-scheme equivalent.
64
+ *
65
+ * For a "Open in app" button on the web fallback page — a deliberate tap, where
66
+ * a failure is recoverable because the user is already looking at a working web
67
+ * page. Never for the QR itself.
68
+ */
69
+ declare const buildAppSchemeUrl: (userCode: string, { scheme, path, codeParam }: LinkConfig) => string;
70
+ declare const parseApprovalLink: (href: string, { codeParam, scheme }?: Partial<LinkConfig>) => ParsedApprovalLink | null;
71
+ /**
72
+ * Apple App Site Association, for `/.well-known/apple-app-site-association`.
73
+ *
74
+ * Serve as `application/json` over HTTPS with **no redirects** — Apple fetches
75
+ * it directly and a redirect makes the association fail silently, which is the
76
+ * single most common reason universal links "just don't work".
77
+ *
78
+ * @param appIds `<TEAM_ID>.<BUNDLE_ID>`, e.g. `QQ57RJ5UTD.gg.saeris.beerjournal`
79
+ */
80
+ declare const appleAppSiteAssociation: (appIds: string[], { paths }?: {
81
+ paths?: string[];
82
+ }) => object;
83
+ /**
84
+ * Digital Asset Links, for `/.well-known/assetlinks.json`.
85
+ *
86
+ * @param fingerprints SHA-256 of the app's SIGNING certificate. Note that Play
87
+ * App Signing re-signs the upload, so the fingerprint that works in
88
+ * production is the one from the Play Console — not your local keystore. A
89
+ * local-only fingerprint is why app links commonly work in debug and break
90
+ * after release.
91
+ */
92
+ declare const digitalAssetLinks: (packageName: string, fingerprints: string[]) => object[];
93
+ /**
94
+ * Expo app config fragment for universal/app links.
95
+ *
96
+ * Merge into `app.json`. Requires a development or production build — the
97
+ * entitlement is registered at build time, so **universal links do not work in
98
+ * Expo Go**, and a project pinned to Expo Go must use the web fallback until it
99
+ * moves to dev builds.
100
+ */
101
+ declare const expoLinkingConfig: ({ origin, path, scheme }: LinkConfig) => object;
102
+ /**
103
+ * `launch_handler` fragment for a PWA manifest.
104
+ *
105
+ * `navigate-existing` so a scanned link reuses the already-open window rather
106
+ * than stacking a second one. An approval screen that opened behind the window
107
+ * the user was already looking at would appear not to have worked at all.
108
+ *
109
+ * Pair with `window.launchQueue.setConsumer()` to read the target URL — see
110
+ * {@link consumeLaunchTarget}.
111
+ */
112
+ declare const pwaLaunchHandler: () => object;
113
+ /**
114
+ * Read the URL an installed PWA was launched with.
115
+ *
116
+ * With `navigate-existing`, the window is reused and `location.href` may
117
+ * already be correct — but when the app was cold-started or the consumer runs
118
+ * before navigation settles, `launchQueue` is the only reliable source.
119
+ *
120
+ * Where `launchQueue` is unsupported — Safari and Firefox, as of 2026 — this
121
+ * calls back with the current location ONLY when `fallbackToLocation` is set.
122
+ * It defaults to false because the fallback cannot tell a launch from an
123
+ * ordinary page load: it fires on every visit, and a caller that treats the
124
+ * result as "the user just arrived from a link" will act on a URL nobody
125
+ * followed. Opt in when the page is only ever reached by launch.
126
+ */
127
+ declare const consumeLaunchTarget: (onTarget: (href: string) => void, { currentHref, fallbackToLocation }?: {
128
+ currentHref?: string;
129
+ fallbackToLocation?: boolean;
130
+ }) => void;
131
+ //#endregion
132
+ export { buildAppSchemeUrl as a, digitalAssetLinks as c, pwaLaunchHandler as d, appleAppSiteAssociation as i, expoLinkingConfig as l, LinkSource as n, buildApprovalUrl as o, ParsedApprovalLink as r, consumeLaunchTarget as s, LinkConfig as t, parseApprovalLink as u };
133
+ //# sourceMappingURL=linking-nKoayyHf.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linking-nKoayyHf.d.mts","names":[],"sources":["../src/linking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;KAqBY;;;;;;;UAQK;EACf;EACA,QAAQ;;EAER;;UAGe;;;;;EAKf;;EAEA;;EAEA;;;;;;;;EAQA;;;;;;;;;cAUW,mBACX,oBACA,QAAA,MAAA,aAAqD;;;;;;;;cAc1C,oBACX,oBACA,QAAA,MAAA,aAAqD;cAuC1C,oBACX,gBACA,WAAA,WAAqC,QAAQ,gBAC5C;;;;;;;;;;cAsCU,0BACX,oBACA;EAAoC;;;;;;;;;;;cAkBzB,oBACX,qBACA;;;;;;;;;cAoBW,sBAAqB,QAAA,MAAA,UAI/B;;;;;;;;;;;cAoCU;;;;;;;;;;;;;;;cAkBA,sBACX,WAAW,yBACX,aAAA;EAGK;EAAsB"}
@@ -0,0 +1,223 @@
1
+ //#region src/machine.d.ts
2
+ /**
3
+ * The device-authorization flow as two explicit state machines.
4
+ *
5
+ * Modeled the way xstate would model it — states, events, and a declarative
6
+ * transition table — but hand-rolled on WinterTC primitives so the library
7
+ * stays dependency-free. What we keep from that shape is the part that pays:
8
+ * transitions are DATA, readable in one place and checkable against RFC 8628
9
+ * side by side, rather than control flow scattered across functions.
10
+ *
11
+ * What we deliberately omit is the actor/service layer. Nothing here needs to
12
+ * observe transitions from outside, and an interpreter abstraction for a
13
+ * single consumer would be weight without benefit.
14
+ *
15
+ * Side effects live outside: these reducers are pure, and the classes in
16
+ * `server.ts` / `client/index.ts` own the I/O and the state itself.
17
+ */
18
+ /**
19
+ * Server-side lifecycle of one authorization attempt.
20
+ *
21
+ * `pending` is the only non-terminal state. `consumed` is distinct from
22
+ * `approved` because approval is not what ends the flow — redemption is. A
23
+ * device_code that stayed redeemable after approval would be a replayable
24
+ * bearer credential.
25
+ */
26
+ type GrantState = `pending` | `approved` | `denied` | `expired` | `consumed`;
27
+ /** Events that can move a grant. */
28
+ type GrantEvent =
29
+ /** The user authorized on their phone. Carries who they are. */
30
+ {
31
+ type: `APPROVE`;
32
+ subject: string;
33
+ } |
34
+ /** The user refused. */
35
+ {
36
+ type: `DENY`;
37
+ } |
38
+ /** The deadline passed. Raised on read, not by a timer — see `server.ts`. */
39
+ {
40
+ type: `EXPIRE`;
41
+ } |
42
+ /** The device redeemed its approval. Terminal. */
43
+ {
44
+ type: `REDEEM`;
45
+ };
46
+ /** Whether `event` is legal in `state`. */
47
+ declare const canTransitionGrant: (state: GrantState, event: GrantEvent[`type`]) => boolean;
48
+ /**
49
+ * Pure grant reducer. Returns the same state when the event is illegal, so
50
+ * callers can compare identity to detect a rejected transition.
51
+ */
52
+ declare const grantTransition: (state: GrantState, event: GrantEvent) => GrantState;
53
+ /** Terminal states accept no further events. */
54
+ declare const isGrantSettled: (state: GrantState) => boolean;
55
+ /**
56
+ * Device-side polling lifecycle.
57
+ *
58
+ * States mirror what the screen is doing, so a UI can render directly from
59
+ * them: `waiting` is the gap between polls, `polling` is a request in flight.
60
+ * The three terminal states are the outcomes the screen must show.
61
+ */
62
+ type PollState = `idle` | `waiting` | `polling` | `authorized` | `denied` | `expired` | `aborted`;
63
+ /**
64
+ * Events driving the poll loop.
65
+ *
66
+ * The four RFC error codes appear verbatim as event types so the mapping from
67
+ * a token-endpoint response to a transition needs no translation layer.
68
+ */
69
+ type PollEvent = {
70
+ type: `START`;
71
+ } | {
72
+ type: `TICK`;
73
+ } | {
74
+ type: `AUTHORIZATION_PENDING`;
75
+ } | {
76
+ type: `SLOW_DOWN`;
77
+ } | {
78
+ type: `ACCESS_DENIED`;
79
+ } | {
80
+ type: `EXPIRED_TOKEN`;
81
+ } |
82
+ /** A response with no `error` — the grant was redeemed. */
83
+ {
84
+ type: `SUCCESS`;
85
+ } |
86
+ /** Network failure. Distinct from SLOW_DOWN: congestion, not policy. */
87
+ {
88
+ type: `NETWORK_ERROR`;
89
+ } |
90
+ /** The client's own deadline elapsed. */
91
+ {
92
+ type: `DEADLINE`;
93
+ } |
94
+ /** The screen was dismissed. */
95
+ {
96
+ type: `ABORT`;
97
+ };
98
+ /**
99
+ * Context carried alongside the poll state.
100
+ *
101
+ * Separate from the state itself, exactly as xstate separates finite state
102
+ * from extended state: the interval changes constantly but is not a state —
103
+ * `waiting` at 5s and `waiting` at 20s are the same state, different context.
104
+ */
105
+ interface PollContext {
106
+ /** Current wait between polls, in seconds. */
107
+ intervalSeconds: number;
108
+ /** Epoch ms after which the flow is over regardless of the server. */
109
+ deadline: number;
110
+ /** Polls attempted. Useful for UI ("still waiting…") and diagnostics. */
111
+ attempts: number;
112
+ }
113
+ declare const canTransitionPoll: (state: PollState, event: PollEvent[`type`]) => boolean;
114
+ declare const pollTransition: (state: PollState, event: PollEvent) => PollState;
115
+ declare const isPollSettled: (state: PollState) => boolean;
116
+ /**
117
+ * Context reducer, pure and separate from the state reducer.
118
+ *
119
+ * The two interval rules live here together, which is the point: `SLOW_DOWN`
120
+ * is a fixed +5s per RFC 8628 §3.5 ("increased by 5 seconds for this and all
121
+ * subsequent requests"), while `NETWORK_ERROR` doubles. Conflating them is the
122
+ * easy mistake, and side-by-side they cannot be confused.
123
+ */
124
+ declare const pollContextTransition: (context: PollContext, event: PollEvent) => PollContext;
125
+ /** RFC 8628 §3.5: `slow_down` adds exactly this many seconds, permanently. */
126
+ declare const SLOW_DOWN_INCREMENT_SECONDS = 5;
127
+ /** Ceiling for network backoff, so a long outage cannot stall the flow. */
128
+ declare const MAX_BACKOFF_SECONDS = 60;
129
+ /**
130
+ * Map a token-endpoint response's `error` field to a poll event.
131
+ *
132
+ * `undefined` means success. An unrecognized code maps to
133
+ * `AUTHORIZATION_PENDING` rather than success or failure: an unknown code is
134
+ * not evidence the user decided anything, and the deadline still bounds the
135
+ * loop. Treating it as success would sign the device in on a garbage payload.
136
+ */
137
+ declare const eventForTokenError: (error: string | undefined) => PollEvent;
138
+ /**
139
+ * The approving device's lifecycle — the third participant in the flow.
140
+ *
141
+ * Runs on the phone that is ALREADY authenticated. Two ways in, as the
142
+ * ecosystem shows: Plex hands the OS camera a URL and the user lands in a
143
+ * browser (`scanning` is skipped, the code arrives from the query string),
144
+ * while Discord and Steam scan inside their own app and never leave it.
145
+ *
146
+ * `confirming` exists because approving must not be one tap on an unverified
147
+ * code. RFC 8628 §5.4 asks the user to check that the code matches the screen
148
+ * in front of them; that check is the only defense against a phished QR
149
+ * pointing at an attacker's device.
150
+ */
151
+ type ApprovalState = `idle` |
152
+ /** Camera open, looking for a code. Skipped when arriving by URL. */
153
+ `scanning` |
154
+ /** A code was read or typed; asking the server what it belongs to. */
155
+ `resolving` |
156
+ /** Server knows the grant. Running the challenge before approve/deny. */
157
+ `confirming` |
158
+ /** Sending the decision. */
159
+ `submitting` | `approved` | `denied` |
160
+ /** The grant was already expired, consumed, or unknown. */
161
+ `invalid` |
162
+ /** Camera or network failure. Recoverable — the user can retry. */
163
+ `failed`;
164
+ type ApprovalEvent =
165
+ /** Open the scanner (in-app path). */
166
+ {
167
+ type: `SCAN`;
168
+ } |
169
+ /** A code arrived — scanned, typed, or read from the URL. */
170
+ {
171
+ type: `CODE`;
172
+ userCode: string;
173
+ } |
174
+ /** The server resolved the code to a live grant. */
175
+ {
176
+ type: `RESOLVED`;
177
+ } |
178
+ /** The server did not recognize the code, or it is no longer live. */
179
+ {
180
+ type: `REJECTED`;
181
+ } |
182
+ /** The challenge passed — the user proved they are looking at the screen. */
183
+ {
184
+ type: `CONFIRMED`;
185
+ } |
186
+ /** The challenge failed. Back to confirming so the user can retry. */
187
+ {
188
+ type: `CHALLENGE_FAILED`;
189
+ } |
190
+ /** The user chose to approve. */
191
+ {
192
+ type: `APPROVE`;
193
+ } |
194
+ /** The user chose to refuse. */
195
+ {
196
+ type: `DENY`;
197
+ } |
198
+ /** The server accepted the decision. */
199
+ {
200
+ type: `SUBMITTED`;
201
+ approved: boolean;
202
+ } |
203
+ /** Camera or network failure. */
204
+ {
205
+ type: `ERROR`;
206
+ } |
207
+ /** Start over after a failure or an invalid code. */
208
+ {
209
+ type: `RESET`;
210
+ };
211
+ declare const canTransitionApproval: (state: ApprovalState, event: ApprovalEvent[`type`]) => boolean;
212
+ /**
213
+ * Pure approval reducer.
214
+ *
215
+ * `SUBMITTED` is the one event whose target depends on its payload — the same
216
+ * transition lands on `approved` or `denied` according to what the user chose,
217
+ * so the table's entry is overridden here.
218
+ */
219
+ declare const approvalTransition: (state: ApprovalState, event: ApprovalEvent) => ApprovalState;
220
+ declare const isApprovalSettled: (state: ApprovalState) => boolean;
221
+ //#endregion
222
+ export { isGrantSettled as _, MAX_BACKOFF_SECONDS as a, pollTransition as b, PollState as c, canTransitionApproval as d, canTransitionGrant as f, isApprovalSettled as g, grantTransition as h, GrantState as i, SLOW_DOWN_INCREMENT_SECONDS as l, eventForTokenError as m, ApprovalState as n, PollContext as o, canTransitionPoll as p, GrantEvent as r, PollEvent as s, ApprovalEvent as t, approvalTransition as u, isPollSettled as v, pollContextTransition as y };
223
+ //# sourceMappingURL=machine-CRHKjtoP.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"machine-CRHKjtoP.d.mts","names":[],"sources":["../src/machine.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;KA2BY;;KAQA;;;EAEN;EAAiB;;;;EAEjB;;;;EAEA;;;;EAEA;;;cA+BO,qBACX,OAAO,YACP,OAAO;;;;;cAOI,kBACX,OAAO,YACP,OAAO,eACN;;cAGU,iBAAkB,OAAO;;;;;;;;KAY1B;;;;;;;KAeA;EACN;;EACA;;EACA;;EACA;;EACA;;EACA;;;;EAEA;;;;EAEA;;;;EAEA;;;;EAEA;;;;;;;;;UAoCW;;EAEf;;EAEA;;EAEA;;cAGW,oBACX,OAAO,WACP,OAAO;cAGI,iBAAkB,OAAO,WAAW,OAAO,cAAY;cAGvD,gBAAiB,OAAO;;;;;;;;;cAWxB,wBACX,SAAS,aACT,OAAO,cACN;;cAoCU;;cAGA;;;;;;;;;cAUA,qBAAsB,8BAA4B;;;;;;;;;;;;;;KAgCnD;;;;;;;;;;;;;KAiBA;;;EAEN;;;;EAEA;EAAc;;;;EAEd;;;;EAEA;;;;EAEA;;;;EAEA;;;;EAEA;;;;EAEA;;;;EAEA;EAAmB;;;;EAEnB;;;;EAEA;;cA6CO,wBACX,OAAO,eACP,OAAO;;;;;;;;cAUI,qBACX,OAAO,eACP,OAAO,kBACN;cAOU,oBAAqB,OAAO"}
@@ -0,0 +1,155 @@
1
+ //#region src/machine.ts
2
+ /**
3
+ * Transitions, as a table.
4
+ *
5
+ * Read this against RFC 8628 §3.3–3.5: every legal move is one entry, and
6
+ * anything absent is illegal by construction. An event with no entry for the
7
+ * current state is a no-op — which is what makes double-approval, approving an
8
+ * expired grant, and re-redeeming a consumed one all impossible without a
9
+ * scattering of guard clauses.
10
+ */
11
+ const GRANT_TRANSITIONS = {
12
+ pending: {
13
+ APPROVE: `approved`,
14
+ DENY: `denied`,
15
+ EXPIRE: `expired`
16
+ },
17
+ approved: {
18
+ REDEEM: `consumed`,
19
+ EXPIRE: `expired`
20
+ }
21
+ };
22
+ /** Whether `event` is legal in `state`. */
23
+ const canTransitionGrant = (state, event) => GRANT_TRANSITIONS[state]?.[event] !== void 0;
24
+ /**
25
+ * Pure grant reducer. Returns the same state when the event is illegal, so
26
+ * callers can compare identity to detect a rejected transition.
27
+ */
28
+ const grantTransition = (state, event) => GRANT_TRANSITIONS[state]?.[event.type] ?? state;
29
+ /** Terminal states accept no further events. */
30
+ const isGrantSettled = (state) => GRANT_TRANSITIONS[state] === void 0;
31
+ const POLL_TRANSITIONS = {
32
+ idle: {
33
+ START: `waiting`,
34
+ ABORT: `aborted`
35
+ },
36
+ waiting: {
37
+ TICK: `polling`,
38
+ DEADLINE: `expired`,
39
+ ABORT: `aborted`
40
+ },
41
+ polling: {
42
+ AUTHORIZATION_PENDING: `waiting`,
43
+ SLOW_DOWN: `waiting`,
44
+ NETWORK_ERROR: `waiting`,
45
+ SUCCESS: `authorized`,
46
+ ACCESS_DENIED: `denied`,
47
+ EXPIRED_TOKEN: `expired`,
48
+ DEADLINE: `expired`,
49
+ ABORT: `aborted`
50
+ }
51
+ };
52
+ const canTransitionPoll = (state, event) => POLL_TRANSITIONS[state]?.[event] !== void 0;
53
+ const pollTransition = (state, event) => POLL_TRANSITIONS[state]?.[event.type] ?? state;
54
+ const isPollSettled = (state) => POLL_TRANSITIONS[state] === void 0;
55
+ /**
56
+ * Context reducer, pure and separate from the state reducer.
57
+ *
58
+ * The two interval rules live here together, which is the point: `SLOW_DOWN`
59
+ * is a fixed +5s per RFC 8628 §3.5 ("increased by 5 seconds for this and all
60
+ * subsequent requests"), while `NETWORK_ERROR` doubles. Conflating them is the
61
+ * easy mistake, and side-by-side they cannot be confused.
62
+ */
63
+ const pollContextTransition = (context, event) => {
64
+ switch (event.type) {
65
+ case `SLOW_DOWN`: return {
66
+ ...context,
67
+ intervalSeconds: context.intervalSeconds + 5,
68
+ attempts: context.attempts + 1
69
+ };
70
+ case `NETWORK_ERROR`: return {
71
+ ...context,
72
+ intervalSeconds: Math.min(context.intervalSeconds * 2, 60),
73
+ attempts: context.attempts + 1
74
+ };
75
+ case `AUTHORIZATION_PENDING`: return {
76
+ ...context,
77
+ attempts: context.attempts + 1
78
+ };
79
+ case `START`:
80
+ case `TICK`:
81
+ case `SUCCESS`:
82
+ case `ACCESS_DENIED`:
83
+ case `EXPIRED_TOKEN`:
84
+ case `DEADLINE`:
85
+ case `ABORT`: return context;
86
+ }
87
+ };
88
+ /** RFC 8628 §3.5: `slow_down` adds exactly this many seconds, permanently. */
89
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
90
+ /** Ceiling for network backoff, so a long outage cannot stall the flow. */
91
+ const MAX_BACKOFF_SECONDS = 60;
92
+ /**
93
+ * Map a token-endpoint response's `error` field to a poll event.
94
+ *
95
+ * `undefined` means success. An unrecognized code maps to
96
+ * `AUTHORIZATION_PENDING` rather than success or failure: an unknown code is
97
+ * not evidence the user decided anything, and the deadline still bounds the
98
+ * loop. Treating it as success would sign the device in on a garbage payload.
99
+ */
100
+ const eventForTokenError = (error) => {
101
+ switch (error) {
102
+ case void 0: return { type: `SUCCESS` };
103
+ case `authorization_pending`: return { type: `AUTHORIZATION_PENDING` };
104
+ case `slow_down`: return { type: `SLOW_DOWN` };
105
+ case `access_denied`: return { type: `ACCESS_DENIED` };
106
+ case `expired_token`: return { type: `EXPIRED_TOKEN` };
107
+ default: return { type: `AUTHORIZATION_PENDING` };
108
+ }
109
+ };
110
+ const APPROVAL_TRANSITIONS = {
111
+ idle: {
112
+ SCAN: `scanning`,
113
+ CODE: `resolving`
114
+ },
115
+ scanning: {
116
+ CODE: `resolving`,
117
+ ERROR: `failed`,
118
+ RESET: `idle`
119
+ },
120
+ resolving: {
121
+ RESOLVED: `confirming`,
122
+ REJECTED: `invalid`,
123
+ ERROR: `failed`
124
+ },
125
+ confirming: {
126
+ CONFIRMED: `confirming`,
127
+ CHALLENGE_FAILED: `confirming`,
128
+ APPROVE: `submitting`,
129
+ DENY: `submitting`,
130
+ RESET: `idle`
131
+ },
132
+ submitting: {
133
+ SUBMITTED: `approved`,
134
+ ERROR: `failed`
135
+ },
136
+ failed: { RESET: `idle` },
137
+ invalid: { RESET: `idle` }
138
+ };
139
+ const canTransitionApproval = (state, event) => APPROVAL_TRANSITIONS[state]?.[event] !== void 0;
140
+ /**
141
+ * Pure approval reducer.
142
+ *
143
+ * `SUBMITTED` is the one event whose target depends on its payload — the same
144
+ * transition lands on `approved` or `denied` according to what the user chose,
145
+ * so the table's entry is overridden here.
146
+ */
147
+ const approvalTransition = (state, event) => {
148
+ if (event.type === `SUBMITTED` && state === `submitting`) return event.approved ? `approved` : `denied`;
149
+ return APPROVAL_TRANSITIONS[state]?.[event.type] ?? state;
150
+ };
151
+ const isApprovalSettled = (state) => APPROVAL_TRANSITIONS[state] === void 0;
152
+ //#endregion
153
+ export { canTransitionGrant as a, grantTransition as c, isPollSettled as d, pollContextTransition as f, canTransitionApproval as i, isApprovalSettled as l, SLOW_DOWN_INCREMENT_SECONDS as n, canTransitionPoll as o, pollTransition as p, approvalTransition as r, eventForTokenError as s, MAX_BACKOFF_SECONDS as t, isGrantSettled as u };
154
+
155
+ //# sourceMappingURL=machine-D_5DAFxi.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"machine-D_5DAFxi.mjs","names":[],"sources":["../src/machine.ts"],"sourcesContent":["/**\n * The device-authorization flow as two explicit state machines.\n *\n * Modeled the way xstate would model it — states, events, and a declarative\n * transition table — but hand-rolled on WinterTC primitives so the library\n * stays dependency-free. What we keep from that shape is the part that pays:\n * transitions are DATA, readable in one place and checkable against RFC 8628\n * side by side, rather than control flow scattered across functions.\n *\n * What we deliberately omit is the actor/service layer. Nothing here needs to\n * observe transitions from outside, and an interpreter abstraction for a\n * single consumer would be weight without benefit.\n *\n * Side effects live outside: these reducers are pure, and the classes in\n * `server.ts` / `client/index.ts` own the I/O and the state itself.\n */\n\n/* ──────────────────────────── Grant machine ──────────────────────────── */\n\n/**\n * Server-side lifecycle of one authorization attempt.\n *\n * `pending` is the only non-terminal state. `consumed` is distinct from\n * `approved` because approval is not what ends the flow — redemption is. A\n * device_code that stayed redeemable after approval would be a replayable\n * bearer credential.\n */\nexport type GrantState =\n | `pending`\n | `approved`\n | `denied`\n | `expired`\n | `consumed`;\n\n/** Events that can move a grant. */\nexport type GrantEvent =\n /** The user authorized on their phone. Carries who they are. */\n | { type: `APPROVE`; subject: string }\n /** The user refused. */\n | { type: `DENY` }\n /** The deadline passed. Raised on read, not by a timer — see `server.ts`. */\n | { type: `EXPIRE` }\n /** The device redeemed its approval. Terminal. */\n | { type: `REDEEM` };\n\n/**\n * Transitions, as a table.\n *\n * Read this against RFC 8628 §3.3–3.5: every legal move is one entry, and\n * anything absent is illegal by construction. An event with no entry for the\n * current state is a no-op — which is what makes double-approval, approving an\n * expired grant, and re-redeeming a consumed one all impossible without a\n * scattering of guard clauses.\n */\nconst GRANT_TRANSITIONS: {\n readonly [S in GrantState]?: {\n readonly [E in GrantEvent[`type`]]?: GrantState;\n };\n} = {\n pending: {\n APPROVE: `approved`,\n DENY: `denied`,\n EXPIRE: `expired`\n },\n // Approved grants still expire: an approval nobody collected before the\n // deadline must not stay redeemable indefinitely.\n approved: {\n REDEEM: `consumed`,\n EXPIRE: `expired`\n }\n // denied, expired, consumed: terminal. No entry, so no event moves them.\n};\n\n/** Whether `event` is legal in `state`. */\nexport const canTransitionGrant = (\n state: GrantState,\n event: GrantEvent[`type`]\n): boolean => GRANT_TRANSITIONS[state]?.[event] !== undefined;\n\n/**\n * Pure grant reducer. Returns the same state when the event is illegal, so\n * callers can compare identity to detect a rejected transition.\n */\nexport const grantTransition = (\n state: GrantState,\n event: GrantEvent\n): GrantState => GRANT_TRANSITIONS[state]?.[event.type] ?? state;\n\n/** Terminal states accept no further events. */\nexport const isGrantSettled = (state: GrantState): boolean =>\n GRANT_TRANSITIONS[state] === undefined;\n\n/* ──────────────────────────── Poll machine ───────────────────────────── */\n\n/**\n * Device-side polling lifecycle.\n *\n * States mirror what the screen is doing, so a UI can render directly from\n * them: `waiting` is the gap between polls, `polling` is a request in flight.\n * The three terminal states are the outcomes the screen must show.\n */\nexport type PollState =\n | `idle`\n | `waiting`\n | `polling`\n | `authorized`\n | `denied`\n | `expired`\n | `aborted`;\n\n/**\n * Events driving the poll loop.\n *\n * The four RFC error codes appear verbatim as event types so the mapping from\n * a token-endpoint response to a transition needs no translation layer.\n */\nexport type PollEvent =\n | { type: `START` }\n | { type: `TICK` }\n | { type: `AUTHORIZATION_PENDING` }\n | { type: `SLOW_DOWN` }\n | { type: `ACCESS_DENIED` }\n | { type: `EXPIRED_TOKEN` }\n /** A response with no `error` — the grant was redeemed. */\n | { type: `SUCCESS` }\n /** Network failure. Distinct from SLOW_DOWN: congestion, not policy. */\n | { type: `NETWORK_ERROR` }\n /** The client's own deadline elapsed. */\n | { type: `DEADLINE` }\n /** The screen was dismissed. */\n | { type: `ABORT` };\n\nconst POLL_TRANSITIONS: {\n readonly [S in PollState]?: { readonly [E in PollEvent[`type`]]?: PollState };\n} = {\n idle: {\n START: `waiting`,\n ABORT: `aborted`\n },\n waiting: {\n TICK: `polling`,\n DEADLINE: `expired`,\n ABORT: `aborted`\n },\n polling: {\n // Back to waiting: these three mean \"keep going\", differing only in how\n // long the next wait is — which is context, not state.\n AUTHORIZATION_PENDING: `waiting`,\n SLOW_DOWN: `waiting`,\n NETWORK_ERROR: `waiting`,\n SUCCESS: `authorized`,\n ACCESS_DENIED: `denied`,\n EXPIRED_TOKEN: `expired`,\n DEADLINE: `expired`,\n ABORT: `aborted`\n }\n // authorized, denied, expired, aborted: terminal.\n};\n\n/**\n * Context carried alongside the poll state.\n *\n * Separate from the state itself, exactly as xstate separates finite state\n * from extended state: the interval changes constantly but is not a state —\n * `waiting` at 5s and `waiting` at 20s are the same state, different context.\n */\nexport interface PollContext {\n /** Current wait between polls, in seconds. */\n intervalSeconds: number;\n /** Epoch ms after which the flow is over regardless of the server. */\n deadline: number;\n /** Polls attempted. Useful for UI (\"still waiting…\") and diagnostics. */\n attempts: number;\n}\n\nexport const canTransitionPoll = (\n state: PollState,\n event: PollEvent[`type`]\n): boolean => POLL_TRANSITIONS[state]?.[event] !== undefined;\n\nexport const pollTransition = (state: PollState, event: PollEvent): PollState =>\n POLL_TRANSITIONS[state]?.[event.type] ?? state;\n\nexport const isPollSettled = (state: PollState): boolean =>\n POLL_TRANSITIONS[state] === undefined;\n\n/**\n * Context reducer, pure and separate from the state reducer.\n *\n * The two interval rules live here together, which is the point: `SLOW_DOWN`\n * is a fixed +5s per RFC 8628 §3.5 (\"increased by 5 seconds for this and all\n * subsequent requests\"), while `NETWORK_ERROR` doubles. Conflating them is the\n * easy mistake, and side-by-side they cannot be confused.\n */\nexport const pollContextTransition = (\n context: PollContext,\n event: PollEvent\n): PollContext => {\n switch (event.type) {\n case `SLOW_DOWN`:\n // Additive and sticky. Never reset on a later success.\n return {\n ...context,\n intervalSeconds: context.intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS,\n attempts: context.attempts + 1\n };\n case `NETWORK_ERROR`:\n // Exponential, capped. The spec recommends backoff for connection\n // failures specifically, as distinct from the server's pacing signal.\n return {\n ...context,\n intervalSeconds: Math.min(\n context.intervalSeconds * 2,\n MAX_BACKOFF_SECONDS\n ),\n attempts: context.attempts + 1\n };\n case `AUTHORIZATION_PENDING`:\n return { ...context, attempts: context.attempts + 1 };\n // Lifecycle and terminal events carry no cadence change: the interval a\n // flow ended on is not information anyone needs afterwards.\n case `START`:\n case `TICK`:\n case `SUCCESS`:\n case `ACCESS_DENIED`:\n case `EXPIRED_TOKEN`:\n case `DEADLINE`:\n case `ABORT`:\n return context;\n }\n};\n\n/** RFC 8628 §3.5: `slow_down` adds exactly this many seconds, permanently. */\nexport const SLOW_DOWN_INCREMENT_SECONDS = 5;\n\n/** Ceiling for network backoff, so a long outage cannot stall the flow. */\nexport const MAX_BACKOFF_SECONDS = 60;\n\n/**\n * Map a token-endpoint response's `error` field to a poll event.\n *\n * `undefined` means success. An unrecognized code maps to\n * `AUTHORIZATION_PENDING` rather than success or failure: an unknown code is\n * not evidence the user decided anything, and the deadline still bounds the\n * loop. Treating it as success would sign the device in on a garbage payload.\n */\nexport const eventForTokenError = (error: string | undefined): PollEvent => {\n switch (error) {\n case undefined:\n return { type: `SUCCESS` };\n case `authorization_pending`:\n return { type: `AUTHORIZATION_PENDING` };\n case `slow_down`:\n return { type: `SLOW_DOWN` };\n case `access_denied`:\n return { type: `ACCESS_DENIED` };\n case `expired_token`:\n return { type: `EXPIRED_TOKEN` };\n default:\n return { type: `AUTHORIZATION_PENDING` };\n }\n};\n\n/* ────────────────────────── Approval machine ─────────────────────────── */\n\n/**\n * The approving device's lifecycle — the third participant in the flow.\n *\n * Runs on the phone that is ALREADY authenticated. Two ways in, as the\n * ecosystem shows: Plex hands the OS camera a URL and the user lands in a\n * browser (`scanning` is skipped, the code arrives from the query string),\n * while Discord and Steam scan inside their own app and never leave it.\n *\n * `confirming` exists because approving must not be one tap on an unverified\n * code. RFC 8628 §5.4 asks the user to check that the code matches the screen\n * in front of them; that check is the only defense against a phished QR\n * pointing at an attacker's device.\n */\nexport type ApprovalState =\n | `idle`\n /** Camera open, looking for a code. Skipped when arriving by URL. */\n | `scanning`\n /** A code was read or typed; asking the server what it belongs to. */\n | `resolving`\n /** Server knows the grant. Running the challenge before approve/deny. */\n | `confirming`\n /** Sending the decision. */\n | `submitting`\n | `approved`\n | `denied`\n /** The grant was already expired, consumed, or unknown. */\n | `invalid`\n /** Camera or network failure. Recoverable — the user can retry. */\n | `failed`;\n\nexport type ApprovalEvent =\n /** Open the scanner (in-app path). */\n | { type: `SCAN` }\n /** A code arrived — scanned, typed, or read from the URL. */\n | { type: `CODE`; userCode: string }\n /** The server resolved the code to a live grant. */\n | { type: `RESOLVED` }\n /** The server did not recognize the code, or it is no longer live. */\n | { type: `REJECTED` }\n /** The challenge passed — the user proved they are looking at the screen. */\n | { type: `CONFIRMED` }\n /** The challenge failed. Back to confirming so the user can retry. */\n | { type: `CHALLENGE_FAILED` }\n /** The user chose to approve. */\n | { type: `APPROVE` }\n /** The user chose to refuse. */\n | { type: `DENY` }\n /** The server accepted the decision. */\n | { type: `SUBMITTED`; approved: boolean }\n /** Camera or network failure. */\n | { type: `ERROR` }\n /** Start over after a failure or an invalid code. */\n | { type: `RESET` };\n\nconst APPROVAL_TRANSITIONS: {\n readonly [S in ApprovalState]?: {\n readonly [E in ApprovalEvent[`type`]]?: ApprovalState;\n };\n} = {\n idle: {\n SCAN: `scanning`,\n // No SCAN first: this is the Plex path, where the code came from the URL\n // and there is no camera step at all.\n CODE: `resolving`\n },\n scanning: {\n CODE: `resolving`,\n ERROR: `failed`,\n RESET: `idle`\n },\n resolving: {\n RESOLVED: `confirming`,\n REJECTED: `invalid`,\n ERROR: `failed`\n },\n confirming: {\n CONFIRMED: `confirming`,\n // Stays put: a wrong code or a failed biometric is a retry, not a dead\n // end. The host decides how many attempts to allow.\n CHALLENGE_FAILED: `confirming`,\n APPROVE: `submitting`,\n DENY: `submitting`,\n RESET: `idle`\n },\n submitting: {\n SUBMITTED: `approved`,\n ERROR: `failed`\n },\n failed: {\n RESET: `idle`\n },\n invalid: {\n RESET: `idle`\n }\n // approved, denied: terminal.\n};\n\nexport const canTransitionApproval = (\n state: ApprovalState,\n event: ApprovalEvent[`type`]\n): boolean => APPROVAL_TRANSITIONS[state]?.[event] !== undefined;\n\n/**\n * Pure approval reducer.\n *\n * `SUBMITTED` is the one event whose target depends on its payload — the same\n * transition lands on `approved` or `denied` according to what the user chose,\n * so the table's entry is overridden here.\n */\nexport const approvalTransition = (\n state: ApprovalState,\n event: ApprovalEvent\n): ApprovalState => {\n if (event.type === `SUBMITTED` && state === `submitting`) {\n return event.approved ? `approved` : `denied`;\n }\n return APPROVAL_TRANSITIONS[state]?.[event.type] ?? state;\n};\n\nexport const isApprovalSettled = (state: ApprovalState): boolean =>\n APPROVAL_TRANSITIONS[state] === undefined;\n"],"mappings":";;;;;;;;;;AAsDA,MAAM,oBAIF;CACF,SAAS;EACP,SAAS;EACT,MAAM;EACN,QAAQ;CACV;CAGA,UAAU;EACR,QAAQ;EACR,QAAQ;CACV;AAEF;;AAGA,MAAa,sBACX,OACA,UACY,kBAAkB,MAAM,GAAG,WAAW,KAAA;;;;;AAMpD,MAAa,mBACX,OACA,UACe,kBAAkB,MAAM,GAAG,MAAM,SAAS;;AAG3D,MAAa,kBAAkB,UAC7B,kBAAkB,WAAW,KAAA;AA0C/B,MAAM,mBAEF;CACF,MAAM;EACJ,OAAO;EACP,OAAO;CACT;CACA,SAAS;EACP,MAAM;EACN,UAAU;EACV,OAAO;CACT;CACA,SAAS;EAGP,uBAAuB;EACvB,WAAW;EACX,eAAe;EACf,SAAS;EACT,eAAe;EACf,eAAe;EACf,UAAU;EACV,OAAO;CACT;AAEF;AAkBA,MAAa,qBACX,OACA,UACY,iBAAiB,MAAM,GAAG,WAAW,KAAA;AAEnD,MAAa,kBAAkB,OAAkB,UAC/C,iBAAiB,MAAM,GAAG,MAAM,SAAS;AAE3C,MAAa,iBAAiB,UAC5B,iBAAiB,WAAW,KAAA;;;;;;;;;AAU9B,MAAa,yBACX,SACA,UACgB;CAChB,QAAQ,MAAM,MAAd;EACE,KAAK,aAEH,OAAO;GACL,GAAG;GACH,iBAAiB,QAAQ,kBAAA;GACzB,UAAU,QAAQ,WAAW;EAC/B;EACF,KAAK,iBAGH,OAAO;GACL,GAAG;GACH,iBAAiB,KAAK,IACpB,QAAQ,kBAAkB,GAAA,EAE5B;GACA,UAAU,QAAQ,WAAW;EAC/B;EACF,KAAK,yBACH,OAAO;GAAE,GAAG;GAAS,UAAU,QAAQ,WAAW;EAAE;EAGtD,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK,SACH,OAAO;CACX;AACF;;AAGA,MAAa,8BAA8B;;AAG3C,MAAa,sBAAsB;;;;;;;;;AAUnC,MAAa,sBAAsB,UAAyC;CAC1E,QAAQ,OAAR;EACE,KAAK,KAAA,GACH,OAAO,EAAE,MAAM,UAAU;EAC3B,KAAK,yBACH,OAAO,EAAE,MAAM,wBAAwB;EACzC,KAAK,aACH,OAAO,EAAE,MAAM,YAAY;EAC7B,KAAK,iBACH,OAAO,EAAE,MAAM,gBAAgB;EACjC,KAAK,iBACH,OAAO,EAAE,MAAM,gBAAgB;EACjC,SACE,OAAO,EAAE,MAAM,wBAAwB;CAC3C;AACF;AA0DA,MAAM,uBAIF;CACF,MAAM;EACJ,MAAM;EAGN,MAAM;CACR;CACA,UAAU;EACR,MAAM;EACN,OAAO;EACP,OAAO;CACT;CACA,WAAW;EACT,UAAU;EACV,UAAU;EACV,OAAO;CACT;CACA,YAAY;EACV,WAAW;EAGX,kBAAkB;EAClB,SAAS;EACT,MAAM;EACN,OAAO;CACT;CACA,YAAY;EACV,WAAW;EACX,OAAO;CACT;CACA,QAAQ,EACN,OAAO,OACT;CACA,SAAS,EACP,OAAO,OACT;AAEF;AAEA,MAAa,yBACX,OACA,UACY,qBAAqB,MAAM,GAAG,WAAW,KAAA;;;;;;;;AASvD,MAAa,sBACX,OACA,UACkB;CAClB,IAAI,MAAM,SAAS,eAAe,UAAU,cAC1C,OAAO,MAAM,WAAW,aAAa;CAEvC,OAAO,qBAAqB,MAAM,GAAG,MAAM,SAAS;AACtD;AAEA,MAAa,qBAAqB,UAChC,qBAAqB,WAAW,KAAA"}
package/dist/qr.d.mts ADDED
@@ -0,0 +1,58 @@
1
+ //#region src/qr.d.ts
2
+ /**
3
+ * QR rendering for the device screen.
4
+ *
5
+ * A thin, opinionated wrapper over `etiket` — the defaults here exist because
6
+ * this QR is scanned off a TV from across a room, which is a harsher case than
7
+ * the usual on-screen QR.
8
+ */
9
+ interface DeviceQrOptions {
10
+ /** Rendered edge length in px. Large by default: TVs are viewed from far away. */
11
+ size?: number;
12
+ /**
13
+ * Error-correction level. Defaults to `M`.
14
+ *
15
+ * NOT `H`, despite the instinct to max it out. Higher correction adds modules
16
+ * to encode the same URL, so at a fixed pixel size each module gets smaller —
17
+ * on a 1080p panel viewed from 3m, denser modules hurt scan rate more than
18
+ * damage-tolerance helps. A TV screen is clean and unscuffed; `M` is the
19
+ * right trade. Raise it only if you overlay a logo.
20
+ */
21
+ ecLevel?: `L` | `M` | `Q` | `H`;
22
+ /** Foreground. Must stay dark-on-light for reliable scanning. */
23
+ color?: string;
24
+ /**
25
+ * Background. Keep it opaque and light.
26
+ *
27
+ * Do not make this transparent to sit the QR on a dark or photographic
28
+ * backdrop — scanners need the quiet zone to contrast with the modules.
29
+ *
30
+ * Inverting (light modules on a dark ground, as Plex does) is a constraint
31
+ * on READERS, and is not a contrast question — an inverted symbol can sit
32
+ * at 10:1 and still be unreadable. Many decoders binarize assuming dark
33
+ * modules on a light ground and never try the opposite: jsQR, the engine
34
+ * behind qr-scanner, defaults its live camera path to `dontInvert`, so an
35
+ * inverted symbol decodes as nothing at all while the camera looks
36
+ * perfectly healthy. Apple's detector tries both, so the iOS camera reads
37
+ * what your own scanner cannot — which makes this look like a broken
38
+ * scanner rather than a polarity mismatch.
39
+ *
40
+ * So invert deliberately, not by accident: it is fine when you control the
41
+ * readers and have configured them for it (see `setInversionMode`), and a
42
+ * silent failure when you have not.
43
+ */
44
+ background?: string;
45
+ /** Quiet-zone width in modules. The spec minimum is 4; do not go below it. */
46
+ margin?: number;
47
+ }
48
+ /**
49
+ * Render `verification_uri_complete` as an SVG string.
50
+ *
51
+ * SVG rather than PNG so it scales to any panel without resampling, and so it
52
+ * can be inlined into the page with no extra request — which matters on a Fire
53
+ * TV / Pi-class device where every fetch is expensive.
54
+ */
55
+ declare const renderDeviceQr: (verificationUriComplete: string, { size, ecLevel, color, background, margin }?: DeviceQrOptions) => string;
56
+ //#endregion
57
+ export { DeviceQrOptions, renderDeviceQr };
58
+ //# sourceMappingURL=qr.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"qr.d.mts","names":[],"sources":["../src/qr.ts"],"mappings":";;;;;;;;UAaiB;;EAEf;;;;;;;;;;EAUA;;EAEA;;;;;;;;;;;;;;;;;;;;;EAqBA;;EAEA;;;;;;;;;cAUW,iBACX,mCACA,MAAA,SAAA,OAAA,YAAA,WAMG"}
package/dist/qr.mjs ADDED
@@ -0,0 +1,27 @@
1
+ import { qrcode } from "etiket/qr";
2
+ //#region src/qr.ts
3
+ /**
4
+ * QR rendering for the device screen.
5
+ *
6
+ * A thin, opinionated wrapper over `etiket` — the defaults here exist because
7
+ * this QR is scanned off a TV from across a room, which is a harsher case than
8
+ * the usual on-screen QR.
9
+ */
10
+ /**
11
+ * Render `verification_uri_complete` as an SVG string.
12
+ *
13
+ * SVG rather than PNG so it scales to any panel without resampling, and so it
14
+ * can be inlined into the page with no extra request — which matters on a Fire
15
+ * TV / Pi-class device where every fetch is expensive.
16
+ */
17
+ const renderDeviceQr = (verificationUriComplete, { size = 512, ecLevel = `M`, color = `#000000`, background = `#ffffff`, margin = 4 } = {}) => qrcode(verificationUriComplete, {
18
+ size,
19
+ ecLevel,
20
+ color,
21
+ background,
22
+ margin
23
+ });
24
+ //#endregion
25
+ export { renderDeviceQr };
26
+
27
+ //# sourceMappingURL=qr.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"qr.mjs","names":[],"sources":["../src/qr.ts"],"sourcesContent":["/**\n * QR rendering for the device screen.\n *\n * A thin, opinionated wrapper over `etiket` — the defaults here exist because\n * this QR is scanned off a TV from across a room, which is a harsher case than\n * the usual on-screen QR.\n */\n\n// etiket's QR subpath, not its barrel: the barrel re-exports every symbology\n// it supports (Aztec, PDF417, DataMatrix, postal), and pulling those into a\n// consumer's graph to draw one QR relies on their bundler shaking perfectly.\nimport { qrcode } from \"etiket/qr\";\n\nexport interface DeviceQrOptions {\n /** Rendered edge length in px. Large by default: TVs are viewed from far away. */\n size?: number;\n /**\n * Error-correction level. Defaults to `M`.\n *\n * NOT `H`, despite the instinct to max it out. Higher correction adds modules\n * to encode the same URL, so at a fixed pixel size each module gets smaller —\n * on a 1080p panel viewed from 3m, denser modules hurt scan rate more than\n * damage-tolerance helps. A TV screen is clean and unscuffed; `M` is the\n * right trade. Raise it only if you overlay a logo.\n */\n ecLevel?: `L` | `M` | `Q` | `H`;\n /** Foreground. Must stay dark-on-light for reliable scanning. */\n color?: string;\n /**\n * Background. Keep it opaque and light.\n *\n * Do not make this transparent to sit the QR on a dark or photographic\n * backdrop — scanners need the quiet zone to contrast with the modules.\n *\n * Inverting (light modules on a dark ground, as Plex does) is a constraint\n * on READERS, and is not a contrast question — an inverted symbol can sit\n * at 10:1 and still be unreadable. Many decoders binarize assuming dark\n * modules on a light ground and never try the opposite: jsQR, the engine\n * behind qr-scanner, defaults its live camera path to `dontInvert`, so an\n * inverted symbol decodes as nothing at all while the camera looks\n * perfectly healthy. Apple's detector tries both, so the iOS camera reads\n * what your own scanner cannot — which makes this look like a broken\n * scanner rather than a polarity mismatch.\n *\n * So invert deliberately, not by accident: it is fine when you control the\n * readers and have configured them for it (see `setInversionMode`), and a\n * silent failure when you have not.\n */\n background?: string;\n /** Quiet-zone width in modules. The spec minimum is 4; do not go below it. */\n margin?: number;\n}\n\n/**\n * Render `verification_uri_complete` as an SVG string.\n *\n * SVG rather than PNG so it scales to any panel without resampling, and so it\n * can be inlined into the page with no extra request — which matters on a Fire\n * TV / Pi-class device where every fetch is expensive.\n */\nexport const renderDeviceQr = (\n verificationUriComplete: string,\n {\n size = 512,\n ecLevel = `M`,\n color = `#000000`,\n background = `#ffffff`,\n margin = 4\n }: DeviceQrOptions = {}\n): string =>\n qrcode(verificationUriComplete, {\n size,\n ecLevel,\n color,\n background,\n margin\n });\n"],"mappings":";;;;;;;;;;;;;;;;AA4DA,MAAa,kBACX,yBACA,EACE,OAAO,KACP,UAAU,KACV,QAAQ,WACR,aAAa,WACb,SAAS,MACU,CAAC,MAEtB,OAAO,yBAAyB;CAC9B;CACA;CACA;CACA;CACA;AACF,CAAC"}