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.
@@ -0,0 +1,183 @@
1
+ import { decodeCbor } from './cbor.js'
2
+ import { base64Url, concat, fromBase64Url, sha256 } from '../util.js'
3
+
4
+ /**
5
+ * WebAuthn (passkeys), the parts a server needs, with Web Crypto only:
6
+ *
7
+ * - registration: check the browser's answer to navigator.credentials.create() and pull
8
+ * out the new public key;
9
+ * - sign-in: check a signature from navigator.credentials.get() with that key.
10
+ *
11
+ * Attestation is not checked ("none"): for a personal project what matters is that the
12
+ * same passkey signs in later, not which company made the authenticator.
13
+ *
14
+ * Supports ES256 (every platform passkey) and RS256 (some Windows Hello keys).
15
+ */
16
+
17
+ export const ES256 = -7
18
+ export const RS256 = -257
19
+
20
+ const FLAG_UP = 0x01
21
+ const FLAG_UV = 0x04
22
+ const FLAG_AT = 0x40
23
+
24
+ /**
25
+ * @typedef {{ origin: string | string[], rpId: string, challenge: string, requireUserVerification?: boolean }} Expect
26
+ * @typedef {{ id: string, publicKey: JsonWebKey, alg: number, counter: number, transports?: string[] }} Credential
27
+ */
28
+
29
+ /**
30
+ * Checks a registration response.
31
+ * @param {{ id: string, rawId?: string, type: string, response: { clientDataJSON: string, attestationObject: string, transports?: string[] } }} credential
32
+ * @param {Expect} expect
33
+ * @returns {Promise<Credential>}
34
+ */
35
+ export async function verifyRegistration(credential, expect) {
36
+ if (credential?.type !== 'public-key' || typeof credential.id !== 'string') throw new PasskeyError('Not a passkey.')
37
+ await checkClientData(credential.response?.clientDataJSON, 'webauthn.create', expect)
38
+
39
+ const attestation = decodeCbor(fromBase64Url(credential.response.attestationObject)).value
40
+ if (!(attestation instanceof Map) || !(attestation.get('authData') instanceof Uint8Array)) {
41
+ throw new PasskeyError('The attestation is malformed.')
42
+ }
43
+ const auth = await parseAuthData(attestation.get('authData'), expect)
44
+ if (!auth.credentialId || !auth.coseKey) throw new PasskeyError('No new credential in the response.')
45
+ if (base64Url(auth.credentialId) !== credential.id) throw new PasskeyError('Credential id mismatch.')
46
+
47
+ const { jwk, alg } = coseToJwk(auth.coseKey)
48
+ // Make sure Web Crypto accepts the key now, rather than at the first sign-in.
49
+ await importKey(jwk, alg)
50
+ const transports = Array.isArray(credential.response.transports) ? credential.response.transports.filter((t) => typeof t === 'string') : undefined
51
+ return { id: credential.id, publicKey: jwk, alg, counter: auth.signCount, transports }
52
+ }
53
+
54
+ /**
55
+ * Checks a sign-in (assertion) response against a stored credential.
56
+ * @param {{ id: string, type: string, response: { clientDataJSON: string, authenticatorData: string, signature: string, userHandle?: string | null } }} assertion
57
+ * @param {Credential} stored
58
+ * @param {Expect} expect
59
+ * @returns {Promise<{ counter: number }>} the new signature counter to store
60
+ */
61
+ export async function verifyAuthentication(assertion, stored, expect) {
62
+ if (assertion?.type !== 'public-key' || assertion.id !== stored.id) throw new PasskeyError('Not the expected passkey.')
63
+ const clientDataBytes = fromBase64Url(assertion.response?.clientDataJSON ?? '')
64
+ await checkClientData(assertion.response.clientDataJSON, 'webauthn.get', expect)
65
+
66
+ const authData = fromBase64Url(assertion.response.authenticatorData)
67
+ const auth = await parseAuthData(authData, expect)
68
+
69
+ const signed = concat(authData, await sha256(clientDataBytes))
70
+ let signature = fromBase64Url(assertion.response.signature)
71
+ const key = await importKey(stored.publicKey, stored.alg)
72
+ if (stored.alg === ES256) signature = derToRaw(signature)
73
+ const algorithm = stored.alg === ES256 ? { name: 'ECDSA', hash: 'SHA-256' } : { name: 'RSASSA-PKCS1-v1_5' }
74
+ if (!(await crypto.subtle.verify(algorithm, key, signature, signed))) throw new PasskeyError('The signature is wrong.')
75
+
76
+ // A counter that goes backwards means the passkey was copied. Many passkeys always
77
+ // send 0, which means "no counter" and is fine.
78
+ if ((auth.signCount !== 0 || stored.counter !== 0) && auth.signCount <= stored.counter) {
79
+ throw new PasskeyError('This passkey may have been cloned (its counter went backwards).')
80
+ }
81
+ return { counter: auth.signCount }
82
+ }
83
+
84
+ export class PasskeyError extends Error {}
85
+
86
+ /** The challenge a response answers, so the server can look up what it sent. */
87
+ export function readChallenge(clientDataJSON) {
88
+ try {
89
+ const challenge = JSON.parse(new TextDecoder().decode(fromBase64Url(clientDataJSON))).challenge
90
+ return typeof challenge === 'string' ? challenge : null
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ async function checkClientData(encoded, type, expect) {
97
+ let data
98
+ try {
99
+ data = JSON.parse(new TextDecoder().decode(fromBase64Url(encoded)))
100
+ } catch {
101
+ throw new PasskeyError('clientDataJSON is malformed.')
102
+ }
103
+ if (data.type !== type) throw new PasskeyError(`Expected a ${type} response.`)
104
+ if (data.challenge !== expect.challenge) throw new PasskeyError('The challenge does not match.')
105
+ const origins = [expect.origin].flat()
106
+ if (!origins.includes(data.origin)) throw new PasskeyError(`Unexpected origin ${data.origin}.`)
107
+ if (data.crossOrigin === true) throw new PasskeyError('Cross-origin passkey requests are not allowed.')
108
+ return data
109
+ }
110
+
111
+ async function parseAuthData(bytes, expect) {
112
+ if (bytes.length < 37) throw new PasskeyError('authenticatorData is too short.')
113
+ const rpIdHash = bytes.subarray(0, 32)
114
+ const flags = bytes[32]
115
+ const signCount = new DataView(bytes.buffer, bytes.byteOffset + 33, 4).getUint32(0)
116
+
117
+ const expected = await sha256(expect.rpId)
118
+ if (base64Url(rpIdHash) !== base64Url(expected)) throw new PasskeyError('The passkey is for a different site (rpId).')
119
+ if (!(flags & FLAG_UP)) throw new PasskeyError('The user was not present.')
120
+ if (expect.requireUserVerification && !(flags & FLAG_UV)) throw new PasskeyError('The user was not verified.')
121
+
122
+ const out = { flags, signCount, credentialId: null, coseKey: null }
123
+ if (flags & FLAG_AT) {
124
+ let at = 37 + 16 // skip the AAGUID
125
+ if (bytes.length < at + 2) throw new PasskeyError('authenticatorData is truncated.')
126
+ const idLength = (bytes[at] << 8) | bytes[at + 1]
127
+ at += 2
128
+ if (bytes.length < at + idLength) throw new PasskeyError('authenticatorData is truncated.')
129
+ out.credentialId = bytes.slice(at, at + idLength)
130
+ at += idLength
131
+ out.coseKey = decodeCbor(bytes, at).value
132
+ }
133
+ return out
134
+ }
135
+
136
+ /** COSE_Key (RFC 9053) -> JWK. */
137
+ function coseToJwk(cose) {
138
+ if (!(cose instanceof Map)) throw new PasskeyError('The public key is malformed.')
139
+ const kty = cose.get(1)
140
+ const alg = cose.get(3)
141
+ if (kty === 2 && alg === ES256 && cose.get(-1) === 1) {
142
+ const x = cose.get(-2)
143
+ const y = cose.get(-3)
144
+ if (!(x instanceof Uint8Array) || !(y instanceof Uint8Array) || x.length !== 32 || y.length !== 32) {
145
+ throw new PasskeyError('The P-256 key is malformed.')
146
+ }
147
+ return { alg, jwk: { kty: 'EC', crv: 'P-256', x: base64Url(x), y: base64Url(y) } }
148
+ }
149
+ if (kty === 3 && alg === RS256) {
150
+ const n = cose.get(-1)
151
+ const e = cose.get(-2)
152
+ if (!(n instanceof Uint8Array) || !(e instanceof Uint8Array)) throw new PasskeyError('The RSA key is malformed.')
153
+ return { alg, jwk: { kty: 'RSA', n: base64Url(n), e: base64Url(e), alg: 'RS256' } }
154
+ }
155
+ throw new PasskeyError(`Unsupported passkey algorithm (${alg}).`)
156
+ }
157
+
158
+ function importKey(jwk, alg) {
159
+ const algorithm = alg === ES256 ? { name: 'ECDSA', namedCurve: 'P-256' } : { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
160
+ return crypto.subtle.importKey('jwk', jwk, algorithm, false, ['verify'])
161
+ }
162
+
163
+ /** ECDSA signatures arrive DER-encoded; Web Crypto wants r || s, 32 bytes each. */
164
+ function derToRaw(der) {
165
+ const fail = () => {
166
+ throw new PasskeyError('The signature is malformed.')
167
+ }
168
+ if (der[0] !== 0x30) fail()
169
+ let at = 2
170
+ if (der[1] & 0x80) at = 2 + (der[1] & 0x7f)
171
+ const int = () => {
172
+ if (der[at] !== 0x02) fail()
173
+ const len = der[at + 1]
174
+ let value = der.subarray(at + 2, at + 2 + len)
175
+ at += 2 + len
176
+ while (value.length > 32 && value[0] === 0) value = value.subarray(1)
177
+ if (value.length > 32) fail()
178
+ const out = new Uint8Array(32)
179
+ out.set(value, 32 - value.length)
180
+ return out
181
+ }
182
+ return concat(int(), int())
183
+ }
package/src/session.js ADDED
@@ -0,0 +1,49 @@
1
+ import { base64Url, randomToken, readCookie, serializeCookie, sha256 } from './util.js'
2
+
3
+ const DAY = 24 * 60 * 60
4
+
5
+ /**
6
+ * Sessions: a random token in an HttpOnly cookie. Only a SHA-256 of the token is
7
+ * stored, so a leaked database can't be used to sign in. Sessions slide: each day
8
+ * someone uses the app, the 30 days start again.
9
+ *
10
+ * @param {{ store: import('./index.js').Store, cookie: string, days: number }} options
11
+ */
12
+ export function createSessions({ store, cookie, days }) {
13
+ const ttl = days * DAY
14
+ const keyFor = async (token) => `session:${base64Url(await sha256(token))}`
15
+ const secure = (request) => new URL(request.url).protocol === 'https:'
16
+
17
+ return {
18
+ /** Starts a session; returns the Set-Cookie value. */
19
+ async create(userId, request) {
20
+ const token = randomToken()
21
+ const now = Date.now()
22
+ await store.put(await keyFor(token), { userId, createdAt: now, refreshedAt: now }, { ttl })
23
+ return serializeCookie(cookie, token, { maxAge: ttl, secure: secure(request) })
24
+ },
25
+
26
+ /** The session behind this request, or null. */
27
+ async read(request) {
28
+ const token = readCookie(request.headers.get('cookie'), cookie)
29
+ if (!token || token.length > 128) return null
30
+ const session = await store.get(await keyFor(token))
31
+ return session ? { ...session, token } : null
32
+ },
33
+
34
+ /** Restarts the 30 days at most once a day; returns a Set-Cookie value when it did. */
35
+ async refresh(session, request) {
36
+ if (Date.now() - session.refreshedAt < DAY * 1000) return null
37
+ const { token, ...value } = session
38
+ await store.put(await keyFor(token), { ...value, refreshedAt: Date.now() }, { ttl })
39
+ return serializeCookie(cookie, token, { maxAge: ttl, secure: secure(request) })
40
+ },
41
+
42
+ /** Ends this request's session; returns the Set-Cookie value that clears it. */
43
+ async destroy(request) {
44
+ const token = readCookie(request.headers.get('cookie'), cookie)
45
+ if (token && token.length <= 128) await store.delete(await keyFor(token))
46
+ return serializeCookie(cookie, '', { maxAge: 0, secure: secure(request) })
47
+ },
48
+ }
49
+ }
@@ -0,0 +1 @@
1
+ export { d1 } from './index.js'
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cloudflare D1. Creates its own table (`slauth_kv`) on first use, so there are no
3
+ * migrations to run; it sits happily next to your app's own tables.
4
+ *
5
+ * @param {D1Database} db the D1 binding, e.g. env.DB
6
+ * @param {{ table?: string }} [options]
7
+ * @returns {import('../index.js').Store}
8
+ */
9
+ export function d1(db, { table = 'slauth_kv' } = {}) {
10
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`slauth: "${table}" isn't a valid table name.`)
11
+ let ready = null
12
+ const init = () =>
13
+ (ready ??= db
14
+ .prepare(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)`)
15
+ .run()
16
+ .catch((err) => {
17
+ ready = null
18
+ throw err
19
+ }))
20
+
21
+ return {
22
+ async get(key) {
23
+ await init()
24
+ const row = await db.prepare(`SELECT value, expires_at FROM ${table} WHERE key = ?`).bind(key).first()
25
+ if (!row) return null
26
+ if (row.expires_at !== null && row.expires_at <= Date.now()) {
27
+ await db.prepare(`DELETE FROM ${table} WHERE key = ?`).bind(key).run()
28
+ return null
29
+ }
30
+ return JSON.parse(row.value)
31
+ },
32
+ async put(key, value, { ttl } = {}) {
33
+ await init()
34
+ await db
35
+ .prepare(`INSERT INTO ${table} (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at`)
36
+ .bind(key, JSON.stringify(value), ttl ? Date.now() + ttl * 1000 : null)
37
+ .run()
38
+ },
39
+ async delete(key) {
40
+ await init()
41
+ await db.prepare(`DELETE FROM ${table} WHERE key = ?`).bind(key).run()
42
+ },
43
+ }
44
+ }
@@ -0,0 +1,5 @@
1
+ import type { Store } from '../index.js'
2
+ export function memory(): Store
3
+ export function d1(db: { prepare(sql: string): any }, options?: { table?: string }): Store
4
+ export function kv(namespace: { get(key: string, type: 'json'): Promise<any>; put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>; delete(key: string): Promise<void> }, options?: { prefix?: string }): Store
5
+ export function sqlite(db: { prepare(sql: string): { get(...args: unknown[]): any; run(...args: unknown[]): unknown } }, options?: { table?: string }): Store
@@ -0,0 +1 @@
1
+ export { kv } from './index.js'
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Cloudflare Workers KV. Fine for small projects; note KV is eventually consistent
3
+ * (a sign-in can take a moment to be seen in other regions) and its free tier allows
4
+ * 1,000 writes a day. D1 is the better default on Workers.
5
+ *
6
+ * @param {KVNamespace} namespace the KV binding, e.g. env.AUTH
7
+ * @param {{ prefix?: string }} [options]
8
+ * @returns {import('../index.js').Store}
9
+ */
10
+ export function kv(namespace, { prefix = 'slauth:' } = {}) {
11
+ return {
12
+ get: (key) => namespace.get(prefix + key, 'json'),
13
+ // KV can't expire anything sooner than 60 seconds.
14
+ put: (key, value, { ttl } = {}) =>
15
+ namespace.put(prefix + key, JSON.stringify(value), ttl ? { expirationTtl: Math.max(60, Math.ceil(ttl)) } : undefined),
16
+ delete: (key) => namespace.delete(prefix + key),
17
+ }
18
+ }
@@ -0,0 +1 @@
1
+ export { memory } from './index.js'
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Keeps everything in memory. Lovely for tests and trying things out; everything is
3
+ * forgotten when the process restarts, so use a real store before you ship.
4
+ *
5
+ * @returns {import('../index.js').Store}
6
+ */
7
+ export function memory() {
8
+ const rows = new Map()
9
+ const live = (key) => {
10
+ const row = rows.get(key)
11
+ if (!row) return null
12
+ if (row.expiresAt !== null && row.expiresAt <= Date.now()) {
13
+ rows.delete(key)
14
+ return null
15
+ }
16
+ return row
17
+ }
18
+ return {
19
+ async get(key) {
20
+ const row = live(key)
21
+ return row ? structuredClone(row.value) : null
22
+ },
23
+ async put(key, value, { ttl } = {}) {
24
+ rows.set(key, { value: structuredClone(value), expiresAt: ttl ? Date.now() + ttl * 1000 : null })
25
+ },
26
+ async delete(key) {
27
+ rows.delete(key)
28
+ },
29
+ }
30
+ }
@@ -0,0 +1 @@
1
+ export { sqlite } from './index.js'
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Any synchronous SQLite handle with `prepare(sql).get(...)` / `.run(...)`: bun:sqlite,
3
+ * better-sqlite3 and node:sqlite all fit. Creates its own table on first use.
4
+ *
5
+ * @param {{ prepare(sql: string): { get(...args: unknown[]): any, run(...args: unknown[]): unknown } }} db
6
+ * @param {{ table?: string }} [options]
7
+ * @returns {import('../index.js').Store}
8
+ */
9
+ export function sqlite(db, { table = 'slauth_kv' } = {}) {
10
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`slauth: "${table}" isn't a valid table name.`)
11
+ db.prepare(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)`).run()
12
+ const select = db.prepare(`SELECT value, expires_at FROM ${table} WHERE key = ?`)
13
+ const upsert = db.prepare(
14
+ `INSERT INTO ${table} (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at`,
15
+ )
16
+ const remove = db.prepare(`DELETE FROM ${table} WHERE key = ?`)
17
+
18
+ return {
19
+ async get(key) {
20
+ const row = select.get(key)
21
+ if (!row) return null
22
+ if (row.expires_at !== null && row.expires_at <= Date.now()) {
23
+ remove.run(key)
24
+ return null
25
+ }
26
+ return JSON.parse(row.value)
27
+ },
28
+ async put(key, value, { ttl } = {}) {
29
+ upsert.run(key, JSON.stringify(value), ttl ? Date.now() + ttl * 1000 : null)
30
+ },
31
+ async delete(key) {
32
+ remove.run(key)
33
+ },
34
+ }
35
+ }
package/src/users.js ADDED
@@ -0,0 +1,56 @@
1
+ import { base64Url } from './util.js'
2
+
3
+ /**
4
+ * People. A user is just `{ id, email, createdAt }`; keep anything else about them in
5
+ * your own tables, keyed by `user.id`.
6
+ *
7
+ * user:<id> the user
8
+ * email:<email> -> id
9
+ *
10
+ * @param {import('./index.js').Store} store
11
+ * @param {undefined | string[] | ((email: string) => boolean | Promise<boolean>)} allow
12
+ */
13
+ export function createUsers(store, allow) {
14
+ const list = Array.isArray(allow) ? allow.map((e) => e.trim().toLowerCase()) : null
15
+
16
+ async function allowed(email) {
17
+ if (typeof allow === 'function') return Boolean(await allow(email))
18
+ if (!list) return true
19
+ // "@family.com" lets in anyone at that domain.
20
+ return list.includes(email) || list.some((entry) => entry.startsWith('@') && email.endsWith(entry))
21
+ }
22
+
23
+ return {
24
+ allowed,
25
+
26
+ async byId(id) {
27
+ return store.get(`user:${id}`)
28
+ },
29
+
30
+ async byEmail(email) {
31
+ const id = await store.get(`email:${normalizeEmail(email)}`)
32
+ return id ? store.get(`user:${id}`) : null
33
+ },
34
+
35
+ /** The user with this email, created on first sign-in. Null when they aren't allowed in. */
36
+ async signIn(rawEmail) {
37
+ const email = normalizeEmail(rawEmail)
38
+ if (!(await allowed(email))) return null
39
+ const existing = await this.byEmail(email)
40
+ if (existing) return existing
41
+ const user = { id: `usr_${base64Url(crypto.getRandomValues(new Uint8Array(15)))}`, email, createdAt: Date.now() }
42
+ await store.put(`user:${user.id}`, user)
43
+ await store.put(`email:${email}`, user.id)
44
+ return user
45
+ },
46
+ }
47
+ }
48
+
49
+ export function normalizeEmail(email) {
50
+ return String(email ?? '').trim().toLowerCase()
51
+ }
52
+
53
+ /** Deliberately loose: one @, something either side, a dot in the domain, no spaces. */
54
+ export function validEmail(email) {
55
+ return typeof email === 'string' && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
56
+ }
package/src/util.js ADDED
@@ -0,0 +1,69 @@
1
+ /** Small helpers shared across slauth. Web Crypto only, so they run anywhere. */
2
+
3
+ export function readCookie(header, name) {
4
+ for (const part of (header ?? '').split(';')) {
5
+ const [key, ...rest] = part.trim().split('=')
6
+ if (key === name) return rest.join('=')
7
+ }
8
+ return null
9
+ }
10
+
11
+ export function randomToken() {
12
+ return base64Url(crypto.getRandomValues(new Uint8Array(32)).buffer)
13
+ }
14
+
15
+ export function base64Url(buffer) {
16
+ let binary = ''
17
+ for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
18
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
19
+ }
20
+
21
+ /** Compares without stopping at the first difference, so timing gives nothing away. */
22
+ export function safeEqual(a, b) {
23
+ if (a.length !== b.length) return false
24
+ let diff = 0
25
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
26
+ return diff === 0
27
+ }
28
+
29
+ export function fromBase64Url(text) {
30
+ const base64 = String(text).replace(/-/g, '+').replace(/_/g, '/')
31
+ const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4))
32
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0))
33
+ }
34
+
35
+ const encoder = new TextEncoder()
36
+
37
+ export async function sha256(data) {
38
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', typeof data === 'string' ? encoder.encode(data) : data))
39
+ }
40
+
41
+ export async function hmac(secret, message) {
42
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
43
+ return base64Url(await crypto.subtle.sign('HMAC', key, encoder.encode(message)))
44
+ }
45
+
46
+ /** A Set-Cookie value. maxAge null makes a browser-session cookie; 0 deletes it. */
47
+ export function serializeCookie(name, value, { maxAge = null, secure = true, sameSite = 'Lax', path = '/' } = {}) {
48
+ const parts = [`${name}=${value}`, `Path=${path}`, 'HttpOnly', `SameSite=${sameSite}`]
49
+ if (maxAge !== null) parts.push(`Max-Age=${maxAge}`)
50
+ if (secure) parts.push('Secure')
51
+ return parts.join('; ')
52
+ }
53
+
54
+ export function json(body, status = 200, headers = {}) {
55
+ return new Response(JSON.stringify(body), {
56
+ status,
57
+ headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
58
+ })
59
+ }
60
+
61
+ export function concat(...parts) {
62
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
63
+ let at = 0
64
+ for (const part of parts) {
65
+ out.set(part, at)
66
+ at += part.length
67
+ }
68
+ return out
69
+ }