From 837a91a189d703bb764f9a088233d7e959596e6f Mon Sep 17 00:00:00 2001 From: sec-check Date: Thu, 1 Oct 2026 12:19:48 -0400 Subject: [PATCH] fix: use conditional Blobs writes for NPS relay rate, nonce and install records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump @netlify/blobs to ^10.7.13 (conditional writes landed in 10.0.0 and reach setJSON in 10.7.12) and update every bounded record in the relay — per-IP and per-install submission limits, per-IP and per-day registration caps, and the per-install nonce list — through a compare-and-swap loop guarded by the record's ETag. Install records are written create-only so an install id is bound to exactly one key even when registrations race. Persistent contention fails closed with 429. Adds MemoryStore ETag/conditional-write emulation and concurrency tests that fire bursts of requests and assert each cap holds. Closes #151 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: sec-check Co-authored-by: clubanderson <407614+clubanderson@users.noreply.github.com> --- .../fixed-nps-relay-conditional-writes.md | 1 + netlify/nps-relay/relay.test.ts | 124 +++- netlify/nps-relay/relay.ts | 137 ++++- package-lock.json | 552 +++++++++++++++++- package.json | 2 +- 5 files changed, 779 insertions(+), 37 deletions(-) create mode 100644 changelog.d/fixed-nps-relay-conditional-writes.md diff --git a/changelog.d/fixed-nps-relay-conditional-writes.md b/changelog.d/fixed-nps-relay-conditional-writes.md new file mode 100644 index 0000000..36bcbb7 --- /dev/null +++ b/changelog.d/fixed-nps-relay-conditional-writes.md @@ -0,0 +1 @@ +- Bump `@netlify/blobs` to 10.x and make the NPS relay update its rate-limit, nonce and install records with conditional (ETag-guarded) writes, so concurrent requests are bounded by the same caps as sequential ones. diff --git a/netlify/nps-relay/relay.test.ts b/netlify/nps-relay/relay.test.ts index 68398a5..ccf45f0 100644 --- a/netlify/nps-relay/relay.test.ts +++ b/netlify/nps-relay/relay.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { beforeEach, describe, expect, it } from 'vitest' import { ACK_PATH, + CAS_MAX_ATTEMPTS, ENTRY_PREFIX, HEADER_INSTALL_ID, HEADER_NONCE, @@ -15,6 +16,7 @@ import { MAX_FEEDBACK_CHARS, MAX_NONCES_PER_INSTALL, MAX_PER_INSTALL_PER_WINDOW, + MAX_PER_IP_PER_WINDOW, MAX_PULL_BATCH, MAX_REGISTER_BODY_BYTES, MAX_REGISTRATIONS_PER_DAY, @@ -34,10 +36,12 @@ import { signingInput, validateRegistration, verifyEd25519, + type ConditionalSetOptions, type InstallRecord, type RelayDeps, type RelayEntry, type RelayStore, + type WriteOutcome, } from './relay' const HERE = path.dirname(fileURLToPath(import.meta.url)) @@ -51,22 +55,43 @@ function sha256(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex') } -/** In-memory stand-in for the Netlify Blobs store. */ +/** + * In-memory stand-in for the Netlify Blobs store, including its ETag-based + * conditional writes. Every awaited call yields to the event loop so that + * concurrent handler invocations interleave the way separate function + * instances would. + */ class MemoryStore implements RelayStore { data = new Map() + etags = new Map() writes = 0 + private version = 0 async get(key: string): Promise { + await new Promise((r) => setImmediate(r)) return this.data.has(key) ? JSON.parse(JSON.stringify(this.data.get(key))) : null } - async setJSON(key: string, value: unknown): Promise { + async getWithMetadata(key: string): Promise<{ data: unknown; etag?: string } | null> { + const data = await this.get(key) + if (data === null) return null + return { data, etag: this.etags.get(key) } + } + + async setJSON(key: string, value: unknown, options?: ConditionalSetOptions): Promise { + await new Promise((r) => setImmediate(r)) + if (options?.onlyIfNew && this.data.has(key)) return { modified: false } + if (options?.onlyIfMatch !== undefined && this.etags.get(key) !== options.onlyIfMatch) return { modified: false } this.writes++ + const etag = `"v${++this.version}"` this.data.set(key, JSON.parse(JSON.stringify(value))) + this.etags.set(key, etag) + return { modified: true, etag } } async delete(key: string): Promise { this.data.delete(key) + this.etags.delete(key) } async list({ prefix }: { prefix: string }): Promise<{ blobs: Array<{ key: string }> }> { @@ -584,6 +609,101 @@ describe('hub pull and ack', () => { }) }) +describe('concurrent requests', () => { + // Each test fires a burst of requests without awaiting between them, so + // every handler reads the same pre-burst records: the race an attacker + // creates by sending many requests at once to parallel function instances. + function statuses(responses: Response[]): Record { + return responses.reduce>((acc, r) => ({ ...acc, [r.status]: (acc[r.status] ?? 0) + 1 }), {}) + } + + it('holds the per-IP submission cap under a concurrent burst', async () => { + await registerOk() + const burst = 8 + const responses = await Promise.all(Array.from({ length: burst }, () => handleRelayRequest(submit(GOOD), deps()))) + expect(statuses(responses)[201]).toBe(MAX_PER_IP_PER_WINDOW) + expect(statuses(responses)[429]).toBe(burst - MAX_PER_IP_PER_WINDOW) + expect(store.entries()).toHaveLength(MAX_PER_IP_PER_WINDOW) + }) + + it('holds the per-install submission cap under a concurrent burst from many IPs', async () => { + await registerOk() + const burst = MAX_PER_INSTALL_PER_WINDOW + 6 + const responses = await Promise.all( + Array.from({ length: burst }, (_, i) => handleRelayRequest(submit(GOOD), deps({ clientIp: `198.51.100.${i}` }))), + ) + // Losers of the compare-and-swap fail closed, so a lockstep burst may + // admit fewer than the cap; it must never admit more. + const accepted = statuses(responses)[201] ?? 0 + expect(accepted).toBeGreaterThan(0) + expect(accepted).toBeLessThanOrEqual(MAX_PER_INSTALL_PER_WINDOW) + expect(statuses(responses)[429]).toBe(burst - accepted) + expect(store.entries()).toHaveLength(accepted) + }) + + it('stores a replayed request at most once even when the copies race', async () => { + await registerOk() + const nonce = 'b'.repeat(32) + const responses = await Promise.all( + Array.from({ length: 6 }, (_, i) => handleRelayRequest(submit(GOOD, { nonce }), deps({ clientIp: `198.51.100.${i}` }))), + ) + expect(statuses(responses)[201]).toBe(1) + expect(statuses(responses)[401]).toBe(5) + expect(store.entries()).toHaveLength(1) + }) + + it('holds the per-IP and per-day registration caps under a concurrent burst', async () => { + const burst = MAX_REGISTRATIONS_PER_IP_PER_WINDOW + 5 + const responses = await Promise.all(Array.from({ length: burst }, () => handleRelayRequest(register(newHive()), deps()))) + expect(statuses(responses)[201]).toBe(MAX_REGISTRATIONS_PER_IP_PER_WINDOW) + expect([...store.data.keys()].filter((k) => k.startsWith(INSTALL_PREFIX))).toHaveLength(MAX_REGISTRATIONS_PER_IP_PER_WINDOW) + + const dayKey = `${REGISTER_DAY_PREFIX}${new Date(T0).toISOString().slice(0, 10)}` + expect((store.data.get(dayKey) as { count: number }).count).toBe(MAX_REGISTRATIONS_PER_IP_PER_WINDOW) + store.data.set(dayKey, { count: MAX_REGISTRATIONS_PER_DAY - 1 }) + const dayBurst = await Promise.all( + Array.from({ length: 5 }, (_, i) => handleRelayRequest(register(newHive()), deps({ clientIp: `192.0.2.${i}` }))), + ) + expect(statuses(dayBurst)[201]).toBe(1) + expect((store.data.get(dayKey) as { count: number }).count).toBe(MAX_REGISTRATIONS_PER_DAY) + }) + + it('binds a raced install id to exactly one key', async () => { + const a = newHive() + const b = { ...newHive(), installId: a.installId } + const [ra, rb] = await Promise.all([ + handleRelayRequest(register(a), deps({ clientIp: '198.51.100.1' })), + handleRelayRequest(register(b), deps({ clientIp: '198.51.100.2' })), + ]) + expect([ra.status, rb.status].sort()).toEqual([201, 409]) + const bound = (store.data.get(`${INSTALL_PREFIX}${a.installId}`) as InstallRecord).public_key + expect(bound).toBe(ra.status === 201 ? a.publicKey : b.publicKey) + }) + + it('fails closed when a record stays contended past the retry budget', async () => { + await registerOk() + let attempts = 0 + const contended: RelayStore = { + ...store, + get: (k) => store.get(k), + getWithMetadata: (k) => store.getWithMetadata(k), + delete: (k) => store.delete(k), + list: (o) => store.list(o), + setJSON: async (k, v, o) => { + if (k.startsWith(NONCE_PREFIX)) { + attempts++ + return { modified: false } + } + return store.setJSON(k, v, o) + }, + } + const res = await handleRelayRequest(submit(GOOD), deps({ store: contended })) + expect(res.status).toBe(429) + expect(attempts).toBe(CAS_MAX_ATTEMPTS) + expect(store.entries()).toHaveLength(0) + }) +}) + describe('Netlify function wiring', () => { it('routes exactly the relay paths and reads only the hub secret env var', () => { const fn = readFileSync(path.join(HERE, '..', 'functions', 'nps.mts'), 'utf8') diff --git a/netlify/nps-relay/relay.ts b/netlify/nps-relay/relay.ts index 91896c3..aa27922 100644 --- a/netlify/nps-relay/relay.ts +++ b/netlify/nps-relay/relay.ts @@ -56,6 +56,9 @@ * - Bodies are capped by the bytes actually READ, never by Content-Length, * so a chunked or lying request cannot make the function buffer more * (console#16666). + * - Every bound above is enforced with conditional (compare-and-swap) + * writes, so concurrent requests cannot all pass a check against the + * same stale record; under persistent contention the relay fails closed. * - Score must be an integer 1-4; feedback at most 500 characters; hive_id * must match the hub's name rule. * - Entries live in Netlify Blobs as a rolling window. @@ -148,6 +151,12 @@ export const SIGNATURE_MAX_SKEW_MS = 5 * 60 * 1000 export const NONCE_RETENTION_MS = 2 * SIGNATURE_MAX_SKEW_MS /** Most live nonces kept per install; more signed requests in the window get 429. */ export const MAX_NONCES_PER_INSTALL = 100 +/** + * Attempts made to update one record with a conditional write before giving + * up. Each retry re-reads the record, so a lost race is re-checked against + * the winner's write rather than the stale copy. + */ +export const CAS_MAX_ATTEMPTS = 4 /** Prefix of the signed input; a future format must change it. */ export const SIGNATURE_VERSION = 'hive-nps-relay-v1' @@ -194,10 +203,22 @@ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f]/g // ── Types ──────────────────────────────────────────────────────────── +/** Conditions a write may carry: update only this version, or create only. */ +export type ConditionalSetOptions = { onlyIfMatch?: string; onlyIfNew?: never } | { onlyIfNew?: boolean; onlyIfMatch?: never } + +/** Result of a (possibly conditional) write. */ +export interface WriteOutcome { + /** False when the condition was not met and nothing was written. */ + modified: boolean + etag?: string +} + /** The subset of the Netlify Blobs store API the relay uses. */ export interface RelayStore { get(key: string, options: { type: 'json' }): Promise - setJSON(key: string, value: unknown): Promise + /** Like get, also returning the ETag the record can be conditionally updated with. */ + getWithMetadata(key: string, options: { type: 'json' }): Promise<{ data: unknown; etag?: string } | null> + setJSON(key: string, value: unknown, options?: ConditionalSetOptions): Promise delete(key: string): Promise list(options: { prefix: string }): Promise<{ blobs: Array<{ key: string }> }> } @@ -437,12 +458,53 @@ async function entryKeysOldestFirst(store: RelayStore): Promise { } /** Timestamps inside the window, from a stored rate record. */ -async function recentTimes(store: RelayStore, key: string, now: number): Promise { - const rec = (await store.get(key, { type: 'json' })) as RateRecord | null - const times = rec && Array.isArray(rec.times) ? rec.times : [] +function liveTimes(rec: unknown, now: number): number[] { + const times = rec && Array.isArray((rec as RateRecord).times) ? (rec as RateRecord).times : [] return times.filter((t) => typeof t === 'number' && now - t < RATE_WINDOW_MS) } +/** One compare-and-swap step: the record to write, or the response to stop with. */ +type CasStep = { next: unknown } | { reject: Response } + +/** + * Updates one record atomically. `step` sees the current record and decides + * the next one (or rejects); the write only lands if the record is still the + * version that was read. A lost race re-reads and re-decides, so a check + * such as "under the limit" is always made against the record the write + * replaces. Returns null on success, else the response to send. Persistent + * contention fails closed (429) rather than letting a write through + * unchecked. + */ +async function compareAndSwap(store: RelayStore, key: string, step: (current: unknown) => CasStep): Promise { + for (let attempt = 0; attempt < CAS_MAX_ATTEMPTS; attempt++) { + const current = await store.getWithMetadata(key, { type: 'json' }) + const decision = step(current ? current.data : null) + if ('reject' in decision) return decision.reject + const condition: ConditionalSetOptions = current ? { onlyIfMatch: current.etag } : { onlyIfNew: true } + const result = await store.setJSON(key, decision.next, condition) + if (result.modified) return null + } + return RATE_LIMITED() +} + +/** + * Reserves one slot in a windowed rate record, or returns 429 when the + * record already holds `max` live timestamps. Check and reservation are one + * conditional write, so concurrent callers cannot all fit in the last slot. + */ +function reserveSlot(store: RelayStore, key: string, max: number, now: number): Promise { + return compareAndSwap(store, key, (current) => { + const times = liveTimes(current, now) + if (times.length >= max) return { reject: RATE_LIMITED() } + return { next: { times: [...times, now] } satisfies RateRecord } + }) +} + +/** Whether a windowed rate record is already at `max`, without reserving. */ +async function atLimit(store: RelayStore, key: string, max: number, now: number): Promise { + return liveTimes(await store.get(key, { type: 'json' }), now).length >= max +} + async function getInstall(store: RelayStore, installId: string): Promise { const rec = (await store.get(`${INSTALL_PREFIX}${installId}`, { type: 'json' })) as InstallRecord | null return rec && typeof rec.public_key === 'string' ? rec : null @@ -535,39 +597,49 @@ async function handleRegister(req: Request, deps: RelayDeps, now: number): Promi return json(409, { error: 'install_id is registered to a different key' }) } - const ipKey = `${REGISTER_IP_RATE_PREFIX}${ipHash(deps)}` - const ipTimes = await recentTimes(store, ipKey, now) - if (ipTimes.length >= MAX_REGISTRATIONS_PER_IP_PER_WINDOW) return RATE_LIMITED() + // Reserve the per-IP and per-day slots before creating the install, so a + // limit that is hit never leaves a registration behind. + const ipDenied = await reserveSlot(store, `${REGISTER_IP_RATE_PREFIX}${ipHash(deps)}`, MAX_REGISTRATIONS_PER_IP_PER_WINDOW, now) + if (ipDenied) return ipDenied const dayKey = `${REGISTER_DAY_PREFIX}${new Date(now).toISOString().slice(0, ISO_DATE_LENGTH)}` - const day = (await store.get(dayKey, { type: 'json' })) as DayCounter | null - const dayCount = day && typeof day.count === 'number' ? day.count : 0 - if (dayCount >= MAX_REGISTRATIONS_PER_DAY) return RATE_LIMITED() + const dayDenied = await compareAndSwap(store, dayKey, (current) => { + const count = current && typeof (current as DayCounter).count === 'number' ? (current as DayCounter).count : 0 + if (count >= MAX_REGISTRATIONS_PER_DAY) return { reject: RATE_LIMITED() } + return { next: { count: count + 1 } satisfies DayCounter } + }) + if (dayDenied) return dayDenied const record: InstallRecord = { public_key: reg.public_key, ...(reg.hive_version ? { hive_version: reg.hive_version } : {}), registered_at: new Date(now).toISOString(), } - await store.setJSON(`${INSTALL_PREFIX}${reg.install_id}`, record) - await store.setJSON(ipKey, { times: [...ipTimes, now] }) - await store.setJSON(dayKey, { count: dayCount + 1 }) + // Create-only: two registrations racing for the same install id cannot both + // win, so the id is bound to exactly one key — the first write's. + const created = await store.setJSON(`${INSTALL_PREFIX}${reg.install_id}`, record, { onlyIfNew: true }) + if (!created.modified) { + const winner = await getInstall(store, reg.install_id) + if (winner && winner.public_key === reg.public_key) return json(200, { ok: true, registered: false }) + return json(409, { error: 'install_id is registered to a different key' }) + } return json(201, { ok: true, registered: true }) } /** * Records a nonce for an install, rejecting a reuse. Returns null when the - * nonce is fresh, else the response to send. + * nonce is fresh, else the response to send. Check and record are one + * conditional write, so two copies of the same request cannot both pass. */ -async function consumeNonce(store: RelayStore, installId: string, nonce: string, now: number): Promise { - const key = `${NONCE_PREFIX}${installId}` - const rec = (await store.get(key, { type: 'json' })) as NonceRecord | null - const live = (rec && Array.isArray(rec.seen) ? rec.seen : []).filter( - (s) => s && typeof s.n === 'string' && typeof s.t === 'number' && now - s.t < NONCE_RETENTION_MS, - ) - if (live.some((s) => s.n === nonce)) return json(401, { error: 'replayed request' }) - if (live.length >= MAX_NONCES_PER_INSTALL) return RATE_LIMITED() - await store.setJSON(key, { seen: [...live, { n: nonce, t: now }] }) - return null +function consumeNonce(store: RelayStore, installId: string, nonce: string, now: number): Promise { + return compareAndSwap(store, `${NONCE_PREFIX}${installId}`, (current) => { + const rec = current as NonceRecord | null + const live = (rec && Array.isArray(rec.seen) ? rec.seen : []).filter( + (s) => s && typeof s.n === 'string' && typeof s.t === 'number' && now - s.t < NONCE_RETENTION_MS, + ) + if (live.some((s) => s.n === nonce)) return { reject: json(401, { error: 'replayed request' }) } + if (live.length >= MAX_NONCES_PER_INSTALL) return { reject: RATE_LIMITED() } + return { next: { seen: [...live, { n: nonce, t: now }] } satisfies NonceRecord } + }) } async function handleSubmit(req: Request, deps: RelayDeps, now: number): Promise { @@ -585,9 +657,10 @@ async function handleSubmit(req: Request, deps: RelayDeps, now: number): Promise const replay = await consumeNonce(store, signed.installId, signed.nonce, now) if (replay) return replay + // Cheap pre-checks keep today's response order (429 before 400) without + // reserving anything; the reservations below are what actually bound. const ipKey = `${IP_RATE_PREFIX}${ipHash(deps)}` - const ipTimes = await recentTimes(store, ipKey, now) - if (ipTimes.length >= MAX_PER_IP_PER_WINDOW) return RATE_LIMITED() + if (await atLimit(store, ipKey, MAX_PER_IP_PER_WINDOW, now)) return RATE_LIMITED() const body = parseJson(bytes) if (body === undefined) return json(400, { error: 'invalid JSON body' }) @@ -595,8 +668,14 @@ async function handleSubmit(req: Request, deps: RelayDeps, now: number): Promise if (!parsed.ok) return json(400, { error: parsed.error }) const installKey = `${INSTALL_RATE_PREFIX}${signed.installId}` - const installTimes = await recentTimes(store, installKey, now) - if (installTimes.length >= MAX_PER_INSTALL_PER_WINDOW) return RATE_LIMITED() + if (await atLimit(store, installKey, MAX_PER_INSTALL_PER_WINDOW, now)) return RATE_LIMITED() + + // Reserve both slots atomically before storing the entry, so concurrent + // submissions cannot all squeeze into the same remaining slot. + const ipDenied = await reserveSlot(store, ipKey, MAX_PER_IP_PER_WINDOW, now) + if (ipDenied) return ipDenied + const installDenied = await reserveSlot(store, installKey, MAX_PER_INSTALL_PER_WINDOW, now) + if (installDenied) return installDenied const entry: RelayEntry = { id: newEntryId(now), @@ -608,8 +687,6 @@ async function handleSubmit(req: Request, deps: RelayDeps, now: number): Promise timestamp: new Date(now).toISOString(), } await store.setJSON(`${ENTRY_PREFIX}${entry.id}`, entry) - await store.setJSON(ipKey, { times: [...ipTimes, now] }) - await store.setJSON(installKey, { times: [...installTimes, now] }) // Rolling window: drop the oldest entries beyond the cap. const keys = await entryKeysOldestFirst(store) diff --git a/package-lock.json b/package-lock.json index f5927f0..00edcb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "docs", "version": "0.1.0", "dependencies": { - "@netlify/blobs": "^8.2.0", + "@netlify/blobs": "^10.7.13", "@react-three/drei": "^10.7.8", "@react-three/fiber": "^9.8.1", "@theguild/remark-mermaid": "^0.3.0", @@ -686,6 +686,19 @@ "tslib": "^2.4.0" } }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.1.tgz", + "integrity": "sha512-FuexE2qd3Yc1Kh1DjTliwGxb9kyyv4ySBNnfJOxnQasVmGdcqX6AkkO5sIMxLWFRftp8PErOcIn2dQW/1RwytQ==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1329,6 +1342,12 @@ } } }, + "node_modules/@fastify/busboy": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz", + "integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -2521,14 +2540,78 @@ } }, "node_modules/@netlify/blobs": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-8.2.0.tgz", - "integrity": "sha512-9djLZHBKsoKk8XCgwWSEPK9QnT8qqxEQGuYh48gFIcNLvpBKkLnHbDZuyUxmNemCfDz7h0HnMXgSPnnUVgARhg==", + "version": "10.7.13", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.7.13.tgz", + "integrity": "sha512-LJnmGtQQ2/NdTo0Cm+YP2xR1vtRle6V3kkzMrAOJgRm1SEFOJOcaAFQrth7RnbxCH/IzCM8QakTaCHHKY+K2qA==", "license": "MIT", + "dependencies": { + "@netlify/dev-utils": "5.0.0", + "@netlify/otel": "^6.0.6", + "@netlify/runtime-utils": "2.3.0" + }, "engines": { "node": "^14.16.0 || >=16.0.0" } }, + "node_modules/@netlify/dev-utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-5.0.0.tgz", + "integrity": "sha512-ICAsnvbJW9Dv9PGfmJGdjMBsX6uXdJxrA76QE3l3rnGKL5ZcyaA2cJZXacdEmE2ZmtkiVhEJogM15a9nK/ZxDw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/server": "^0.11.0", + "ansis": "^4.1.0", + "atomically": "^2.0.3", + "chokidar": "^4.0.1", + "decache": "^4.6.2", + "dettle": "^1.0.5", + "dot-prop": "9.0.0", + "empathic": "^2.0.0", + "env-paths": "^3.0.0", + "parse-gitignore": "^2.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@netlify/dev-utils/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@netlify/otel": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-6.0.6.tgz", + "integrity": "sha512-KpiJ8c4V4GvgpQH5E1axg43kYdIN9saToLbzvgrDzPun4XMGwz/tLfoIkwJ4jwrVmbPj35+8+dj3gkggQAtjtg==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "1.9.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace-node": "2.9.0" + }, + "engines": { + "node": "^18.14.0 || >=20.6.1" + } + }, + "node_modules/@netlify/runtime-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", + "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, "node_modules/@next/env": { "version": "16.3.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.6.tgz", @@ -2772,6 +2855,199 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz", + "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.9.0.tgz", + "integrity": "sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.9.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.151.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", @@ -5526,6 +5802,75 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz", + "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/server": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.11.0.tgz", + "integrity": "sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==", + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -5590,6 +5935,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz", + "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -5843,6 +6197,16 @@ "node": ">= 0.4" } }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6104,6 +6468,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", + "engines": { + "node": "*" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6221,6 +6593,27 @@ "lodash-es": "4.17.23" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -6969,6 +7362,15 @@ } } }, + "node_modules/decache": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/decache/-/decache-4.6.2.tgz", + "integrity": "sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==", + "license": "MIT", + "dependencies": { + "callsite": "^1.0.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -7068,6 +7470,12 @@ "node": ">=8" } }, + "node_modules/dettle": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz", + "integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -7110,6 +7518,21 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dotenv": { "version": "18.0.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.3.tgz", @@ -7166,6 +7589,15 @@ "dev": true, "license": "MIT" }, + "node_modules/empathic": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", @@ -7193,6 +7625,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -9107,6 +9551,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.2.tgz", + "integrity": "sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.2", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/import-in-the-middle/node_modules/es-module-lexer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.2.tgz", + "integrity": "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg==", + "license": "MIT" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -11892,6 +12356,12 @@ "integrity": "sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==", "license": "Apache-2.0" }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/motion-dom": { "version": "13.4.2", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.4.2.tgz", @@ -12527,6 +12997,15 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-gitignore": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-gitignore/-/parse-gitignore-2.0.0.tgz", + "integrity": "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/parse-latin": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", @@ -12967,6 +13446,19 @@ } } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reading-time": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", @@ -13340,6 +13832,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -14215,6 +14720,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -14713,6 +15233,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -15134,6 +15666,12 @@ "punycode": "^2.1.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "license": "MIT" + }, "node_modules/use-intl": { "version": "4.14.7", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.14.7.tgz", @@ -15773,6 +16311,12 @@ "node": "^22.14.0 || >=24.0.0" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index cc229fe..1125fc4 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "update-deps": "npm update && npm audit fix" }, "dependencies": { - "@netlify/blobs": "^8.2.0", + "@netlify/blobs": "^10.7.13", "@react-three/drei": "^10.7.8", "@react-three/fiber": "^9.8.1", "@theguild/remark-mermaid": "^0.3.0",