slauthjs 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.d.ts ADDED
@@ -0,0 +1,149 @@
1
+ /** A key-value store: the only thing slauth needs from your database. */
2
+ export interface Store {
3
+ get(key: string): Promise<any>
4
+ /** ttl is in seconds. */
5
+ put(key: string, value: unknown, options?: { ttl?: number }): Promise<void>
6
+ delete(key: string): Promise<void>
7
+ }
8
+
9
+ /** Sends the sign-in email. `subject`, `text` and `html` are ready to use. */
10
+ export interface EmailSender {
11
+ send(message: { to: string; code: string; appName: string; subject: string; text: string; html: string }): Promise<void>
12
+ }
13
+
14
+ export interface SlauthUser {
15
+ id: string
16
+ email: string
17
+ createdAt: number
18
+ }
19
+
20
+ export interface SlauthConfig {
21
+ /** A random string of 16+ characters. Make one with: openssl rand -base64 32 */
22
+ secret: string
23
+ /** Where users, sessions and passkeys live: memory(), d1(env.DB), kv(env.KV), sqlite(db), or your own. */
24
+ store: Store
25
+ /** How codes are emailed. Defaults to printing them to the console. */
26
+ email?: EmailSender
27
+ /** Shown in emails and as the passkey's site name. */
28
+ appName?: string
29
+ /** Who may sign in: a list of emails ("@family.com" for a whole domain) or a function. Everyone, if left out. */
30
+ allow?: string[] | ((email: string) => boolean | Promise<boolean>)
31
+ /** Where the routes live. Default "/auth". */
32
+ basePath?: string
33
+ /** Session cookie name. Default "slauth_session". */
34
+ cookie?: string
35
+ /** Sessions last this many days after the last visit. Default 30. */
36
+ sessionDays?: number
37
+ /** Origins allowed to post to the routes (and to use passkeys). Default: the request's own origin. */
38
+ origins?: string | string[]
39
+ /** Passkey settings, or false to turn passkeys off. */
40
+ passkeys?:
41
+ | false
42
+ | {
43
+ /** The site passkeys belong to, e.g. "myapp.dev" (works for every subdomain). Default: the request's hostname. */
44
+ rpId?: string
45
+ rpName?: string
46
+ origin?: string | string[]
47
+ userVerification?: 'required' | 'preferred' | 'discouraged'
48
+ }
49
+ /** Also accept people signed in by Cloudflare Access. */
50
+ access?: { team: string; aud: string | string[]; devUser?: string; fetch?: typeof fetch }
51
+ /** Turn on the PIN app lock. true = off by default for each person; or set their default. */
52
+ lock?: true | { default?: Policy; cookie?: string }
53
+ }
54
+
55
+ export interface Slauth {
56
+ /** Answers every request under basePath (404 for anything else). */
57
+ handle(request: Request): Promise<Response>
58
+ /** The signed-in person, or null. Ignores the app lock. */
59
+ user(request: Request): Promise<SlauthUser | null>
60
+ /** The signed-in, unlocked person, or a 401 Response to return as it is. */
61
+ requireUser(request: Request): Promise<SlauthUser | Response>
62
+ store: Store
63
+ }
64
+
65
+ export function slauth(config: SlauthConfig): Slauth
66
+ export function memory(): Store
67
+ export function consoleEmail(): EmailSender
68
+
69
+ /** What createAccess and createLock work with. */
70
+ export interface User {
71
+ /** Lower-cased email from the Access sign-in. */
72
+ email: string
73
+ /** Stable, opaque id: the same in every app for the same email. */
74
+ userId: string
75
+ }
76
+
77
+ export interface AccessOptions {
78
+ /** "yourteam" or "yourteam.cloudflareaccess.com". */
79
+ teamDomain: string
80
+ /** The Application Audience (AUD) tag of the Access application(s). */
81
+ audience: string | string[]
82
+ /** Optional second check after the Access policy. */
83
+ allowedEmails?: string[]
84
+ /** email -> userId, to keep someone's data when their email changes. */
85
+ aliases?: Record<string, string>
86
+ /** Local development only: treat every request as this email. Never set in production. */
87
+ devUser?: string
88
+ fetch?: typeof fetch
89
+ }
90
+
91
+ export function createAccess(options: AccessOptions): {
92
+ /** The signed-in person, or null when the request did not come through Access. */
93
+ getUser(request: Request): Promise<User | null>
94
+ }
95
+
96
+ export function userIdFor(email: string): Promise<string>
97
+
98
+ export type Policy = 'always' | 'daily' | 'weekly' | 'never'
99
+ export const POLICIES: readonly Policy[]
100
+
101
+ export interface LockDoc {
102
+ pinHash?: string
103
+ salt?: string
104
+ sessionKey: string
105
+ failures: number
106
+ lockedUntil: number
107
+ policy?: Policy
108
+ }
109
+
110
+ export interface LockStore {
111
+ get(userId: string): Promise<LockDoc | null>
112
+ put(userId: string, doc: LockDoc): Promise<void>
113
+ }
114
+
115
+ export interface LockReply {
116
+ status: number
117
+ body: Record<string, unknown>
118
+ /** A Set-Cookie header value, when the reply changes the unlock. */
119
+ cookie?: string
120
+ }
121
+
122
+ export interface LockOptions {
123
+ /** Cookie name; keep an app's old one so nobody is signed out twice. Default "slauth_lock". */
124
+ cookie?: string
125
+ /** For people who haven't chosen yet. Default "weekly". */
126
+ defaultPolicy?: Policy
127
+ }
128
+
129
+ export function createLock(
130
+ store: LockStore,
131
+ options?: LockOptions,
132
+ ): {
133
+ /**
134
+ * Routes: GET session, POST login | setup | logout | pin | policy.
135
+ * Returns null for any other route.
136
+ */
137
+ handle(
138
+ route: string,
139
+ method: string,
140
+ readBody: () => Promise<any>,
141
+ user: User,
142
+ cookieHeader: string | null | undefined,
143
+ secure: boolean,
144
+ ): Promise<LockReply | null>
145
+ isUnlocked(user: User, cookieHeader: string | null | undefined): Promise<boolean>
146
+ }
147
+
148
+ export function validPin(pin: unknown): pin is string
149
+ export function validPolicy(policy: unknown): policy is Policy
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { slauth } from './core.js'
2
+ export { memory } from './stores/memory.js'
3
+ export { consoleEmail } from './email/console.js'
4
+ // The Cloudflare Access check and the PIN lock, usable on their own too.
5
+ export { createAccess, userIdFor } from './access.js'
6
+ export { createLock, validPin, validPolicy, POLICIES } from './lock.js'
package/src/lock.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The app lock: a 4-digit PIN that each person can ask an app to check again now and
3
+ * then, on top of the Access sign-in. Access says *who* you are; the lock guards an
4
+ * unlocked phone or iPad from whoever picks it up.
5
+ *
6
+ * Each person picks how often, per app:
7
+ * always every visit (the unlock ends when the browser closes, at most 12 hours)
8
+ * daily once a day
9
+ * weekly once a week
10
+ * never no PIN; the Access sign-in is enough
11
+ *
12
+ * Grown out of the Planda / LedgerFox PIN pad, and compatible with its stored hashes:
13
+ * a salted PBKDF2 hash, 5 free tries then a lock of 5 minutes doubling to an hour, and a
14
+ * signed HttpOnly cookie. The signing key is random and per person; changing the PIN
15
+ * replaces it, which locks that person's other devices.
16
+ */
17
+
18
+ import { base64Url, randomToken, readCookie, safeEqual } from './util.js'
19
+
20
+ const DAY = 24 * 60 * 60
21
+ export const POLICIES = /** @type {const} */ (['always', 'daily', 'weekly', 'never'])
22
+ const TTL_SECONDS = { always: DAY / 2, daily: DAY, weekly: 7 * DAY }
23
+ const FREE_ATTEMPTS = 5
24
+ const PBKDF2_ITERATIONS = 100_000
25
+
26
+ export function validPin(pin) {
27
+ return typeof pin === 'string' && /^\d{4}$/.test(pin)
28
+ }
29
+
30
+ export function validPolicy(policy) {
31
+ return POLICIES.includes(policy)
32
+ }
33
+
34
+ /**
35
+ * @typedef {'always' | 'daily' | 'weekly' | 'never'} Policy
36
+ * @typedef {{ pinHash?: string, salt?: string, sessionKey: string, failures: number, lockedUntil: number, policy?: Policy }} LockDoc
37
+ * @typedef {{ get(userId: string): Promise<LockDoc | null>, put(userId: string, doc: LockDoc): Promise<void> }} LockStore
38
+ * @typedef {{ email: string, userId: string }} User
39
+ * @typedef {{ status: number, body: object, cookie?: string }} LockReply
40
+ */
41
+
42
+ /**
43
+ * @param {LockStore} store where each person's lock document lives
44
+ * @param {{ cookie?: string, defaultPolicy?: Policy }} [options]
45
+ * cookie the cookie name (keep an app's old one so nobody is signed out twice)
46
+ * defaultPolicy for people who haven't chosen yet; "weekly" unless set
47
+ */
48
+ export function createLock(store, { cookie: cookieName = 'slauth_lock', defaultPolicy = 'weekly' } = {}) {
49
+ if (!validPolicy(defaultPolicy)) throw new Error(`slauth: unknown policy "${defaultPolicy}".`)
50
+
51
+ async function load(userId) {
52
+ const doc = await store.get(userId)
53
+ if (doc && typeof doc.sessionKey === 'string') return doc
54
+ const fresh = { sessionKey: randomToken(), failures: 0, lockedUntil: 0 }
55
+ await store.put(userId, fresh)
56
+ return fresh
57
+ }
58
+
59
+ const policyOf = (doc) => (validPolicy(doc.policy) ? doc.policy : defaultPolicy)
60
+ const pinIsSet = (doc) => Boolean(doc.pinHash && doc.salt)
61
+ const matches = async (doc, pin) => pinIsSet(doc) && safeEqual(await hashPin(pin, doc.salt), doc.pinHash)
62
+
63
+ async function unlockCookie(userId, doc, secure) {
64
+ const issued = String(Math.floor(Date.now() / 1000))
65
+ const value = `${issued}.${await sign(`${userId}.${issued}`, doc.sessionKey)}`
66
+ // "always" is a browser-session cookie; the others last as long as the longest policy,
67
+ // and the policy in force decides on each request whether the unlock still counts.
68
+ return cookie(cookieName, value, policyOf(doc) === 'always' ? null : TTL_SECONDS.weekly, secure)
69
+ }
70
+
71
+ /**
72
+ * True when this person may use the app right now: they chose "never", or they
73
+ * unlocked recently enough for their policy.
74
+ * @param {User} user
75
+ * @param {string | null | undefined} cookieHeader
76
+ */
77
+ async function isUnlocked(user, cookieHeader) {
78
+ const doc = await load(user.userId)
79
+ const policy = policyOf(doc)
80
+ if (policy === 'never') return true
81
+ if (!pinIsSet(doc)) return false
82
+
83
+ const [issued, signature] = (readCookie(cookieHeader, cookieName) ?? '').split('.')
84
+ const issuedAt = Number(issued)
85
+ if (!signature || !Number.isFinite(issuedAt)) return false
86
+ const age = Date.now() / 1000 - issuedAt
87
+ if (age < -60 || age > TTL_SECONDS[policy]) return false
88
+ // The user id is part of what's signed, so one person's cookie never unlocks another's.
89
+ return safeEqual(signature, await sign(`${user.userId}.${issued}`, doc.sessionKey))
90
+ }
91
+
92
+ async function savePin(userId, doc, pin, policy, secure) {
93
+ const salt = randomToken()
94
+ const next = { ...doc, pinHash: await hashPin(pin, salt), salt, sessionKey: randomToken(), failures: 0, lockedUntil: 0, policy }
95
+ await store.put(userId, next)
96
+ return unlockCookie(userId, next, secure)
97
+ }
98
+
99
+ /** @returns {Promise<LockReply>} */
100
+ async function login(user, guess, secure) {
101
+ if (!validPin(guess)) return { status: 400, body: { error: 'The PIN is 4 digits.' } }
102
+ const doc = await load(user.userId)
103
+ const now = Date.now()
104
+ if (!pinIsSet(doc)) return { status: 409, body: { error: 'No PIN has been created yet.', reason: 'no-pin' } }
105
+ if (doc.lockedUntil > now) {
106
+ return { status: 429, body: { error: 'Too many tries.', reason: 'locked', retryAt: doc.lockedUntil } }
107
+ }
108
+
109
+ if (await matches(doc, guess)) {
110
+ if (doc.failures !== 0 || doc.lockedUntil !== 0) await store.put(user.userId, { ...doc, failures: 0, lockedUntil: 0 })
111
+ return { status: 200, body: { ok: true }, cookie: await unlockCookie(user.userId, doc, secure) }
112
+ }
113
+
114
+ const failures = doc.failures + 1
115
+ const over = failures - FREE_ATTEMPTS
116
+ const lockedUntil = over >= 0 ? now + Math.min(60, 5 * 2 ** over) * 60_000 : 0
117
+ await store.put(user.userId, { ...doc, failures, lockedUntil })
118
+ return lockedUntil
119
+ ? { status: 429, body: { error: 'Too many tries.', reason: 'locked', retryAt: lockedUntil } }
120
+ : { status: 401, body: { error: 'Wrong PIN.', reason: 'wrong', attemptsLeft: FREE_ATTEMPTS - failures } }
121
+ }
122
+
123
+ /** First time in this app: choose a PIN (and optionally how often to ask for it). */
124
+ async function setup(user, pin, policy, secure) {
125
+ if (!validPin(pin)) return { status: 400, body: { error: 'The PIN is 4 digits.' } }
126
+ if (policy !== undefined && !validPolicy(policy)) return { status: 400, body: { error: 'Unknown lock setting.' } }
127
+ const doc = await load(user.userId)
128
+ if (pinIsSet(doc)) return { status: 409, body: { error: 'A PIN already exists.' } }
129
+ return { status: 200, body: { ok: true }, cookie: await savePin(user.userId, doc, pin, policy ?? policyOf(doc), secure) }
130
+ }
131
+
132
+ /** Change PIN from Settings. Not counted toward the lockout; locks this person's other devices. */
133
+ async function change(user, current, next, secure) {
134
+ if (!validPin(current) || !validPin(next)) return { status: 400, body: { error: 'PINs are 4 digits.' } }
135
+ const doc = await load(user.userId)
136
+ if (!(await matches(doc, current))) return { status: 403, body: { error: 'The current PIN is wrong.' } }
137
+ return { status: 200, body: { ok: true }, cookie: await savePin(user.userId, doc, next, policyOf(doc), secure) }
138
+ }
139
+
140
+ /**
141
+ * Change how often the PIN is asked for. With a PIN set, the current PIN is needed,
142
+ * so a borrowed unlocked device can't switch the lock off.
143
+ */
144
+ async function setPolicy(user, policy, pin, secure) {
145
+ if (!validPolicy(policy)) return { status: 400, body: { error: 'Unknown lock setting.' } }
146
+ const doc = await load(user.userId)
147
+ if (pinIsSet(doc)) {
148
+ if (!validPin(pin) || !(await matches(doc, pin))) return { status: 403, body: { error: 'The current PIN is wrong.' } }
149
+ } else if (policy !== 'never') {
150
+ return { status: 409, body: { error: 'Create a PIN first.', reason: 'no-pin' } }
151
+ }
152
+ const next = { ...doc, policy }
153
+ await store.put(user.userId, next)
154
+ const reply = { status: 200, body: { ok: true, policy } }
155
+ return pinIsSet(next) ? { ...reply, cookie: await unlockCookie(user.userId, next, secure) } : reply
156
+ }
157
+
158
+ /**
159
+ * The lock routes. `route` is the part after the app's prefix (e.g. "session" for
160
+ * /api/lock/session). Returns null for any other route.
161
+ * @param {string} route
162
+ * @param {string} method
163
+ * @param {() => Promise<any>} readBody
164
+ * @param {User} user from Access
165
+ * @param {string | null | undefined} cookieHeader
166
+ * @param {boolean} secure https, so the cookie gets the Secure flag
167
+ * @returns {Promise<LockReply | null>}
168
+ */
169
+ async function handle(route, method, readBody, user, cookieHeader, secure) {
170
+ if (route === 'session' && method === 'GET') {
171
+ const doc = await load(user.userId)
172
+ const policy = policyOf(doc)
173
+ return {
174
+ status: 200,
175
+ body: { email: user.email, policy, required: policy !== 'never', pinSet: pinIsSet(doc), unlocked: await isUnlocked(user, cookieHeader) },
176
+ }
177
+ }
178
+ if (route === 'login' && method === 'POST') return login(user, (await readBody())?.pin, secure)
179
+ if (route === 'setup' && method === 'POST') {
180
+ const body = await readBody()
181
+ if (body?.policy === 'never' && body?.pin === undefined) return setPolicy(user, 'never', undefined, secure)
182
+ return setup(user, body?.pin, body?.policy, secure)
183
+ }
184
+ if (route === 'logout' && method === 'POST') return { status: 200, body: { ok: true }, cookie: cookie(cookieName, '', 0, secure) }
185
+ if (route === 'pin' && method === 'POST') {
186
+ if (!(await isUnlocked(user, cookieHeader))) return { status: 401, body: { error: 'Please enter your PIN.' } }
187
+ const body = await readBody()
188
+ return change(user, body?.current, body?.next, secure)
189
+ }
190
+ if (route === 'policy' && method === 'POST') {
191
+ if (!(await isUnlocked(user, cookieHeader))) return { status: 401, body: { error: 'Please enter your PIN.' } }
192
+ const body = await readBody()
193
+ return setPolicy(user, body?.policy, body?.pin, secure)
194
+ }
195
+ return null
196
+ }
197
+
198
+ return { handle, isUnlocked }
199
+ }
200
+
201
+ function cookie(name, value, maxAge, secure) {
202
+ const age = maxAge === null ? '' : `; Max-Age=${maxAge}`
203
+ return `${name}=${value}; Path=/; HttpOnly; SameSite=Strict${age}${secure ? '; Secure' : ''}`
204
+ }
205
+
206
+
207
+ async function hashPin(pin, salt) {
208
+ const encoder = new TextEncoder()
209
+ const key = await crypto.subtle.importKey('raw', encoder.encode(pin), 'PBKDF2', false, ['deriveBits'])
210
+ const bits = await crypto.subtle.deriveBits(
211
+ { name: 'PBKDF2', hash: 'SHA-256', salt: encoder.encode(salt), iterations: PBKDF2_ITERATIONS },
212
+ key,
213
+ 256,
214
+ )
215
+ return base64Url(bits)
216
+ }
217
+
218
+ async function sign(payload, secret) {
219
+ const encoder = new TextEncoder()
220
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
221
+ return base64Url(await crypto.subtle.sign('HMAC', key, encoder.encode(payload)))
222
+ }
223
+
224
+
225
+
package/src/node.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ export function toNodeHandler(auth: { handle(request: Request): Promise<Response> }): (req: IncomingMessage, res: ServerResponse, next?: (error?: unknown) => void) => Promise<void>
3
+ export function toRequest(req: IncomingMessage): Promise<Request>
package/src/node.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Plain Node (node:http, Express, Connect): turns `auth.handle` into `(req, res, next)`.
3
+ *
4
+ * import { toNodeHandler } from 'slauthjs/node'
5
+ * app.use('/auth', toNodeHandler(auth)) // Express
6
+ * const user = await auth.user(toRequest(req)) // in your own routes
7
+ *
8
+ * Everything else (Workers, Bun, Deno, Next.js, Astro, Hono) speaks Request/Response
9
+ * already and can call auth.handle(request) directly.
10
+ */
11
+
12
+ /** @param {{ handle(request: Request): Promise<Response> }} auth */
13
+ export function toNodeHandler(auth) {
14
+ return async (req, res, next) => {
15
+ try {
16
+ const response = await auth.handle(await toRequest(req))
17
+ res.statusCode = response.status
18
+ for (const [key, value] of response.headers) {
19
+ if (key.toLowerCase() === 'set-cookie') continue
20
+ res.setHeader(key, value)
21
+ }
22
+ const cookies = response.headers.getSetCookie?.() ?? []
23
+ if (cookies.length) res.setHeader('Set-Cookie', cookies)
24
+ res.end(Buffer.from(await response.arrayBuffer()))
25
+ } catch (error) {
26
+ if (next) next(error)
27
+ else {
28
+ res.statusCode = 500
29
+ res.end()
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ /**
36
+ * A Web Request from a Node IncomingMessage (body included).
37
+ * @param {import('node:http').IncomingMessage & { originalUrl?: string, body?: unknown }} req
38
+ */
39
+ export async function toRequest(req) {
40
+ const proto = req.headers['x-forwarded-proto']?.toString().split(',')[0] ?? (req.socket?.encrypted ? 'https' : 'http')
41
+ const host = req.headers['x-forwarded-host']?.toString() ?? req.headers.host ?? 'localhost'
42
+ // Under app.use('/auth', ...) Express strips the prefix from req.url; originalUrl keeps it.
43
+ const url = new URL(req.originalUrl ?? req.url ?? '/', `${proto}://${host}`)
44
+
45
+ const headers = new Headers()
46
+ for (const [key, value] of Object.entries(req.headers)) {
47
+ if (value === undefined) continue
48
+ for (const v of [value].flat()) headers.append(key, v)
49
+ }
50
+
51
+ let body
52
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
53
+ // Express's json() may already have read the body.
54
+ if (req.body !== undefined && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) body = JSON.stringify(req.body)
55
+ else {
56
+ const chunks = []
57
+ let size = 0
58
+ for await (const chunk of req) {
59
+ size += chunk.length
60
+ if (size > 1_000_000) throw new Error('slauth: request body too large.')
61
+ chunks.push(chunk)
62
+ }
63
+ body = Buffer.concat(chunks)
64
+ }
65
+ }
66
+ return new Request(url, { method: req.method, headers, body })
67
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Just enough CBOR (RFC 8949) to read what authenticators send: integers, byte and
3
+ * text strings, arrays, maps, booleans, null and floats. Definite lengths only, which
4
+ * is all WebAuthn uses.
5
+ */
6
+
7
+ /**
8
+ * Decodes one item from `bytes` starting at `offset`.
9
+ * @param {Uint8Array} bytes
10
+ * @returns {{ value: any, offset: number }} the item and where the next one starts
11
+ */
12
+ export function decodeCbor(bytes, offset = 0) {
13
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
14
+ let at = offset
15
+
16
+ const need = (n) => {
17
+ if (at + n > bytes.length) throw new Error('CBOR: ran out of bytes.')
18
+ }
19
+
20
+ const length = (info) => {
21
+ if (info < 24) return info
22
+ if (info === 24) return need(1), view.getUint8(at++)
23
+ if (info === 25) return need(2), (at += 2), view.getUint16(at - 2)
24
+ if (info === 26) return need(4), (at += 4), view.getUint32(at - 4)
25
+ if (info === 27) {
26
+ need(8)
27
+ const big = view.getBigUint64(at)
28
+ at += 8
29
+ if (big > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('CBOR: number too large.')
30
+ return Number(big)
31
+ }
32
+ throw new Error('CBOR: indefinite lengths are not supported.')
33
+ }
34
+
35
+ const item = (depth) => {
36
+ if (depth > 16) throw new Error('CBOR: nested too deeply.')
37
+ need(1)
38
+ const first = view.getUint8(at++)
39
+ const major = first >> 5
40
+ const info = first & 31
41
+ switch (major) {
42
+ case 0:
43
+ return length(info)
44
+ case 1:
45
+ return -1 - length(info)
46
+ case 2: {
47
+ const n = length(info)
48
+ need(n)
49
+ at += n
50
+ return bytes.slice(at - n, at)
51
+ }
52
+ case 3: {
53
+ const n = length(info)
54
+ need(n)
55
+ at += n
56
+ return new TextDecoder().decode(bytes.subarray(at - n, at))
57
+ }
58
+ case 4: {
59
+ const n = length(info)
60
+ const out = []
61
+ for (let i = 0; i < n; i++) out.push(item(depth + 1))
62
+ return out
63
+ }
64
+ case 5: {
65
+ const n = length(info)
66
+ const out = new Map()
67
+ for (let i = 0; i < n; i++) {
68
+ const key = item(depth + 1)
69
+ out.set(key, item(depth + 1))
70
+ }
71
+ return out
72
+ }
73
+ case 6:
74
+ // A tag: skip it and read what it wraps.
75
+ length(info)
76
+ return item(depth + 1)
77
+ case 7:
78
+ if (info === 20) return false
79
+ if (info === 21) return true
80
+ if (info === 22 || info === 23) return null
81
+ if (info === 25) return need(2), (at += 2), halfFloat(view.getUint16(at - 2))
82
+ if (info === 26) return need(4), (at += 4), view.getFloat32(at - 4)
83
+ if (info === 27) return need(8), (at += 8), view.getFloat64(at - 8)
84
+ throw new Error('CBOR: unsupported simple value.')
85
+ }
86
+ }
87
+
88
+ const value = item(0)
89
+ return { value, offset: at }
90
+ }
91
+
92
+ function halfFloat(bits) {
93
+ const exp = (bits >> 10) & 0x1f
94
+ const frac = bits & 0x3ff
95
+ const sign = bits & 0x8000 ? -1 : 1
96
+ if (exp === 0) return sign * 2 ** -14 * (frac / 1024)
97
+ if (exp === 31) return frac ? NaN : sign * Infinity
98
+ return sign * 2 ** (exp - 15) * (1 + frac / 1024)
99
+ }