diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fd8abbb6..939309448 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,8 +97,17 @@ jobs: - name: Run API tests (DB-less) run: pnpm --filter @uoa/api exec vitest run --exclude 'tests/integration/**' - - name: Run API integration tests (Postgres) - run: pnpm --filter @uoa/api exec vitest run tests/integration + # Schemas do not isolate PostgreSQL advisory locks. This suite deliberately + # holds a shared product-policy lock while filling the admin pool; an + # exclusive policy writer from another suite can queue ahead of its readers. + - name: Run pool contention integration tests (Postgres) + run: pnpm --filter @uoa/api exec vitest run tests/integration/org-me-pool-contention.test.ts + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + BILLING_FUNDING_DATABASE_TESTS: 'true' + + - name: Run remaining API integration tests (Postgres) + run: pnpm --filter @uoa/api exec vitest run tests/integration --exclude 'tests/integration/org-me-pool-contention.test.ts' env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres BILLING_FUNDING_DATABASE_TESTS: 'true' diff --git a/API/package.json b/API/package.json index 0183436e0..bd914046e 100644 --- a/API/package.json +++ b/API/package.json @@ -45,6 +45,7 @@ "jose": "^5.9.6", "nodemailer": "^10.0.6", "pdf-lib": "^1.17.1", + "pg": "8.23.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "stripe": "22.3.2", @@ -56,6 +57,7 @@ "@redocly/cli": "^2.40.0", "@types/node": "^22.13.1", "@types/nodemailer": "^6.4.17", + "@types/pg": "8.23.1", "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@types/xml-encryption": "^1.2.4", @@ -63,6 +65,7 @@ "tsx": "^4.19.2", "typescript": "^5.7.3", "vitest": "^3.2.6", + "ws": "8.21.0", "xml-crypto": "^6.4.0", "xml-encryption": "^3.1.0" } diff --git a/API/prisma/migrations/20261104160000_settings_change_notifications/migration.sql b/API/prisma/migrations/20261104160000_settings_change_notifications/migration.sql new file mode 100644 index 000000000..0c2aa7cd6 --- /dev/null +++ b/API/prisma/migrations/20261104160000_settings_change_notifications/migration.sql @@ -0,0 +1,23 @@ +SET lock_timeout = '5s'; +SET statement_timeout = '120s'; + +-- Commit-scoped hints for every settings writer. Values never enter NOTIFY. +CREATE OR REPLACE FUNCTION notify_user_setting_change() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.value IS NOT DISTINCT FROM NEW.value THEN + RETURN NEW; + END IF; + IF TG_OP = 'DELETE' THEN + PERFORM pg_notify('uoa_settings_changed', json_build_object( + 'userId', OLD.user_id, 'namespace', OLD.namespace, 'key', OLD.key)::text); + RETURN OLD; + END IF; + PERFORM pg_notify('uoa_settings_changed', json_build_object( + 'userId', NEW.user_id, 'namespace', NEW.namespace, 'key', NEW.key)::text); + RETURN NEW; +END; +$$; +CREATE TRIGGER user_settings_changed +AFTER INSERT OR UPDATE OR DELETE ON user_settings +FOR EACH ROW EXECUTE FUNCTION notify_user_setting_change(); diff --git a/API/src/routes/oauth/index.ts b/API/src/routes/oauth/index.ts index 1df3199ca..313bf27a1 100644 --- a/API/src/routes/oauth/index.ts +++ b/API/src/routes/oauth/index.ts @@ -1,3 +1,4 @@ +import { registerSettingsEvents } from './settings-events.js'; import { registerOAuthLifecycleStatus } from './lifecycle-status.js'; import type { FastifyInstance } from 'fastify'; import { registerNativeAppIcon } from './native-app-icon.js'; @@ -20,6 +21,7 @@ export function registerOAuthRoutes(app: FastifyInstance): void { registerNativeAppIcon(app); registerPublicSocialRoutes(app); registerOAuthAccountRoutes(app); + registerSettingsEvents(app); registerKelpieConnectRoutes(app); registerKelpiePairRoutes(app); registerOAuthMetadataRoute(app); diff --git a/API/src/routes/oauth/settings-events.ts b/API/src/routes/oauth/settings-events.ts new file mode 100644 index 000000000..0dc99f22a --- /dev/null +++ b/API/src/routes/oauth/settings-events.ts @@ -0,0 +1,121 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import type { WebSocket } from 'ws'; +import { z } from 'zod'; +import { getEnv } from '../../config/env.js'; +import { verifyPublicAccountToken, authorizePublicAccount } from '../../services/oauth/account.service.js'; +import { settingVersion } from '../../services/setting-version.js'; +import { settingsDelta } from '../../services/settings-delta.js'; +import { SettingsNotifications, type SettingsTopic } from '../../services/settings-notifications.js'; +import { SettingsStreamQueue } from '../../services/settings-stream-queue.js'; +import { USER_SETTINGS_NAMESPACE_PATTERN, USER_SETTINGS_KEY_PATTERN } from '../../services/user-settings.service.js'; +import { AppError } from '../../utils/errors.js'; +import { requireMcpOAuthPublicProfile } from './public-profile-guard.js'; + +const paramsSchema = z.object({ namespace: z.string().regex(USER_SETTINGS_NAMESPACE_PATTERN), key: z.string().regex(USER_SETTINGS_KEY_PATTERN) }); +const MAX_SOCKETS = 32; // Live Cloud Run request concurrency is 80; leave capacity for sign-in. + +export function registerSettingsEvents(app: FastifyInstance): void { + const lane = new SettingsStreamQueue(); + const subjects = new Map(); + const reservations = new WeakMap(); + const sockets = new Set(); + let admitted = 0; + let notifications: SettingsNotifications | undefined; + const release = (userId: string) => { + admitted -= 1; + const remaining = (subjects.get(userId) ?? 1) - 1; + if (remaining > 0) subjects.set(userId, remaining); else subjects.delete(userId); + }; + const reserve = async (request: FastifyRequest, reply: FastifyReply) => { + reply.header('Retry-After', '5'); + if (request.headers.upgrade?.toLowerCase() !== 'websocket') throw new AppError('BAD_REQUEST', 400, 'INVALID_REQUEST'); + const params = paramsSchema.parse(request.params); + z.object({}).strict().parse(request.query); + if (admitted >= MAX_SOCKETS) throw new AppError('SERVICE_UNAVAILABLE', 503, 'SETTINGS_STREAM_BUSY'); + admitted += 1; + try { + const account = await lane.run(() => verifyPublicAccountToken(request.headers.authorization, 'settings.read')); + const count = subjects.get(account.userId) ?? 0; + if (count >= 8) throw new AppError('SERVICE_UNAVAILABLE', 503, 'SETTINGS_STREAM_BUSY'); + if (request.raw.socket.destroyed) throw new Error('Connection closed'); + subjects.set(account.userId, count + 1); + reservations.set(request, { userId: account.userId, ...params }); + request.raw.socket.once('close', () => { + const pending = reservations.get(request); + if (pending) { reservations.delete(request); release(pending.userId); } + }); + } catch (error) { admitted -= 1; throw error; } + }; + app.get('/oauth/me/settings/:namespace/:key/events', { + websocket: true, + preHandler: [requireMcpOAuthPublicProfile, reserve], + }, (socket, request) => { + const topic = reservations.get(request); + if (!topic) { socket.close(1011); return; } + reservations.delete(request); + sockets.add(socket); + let closed = false; + let pending = false; + let dirty = false; + let ready = false; + let previous: { value: unknown; version: string } | undefined; + let unsubscribe: (() => void) | undefined; + const cleanup = () => { + if (closed) return; + closed = true; + sockets.delete(socket); + release(topic.userId); + clearInterval(repair); clearTimeout(recycle); + unsubscribe?.(); + }; + const check = () => { + if (closed || !ready) return; + if (pending) { dirty = true; return; } + pending = true; + void lane.run(async () => { + if (closed) return; + // Recheck current client/scopes as well as live subject authority. + const account = await verifyPublicAccountToken(request.headers.authorization, 'settings.read'); + if (account.userId !== topic.userId) throw new Error('Subject changed'); + const value = await request.adminDb.$transaction(async tx => { + await authorizePublicAccount(account, tx); + const row = await tx.userSetting.findUnique({ where: { userId_namespace_key: topic }, select: { value: true } }); + return row?.value ?? null; + }); + if (closed) return; + const version = settingVersion(value); + if (version === previous?.version) { socket.ping(); return; } + if (socket.bufferedAmount > 1024 * 1024) { socket.close(1013); return; } + const frame = previous + ? { type: 'settings.delta', protocolVersion: 1, namespace: topic.namespace, key: topic.key, + baseVersion: previous.version, version, operation: settingsDelta(previous.value, value) } + : { type: 'settings.snapshot', protocolVersion: 1, namespace: topic.namespace, key: topic.key, version, value }; + socket.send(JSON.stringify(frame), error => { if (error) socket.close(1011); }); + previous = { value, version }; + }).catch(() => { if (!closed) socket.close(1008); }).finally(() => { + pending = false; + if (dirty && !closed) { dirty = false; check(); } + }); + }; + const repair = setInterval(check, 30_000); + const recycle = setTimeout(() => socket.close(1000), 240_000 + Math.random() * 40_000); + repair.unref(); recycle.unref(); + socket.on('close', cleanup); + socket.on('error', cleanup); + socket.on('message', () => socket.close(1008)); + void (async () => { + const env = getEnv(); + const databaseUrl = env.DATABASE_ADMIN_URL ?? env.DATABASE_URL; + if (!databaseUrl) throw new Error('Database unavailable'); + notifications ??= new SettingsNotifications(databaseUrl); + unsubscribe = await notifications.subscribe(topic, check); + if (closed) { unsubscribe(); return; } + ready = true; + check(); + })().catch(() => socket.close(1013)); + }); + app.addHook('onClose', async () => { + sockets.forEach(socket => socket.close(1001)); + await notifications?.stop(); + }); +} diff --git a/API/src/routes/root/index.ts b/API/src/routes/root/index.ts index 874439cfd..dfdb08177 100644 --- a/API/src/routes/root/index.ts +++ b/API/src/routes/root/index.ts @@ -70,6 +70,7 @@ export function registerRootRoute(app: FastifyInstance): void { sms_admission: '/apps/v1/nessie/phone-profile/sms-admission', personal_owner_membership: 'optional owner_subject in service assertion; exact-team ownerActive or owner_inactive', prepaid_delivery: '/apps/v1/nessie/phone-profile/call-proof/reserve', caller_id_authentication: false }, + settings_events: { path: '/oauth/me/settings/:namespace/:key/events', transport: 'WebSocket', scope: 'settings.read', protocol_version: 1, recovery: 'snapshot on reconnect; version-fenced splice or replace deltas' }, native_apps: { admin: '/admin/apps', registration: '/oauth/register', public_identifier: 'app_id', client_secret_required: false }, enterprise_sso: { version: 1, settings: '/auth/organisation-access', entry: '/sso/:connectionId/start', domain_discovery: false, diff --git a/API/src/routes/root/llm.ts b/API/src/routes/root/llm.ts index a28709ac4..82df6e79f 100644 --- a/API/src/routes/root/llm.ts +++ b/API/src/routes/root/llm.ts @@ -39,6 +39,8 @@ GET /auth/social/x starts X OAuth 2.0 authorization-code sign-in with S256 PKCE Facebook's basic profile has no reliable verified-email claim. Its first callback sends a UOA code to the claimed mailbox and opens flow=facebook_proof. POST /auth/facebook-proof/verify with {code} and the signed HttpOnly proof cookie returns a one-use same-origin callback URL after mailbox proof. UOA then binds Facebook's stable subject to the verified account; later logins require that exact binding and current email, or fresh mailbox proof. Five wrong codes or expiry fail generically. ## Billing provider availability Billing calls that cannot resolve the configured payment-provider account return 503 BILLING_PAYMENT_PROVIDER_UNAVAILABLE. Preserve the user session and report billing temporarily unavailable. Retry reads with backoff; never substitute zero credits, infer insufficient funds, or admit paid work from this response. Payment mutations keep their existing idempotency and reconciliation requirements. +## Personal settings live updates +Native public OAuth clients with settings.read can open GET /oauth/me/settings/:namespace/:key/events as a WebSocket using the Authorization header. No query tokens or user selectors. protocolVersion 1 sends a settings.snapshot with topic, opaque version and value, then settings.delta with baseVersion, version and operation. Operations are root-array splice(index,deleteCount,items) or replace(value). Apply only to the exact baseVersion; discard malformed frames and reconnect for a fresh snapshot. Versions support equality only. Writes continue through conditional PUT; this stream cannot mutate settings. Reconnect with backoff and jitter; normal recycling happens after 240–280 seconds. Live authority is checked before every delivery and every 30 seconds. No historical replay guarantee; rapid changes may coalesce. Busy subscriptions receive 503 and Retry-After. ## Enterprise organisation access (version1) UOA alone owns enterprise settings at /auth/organisation-access. Products read GET /org/organisations/:orgId/sso/status and owners request POST its /management-continuation subroute; domain backend authority never substitutes for fresh independent owner proof. The same-origin cookie/CSRF management API reserves a draft callback/SP identity before credentials are configured. OIDC discovery never widens the server-owned secret destination allowlist. Tests use real browser-bound protocol callbacks and never provision/link users or issue product credentials. Live activation requires verified authority/provenance/revocation readiness; enforcement also requires two independently authenticated custodians and a completed recovery drill. One custodian must be the canonical owner; no backup codes or global account-recovery capability are granted. No Nessie billing gate exists. Dedicated scoped SCIM resources never link global users by email/externalId. Provider tiles/bookmarks use /sso/:connectionId/start and the relying product’s own PKCE/state flow. Pre-authentication domain routing is deferred: no discovery endpoint, owner checkbox or writable discovery policy ships. Operators deploy genuine fresh proof issuance and the global validation contract before a strict consumer. Prepare the exact product’s assurance floor and fixed entry/config URLs as a separately reviewed legacy cutover; migration/revocation revisions may remain explicitly null during preparation, which cannot activate SSO or SCIM. Record audited revisions and the actual strict receiver acknowledgement only after consumer verification. Backend token responses after new completed independent authorization carry assurance_ref and assurance_expires_at even before server-owned minimum-floor enrollment. Existing proof-bearing families retain that same original reference and absolute deadline on refresh; legacy families without original evidence remain legacy and cannot upgrade through refresh. Explicit encrypted custody is required and missing custody fails closed. SSO/recovery activation gates are unchanged. Server-enrolled assurance-version1 clients require proof; only the backend retains them encrypted. Human subject and billing assertions include top-level assurance_ref. Signed UOA access tokens carry opaque assurance_id, not the custody reference. POST /org/assurance/validate accepts the original backend reference with its fresh confidential subject assertion (or existing UOA access-token transport). Omit org_id/team_id only for an independent original whose scope is exactly null; team_id requires org_id. The person credential must resolve the same assurance as the supplied reference, even below the compatibility floor. No public assurance_id introspection is provided. Every reference names exact client/subject/epoch/org/team/family and original expiry; missing proof cannot downgrade an enrolled product. GET /domain/revocations is an authenticated ordered exact-client feed; persist committed handling before advancing its opaque cursor. See /api for exact routes and Docs/Auth/enterprise-sso.md for deployment gates and remaining work. diff --git a/API/src/routes/root/schema.oauth-account.ts b/API/src/routes/root/schema.oauth-account.ts index 3aee2b370..97031b9ff 100644 --- a/API/src/routes/root/schema.oauth-account.ts +++ b/API/src/routes/root/schema.oauth-account.ts @@ -23,6 +23,10 @@ export const oauthAccountEndpoints: EndpointSchema[] = [ response: { 200: 'image bytes; no-store' } }, { method: 'GET', path: '/oauth/me/settings/:namespace/:key', description: 'Read an opaque personal setting. settings.read scope required.', auth, response: { value: 'JSON value or null when absent', ETag: 'response header; opaque version for conditional writes' } }, + { method: 'GET', path: '/oauth/me/settings/:namespace/:key/events', description: 'WebSocket subscription to one current-user setting; settings.read scope required.', auth, + response: { snapshot: 'settings.snapshot: protocolVersion=1, namespace, key, version (opaque ETag), value', + delta: 'settings.delta: same topic, baseVersion, version, operation: replace(value) or splice(index,deleteCount,items)' }, + notes: 'Authorization header only; no query parameters. LISTEN before initial snapshot. Revalidates live authority before each delivery and every 30 seconds. Reconnect with jitter after close to obtain a fresh snapshot; reject a delta whose baseVersion does not match. No durable replay cursor or event-history guarantee. Limits: 32 streams/instance, 8/subject/instance; busy returns 503 with Retry-After. Connections recycle after 240–280 seconds.' }, { method: 'PUT', path: '/oauth/me/settings/:namespace/:key', description: 'Replace or delete one personal setting. settings.write scope required.', auth, body: { value: 'JSON value; null deletes' }, response: { value: 'saved JSON value or null' }, notes: 'Requires If-Match with the ETag from GET. Missing/invalid precondition: 428; stale value: 409. Same store and quotas as /settings/me. Never supply a user id. No-store responses.' }, diff --git a/API/src/services/settings-delta.ts b/API/src/services/settings-delta.ts new file mode 100644 index 000000000..3c47e4ba8 --- /dev/null +++ b/API/src/services/settings-delta.ts @@ -0,0 +1,21 @@ +import { settingVersion } from './setting-version.js'; + +export type SettingsOperation = + | { type: 'replace'; value: unknown } + | { type: 'splice'; index: number; deleteCount: number; items: unknown[] }; + +/** One bounded root-array edit. Other JSON values deliberately replace whole. */ +export function settingsDelta(before: unknown, after: unknown): SettingsOperation { + const replacement: SettingsOperation = { type: 'replace', value: after }; + if (!Array.isArray(before) || !Array.isArray(after) || before.length + after.length > 20_000) return replacement; + let first = 0; + const equal = (a: unknown, b: unknown) => settingVersion(a) === settingVersion(b); + while (first < before.length && first < after.length && equal(before[first], after[first])) first += 1; + let oldEnd = before.length; + let newEnd = after.length; + while (oldEnd > first && newEnd > first && equal(before[oldEnd - 1], after[newEnd - 1])) { + oldEnd -= 1; newEnd -= 1; + } + const splice: SettingsOperation = { type: 'splice', index: first, deleteCount: oldEnd - first, items: after.slice(first, newEnd) }; + return Buffer.byteLength(JSON.stringify(splice)) < Buffer.byteLength(JSON.stringify(replacement)) ? splice : replacement; +} diff --git a/API/src/services/settings-notifications.ts b/API/src/services/settings-notifications.ts new file mode 100644 index 000000000..b548c41e2 --- /dev/null +++ b/API/src/services/settings-notifications.ts @@ -0,0 +1,108 @@ +import { Client } from 'pg'; + +export type SettingsTopic = { userId: string; namespace: string; key: string }; +const topicKey = (topic: SettingsTopic) => JSON.stringify([topic.userId, topic.namespace, topic.key]); + +/** One session connection per serving process, never a Prisma pool checkout. */ +export class SettingsNotifications { + private client?: Client; + private connecting?: Promise; + private retry?: NodeJS.Timeout; + private idle?: NodeJS.Timeout; + private stopped = false; + private failures = 0; + private readonly watchers = new Map void>>(); + + constructor(private readonly databaseUrl: string) {} + + async subscribe(topic: SettingsTopic, changed: () => void): Promise<() => void> { + if (this.stopped) throw new Error('Settings notifications stopped'); + if (this.idle) clearTimeout(this.idle); + const key = topicKey(topic); + const group = this.watchers.get(key) ?? new Set<() => void>(); + group.add(changed); + this.watchers.set(key, group); + const unsubscribe = () => { + group.delete(changed); + if (!group.size) this.watchers.delete(key); + if (!this.watchers.size) { + this.idle = setTimeout(() => { void this.disconnect(); }, 60_000); + this.idle.unref(); + } + }; + try { await this.connect(); } catch (error) { unsubscribe(); throw error; } + return unsubscribe; + } + + private connect(): Promise { + if (this.stopped || !this.watchers.size) return Promise.resolve(); + if (this.connecting) return this.connecting; + if (this.client) return Promise.resolve(); + this.connecting = this.open().finally(() => { this.connecting = undefined; }); + return this.connecting; + } + + private async open(): Promise { + const url = new URL(this.databaseUrl); + const socketDirectory = url.searchParams.get('host'); + for (const key of ['schema', 'connection_limit', 'pool_timeout', 'pgbouncer']) url.searchParams.delete(key); + const client = new Client({ connectionString: url.toString(), ...(socketDirectory ? { host: socketDirectory } : {}), + connectionTimeoutMillis: 5000, keepAlive: true, application_name: 'uoa-settings-listener' }); + client.on('notification', notification => { + if (notification.channel !== 'uoa_settings_changed' || !notification.payload) return; + try { + const topic = JSON.parse(notification.payload) as SettingsTopic; + if (typeof topic.userId !== 'string' || typeof topic.namespace !== 'string' || typeof topic.key !== 'string') return; + this.watchers.get(topicKey(topic))?.forEach(callback => callback()); + } catch { /* An invalid hint never authorizes a read. */ } + }); + let failed = false; + const disconnected = () => { + if (failed) return; + failed = true; + if (this.client === client) this.client = undefined; + void client.end().catch(() => {}); + this.scheduleReconnect(); + }; + client.on('error', disconnected); + client.on('end', disconnected); + try { + await client.connect(); + await client.query('LISTEN uoa_settings_changed'); + if (this.stopped || failed) throw new Error('Settings listener unavailable'); + this.client = client; + this.failures = 0; + // LISTEN completes before reading snapshots, including after reconnect. + this.watchers.forEach(group => group.forEach(callback => callback())); + } catch (error) { + disconnected(); + throw error; + } + } + + private scheduleReconnect(): void { + if (this.stopped || this.retry || !this.watchers.size) return; + const delay = Math.min(30_000, 1000 * 2 ** Math.min(this.failures++, 5)) + Math.random() * 1000; + this.retry = setTimeout(() => { + this.retry = undefined; + void this.connect().catch(() => this.scheduleReconnect()); + }, delay); + this.retry.unref(); + } + + private async disconnect(): Promise { + const client = this.client; + this.client = undefined; + if (this.retry) clearTimeout(this.retry); + this.retry = undefined; + await client?.end().catch(() => {}); + } + + async stop(): Promise { + this.stopped = true; + this.watchers.clear(); + if (this.idle) clearTimeout(this.idle); + await this.connecting?.catch(() => {}); + await this.disconnect(); + } +} diff --git a/API/src/services/settings-stream-queue.ts b/API/src/services/settings-stream-queue.ts new file mode 100644 index 000000000..549d25b67 --- /dev/null +++ b/API/src/services/settings-stream-queue.ts @@ -0,0 +1,11 @@ +/** Settings streams share one serial database lane so fan-out cannot occupy both + * admin connections. Admission bounds the number of producers; per-socket dirty + * flags coalesce repeated notifications while a read is pending. */ +export class SettingsStreamQueue { + private tail: Promise = Promise.resolve(); + run(work: () => Promise): Promise { + const result = this.tail.then(work, work); + this.tail = result.catch(() => {}); + return result; + } +} diff --git a/API/tests/integration/oauth-native-account.test.ts b/API/tests/integration/oauth-native-account.test.ts index 19a51a2ed..7de1f16db 100644 --- a/API/tests/integration/oauth-native-account.test.ts +++ b/API/tests/integration/oauth-native-account.test.ts @@ -7,6 +7,7 @@ import { hashPassword } from '../../src/services/password.service.js'; import { signMcpAccessToken, signConfidentialAccessToken, resetAccessTokenKeyCache } from '../../src/services/oauth/access-token.service.js'; import { encryptTwoFaSecret } from '../../src/utils/twofa-secret.js'; import { createHmac } from 'node:crypto'; +import { WebSocket } from 'ws'; const issuer = 'https://auth.example.com'; const domain = 'native.example.com'; @@ -99,6 +100,115 @@ describe.skipIf(!process.env.DATABASE_URL)('native account PKCE and durable favo expect((await app.inject({ method: 'PUT', url: path, headers: headers(), payload: { value: [] } })).statusCode).toBe(428); }); + it('streams a committed change across app instances, then a delta and deletion', async () => { + const replica = await createApp(); + const address = await replica.listen({ port: 0, host: '127.0.0.1' }); + // Exercise an actual upgrade: injectWS does not provide the underlying TCP socket. + const socket = new WebSocket(address.replace('http:', 'ws:') + path + '/events', { headers: headers() }); + const second = new WebSocket(address.replace('http:', 'ws:') + path + '/events', { headers: headers() }); + const frames: Record[] = []; + const secondFrames: Record[] = []; + socket.on('message', data => frames.push(JSON.parse(data.toString()))); + second.on('message', data => secondFrames.push(JSON.parse(data.toString()))); + try { + await Promise.all([socket, second].map(client => new Promise((resolve, reject) => { + client.once('open', resolve); client.once('error', reject); + }))); + // Streams coalesce pending reads. Pace writes on both clients when checking + // each intermediate delta; one client receiving it does not fence the other. + await expect.poll(() => frames.length).toBe(1); + await expect.poll(() => secondFrames.length).toBe(1); + expect(frames[0]).toMatchObject({ type: 'settings.snapshot', namespace: 'browser', key: 'bookmarks' }); + const value = Array.from({ length: 100 }, (_, id) => ({ id, title: `Page ${id}`, url: 'https://example.com' })); + let read = await app.inject({ method: 'GET', url: path, headers: headers() }); + const save = await app.inject({ method: 'PUT', url: path, headers: { ...headers(), 'if-match': read.headers.etag! }, payload: { value } }); + expect(save.statusCode).toBe(200); + await expect.poll(() => frames.length).toBe(2); + await expect.poll(() => secondFrames.length).toBe(2); + expect(frames[1]).toMatchObject({ type: 'settings.delta', baseVersion: frames[0]!.version, version: save.headers.etag }); + value[50]!.title = 'Renamed'; + read = await app.inject({ method: 'GET', url: path, headers: headers() }); + await app.inject({ method: 'PUT', url: path, headers: { ...headers(), 'if-match': read.headers.etag! }, payload: { value } }); + await expect.poll(() => frames.length).toBe(3); + await expect.poll(() => secondFrames.length).toBe(3); + expect(frames[2]!.operation).toMatchObject({ type: 'splice', index: 50, deleteCount: 1 }); + // A different writer deleting the namespace still fires the database trigger. + await handle.prisma.userSetting.deleteMany({ where: { userId: userID, namespace: 'browser' } }); + await expect.poll(() => frames.length).toBe(4); + expect(frames[3]!.operation).toEqual({ type: 'replace', value: null }); + await expect.poll(() => secondFrames.length).toBe(4); + expect(secondFrames).toEqual(frames); + const reconnect = new WebSocket(address.replace('http:', 'ws:') + path + '/events', { headers: headers() }); + try { + const snapshot = await new Promise((resolve, reject) => { + reconnect.once('message', data => resolve(JSON.parse(data.toString()))); + reconnect.once('error', reject); + }); + expect(snapshot).toMatchObject({ type: 'settings.snapshot', value: null, version: frames[3]!.version }); + // Sending application messages is unsupported and must close, not mutate. + const closed = new Promise(resolve => reconnect.once('close', resolve)); + reconnect.send(JSON.stringify({ value: ['unauthorised write'] })); + expect(await closed).toBe(1008); + } finally { reconnect.terminate(); } + } finally { socket.terminate(); second.terminate(); await replica.close(); } + }); + + it('refuses unauthenticated streams and user-selection query parameters', async () => { + await expect(app.injectWS(path + '/events')).rejects.toThrow(); + await expect(app.injectWS(path + '/events?userId=other', { headers: headers() })).rejects.toThrow(); + }); + + it('bounds subscriptions per subject and releases admission after disconnect', async () => { + const replica = await createApp(); + const address = await replica.listen({ port: 0, host: '127.0.0.1' }); + const url = address.replace('http:', 'ws:') + path + '/events'; + const sockets: WebSocket[] = []; + const connect = async () => { + const socket = new WebSocket(url, { headers: headers() }); + sockets.push(socket); + await new Promise((resolve, reject) => { + socket.once('message', () => resolve()); socket.once('error', reject); + }); + return socket; + }; + try { + await Promise.all(Array.from({ length: 8 }, connect)); + const denied = new WebSocket(url, { headers: headers() }); + sockets.push(denied); + expect(await new Promise((resolve, reject) => { + denied.once('unexpected-response', (_request, response) => { response.resume(); resolve(response.statusCode!); }); + denied.once('open', () => reject(new Error('Admission exceeded'))); + denied.once('error', () => {}); + })).toBe(503); + const first = sockets[0]!; + await new Promise(resolve => { first.once('close', () => resolve()); first.close(); }); + await connect(); + expect((await app.inject({ method: 'GET', url: '/oauth/me', headers: headers() })).statusCode).toBe(200); + } finally { sockets.forEach(socket => socket.terminate()); await replica.close(); } + }); + + it('closes a revoked stream before sending another settings value', async () => { + const subject = await handle.prisma.user.create({ data: { email: 'stream-revocation@example.com', userKey: 'stream-revocation@example.com' } }); + const access = await signMcpAccessToken({ subject: subject.id, email: subject.email, domain, clientId: clientID, + credentialEpoch: 0, role: 'user', resource: issuer, issuer, ttlSeconds: 600, scope }); + const replica = await createApp(); + const address = await replica.listen({ port: 0, host: '127.0.0.1' }); + const socket = new WebSocket(address.replace('http:', 'ws:') + path + '/events', { headers: headers(access) }); + const frames: unknown[] = []; + socket.on('message', data => frames.push(JSON.parse(data.toString()))); + try { + await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); }); + await expect.poll(() => frames.length).toBe(1); + const closed = new Promise(resolve => socket.once('close', resolve)); + await handle.prisma.user.update({ where: { id: subject.id }, data: { tokenVersion: { increment: 1 } } }); + const value = ['after revocation']; + await handle.prisma.userSetting.create({ data: { userId: subject.id, namespace: 'browser', key: 'bookmarks', value, + sizeBytes: Buffer.byteLength(JSON.stringify(value)) } }); + expect(await closed).toBe(1008); + expect(frames).toHaveLength(1); + } finally { socket.terminate(); await replica.close(); } + }); + it('rejects scope escalation, other token classes, wrong audience, missing epoch and cross-user reads', async () => { const claims = { subject: userID, email: credentials.email, domain, clientId: clientID, credentialEpoch: 0, role: 'user' as const, resource: issuer, issuer, ttlSeconds: 600, scope }; diff --git a/API/tests/unit/settings-delta.test.ts b/API/tests/unit/settings-delta.test.ts new file mode 100644 index 000000000..01a985bfb --- /dev/null +++ b/API/tests/unit/settings-delta.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { settingsDelta } from '../../src/services/settings-delta.js'; +import { SettingsStreamQueue } from '../../src/services/settings-stream-queue.js'; + +describe('settings stream operations', () => { + it('reconstructs inserted, deleted, reordered and edited arrays without dropping opaque fields', () => { + const before = Array.from({ length: 100 }, (_, id) => ({ id, title: `Item ${id}`, foreign: { retained: true } })); + const afters = [before.slice(1), [...before, { id: 100, title: 'new', foreign: { retained: true } }], + [before[1], before[0], ...before.slice(2)], before.map((v, i) => i === 50 ? { ...v, title: 'renamed' } : v), []]; + for (const after of afters) { + const operation = settingsDelta(before, after); + const result = [...before] as unknown[]; + if (operation.type === 'splice') result.splice(operation.index, operation.deleteCount, ...operation.items); + expect(operation.type === 'replace' ? operation.value : result).toEqual(after); + expect(JSON.stringify(operation).length).toBeLessThanOrEqual(JSON.stringify({ type: 'replace', value: after }).length); + } + expect(settingsDelta(before, afters[3]).type).toBe('splice'); + }); + it('replaces scalars, objects and deleted values', () => { + for (const value of [null, true, 'text', { nested: { changed: true } }]) { + expect(settingsDelta({ old: true }, value)).toEqual({ type: 'replace', value }); + } + }); + it('serializes reads and continues after a rejected job', async () => { + const queue = new SettingsStreamQueue(); + let finish!: () => void; + const gate = new Promise(resolve => { finish = resolve; }); + const order: number[] = []; + const first = queue.run(async () => { order.push(1); await gate; throw new Error('revoked'); }); + const failed = expect(first).rejects.toThrow('revoked'); + const second = queue.run(async () => { order.push(2); return 3; }); + await Promise.resolve(); + expect(order).toEqual([1]); + finish(); + await failed; + expect(await second).toBe(3); + expect(order).toEqual([1, 2]); + }); +}); diff --git a/Docs/Auth/native-accounts.md b/Docs/Auth/native-accounts.md index 5971e917d..7a719610f 100644 --- a/Docs/Auth/native-accounts.md +++ b/Docs/Auth/native-accounts.md @@ -137,3 +137,7 @@ UOA account reads/writes, native refresh and Kelpie ticket issue/validation rech Expired browser OAuth transactions are removed by the bounded enterprise transient-maintenance sweep (at most 200 per pass), rather than an unbounded delete on the next sign-in. Managed-account mailbox/social admission also applies to historical signed continuations advertising those methods even when their original proof is absent below the legacy floor. + +### Immediate personal-setting updates + +Native accounts with `settings.read` can use `/oauth/me/settings/:namespace/:key/events` for authenticated snapshot/delta WebSocket updates. This is reusable for all opaque personal settings, including Kelpie favourites. It adds no consent scope or product-specific identity store. See [user settings](user-settings.md#live-subscriptions-protocol-version-1) for version fencing, replacement/splice operations, reconnect and limits. A native app should pause its subscription while queued conditional writes complete, then reconnect for a fresh snapshot, or otherwise fence in-flight HTTP responses against newer events. diff --git a/Docs/Auth/user-settings.md b/Docs/Auth/user-settings.md index 2c3cbb408..0642e878b 100644 --- a/Docs/Auth/user-settings.md +++ b/Docs/Auth/user-settings.md @@ -219,3 +219,13 @@ GET/PUT endpoints described in [native-accounts.md](native-accounts.md). This is additional authenticated entry point to the same user-owned store, with conditional writes to protect read/modify/write lists. The existing confidential dual-auth routes and their authorization requirements are unchanged. + +## Live subscriptions (protocol version 1) + +Native public OAuth clients may subscribe to any one current-user setting at `GET /oauth/me/settings/:namespace/:key/events` using a WebSocket upgrade and an `Authorization: Bearer …` header with `settings.read`. No query parameters or user selector are accepted. The public-profile feature gate and all existing live subject, client, scope, credential, factor and lifecycle checks apply. This endpoint reads the same authoritative setting rows as `/settings/me` and `/oauth/me/settings`; no identity or settings replicas are introduced. + +After PostgreSQL LISTEN is established, the server reads and sends `{type:"settings.snapshot",protocolVersion:1,namespace,key,version,value}`. Subsequent changes send `{type:"settings.delta",protocolVersion:1,namespace,key,baseVersion,version,operation}`. `version` is the existing quoted opaque ETag and supports equality only. `operation` is either `{type:"replace",value}` or `{type:"splice",index,deleteCount,items}` over the root JSON array. A splice is used only when smaller than replacement. Other value types use replacement; deletion is replacement with null. + +Clients validate topic, frame version, bounds and exact `baseVersion` before applying atomically. On a gap or malformed frame, disconnect and obtain a fresh snapshot. Duplicate versions can be ignored. Reconnect with exponential backoff and jitter; the server recycles connections after 240–280 seconds. This is current-state convergence, not a durable event log: intermediate updates may coalesce. Conditional HTTP writes remain the mutation interface. + +A database trigger emits topic-only NOTIFY hints after committed inserts, value updates and deletions, including namespace deletion by other writers. One lazy dedicated pg connection per active API instance listens across replicas; values never appear in notifications. Reconnect dirties all active topics after LISTEN. A 30-second repair read also revalidates authority and repairs missed hints. Reads run in one serial lane and transactions use the existing admin pool. There are at most 32 admitted streams and 8 per subject per instance; excess admission returns 503 with Retry-After. Slow consumers close and recover from a snapshot. Client application messages are rejected. diff --git a/Docs/brief.md b/Docs/brief.md index bb78fdfc3..6938d0913 100644 --- a/Docs/brief.md +++ b/Docs/brief.md @@ -3442,3 +3442,7 @@ links to that person's Security panel. A superuser can decline or approve with a new future expiry, or directly extend/remove an expiry for an account. Decisions and direct edits are audited. A correctly verified password may identify the account as expired to its owner; invalid credentials remain indistinguishable. + +### Personal settings live updates (2026-11-04) + +Authenticated native clients can subscribe to reusable personal-setting snapshot/delta streams. The authoritative UOA settings rows remain the only durable store. Committed changes notify all serving instances through PostgreSQL; clients apply version-fenced root-array edits or replacements and recover by obtaining a fresh snapshot. See `Docs/Auth/user-settings.md` for the protocol and `Docs/deployment.md` for connection limits. diff --git a/Docs/deployment.md b/Docs/deployment.md index aad3c5163..964f6850e 100644 --- a/Docs/deployment.md +++ b/Docs/deployment.md @@ -303,3 +303,9 @@ remains disabled unless the explicit evidence policy in [Voice prepaid](Requirements/voice-pricing-and-prepaid.md) is configured. No values or provider resources are seeded. Provision VOICE_RUNTIME only for the consuming backend. Receipt/release recovery remains available when new Voice admission is disabled. + +### Settings WebSocket capacity + +The settings stream adds at most one lazy PostgreSQL LISTEN session per active API instance, outside the Prisma application/admin pools. Account for rolling-deployment overlap. It uses DATABASE_ADMIN_URL (or DATABASE_URL when no admin URL is configured), removes Prisma-only connection parameters and preserves Cloud SQL socket routing. Apply migration `20261104160000_settings_change_notifications` before serving subscriptions. PostgreSQL NOTIFY carries only user ID, namespace and key; consumers always reauthorize and read the row. + +The implementation caps subscriptions at 32 per instance and serializes their reads, reserving capacity under the observed Cloud Run concurrency of 80. The observed service timeout is 300 seconds; streams recycle at 240–280 seconds with client jitter. If those deployment limits change, reassess admission and recycling together. Existing HTTP settings operations continue working when subscriptions are unavailable; clients reconnect with bounded backoff and may explicitly refresh. No Redis or extra durable service is required. diff --git a/Docs/plans/2026-11-04-settings-stream.md b/Docs/plans/2026-11-04-settings-stream.md new file mode 100644 index 000000000..6cf7f777a --- /dev/null +++ b/Docs/plans/2026-11-04-settings-stream.md @@ -0,0 +1,105 @@ +# Versioned settings updates across devices + +The user requested immediate cross-device updates for Kelpie favourites and a +reusable mechanism for all UOA settings. This authorizes an additive change-feed +extension to the historical scope of Docs/Auth/user-settings.md. UOA remains the +only settings authority. No identity/profile copy is introduced in products. + +## Contract + +Add authenticated WebSocket GET `/oauth/me/settings/:namespace/:key/events` for +public native clients with `settings.read`. Credentials stay in headers; query +parameters are rejected. Incoming application messages close the socket. Any +existing namespace/key is supported. Native and confidential writers both +trigger updates; v1 has no unused confidential reader endpoint. + +Every frame includes protocolVersion:1, namespace, key and an opaque quoted ETag +version. An initial settings.snapshot carries value (null for a missing key). +Subsequent settings.delta frames carry baseVersion and operation: either a +single root-array splice {type,index,deleteCount,items}, or {type:"replace",value}. +Use replacement when a splice is larger or the array comparison exceeds its +bound. Nested objects are replaced whole. Versions are equality tokens. + +Malformed deltas, base-version mismatches and reconnects recover through a fresh +authoritative snapshot. No durable event history is required. Writes retain the +existing If-Match transaction and conflict retry contract. + +## Cross-replica delivery and resource bounds + +Every committed settings mutation (native or confidential, including namespace +and key deletion) publishes a PostgreSQL NOTIFY hint inside the same transaction. +The hint contains subject, namespace and key, never values or credentials. A +single dedicated pg LISTEN connection per active API process receives hints; +it does not occupy a Prisma pool slot. Connection loss triggers bounded jittered +reconnect and reconciliation. A 30-second bounded reconciliation also recovers +missed hints. LISTEN starts lazily and stops after the last subscriber leaves. + +All stream authorization/value reads share one serial, coalescing queue per +process, with bounded pending work and bounded sockets (32 total, 8 per subject). +Each delivery rechecks the live credential authority and expiry before reading +and sending. Failures close the socket; no stale authorization cache grants data. +An initial post-subscription read closes the handshake race. Slow consumers close +when buffered output exceeds 1 MiB. Payloads are bounded by the existing 256 KiB +setting limit. A 30-second repair/authentication check closes expired sessions. Sockets recycle +after 240–280 seconds, before the Cloud Run request timeout. + +The pre-implementation deployment inspection found maxScale3, minScale1, concurrency80, 300-second +request timeout, and unthrottled CPU. Database is db-g1-small; code/docs cap app/ +admin pools at3/2. Budget one extra listener per serving process, allowing revision +overlap; do not enlarge those pools. Verify actual capacity before rollout and +stress the queue to show settings pushes do not consume parallel sign-in capacity. +Five-minute Cloud Run reconnects are normal and must be verified. + +## Kelpie consumers + +Swift (iOS/macOS) and Kotlin (Android) share the same wire contract. Keep the raw +settings array and ETag in memory. Apply a delta only when baseVersion matches; +validate/decode the resulting tree before publishing. Serialize push delivery with +queued mutations. Each mutation still GETs the latest value and uses If-Match; +a push must never overwrite a newer save or hide a failed save. Use a stream +generation so late frames after sign-out/account changes are discarded. Obtain a +fresh token on reconnect, retry network failures with capped jittered backoff, +resume after foregrounding, and fetch a snapshot when returning from suspension. +No background execution guarantee is claimed on mobile operating systems. + +## Verification + +Verify root splice/replacement round trips, size fallback, no-op updates and +strict client version/bounds validation. Exercise two real API listeners sharing +PostgreSQL, HTTP writes and direct namespace deletion, scope/subject isolation, +revocation, reconnect snapshots and admission limits. Three signed-in client +connections must also demonstrate one mutation reaching both other devices. +Local tests, deployed service checks and signed-in app verification are distinct +evidence; none substitutes for the others. + +## Cross-provider review + +Claude and Codex reviewed the design before implementation. Database capacity +and Cloud Run request timeout are explicit constraints. + +### Review decisions + +Claude and Codex independently reviewed the plan. Accepted: cap below live HTTP +concurrency, reserve admission before queued authentication, independent live +authorization before each send, a dedicated session listener, bounded coalescing, +jittered socket recycling before the 300-second Cloud Run timeout, and snapshot +recovery. Use an AFTER-row database trigger so every committed writer is covered, +including namespace deletion; no hints contain values. No-op updates emit no hint. + +For v1 use `operation:{type:"splice",index,deleteCount,items}` for a single root +array splice, or `operation:{type:"replace",value}` for other JSON/replacement. +Nested objects are replaced whole, eliminating +three custom JSON Pointer interpreters. Both clients strictly validate the base +version, type and bounds with shared fixtures. Versions stay opaque; copying JS +number/canonical hashing into native code is rejected as unnecessary complexity. +The sender derives the operation from authoritative snapshots and tests verify +round trips. A malformed delta never becomes mutation input: writes always GET +the latest authoritative array before applying intentions. + +Keep the native reader endpoint only in v1; it supports any existing settings +namespace/key through settings.read. Both native and confidential writers trigger +updates. A confidential reader can be added with an actual backend consumer; no +unused parallel credential stream is introduced. Each read reruns token/client +binding outside its transaction, then authorizes and reads inside one transaction. +A verified native-app revision is already checked by getOAuthClient; the additional +reverification also covers scope changes. diff --git a/package.json b/package.json index c819c87fd..9395f26b0 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "deepmerge-ts@<8.0.0": "^8.0.1", "fast-uri": "^3.1.6", "fast-xml-builder": "^1.1.7", - "follow-redirects": "^1.16.1", + "follow-redirects": "^1.16.2", "form-data": "^4.0.6", "handlebars": "^4.7.9", "js-yaml@>=4.0.0 <4.3.0": "4.3.0", @@ -48,7 +48,8 @@ "undici": "^6.29.1", "vite": "^6.4.4", "ws": "^7.5.11", - "@fastify/websocket>ws": "^8.18.3", + "@fastify/websocket>ws": "^8.21.0", + "@uoa/api>ws": "8.21.0", "browserslist": ">=4.28.7", "@fastify/busboy": "^3.2.1", "tinypool@<2.1.2": "2.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9015f23d..b9a005881 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ overrides: deepmerge-ts@<8.0.0: ^8.0.1 fast-uri: ^3.1.6 fast-xml-builder: ^1.1.7 - follow-redirects: ^1.16.1 + follow-redirects: ^1.16.2 form-data: ^4.0.6 handlebars: ^4.7.9 js-yaml@>=4.0.0 <4.3.0: 4.3.0 @@ -21,7 +21,8 @@ overrides: undici: ^6.29.1 vite: ^6.4.4 ws: ^7.5.11 - '@fastify/websocket>ws': ^8.18.3 + '@fastify/websocket>ws': ^8.21.0 + '@uoa/api>ws': 8.21.0 browserslist: '>=4.28.7' '@fastify/busboy': ^3.2.1 tinypool@<2.1.2: 2.1.2 @@ -136,6 +137,9 @@ importers: pdf-lib: specifier: ^1.17.1 version: 1.17.1 + pg: + specifier: 8.23.1 + version: 8.23.1 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -164,6 +168,9 @@ importers: '@types/nodemailer': specifier: ^6.4.17 version: 6.4.23 + '@types/pg': + specifier: 8.23.1 + version: 8.23.1 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -185,6 +192,9 @@ importers: vitest: specifier: ^3.2.6 version: 3.2.6(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@21.0.4)(jiti@2.7.0)(jsdom@30.0.1)(lightningcss@1.32.0)(tsx@4.21.0) + ws: + specifier: 8.21.0 + version: 8.21.0 xml-crypto: specifier: ^6.4.0 version: 6.4.0 @@ -1528,6 +1538,7 @@ packages: '@smithy/smithy-client@4.12.12': resolution: {integrity: sha512-daO7SJn4eM6ArbmrEs+/BTbH7af8AEbSL3OMQdcRvvn8tuUcR5rU2n6DgxIV53aXMS42uwK8NgKKCh5XgqYOPQ==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to a bug in custom logger data redaction. See https://github.com/aws/aws-sdk-js-v3/issues/8393. '@smithy/types@4.14.1': resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} @@ -1785,6 +1796,9 @@ packages: '@types/nodemailer@6.4.23': resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -2462,8 +2476,8 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - follow-redirects@1.16.1: - resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2984,6 +2998,40 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} + + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3025,6 +3073,22 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3591,8 +3655,8 @@ packages: utf-8-validate: optional: true - ws@8.22.0: - resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3645,6 +3709,10 @@ packages: resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} engines: {node: '>=0.6.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -4456,7 +4524,7 @@ snapshots: dependencies: duplexify: 4.1.3 fastify-plugin: 6.0.0 - ws: 8.22.0 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -5287,6 +5355,12 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@types/pg@8.23.1': + dependencies: + '@types/node': 22.19.17 + pg-protocol: 1.16.1 + pg-types: 2.2.0 + '@types/qrcode@1.5.6': dependencies: '@types/node': 22.19.17 @@ -5570,7 +5644,7 @@ snapshots: axios@1.20.0: dependencies: - follow-redirects: 1.16.1 + follow-redirects: 1.16.2 form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -6076,7 +6150,7 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.16.1: {} + follow-redirects@1.16.2: {} form-data@4.0.6: dependencies: @@ -6600,6 +6674,41 @@ snapshots: perfect-debounce@1.0.0: {} + pg-cloudflare@1.4.1: + optional: true + + pg-connection-string@2.14.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.1): + dependencies: + pg: 8.23.1 + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.1: + dependencies: + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -6648,6 +6757,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier@3.8.2: {} @@ -7167,7 +7286,7 @@ snapshots: ws@7.5.11: {} - ws@8.22.0: {} + ws@8.21.0: {} xml-crypto@6.4.0: dependencies: @@ -7202,6 +7321,8 @@ snapshots: xpath@0.0.34: {} + xtend@4.0.2: {} + y18n@4.0.3: {} y18n@5.0.8: {}