@wtfalch/auth 0.20.0 → 0.23.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.
@@ -0,0 +1,191 @@
1
+ import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
2
+ /** A JWT is three base64url segments; a ZITADEL opaque token is not shaped
3
+ * like that at all, so this is enough to route between `jwtVerify` and
4
+ * introspection without ever trying to parse either kind as the other. */
5
+ const JWT_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
6
+ /** How long a positive introspection result is trusted before this host
7
+ * checks again -- bounded well under the token's own `exp` so a revoked
8
+ * token can't outlive a ZITADEL-side revocation for long. */
9
+ const INTROSPECTION_CACHE_TTL_MS = 60_000;
10
+ /** An optional endpoint from the discovery document, required to be on the
11
+ * issuer's own origin -- a compromised or misconfigured discovery
12
+ * response must never point signature checking, or a token's liveness,
13
+ * at somebody else's server. */
14
+ function sameOriginEndpoint(value, name, issuerOrigin) {
15
+ if (value === undefined)
16
+ return null;
17
+ if (typeof value !== 'string')
18
+ throw new Error(`person-token: ${name} is not a string`);
19
+ if (new URL(value).origin !== issuerOrigin)
20
+ throw new Error(`person-token: ${name} origin does not match the issuer`);
21
+ return value;
22
+ }
23
+ async function discoverDocument(issuer, fetchImpl) {
24
+ const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
25
+ const issuerOrigin = new URL(issuer).origin;
26
+ const response = await fetchImpl(discoveryUrl);
27
+ if (!response.ok) {
28
+ throw new Error(`person-token: discovery document request failed with ${response.status}`);
29
+ }
30
+ const doc = (await response.json());
31
+ if (typeof doc.jwks_uri !== 'string') {
32
+ throw new Error('person-token: the issuer publishes no jwks_uri');
33
+ }
34
+ if (new URL(doc.jwks_uri).origin !== issuerOrigin) {
35
+ throw new Error('person-token: jwks_uri origin does not match the issuer');
36
+ }
37
+ return {
38
+ jwksUri: doc.jwks_uri,
39
+ introspectionEndpoint: sameOriginEndpoint(doc.introspection_endpoint, 'introspection_endpoint', issuerOrigin),
40
+ };
41
+ }
42
+ /**
43
+ * A size-bounded, least-recently-used, TTL cache for a positive
44
+ * introspection result only -- a refusal is never cached, so a transient
45
+ * issuer problem never sticks as "no" for whatever is left of the entry's
46
+ * life. Bounded so a flood of distinct garbage tokens cannot grow it
47
+ * without limit.
48
+ */
49
+ class PositiveIntrospectionCache {
50
+ max;
51
+ entries = new Map();
52
+ constructor(max = 1000) {
53
+ this.max = max;
54
+ }
55
+ get(token, now) {
56
+ const entry = this.entries.get(token);
57
+ if (!entry)
58
+ return null;
59
+ if (entry.expiresAt <= now) {
60
+ this.entries.delete(token);
61
+ return null;
62
+ }
63
+ // Refresh recency: delete-then-set moves it to the end of the Map's
64
+ // iteration order, which eviction below reads from the front.
65
+ this.entries.delete(token);
66
+ this.entries.set(token, entry);
67
+ return entry.payload;
68
+ }
69
+ set(token, payload, expiresAt) {
70
+ this.entries.delete(token);
71
+ if (this.entries.size >= this.max) {
72
+ const oldest = this.entries.keys().next().value;
73
+ if (oldest !== undefined)
74
+ this.entries.delete(oldest);
75
+ }
76
+ this.entries.set(token, { payload, expiresAt });
77
+ }
78
+ }
79
+ export function createPersonTokenVerifier(config) {
80
+ const fetchImpl = config.fetch ?? fetch;
81
+ let discovery = null;
82
+ let jwks = null;
83
+ const introspectionCache = new PositiveIntrospectionCache();
84
+ const document = () => {
85
+ discovery ??= discoverDocument(config.issuer, fetchImpl).catch((error) => {
86
+ discovery = null;
87
+ throw error;
88
+ });
89
+ return discovery;
90
+ };
91
+ const keys = () => {
92
+ jwks ??= document()
93
+ .then((doc) => createRemoteJWKSet(new URL(doc.jwksUri), {
94
+ cooldownDuration: config.jwksCooldownMs ?? 5_000,
95
+ [customFetch]: (url, init) => fetchImpl(url, init),
96
+ }))
97
+ .catch((error) => {
98
+ jwks = null;
99
+ throw error;
100
+ });
101
+ return jwks;
102
+ };
103
+ async function verifyJwt(accessToken) {
104
+ try {
105
+ const keyset = await keys();
106
+ const { payload } = await jwtVerify(accessToken, keyset, {
107
+ issuer: config.issuer,
108
+ audience: config.audience,
109
+ algorithms: ['RS256'],
110
+ clockTolerance: 60,
111
+ });
112
+ return payload;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ /**
119
+ * RFC 7662 token introspection for an opaque access token: POSTs
120
+ * `token=<accessToken>` to the issuer's `introspection_endpoint`,
121
+ * authenticating as this resource server with `client_secret_basic`.
122
+ * Fails closed on anything that keeps this from reaching a real
123
+ * `active`/`exp`/`aud` verdict: no credentials configured, no endpoint
124
+ * published, a network failure, a non-2xx response, or an unparseable
125
+ * body -- every one of those returns `null`, refused, never accepted.
126
+ */
127
+ async function introspect(accessToken) {
128
+ if (!config.introspectionClientId || !config.introspectionClientSecret)
129
+ return null;
130
+ const now = Date.now();
131
+ const cached = introspectionCache.get(accessToken, now);
132
+ if (cached)
133
+ return cached;
134
+ let endpoint;
135
+ try {
136
+ ({ introspectionEndpoint: endpoint } = await document());
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ if (!endpoint)
142
+ return null;
143
+ const credentials = Buffer.from(`${config.introspectionClientId}:${config.introspectionClientSecret}`).toString('base64');
144
+ let response;
145
+ try {
146
+ response = await fetchImpl(endpoint, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'content-type': 'application/x-www-form-urlencoded',
150
+ authorization: `Basic ${credentials}`,
151
+ },
152
+ body: new URLSearchParams({ token: accessToken }).toString(),
153
+ });
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ if (!response.ok)
159
+ return null;
160
+ let body;
161
+ try {
162
+ body = (await response.json());
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ if (body.active !== true)
168
+ return null;
169
+ if (typeof body.exp !== 'number' || body.exp <= now / 1000)
170
+ return null;
171
+ const aud = body.aud;
172
+ const audiences = Array.isArray(aud) ? aud : aud === undefined ? [] : [aud];
173
+ if (!audiences.includes(config.audience))
174
+ return null;
175
+ if (typeof body.iss === 'string' && body.iss !== config.issuer)
176
+ return null;
177
+ const payload = body;
178
+ introspectionCache.set(accessToken, payload, Math.min(body.exp * 1000, now + INTROSPECTION_CACHE_TTL_MS));
179
+ return payload;
180
+ }
181
+ return {
182
+ async verify(accessToken) {
183
+ const payload = JWT_SHAPE.test(accessToken)
184
+ ? await verifyJwt(accessToken)
185
+ : await introspect(accessToken);
186
+ if (!payload || typeof payload.sub !== 'string' || !payload.sub)
187
+ return null;
188
+ return { sub: payload.sub };
189
+ },
190
+ };
191
+ }
@@ -0,0 +1,66 @@
1
+ export type QrDoorMark = 'agora' | 'boule' | 'archon';
2
+ export interface QrDoorTheme {
3
+ ink: string;
4
+ accent: string;
5
+ paper: string;
6
+ }
7
+ export declare const QR_DOOR_THEME: QrDoorTheme;
8
+ /** Side of the logical drawing space. */
9
+ export declare const QR_DOOR_SIZE = 360;
10
+ /** Half-width of the clearing the doorway sits in. */
11
+ export declare const QR_DOOR_CLEARING = 25;
12
+ /** Largest QR version allowed: version 7 and up put an alignment pattern dead centre, under the door. */
13
+ export declare const QR_DOOR_MAX_VERSION = 6;
14
+ /** Length of the approval animation. */
15
+ export declare const QR_DOOR_DURATION: number;
16
+ export interface QrDoorPoint {
17
+ x: number;
18
+ y: number;
19
+ }
20
+ export interface QrDoorPiece {
21
+ /** Index into `dots` of the module that lifts out. */
22
+ src: number;
23
+ sx: number;
24
+ sy: number;
25
+ tx: number;
26
+ ty: number;
27
+ /** Delay before it lifts, seconds. */
28
+ dl: number;
29
+ }
30
+ export interface QrDoorScene {
31
+ mark: QrDoorMark;
32
+ /** The error-correction level used, highest that keeps the version at or under `QR_DOOR_MAX_VERSION`. */
33
+ errorCorrection: 'Q' | 'M' | 'L';
34
+ version: number;
35
+ /** Modules per side, and module size in logical px. */
36
+ modules: number;
37
+ moduleSize: number;
38
+ /** Dark data modules, finders and the clearing left out. */
39
+ dots: QrDoorPoint[];
40
+ /** Centres of the three finder eyes. */
41
+ finders: QrDoorPoint[];
42
+ pieces: QrDoorPiece[];
43
+ camera: {
44
+ Zg: number;
45
+ zg: number;
46
+ Dp: number;
47
+ kk: number;
48
+ zf: number;
49
+ t1: number;
50
+ };
51
+ }
52
+ /**
53
+ * Builds the scene for `url` (the device grant's verification URL). Tries
54
+ * error correction Q, then M, then L, keeping the first that fits in version 6.
55
+ * The clearing knocks out about 3% of modules, well inside even L's 7%.
56
+ * Throws a `RangeError` for a URL too long for version 6 at any level.
57
+ */
58
+ export declare function buildQrDoorScene(url: string, mark: QrDoorMark): QrDoorScene;
59
+ /** Icon size (logical px) at time `t` after approval. Continuous by construction. */
60
+ export declare function qrDoorIconSize(scene: QrDoorScene, t: number): number;
61
+ type Ctx = CanvasRenderingContext2D;
62
+ /** The code at rest, waiting for the phone. */
63
+ export declare function drawQrDoorRest(c: Ctx, s: QrDoorScene, th?: QrDoorTheme): void;
64
+ /** The frame `t` seconds after approval; `t` is clamped to `[0, QR_DOOR_DURATION]`. */
65
+ export declare function drawQrDoorScene(c: Ctx, s: QrDoorScene, time: number, th?: QrDoorTheme): void;
66
+ export {};
@@ -0,0 +1,404 @@
1
+ /**
2
+ * "The code builds the door": the QR sign-in code, and the short animation it
3
+ * plays once the phone approves. Framework-free canvas drawing; `./qr-door`
4
+ * wraps it in a React component.
5
+ *
6
+ * At rest it is an ordinary, scannable QR code for the device grant's `url`
7
+ * with a small clearing in the middle. On approval, modules lift out of the
8
+ * code and land on the outline of the app's mark, the solid line traces in
9
+ * behind them, a person (the mark's red dot) steps into the doorway, and one
10
+ * perspective camera move carries you through it to the app's icon.
11
+ *
12
+ * Everything is drawn in a fixed 360×360 logical space; the caller sets the
13
+ * canvas transform for its size and devicePixelRatio. `buildQrDoorScene` does
14
+ * the one-off work (QR matrix, which module flies where, the camera solve) and
15
+ * is plain data, safe on a server. `drawQrDoorRest` and `drawQrDoorScene` need
16
+ * a canvas and are a pure function of (scene, t): scrub, test or freeze them.
17
+ *
18
+ * Browser file for drawing: nothing here touches OIDC, cookies or `node:`.
19
+ */
20
+ import QRCode from 'qrcode';
21
+ const MARKS = {
22
+ agora: {
23
+ d: 'M5.6 18.2V12.6a6.4 6.4 0 0 1 12.8 0V18.2',
24
+ dot: [12, 15.1],
25
+ len: 31.3,
26
+ hc: [12, 13.4],
27
+ hr: 5.2,
28
+ segs: [
29
+ ['L', 5.6, 18.2, 5.6, 12.6],
30
+ ['A', 12, 12.6, 6.4, 180, 360],
31
+ ['L', 18.4, 12.6, 18.4, 18.2],
32
+ ],
33
+ hole: 'M7.3 18.2V12.6a4.7 4.7 0 0 1 9.4 0V18.2Z',
34
+ },
35
+ boule: {
36
+ d: 'M8.6 17.89A6.8 6.8 0 1 1 15.4 17.89',
37
+ dot: [12, 12],
38
+ len: 35.6,
39
+ hc: [12, 12],
40
+ hr: 5.1,
41
+ segs: [['A', 12, 12, 6.8, 120, 420]],
42
+ hole: 'M6.9 12a5.1 5.1 0 1 0 10.2 0a5.1 5.1 0 1 0 -10.2 0Z',
43
+ },
44
+ archon: {
45
+ d: 'M9.3 19.28A5.4 5.4 0 1 1 14.7 19.28',
46
+ dot: [12, 4.3],
47
+ len: 28.3,
48
+ hc: [12, 14.6],
49
+ hr: 3.7,
50
+ segs: [['A', 12, 14.6, 5.4, 120, 420]],
51
+ hole: 'M8.3 14.6a3.7 3.7 0 1 0 7.4 0a3.7 3.7 0 1 0 -7.4 0Z',
52
+ },
53
+ };
54
+ export const QR_DOOR_THEME = { ink: '#1D1B16', accent: '#B23A0C', paper: '#F6F3EC' };
55
+ /* ---------- geometry (logical px) ---------- */
56
+ /** Side of the logical drawing space. */
57
+ export const QR_DOOR_SIZE = 360;
58
+ const L = QR_DOOR_SIZE;
59
+ const C0 = L / 2;
60
+ const CODE = 290;
61
+ const O = (L - CODE) / 2;
62
+ const ICON = 228;
63
+ const TAU = Math.PI * 2;
64
+ /** Icon units to px for the doorway drawn in the code. */
65
+ const S1 = 2.9;
66
+ /** Half-width of the clearing the doorway sits in. */
67
+ export const QR_DOOR_CLEARING = 25;
68
+ /** Icon size when first seen through the doorway, and the instant the doorway clears the stage. */
69
+ const START = 9;
70
+ const S0 = 88;
71
+ /** Finder-eye corner rounding. Above about 0.5 strict decoders stop finding the eyes. */
72
+ const EYE_ROUND = 0.4;
73
+ /** Largest QR version allowed: version 7 and up put an alignment pattern dead centre, under the door. */
74
+ export const QR_DOOR_MAX_VERSION = 6;
75
+ /* ---------- timeline (seconds after approval) ---------- */
76
+ const WALK = 0.95;
77
+ const DOLLY = 1.55;
78
+ /** Length of the approval animation. */
79
+ export const QR_DOOR_DURATION = WALK + DOLLY + 0.3;
80
+ /* ---------- easing ---------- */
81
+ const clamp = (v, a = 0, b = 1) => (v < a ? a : v > b ? b : v);
82
+ const seg = (u, a, b) => clamp((u - a) / (b - a));
83
+ const lerp = (a, b, t) => a + (b - a) * t;
84
+ const io = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
85
+ const sine = (t) => -(Math.cos(Math.PI * t) - 1) / 2;
86
+ const back = (t) => 1 + 2.5 * (t - 1) ** 3 + 1.5 * (t - 1) ** 2;
87
+ /**
88
+ * Builds the scene for `url` (the device grant's verification URL). Tries
89
+ * error correction Q, then M, then L, keeping the first that fits in version 6.
90
+ * The clearing knocks out about 3% of modules, well inside even L's 7%.
91
+ * Throws a `RangeError` for a URL too long for version 6 at any level.
92
+ */
93
+ export function buildQrDoorScene(url, mark) {
94
+ const geo = MARKS[mark];
95
+ let qr = null;
96
+ let errorCorrection = 'Q';
97
+ for (const level of ['Q', 'M', 'L']) {
98
+ const candidate = QRCode.create(url, { errorCorrectionLevel: level });
99
+ if (candidate.version <= QR_DOOR_MAX_VERSION) {
100
+ qr = candidate;
101
+ errorCorrection = level;
102
+ break;
103
+ }
104
+ }
105
+ if (!qr) {
106
+ throw new RangeError(`qr-door: ${url.length} characters needs a QR version above ${QR_DOOR_MAX_VERSION}`);
107
+ }
108
+ const n = qr.modules.size;
109
+ const data = qr.modules.data;
110
+ const m = CODE / n;
111
+ const isFinder = (x, y) => (x < 7 && y < 7) || (x > n - 8 && y < 7) || (x < 7 && y > n - 8);
112
+ const dots = [];
113
+ for (let y = 0; y < n; y++) {
114
+ for (let x = 0; x < n; x++) {
115
+ if (!data[y * n + x] || isFinder(x, y))
116
+ continue;
117
+ const px = O + (x + 0.5) * m;
118
+ const py = O + (y + 0.5) * m;
119
+ if (Math.abs(px - C0) < QR_DOOR_CLEARING && Math.abs(py - C0) < QR_DOOR_CLEARING)
120
+ continue;
121
+ dots.push({ x: px, y: py });
122
+ }
123
+ }
124
+ const finders = [
125
+ [0, 0],
126
+ [n - 7, 0],
127
+ [0, n - 7],
128
+ ].map(([fx, fy]) => ({ x: O + (fx + 3.5) * m, y: O + (fy + 3.5) * m }));
129
+ // Sample the outline evenly and give each sample the nearest module not yet taken.
130
+ const poly = [];
131
+ for (const s of geo.segs) {
132
+ if (s[0] === 'L') {
133
+ for (let i = 0; i <= 30; i++)
134
+ poly.push([lerp(s[1], s[3], i / 30), lerp(s[2], s[4], i / 30)]);
135
+ }
136
+ else {
137
+ for (let i = 0; i <= 120; i++) {
138
+ const a = ((s[4] + ((s[5] - s[4]) * i) / 120) * Math.PI) / 180;
139
+ poly.push([s[1] + s[3] * Math.cos(a), s[2] + s[3] * Math.sin(a)]);
140
+ }
141
+ }
142
+ }
143
+ const cum = [0];
144
+ for (let i = 1; i < poly.length; i++)
145
+ cum.push(cum[i - 1] + Math.hypot(poly[i][0] - poly[i - 1][0], poly[i][1] - poly[i - 1][1]));
146
+ const len = cum[cum.length - 1];
147
+ const count = Math.ceil((len * S1) / 3.6);
148
+ const used = new Set();
149
+ const pieces = [];
150
+ for (let j = 0; j < count; j++) {
151
+ const l = (j / (count - 1)) * len;
152
+ let q = 1;
153
+ while (q < cum.length - 1 && cum[q] < l)
154
+ q++;
155
+ const a = poly[q - 1];
156
+ const b = poly[q];
157
+ const f = clamp((l - cum[q - 1]) / (cum[q] - cum[q - 1] || 1));
158
+ const tx = C0 + (lerp(a[0], b[0], f) - geo.hc[0]) * S1;
159
+ const ty = C0 + (lerp(a[1], b[1], f) - geo.hc[1]) * S1;
160
+ let best = -1;
161
+ let bd = Number.POSITIVE_INFINITY;
162
+ for (let d = 0; d < dots.length; d++) {
163
+ if (used.has(d))
164
+ continue;
165
+ const dd = Math.hypot(dots[d].x - tx, dots[d].y - ty);
166
+ if (dd < bd) {
167
+ bd = dd;
168
+ best = d;
169
+ }
170
+ }
171
+ used.add(best);
172
+ pieces.push({ src: best, sx: dots[best].x, sy: dots[best].y, tx, ty, dl: (j / count) * 0.32 });
173
+ }
174
+ // One perspective camera: the code is a plane at depth 1, the icon at depth Dp.
175
+ // Solved so the icon is START px when first seen, S0 px the instant the doorway
176
+ // fills the stage, and ICON px at rest -- no hand-off between two zooms.
177
+ const Zg = (1.06 * 255) / (geo.hr * S1);
178
+ const zg = 1 - 1 / Zg;
179
+ const Dp = (zg * S0) / (S0 - START);
180
+ const kk = START * Dp;
181
+ const zf = Dp - kk / ICON;
182
+ let lo = 0;
183
+ let hi = 1;
184
+ for (let i = 0; i < 30; i++) {
185
+ const mid = (lo + hi) / 2;
186
+ if (io(mid) * zf < zg)
187
+ lo = mid;
188
+ else
189
+ hi = mid;
190
+ }
191
+ return {
192
+ mark,
193
+ errorCorrection,
194
+ version: qr.version,
195
+ modules: n,
196
+ moduleSize: m,
197
+ dots,
198
+ finders,
199
+ pieces,
200
+ camera: { Zg, zg, Dp, kk, zf, t1: WALK + DOLLY * hi },
201
+ };
202
+ }
203
+ /** Icon size (logical px) at time `t` after approval. Continuous by construction. */
204
+ export function qrDoorIconSize(scene, t) {
205
+ const c = scene.camera;
206
+ const z = c.zf * io(seg(t, WALK, WALK + DOLLY));
207
+ return c.kk / (c.Dp - z);
208
+ }
209
+ const paths = new Map();
210
+ function pathsFor(mark) {
211
+ let p = paths.get(mark);
212
+ if (!p) {
213
+ p = { stroke: new Path2D(MARKS[mark].d), hole: new Path2D(MARKS[mark].hole) };
214
+ paths.set(mark, p);
215
+ }
216
+ return p;
217
+ }
218
+ function rr(c, x, y, w, h, r) {
219
+ c.moveTo(x + r, y);
220
+ c.arcTo(x + w, y, x + w, y + h, r);
221
+ c.arcTo(x + w, y + h, x, y + h, r);
222
+ c.arcTo(x, y + h, x, y, r);
223
+ c.arcTo(x, y, x + w, y, r);
224
+ c.closePath();
225
+ }
226
+ function finder(c, s, p, th) {
227
+ const M = s.moduleSize;
228
+ const h = 3.5 * M;
229
+ c.save();
230
+ c.translate(p.x - h, p.y - h);
231
+ c.fillStyle = th.ink;
232
+ c.beginPath();
233
+ rr(c, 0, 0, 7 * M, 7 * M, 1.6 * M * EYE_ROUND);
234
+ rr(c, M, M, 5 * M, 5 * M, 0.8 * M * EYE_ROUND);
235
+ c.fill('evenodd');
236
+ c.beginPath();
237
+ rr(c, 2 * M, 2 * M, 3 * M, 3 * M, 0.75 * M * EYE_ROUND);
238
+ c.fill();
239
+ c.restore();
240
+ }
241
+ function circles(c, xyr, color) {
242
+ c.fillStyle = color;
243
+ c.beginPath();
244
+ for (let i = 0; i < xyr.length; i += 3) {
245
+ const r = xyr[i + 2];
246
+ if (r <= 0.1)
247
+ continue;
248
+ c.moveTo(xyr[i] + r, xyr[i + 1]);
249
+ c.arc(xyr[i], xyr[i + 1], r, 0, TAU);
250
+ }
251
+ c.fill();
252
+ }
253
+ function stroke(c, s, th, dash) {
254
+ c.lineWidth = 3.4;
255
+ c.lineCap = 'round';
256
+ c.strokeStyle = th.ink;
257
+ if (dash)
258
+ c.setLineDash(dash);
259
+ c.stroke(pathsFor(s.mark).stroke);
260
+ c.setLineDash([]);
261
+ }
262
+ function icon(c, s, th, size) {
263
+ const k = size / 24;
264
+ c.save();
265
+ c.translate(C0 - size / 2, C0 - size / 2);
266
+ c.scale(k, k);
267
+ c.save();
268
+ c.shadowColor = 'rgba(29,27,22,.16)';
269
+ c.shadowBlur = 18 / k;
270
+ c.shadowOffsetY = 6 / k;
271
+ c.beginPath();
272
+ rr(c, 0.25, 0.25, 23.5, 23.5, 5.4);
273
+ c.fillStyle = th.paper;
274
+ c.fill();
275
+ c.shadowColor = 'transparent';
276
+ c.lineWidth = 0.2;
277
+ c.strokeStyle = 'rgba(29,27,22,.14)';
278
+ c.stroke();
279
+ c.restore();
280
+ stroke(c, s, th);
281
+ c.restore();
282
+ }
283
+ /** The code at rest, waiting for the phone. */
284
+ export function drawQrDoorRest(c, s, th = QR_DOOR_THEME) {
285
+ c.fillStyle = th.paper;
286
+ c.fillRect(0, 0, L, L);
287
+ const r = 0.42 * s.moduleSize;
288
+ const a = [];
289
+ for (const p of s.dots)
290
+ a.push(p.x, p.y, r);
291
+ circles(c, a, th.ink);
292
+ for (const f of s.finders)
293
+ finder(c, s, f, th);
294
+ }
295
+ /** The frame `t` seconds after approval; `t` is clamped to `[0, QR_DOOR_DURATION]`. */
296
+ export function drawQrDoorScene(c, s, time, th = QR_DOOR_THEME) {
297
+ const t = clamp(time, 0, QR_DOOR_DURATION);
298
+ const geo = MARKS[s.mark];
299
+ const cam = s.camera;
300
+ const hc = geo.hc;
301
+ const R0 = 0.42 * s.moduleSize;
302
+ const halfLine = 1.7 * S1;
303
+ const { hole: holePath } = pathsFor(s.mark);
304
+ // The opening: modules fly onto the outline; the line traces in behind the landings.
305
+ // Each lands at exactly half the line's width, so there is no cross-fade to see.
306
+ const trace = clamp((t - 0.345) / 0.32);
307
+ const hole = io(seg(t, 0.55, 0.8));
308
+ const arrive = back(seg(t, 0.72, 0.95));
309
+ const lifted = new Set();
310
+ for (const p of s.pieces)
311
+ if (t > p.dl)
312
+ lifted.add(p.src);
313
+ // The walk: one camera for both planes.
314
+ const z = cam.zf * io(seg(t, WALK, WALK + DOLLY));
315
+ const gone = z >= cam.zg;
316
+ const zoom = gone ? cam.Zg : 1 / (1 - z);
317
+ const isz = cam.kk / (cam.Dp - z);
318
+ const k = isz / ICON;
319
+ // Far side: dotted ground and the icon, visible through the doorway from the start of the walk.
320
+ c.fillStyle = th.paper;
321
+ c.fillRect(0, 0, L, L);
322
+ const sp = Math.max(22 * k, 4.5);
323
+ const dr = sp * 0.05;
324
+ const g0 = C0 - Math.ceil(C0 / sp) * sp;
325
+ c.fillStyle = 'rgba(29,27,22,.13)';
326
+ c.beginPath();
327
+ for (let gx = g0; gx < L + sp; gx += sp) {
328
+ for (let gy = g0; gy < L + sp; gy += sp) {
329
+ c.moveTo(gx + dr, gy);
330
+ c.arc(gx, gy, dr, 0, TAU);
331
+ }
332
+ }
333
+ c.fill();
334
+ const iconAlpha = sine(seg(t, WALK - 0.1, WALK + 0.35));
335
+ if (iconAlpha > 0) {
336
+ c.save();
337
+ c.globalAlpha = iconAlpha;
338
+ icon(c, s, th, isz);
339
+ c.restore();
340
+ }
341
+ // The person: from the doorway to the icon's own dot, then rides with it.
342
+ const w = io(seg(t, WALK, cam.t1));
343
+ const px = lerp(C0 + (geo.dot[0] - hc[0]) * S1, C0 + ((geo.dot[0] - 12) * isz) / 24, w);
344
+ const py = lerp(C0 + (geo.dot[1] - hc[1]) * S1, C0 + ((geo.dot[1] - 12) * isz) / 24, w);
345
+ const pr = lerp(2.6 * S1 * arrive, (2.6 * isz) / 24, w);
346
+ // Near side: the code, the doorway cut out of it, rushing past the camera.
347
+ if (!gone) {
348
+ c.save();
349
+ c.translate(C0, C0);
350
+ c.scale(zoom, zoom);
351
+ c.translate(-C0, -C0);
352
+ c.save();
353
+ const clip = new Path2D();
354
+ clip.rect(-4000, -4000, 8000 + L, 8000 + L);
355
+ if (hole > 0) {
356
+ clip.addPath(holePath, new DOMMatrix()
357
+ .translate(C0 - hc[0] * S1, C0 - hc[1] * S1)
358
+ .scale(S1)
359
+ .translate(hc[0], hc[1])
360
+ .scale(hole)
361
+ .translate(-hc[0], -hc[1]));
362
+ }
363
+ c.clip(clip, 'evenodd');
364
+ c.fillStyle = th.paper;
365
+ c.fillRect(-4000, -4000, 8000 + L, 8000 + L);
366
+ const rest = [];
367
+ s.dots.forEach((p, i) => {
368
+ if (!lifted.has(i))
369
+ rest.push(p.x, p.y, R0);
370
+ });
371
+ circles(c, rest, th.ink);
372
+ for (const f of s.finders)
373
+ finder(c, s, f, th);
374
+ c.restore();
375
+ if (trace < 1) {
376
+ const fly = [];
377
+ for (const p of s.pieces) {
378
+ if (t <= p.dl)
379
+ continue;
380
+ const e = io(seg(t, p.dl, p.dl + 0.32));
381
+ const lift = Math.sin(Math.PI * e) * 10;
382
+ fly.push(lerp(p.sx, p.tx, e), lerp(p.sy, p.ty, e) - lift, lerp(R0, halfLine, e));
383
+ }
384
+ circles(c, fly, th.ink);
385
+ }
386
+ c.restore();
387
+ }
388
+ if (pr > 0.1) {
389
+ c.fillStyle = th.accent;
390
+ c.beginPath();
391
+ c.arc(px, py, pr, 0, TAU);
392
+ c.fill();
393
+ }
394
+ if (!gone && trace > 0) {
395
+ c.save();
396
+ c.translate(C0, C0);
397
+ c.scale(zoom, zoom);
398
+ c.translate(-C0, -C0);
399
+ c.translate(C0 - hc[0] * S1, C0 - hc[1] * S1);
400
+ c.scale(S1, S1);
401
+ stroke(c, s, th, trace < 1 ? [geo.len * trace, geo.len * 2] : undefined);
402
+ c.restore();
403
+ }
404
+ }
@@ -0,0 +1,15 @@
1
+ import { type QrDoorMark, type QrDoorTheme } from './qr-door-scene.js';
2
+ export { QR_DOOR_DURATION, QR_DOOR_THEME, type QrDoorMark, type QrDoorScene, type QrDoorTheme, buildQrDoorScene, drawQrDoorRest, drawQrDoorScene, } from './qr-door-scene.js';
3
+ export interface QrDoorProps {
4
+ /** What the phone scans: `startQrSignIn()`'s `url`. */
5
+ url: string;
6
+ /** Whose door: the app's mark. */
7
+ mark: QrDoorMark;
8
+ /** True once `pollQrSignIn()` answered `signed_in`. */
9
+ approved: boolean;
10
+ /** Called when the walk-through ends, or at once under reduced motion. Navigate here. */
11
+ onDone?: () => void;
12
+ theme?: Partial<QrDoorTheme>;
13
+ className?: string;
14
+ }
15
+ export declare function QrDoor({ url, mark, approved, onDone, theme, className }: QrDoorProps): import("react/jsx-runtime").JSX.Element;