From 30b11a711d5c164910ad913807f35692ca3cd787 Mon Sep 17 00:00:00 2001 From: Preciousgift Ejere Date: Fri, 26 Jun 2026 00:22:27 +0100 Subject: [PATCH 1/8] feat(ci): add GitHub Actions workflow for ai_agent module (#150) --- .github/workflows/ai-agent-ci.yml | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/ai-agent-ci.yml diff --git a/.github/workflows/ai-agent-ci.yml b/.github/workflows/ai-agent-ci.yml new file mode 100644 index 0000000..7926eac --- /dev/null +++ b/.github/workflows/ai-agent-ci.yml @@ -0,0 +1,50 @@ +name: AI Agent CI + +on: + push: + paths: + - 'apps/ai_agent/**' + - '.github/workflows/ai-agent-ci.yml' + pull_request: + paths: + - 'apps/ai_agent/**' + - '.github/workflows/ai-agent-ci.yml' + +jobs: + test: + name: Test · Coverage + runs-on: ubuntu-latest + + defaults: + run: + working-directory: apps/ai_agent + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: 'apps/ai_agent/uv.lock' + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run tests with coverage + run: uv run pytest --cov=main --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + continue-on-error: true + with: + files: apps/ai_agent/coverage.xml + flags: ai-agent + + - name: Post coverage summary + if: always() + run: | + echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY + uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 2>/dev/null || \ + echo "Coverage data not available." >> $GITHUB_STEP_SUMMARY From a774654e6fd43e7f512b0a012e165134d943784d Mon Sep 17 00:00:00 2001 From: Preciousgift Ejere Date: Fri, 26 Jun 2026 00:22:34 +0100 Subject: [PATCH 2/8] feat(auth): extend JWT payload with deviceId and validate device state on every request (#158) --- .../src/__tests__/auth.integration.test.ts | 98 +++++++++--- apps/backend/src/__tests__/jwt.test.ts | 13 +- apps/backend/src/__tests__/users.test.ts | 9 +- apps/backend/src/db/schema.ts | 140 +++++++++--------- apps/backend/src/lib/jwt.ts | 14 +- apps/backend/src/middleware/auth.ts | 26 +++- apps/backend/src/middleware/socketAuth.ts | 31 +++- apps/backend/src/routes/auth.ts | 33 ++++- apps/backend/src/schemas/auth.schemas.ts | 7 +- 9 files changed, 264 insertions(+), 107 deletions(-) diff --git a/apps/backend/src/__tests__/auth.integration.test.ts b/apps/backend/src/__tests__/auth.integration.test.ts index ff542b6..f0b9c16 100644 --- a/apps/backend/src/__tests__/auth.integration.test.ts +++ b/apps/backend/src/__tests__/auth.integration.test.ts @@ -11,13 +11,15 @@ vi.mock('../lib/nonce.js', () => ({ consumeNonce: mockConsumeNonce, })); -const mockFindFirst = vi.fn(); +const mockWalletFindFirst = vi.fn(); +const mockDeviceFindFirst = vi.fn(); const mockInsert = vi.fn(); vi.mock('../db/index.js', () => ({ db: { query: { - wallets: { findFirst: mockFindFirst }, + wallets: { findFirst: mockWalletFindFirst }, + devices: { findFirst: mockDeviceFindFirst }, }, insert: mockInsert, execute: vi.fn().mockResolvedValue([]), @@ -46,12 +48,27 @@ function resetRateLimiters() { const WALLET = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ012345678901234567890123456789AB'; const SIGNATURE = 'aabbccdd'; const NONCE = 'test-nonce-abc123'; +const IDENTITY_KEY = 'dGVzdC1pZGVudGl0eS1wdWJsaWMta2V5'; // base64 placeholder + +function setupInsert(userId = 'new-user-id', deviceId = 'new-device-id') { + // mockInsert is called twice when creating a new user: once for users, once for devices. + // For existing users it's called once for devices. + const userReturning = vi.fn().mockResolvedValue([{ id: userId }]); + const deviceReturning = vi.fn().mockResolvedValue([{ id: deviceId }]); + const userValues = vi.fn().mockReturnValue({ returning: userReturning }); + const deviceValues = vi.fn().mockReturnValue({ returning: deviceReturning }); + mockInsert + .mockReturnValueOnce({ values: userValues }) + .mockReturnValueOnce({ values: deviceValues }); + return { userReturning, deviceReturning }; +} -function setupInsert(userId = 'new-user-id') { - const returningFn = vi.fn().mockResolvedValue([{ id: userId }]); - const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); - mockInsert.mockReturnValue({ values: valuesFn }); - return { returningFn, valuesFn }; +function setupExistingUserInsert(deviceId = 'device-id') { + // Only the device insert is called for an existing wallet. + const deviceReturning = vi.fn().mockResolvedValue([{ id: deviceId }]); + const deviceValues = vi.fn().mockReturnValue({ returning: deviceReturning }); + mockInsert.mockReturnValue({ values: deviceValues }); + return { deviceReturning }; } // ── Tests ───────────────────────────────────────────────────────────────── @@ -100,12 +117,13 @@ describe('POST /auth/verify', () => { it('returns 200 with JWT token for valid new-user flow', async () => { mockConsumeNonce.mockReturnValue(true); mockVerify.mockReturnValue(true); - mockFindFirst.mockResolvedValue(undefined); // no existing wallet → create user + mockWalletFindFirst.mockResolvedValue(undefined); // no existing wallet → create user + mockDeviceFindFirst.mockResolvedValue(undefined); // no existing device → create device setupInsert(); const res = await request(app) .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); expect(res.status).toBe(200); expect(res.body).toHaveProperty('token'); @@ -113,14 +131,30 @@ describe('POST /auth/verify', () => { expect(parts).toHaveLength(3); // valid JWT structure }); - it('returns 200 with JWT for existing wallet (returning user)', async () => { + it('returns 200 with JWT for existing wallet and existing device (returning user)', async () => { mockConsumeNonce.mockReturnValue(true); mockVerify.mockReturnValue(true); - mockFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); + mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); + mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: false }); const res = await request(app) .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('token'); + }); + + it('returns 200 with JWT for existing wallet and new device', async () => { + mockConsumeNonce.mockReturnValue(true); + mockVerify.mockReturnValue(true); + mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); + mockDeviceFindFirst.mockResolvedValue(undefined); // new device for existing user + setupExistingUserInsert(); + + const res = await request(app) + .post('/auth/verify') + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); expect(res.status).toBe(200); expect(res.body).toHaveProperty('token'); @@ -131,7 +165,7 @@ describe('POST /auth/verify', () => { const res = await request(app) .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: 'expired-nonce' }); + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: 'expired-nonce', identityPublicKey: IDENTITY_KEY }); expect(res.status).toBe(401); expect(res.body).toHaveProperty('error'); @@ -143,12 +177,26 @@ describe('POST /auth/verify', () => { const res = await request(app) .post('/auth/verify') - .send({ walletAddress: WALLET, signature: 'badsig', nonce: NONCE }); + .send({ walletAddress: WALLET, signature: 'badsig', nonce: NONCE, identityPublicKey: IDENTITY_KEY }); expect(res.status).toBe(401); expect(res.body.error).toMatch(/signature/i); }); + it('returns 401 when device is revoked', async () => { + mockConsumeNonce.mockReturnValue(true); + mockVerify.mockReturnValue(true); + mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); + mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: true }); + + const res = await request(app) + .post('/auth/verify') + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/revoked/i); + }); + it('returns 400 when required fields are missing', async () => { const res = await request(app).post('/auth/verify').send({ walletAddress: WALLET }); @@ -163,6 +211,15 @@ describe('POST /auth/verify', () => { expect(res.body).toHaveProperty('error'); }); + it('returns 400 when identityPublicKey is missing', async () => { + const res = await request(app) + .post('/auth/verify') + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty('error'); + }); + it('returns 401 when Stellar Keypair throws (malformed wallet address)', async () => { mockConsumeNonce.mockReturnValue(true); mockVerify.mockImplementation(() => { @@ -171,7 +228,7 @@ describe('POST /auth/verify', () => { const res = await request(app) .post('/auth/verify') - .send({ walletAddress: 'INVALID', signature: SIGNATURE, nonce: NONCE }); + .send({ walletAddress: 'INVALID', signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); expect(res.status).toBe(401); expect(res.body).toHaveProperty('error'); @@ -184,7 +241,8 @@ describe('Auth rate limiting', () => { resetRateLimiters(); mockConsumeNonce.mockReturnValue(true); mockVerify.mockReturnValue(true); - mockFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); + mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); + mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: false }); }); it('allows up to 10 /auth/challenge requests per minute, blocks the 11th with 429', async () => { @@ -202,13 +260,13 @@ describe('Auth rate limiting', () => { for (let i = 0; i < 5; i++) { const res = await request(app) .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); expect(res.status).toBe(200); } const blocked = await request(app) .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); expect(blocked.status).toBe(429); expect(blocked.headers['retry-after']).toBeDefined(); }); @@ -218,11 +276,11 @@ describe('Auth rate limiting', () => { for (let i = 0; i < 5; i++) { await request(app) .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); } const verifyBlocked = await request(app) .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); expect(verifyBlocked.status).toBe(429); // Challenge limit should still allow requests diff --git a/apps/backend/src/__tests__/jwt.test.ts b/apps/backend/src/__tests__/jwt.test.ts index 524a45d..ebfb3b0 100644 --- a/apps/backend/src/__tests__/jwt.test.ts +++ b/apps/backend/src/__tests__/jwt.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { signToken, verifyToken } from '../lib/jwt.js'; describe('JWT utilities', () => { - const payload = { userId: 'user-123', walletAddress: 'GABCDE' }; + const payload = { userId: 'user-123', walletAddress: 'GABCDE', deviceId: 'device-abc' }; it('signs a token without throwing', () => { const token = signToken(payload); @@ -15,6 +15,7 @@ describe('JWT utilities', () => { const decoded = verifyToken(token); expect(decoded.userId).toBe(payload.userId); expect(decoded.walletAddress).toBe(payload.walletAddress); + expect(decoded.deviceId).toBe(payload.deviceId); }); it('throws on a tampered token', () => { @@ -29,4 +30,14 @@ describe('JWT utilities', () => { const expired = jwt.default.sign(payload, secret, { expiresIn: -1 }); expect(() => verifyToken(expired)).toThrow(/expired/i); }); + + it('throws on a legacy token missing deviceId', async () => { + const jwt = await import('jsonwebtoken'); + const secret = process.env['JWT_SECRET']!; + // Simulate a legacy token with no deviceId field + const legacy = jwt.default.sign({ userId: 'user-123', walletAddress: 'GABCDE' }, secret, { + expiresIn: '7d', + }); + expect(() => verifyToken(legacy)).toThrow(/deviceId/i); + }); }); diff --git a/apps/backend/src/__tests__/users.test.ts b/apps/backend/src/__tests__/users.test.ts index b6fc90e..70fe970 100644 --- a/apps/backend/src/__tests__/users.test.ts +++ b/apps/backend/src/__tests__/users.test.ts @@ -8,6 +8,8 @@ const mockWhere = vi.fn(() => ({ returning: mockReturning })); const mockSet = vi.fn(() => ({ where: mockWhere })); const mockUpdate = vi.fn(() => ({ set: mockSet })); +const mockDeviceFindFirst = vi.fn(); + vi.mock('../db/index.js', () => ({ db: { query: { @@ -15,6 +17,9 @@ vi.mock('../db/index.js', () => ({ findFirst: vi.fn(), findMany: vi.fn(), }, + devices: { + findFirst: mockDeviceFindFirst, + }, }, update: mockUpdate, select: vi.fn(), @@ -28,7 +33,7 @@ const app = express(); app.use(express.json()); app.use('/users', usersRouter); -const VALID_TOKEN = signToken({ userId: 'auth-user-id', walletAddress: 'GAUTH' }); +const VALID_TOKEN = signToken({ userId: 'auth-user-id', walletAddress: 'GAUTH', deviceId: 'device-test-id' }); const AUTH_HEADER = `Bearer ${VALID_TOKEN}`; const MOCK_USER = { @@ -45,6 +50,8 @@ const MOCK_CREATED_AT = new Date('2026-05-31T12:00:00.000Z'); beforeEach(() => { vi.clearAllMocks(); + // Default: device is active; individual tests that need 401 from device checks can override. + mockDeviceFindFirst.mockResolvedValue({ id: 'device-test-id', isRevoked: false }); }); describe('GET /users/me', () => { diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index e85eff7..aff8765 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -4,9 +4,9 @@ import { timestamp, uuid, boolean, - integer, pgEnum, index, + integer, uniqueIndex, } from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; @@ -29,30 +29,6 @@ export const wallets = pgTable('wallets', { createdAt: timestamp('created_at').notNull().defaultNow(), }); -export const devices = pgTable('devices', { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - deviceId: text('device_id').notNull(), - deviceName: text('device_name').notNull(), - platform: text('platform').notNull(), - identityPublicKey: text('identity_public_key').notNull(), - registrationId: text('registration_id'), - isRevoked: boolean('is_revoked').notNull().default(false), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), -}); - -export const devicePrekeys = pgTable('device_prekeys', { - id: uuid('id').primaryKey().defaultRandom(), - deviceId: uuid('device_id') - .notNull() - .references(() => devices.id, { onDelete: 'cascade' }), - prekey: text('prekey').notNull(), - createdAt: timestamp('created_at').notNull().defaultNow(), -}); - // ─── Conversations ──────────────────────────────────────────────────────────── export const conversationTypeEnum = pgEnum('conversation_type', ['dm', 'group']); @@ -103,6 +79,64 @@ export const messages = pgTable( ], ); +// ─── Devices & prekeys (issues #158, #159, #162) ───────────────────────────── +// +// Each user may register multiple devices. Each device has an Ed25519 identity +// key pair; the public key is stored here for fingerprint derivation and prekey +// signature validation. `isRevoked` lets the server reject stale devices +// without deleting the row (preserving audit history). + +export const devices = pgTable( + 'devices', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + // Base64-encoded Ed25519 public key for this device. + identityPublicKey: text('identity_public_key').notNull(), + isRevoked: boolean('is_revoked').notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [uniqueIndex('devices_user_identity_idx').on(table.userId, table.identityPublicKey)], +); + +// One signed prekey per device (upserted on upload). +export const signedPreKeys = pgTable( + 'signed_pre_keys', + { + id: uuid('id').primaryKey().defaultRandom(), + deviceId: uuid('device_id') + .notNull() + .references(() => devices.id, { onDelete: 'cascade' }), + // Application-assigned integer key-id (unique per device). + keyId: integer('key_id').notNull(), + // Base64-encoded public key. + publicKey: text('public_key').notNull(), + // Base64-encoded Ed25519 signature over publicKey, signed by identityPublicKey. + signature: text('signature').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + // Only one signed prekey per device at a time — upsert on this unique constraint. + (table) => [uniqueIndex('spk_device_idx').on(table.deviceId)], +); + +// One-time prekeys — each consumed at most once. +export const oneTimePreKeys = pgTable( + 'one_time_pre_keys', + { + id: uuid('id').primaryKey().defaultRandom(), + deviceId: uuid('device_id') + .notNull() + .references(() => devices.id, { onDelete: 'cascade' }), + keyId: integer('key_id').notNull(), + publicKey: text('public_key').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [uniqueIndex('otp_device_keyid_idx').on(table.deviceId, table.keyId)], +); + // ─── Token transfers (#46) ──────────────────────────────────────────────────── // // One row per Soroban `transfer` event the listener (services/stellarListener.ts) @@ -125,41 +159,6 @@ export const tokenTransfers = pgTable('token_transfers', { createdAt: timestamp('created_at').notNull().defaultNow(), }); -// ─── User devices (#153) ────────────────────────────────────────────────────── -// -// Device identity registry for end-to-end encryption. Each row is one device a -// user has registered, holding its long-term identity public key. A device is -// never hard-deleted — revoking sets `revokedAt` so historical sessions stay -// auditable. `(userId, deviceId)` is unique so a client re-registering the same -// device upserts instead of duplicating, and the partial index keeps lookups of -// a user's *active* devices fast. - -export const devicePlatformEnum = pgEnum('device_platform', ['web', 'ios', 'android']); - -export const userDevices = pgTable( - 'user_devices', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - deviceId: text('device_id').notNull(), - deviceName: text('device_name').notNull(), - platform: devicePlatformEnum('platform').notNull(), - identityPublicKey: text('identity_public_key').notNull(), - registrationId: integer('registration_id'), - lastSeenAt: timestamp('last_seen_at'), - revokedAt: timestamp('revoked_at'), - createdAt: timestamp('created_at').notNull().defaultNow(), - }, - (table) => [ - uniqueIndex('user_devices_user_id_device_id_unique').on(table.userId, table.deviceId), - index('user_devices_user_id_active_idx') - .on(table.userId) - .where(sql`${table.revokedAt} IS NULL`), - ], -); - // ─── Relations ──────────────────────────────────────────────────────────────── export const usersRelations = relations(users, ({ many }) => ({ @@ -167,7 +166,7 @@ export const usersRelations = relations(users, ({ many }) => ({ memberships: many(conversationMembers), messages: many(messages), transfers: many(tokenTransfers), - devices: many(userDevices), + devices: many(devices), })); export const walletsRelations = relations(wallets, ({ one }) => ({ @@ -209,15 +208,16 @@ export const tokenTransfersRelations = relations(tokenTransfers, ({ one }) => ({ export const devicesRelations = relations(devices, ({ one, many }) => ({ user: one(users, { fields: [devices.userId], references: [users.id] }), - prekeys: many(devicePrekeys), + signedPreKey: many(signedPreKeys), + oneTimePreKeys: many(oneTimePreKeys), })); -export const devicePrekeysRelations = relations(devicePrekeys, ({ one }) => ({ - device: one(devices, { fields: [devicePrekeys.deviceId], references: [devices.id] }), +export const signedPreKeysRelations = relations(signedPreKeys, ({ one }) => ({ + device: one(devices, { fields: [signedPreKeys.deviceId], references: [devices.id] }), })); -export const userDevicesRelations = relations(userDevices, ({ one }) => ({ - user: one(users, { fields: [userDevices.userId], references: [users.id] }), +export const oneTimePreKeysRelations = relations(oneTimePreKeys, ({ one }) => ({ + device: one(devices, { fields: [oneTimePreKeys.deviceId], references: [devices.id] }), })); // ─── Types ──────────────────────────────────────────────────────────────────── @@ -235,7 +235,7 @@ export type TokenTransfer = typeof tokenTransfers.$inferSelect; export type NewTokenTransfer = typeof tokenTransfers.$inferInsert; export type Device = typeof devices.$inferSelect; export type NewDevice = typeof devices.$inferInsert; -export type DevicePrekey = typeof devicePrekeys.$inferSelect; -export type NewDevicePrekey = typeof devicePrekeys.$inferInsert; -export type UserDevice = typeof userDevices.$inferSelect; -export type NewUserDevice = typeof userDevices.$inferInsert; +export type SignedPreKey = typeof signedPreKeys.$inferSelect; +export type NewSignedPreKey = typeof signedPreKeys.$inferInsert; +export type OneTimePreKey = typeof oneTimePreKeys.$inferSelect; +export type NewOneTimePreKey = typeof oneTimePreKeys.$inferInsert; diff --git a/apps/backend/src/lib/jwt.ts b/apps/backend/src/lib/jwt.ts index 57955b1..33664b8 100644 --- a/apps/backend/src/lib/jwt.ts +++ b/apps/backend/src/lib/jwt.ts @@ -11,9 +11,8 @@ const JWT_SECRET: string = SECRET; export interface JwtPayload { userId: string; walletAddress: string; - // Present once the session is bound to a registered device (see user_devices). - // Used to flag the requesting device as `current` in device listings. - deviceId?: string; + /** Every token must carry a deviceId. Legacy tokens without it are rejected. */ + deviceId: string; } export function signToken(payload: JwtPayload): string { @@ -21,5 +20,12 @@ export function signToken(payload: JwtPayload): string { } export function verifyToken(token: string): JwtPayload { - return jwt.verify(token, JWT_SECRET) as unknown as JwtPayload; + const decoded = jwt.verify(token, JWT_SECRET) as unknown as JwtPayload; + + // Reject legacy tokens that pre-date device-aware auth. + if (!decoded.deviceId) { + throw new Error('Token missing deviceId — re-authentication required'); + } + + return decoded; } diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts index f81cd87..bf0e488 100644 --- a/apps/backend/src/middleware/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -1,11 +1,14 @@ import type { Request, Response, NextFunction } from 'express'; +import { eq, and } from 'drizzle-orm'; import { verifyToken, type JwtPayload } from '../lib/jwt.js'; +import { db } from '../db/index.js'; +import { devices } from '../db/schema.js'; export interface AuthRequest extends Request { auth?: JwtPayload; } -export function requireAuth(req: AuthRequest, res: Response, next: NextFunction): void { +export async function requireAuth(req: AuthRequest, res: Response, next: NextFunction): Promise { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { @@ -15,10 +18,27 @@ export function requireAuth(req: AuthRequest, res: Response, next: NextFunction) const token = header.slice(7); + let payload: JwtPayload; try { - req.auth = verifyToken(token); - next(); + payload = verifyToken(token); } catch { res.status(401).json({ error: 'Invalid or expired token' }); + return; + } + + // Verify the (userId, deviceId) pair exists and is not revoked. + const device = await db.query.devices.findFirst({ + where: and( + eq(devices.id, payload.deviceId), + eq(devices.userId, payload.userId), + ), + }); + + if (!device || device.isRevoked) { + res.status(401).json({ error: 'Device not found or has been revoked' }); + return; } + + req.auth = payload; + next(); } diff --git a/apps/backend/src/middleware/socketAuth.ts b/apps/backend/src/middleware/socketAuth.ts index 5f1ace5..b2e662f 100644 --- a/apps/backend/src/middleware/socketAuth.ts +++ b/apps/backend/src/middleware/socketAuth.ts @@ -1,11 +1,17 @@ import type { Socket } from 'socket.io'; +import { eq, and } from 'drizzle-orm'; import { verifyToken, type JwtPayload } from '../lib/jwt.js'; +import { db } from '../db/index.js'; +import { devices } from '../db/schema.js'; export interface AuthSocket extends Socket { auth?: JwtPayload; } -export function socketAuthMiddleware(socket: AuthSocket, next: (err?: Error) => void): void { +export async function socketAuthMiddleware( + socket: AuthSocket, + next: (err?: Error) => void, +): Promise { const token = socket.handshake.auth['token'] as string | undefined; if (!token) { @@ -13,10 +19,29 @@ export function socketAuthMiddleware(socket: AuthSocket, next: (err?: Error) => return; } + let payload: JwtPayload; try { - socket.auth = verifyToken(token); - next(); + // verifyToken already rejects tokens without a deviceId field. + payload = verifyToken(token); } catch { next(new Error('Invalid or expired token')); + return; + } + + // Bind socket identity from the verified token — never from event payloads. + // Also confirm the device still exists and has not been revoked. + const device = await db.query.devices.findFirst({ + where: and( + eq(devices.id, payload.deviceId), + eq(devices.userId, payload.userId), + ), + }); + + if (!device || device.isRevoked) { + next(new Error('Device not found or has been revoked')); + return; } + + socket.auth = payload; + next(); } diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c7b71ba..d5ba252 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -4,8 +4,8 @@ import type { Request, Response, IRouter } from 'express'; import rateLimit, { type RateLimitRequestHandler } from 'express-rate-limit'; import { Keypair } from '@stellar/stellar-sdk'; import { db } from '../db/index.js'; -import { users, wallets } from '../db/schema.js'; -import { eq } from 'drizzle-orm'; +import { users, wallets, devices } from '../db/schema.js'; +import { eq, and } from 'drizzle-orm'; import { createNonce, consumeNonce } from '../lib/nonce.js'; import { signToken } from '../lib/jwt.js'; import { validate } from '../middleware/validate.js'; @@ -57,7 +57,7 @@ authRouter.post( verifyLimiter, validate(VerifySchema), async (req: Request, res: Response) => { - const { walletAddress, signature, nonce } = req.body as VerifyBody; + const { walletAddress, signature, nonce, identityPublicKey } = req.body as VerifyBody; // Validate and consume nonce const valid = consumeNonce(walletAddress, nonce); @@ -110,7 +110,32 @@ authRouter.post( await db.insert(wallets).values({ userId, address: walletAddress, isPrimary: true }); } - const token = signToken({ userId, walletAddress }); + // Resolve the device for this (userId, identityPublicKey) pair. + // If the device is revoked, refuse sign-in immediately. + let deviceId: string; + const existingDevice = await db.query.devices.findFirst({ + where: and(eq(devices.userId, userId), eq(devices.identityPublicKey, identityPublicKey)), + }); + + if (existingDevice) { + if (existingDevice.isRevoked) { + res.status(401).json({ error: 'Device has been revoked' }); + return; + } + deviceId = existingDevice.id; + } else { + const [newDevice] = await db + .insert(devices) + .values({ userId, identityPublicKey }) + .returning({ id: devices.id }); + if (!newDevice) { + res.status(500).json({ error: 'Failed to register device' }); + return; + } + deviceId = newDevice.id; + } + + const token = signToken({ userId, walletAddress, deviceId }); res.json({ token }); }, ); diff --git a/apps/backend/src/schemas/auth.schemas.ts b/apps/backend/src/schemas/auth.schemas.ts index a8c1848..a86e39f 100644 --- a/apps/backend/src/schemas/auth.schemas.ts +++ b/apps/backend/src/schemas/auth.schemas.ts @@ -16,7 +16,12 @@ export const VerifySchema = z.object({ walletAddress: z.string().min(1, 'walletAddress is required'), signature: z.string().min(1, 'signature is required'), nonce: z.string().min(1, 'nonce is required'), - device: DeviceSchema.optional(), + /** + * Base64-encoded Ed25519 identity public key for the device initiating sign-in. + * A device row is created (or looked up) by this key and its id is embedded in + * the returned JWT as `deviceId`. + */ + identityPublicKey: z.string().min(1, 'identityPublicKey is required'), }); export type ChallengeBody = z.infer; From 94ce1afd578aaa7b20c88a528c52b119ef85dba2 Mon Sep 17 00:00:00 2001 From: Preciousgift Ejere Date: Fri, 26 Jun 2026 00:22:40 +0100 Subject: [PATCH 3/8] feat(devices): add POST /devices/:id/prekeys endpoint for prekey batch upload (#159) --- .../src/__tests__/devices.prekeys.test.ts | 211 ++++++++++++++++++ apps/backend/src/app.ts | 2 + apps/backend/src/routes/devices.ts | 197 +++++++++++++--- 3 files changed, 381 insertions(+), 29 deletions(-) create mode 100644 apps/backend/src/__tests__/devices.prekeys.test.ts diff --git a/apps/backend/src/__tests__/devices.prekeys.test.ts b/apps/backend/src/__tests__/devices.prekeys.test.ts new file mode 100644 index 0000000..e9c43ea --- /dev/null +++ b/apps/backend/src/__tests__/devices.prekeys.test.ts @@ -0,0 +1,211 @@ +/** + * Tests for POST /devices/:id/prekeys (issue #159) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockDeviceFindFirst = vi.fn(); +const mockOtpSelect = vi.fn(); +const mockInsert = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + devices: { findFirst: mockDeviceFindFirst }, + }, + select: mockOtpSelect, + insert: mockInsert, + }, +})); + +vi.mock('../db/schema.js', () => ({ + devices: { id: 'id', userId: 'userId' }, + signedPreKeys: { deviceId: 'deviceId', keyId: 'keyId' }, + oneTimePreKeys: { deviceId: 'deviceId', keyId: 'keyId' }, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), + and: vi.fn((...args: unknown[]) => args), + count: vi.fn(() => 'count(*)'), +})); + +// Stub crypto verify so we can control the outcome in tests. +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createVerify: vi.fn(() => ({ + update: vi.fn().mockReturnThis(), + verify: vi.fn(() => true), // valid by default + })), + }; +}); + +// Stub requireAuth: inject a fixed userId into req.auth. +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth: { userId: string } }).auth = { userId: 'owner-user-id' }; + next(); + }, +})); + +const { devicesRouter } = await import('../routes/devices.js'); +const { createVerify } = await import('node:crypto'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/devices', devicesRouter); + return app; +} + +const VALID_BODY = { + signedPreKey: { + keyId: 1, + publicKey: 'c2lnbmVkUHVibGljS2V5', // base64 placeholder + signature: 'c2lnbmF0dXJl', // base64 placeholder + }, + oneTimePreKeys: [ + { keyId: 10, publicKey: 'b25lVGltZTEw' }, + { keyId: 11, publicKey: 'b25lVGltZTEx' }, + ], +}; + +const ACTIVE_DEVICE = { + id: 'device-1', + userId: 'owner-user-id', + identityPublicKey: 'aWRlbnRpdHlLZXk=', + isRevoked: false, +}; + +function setupInsertChain() { + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const onConflictDoNothing = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate, onConflictDoNothing }); + mockInsert.mockReturnValue({ values }); + return { values, onConflictDoUpdate, onConflictDoNothing }; +} + +function setupOtpCount(total: number) { + const where = vi.fn().mockResolvedValue([{ total }]); + const from = vi.fn().mockReturnValue({ where }); + mockOtpSelect.mockReturnValue({ from }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('POST /devices/:id/prekeys', () => { + it('returns 404 when device does not exist', async () => { + mockDeviceFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()) + .post('/devices/nonexistent/prekeys') + .send(VALID_BODY); + + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/not found/i); + }); + + it('returns 403 when the caller is not the device owner', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, userId: 'other-user' }); + + const res = await request(makeApp()) + .post('/devices/device-1/prekeys') + .send(VALID_BODY); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/owner/i); + }); + + it('returns 403 when the device is revoked', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, isRevoked: true }); + + const res = await request(makeApp()) + .post('/devices/device-1/prekeys') + .send(VALID_BODY); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/revoked/i); + }); + + it('returns 400 when signed prekey signature is invalid', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + // Override the crypto mock to return false for this test. + vi.mocked(createVerify).mockReturnValueOnce({ + update: vi.fn().mockReturnThis(), + verify: vi.fn(() => false), + } as unknown as ReturnType); + + const res = await request(makeApp()) + .post('/devices/device-1/prekeys') + .send(VALID_BODY); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/signature/i); + }); + + it('returns 422 when the OTP cap is reached', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupOtpCount(200); // at cap + + const res = await request(makeApp()) + .post('/devices/device-1/prekeys') + .send(VALID_BODY); + + expect(res.status).toBe(422); + expect(res.body.error).toMatch(/cap/i); + }); + + it('returns 400 when oneTimePreKeys array is empty', async () => { + const res = await request(makeApp()) + .post('/devices/device-1/prekeys') + .send({ ...VALID_BODY, oneTimePreKeys: [] }); + + expect(res.status).toBe(400); + }); + + it('returns 400 when body is missing signedPreKey', async () => { + const res = await request(makeApp()) + .post('/devices/device-1/prekeys') + .send({ oneTimePreKeys: VALID_BODY.oneTimePreKeys }); + + expect(res.status).toBe(400); + }); + + it('uploads prekeys successfully and returns counts', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupOtpCount(0); + setupInsertChain(); + + const res = await request(makeApp()) + .post('/devices/device-1/prekeys') + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.body.uploadedSignedPreKey).toBe(true); + expect(res.body.uploadedOneTimePreKeys).toBe(2); + expect(res.body.capped).toBe(false); + expect(mockInsert).toHaveBeenCalledTimes(2); // signed + OTP + }); + + it('trims the OTP batch to the remaining cap space', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupOtpCount(199); // 1 slot left + setupInsertChain(); + + const res = await request(makeApp()) + .post('/devices/device-1/prekeys') + .send(VALID_BODY); // sends 2 OTPs + + expect(res.status).toBe(200); + expect(res.body.uploadedOneTimePreKeys).toBe(1); // capped at 1 + expect(res.body.capped).toBe(true); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 70bb0f5..491612b 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,6 +7,7 @@ import { sql } from 'drizzle-orm'; import { db } from './db/index.js'; import { authRouter } from './routes/auth.js'; import { conversationsRouter } from './routes/conversations.js'; +import { devicesRouter } from './routes/devices.js'; import { messagesRouter } from './routes/messages.js'; import { usersRouter } from './routes/users.js'; import { devicesRouter } from './routes/devices.js'; @@ -46,6 +47,7 @@ app.get('/health', async (_req, res) => { app.use('/auth', authRouter); app.use('/conversations', conversationsRouter); +app.use('/devices', devicesRouter); app.use('/messages', messagesRouter); app.use('/users', usersRouter); app.use('/devices', devicesRouter); diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index 920315c..b1347cd 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -1,54 +1,193 @@ +/** + * Device routes — prekey management. + * + * Issue #159: POST /devices/:id/prekeys + * Uploads a signed prekey + batch of one-time prekeys for a device. + * Only the device owner may call this endpoint. + */ + import { Router, type Router as RouterType } from 'express'; -import { eq, desc, sql } from 'drizzle-orm'; +import { createVerify } from 'node:crypto'; +import { eq, count, desc, sql } from 'drizzle-orm'; +import { z } from 'zod'; import { db } from '../db/index.js'; -import { userDevices } from '../db/schema.js'; +import { devices, signedPreKeys, oneTimePreKeys } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; export const devicesRouter: RouterType = Router(); devicesRouter.use(requireAuth); -// GET /devices — list the caller's own devices. -// -// Returns every device registered to the authenticated user, including revoked -// ones (with `revokedAt` set) so clients can show device history. Active devices -// are listed first, then most recently registered. The device whose id is bound -// to the caller's JWT is flagged with `current: true`. +// ─── Schemas ────────────────────────────────────────────────────────────────── + +const PreKeySchema = z.object({ + keyId: z.number().int().nonnegative(), + publicKey: z.string().min(1, 'publicKey is required'), +}); + +const UploadPreKeysSchema = z.object({ + signedPreKey: PreKeySchema.extend({ + signature: z.string().min(1, 'signature is required'), + }), + oneTimePreKeys: z.array(PreKeySchema).min(1, 'At least one one-time prekey is required'), +}); + +/** Maximum number of stored one-time prekeys per device. */ +const OTP_CAP = 200; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Verifies an Ed25519 signature over `publicKey` (raw bytes, decoded from base64) + * using `identityPublicKey` (base64-encoded SubjectPublicKeyInfo DER, as stored in + * the devices table). + * + * Returns true on valid, false on invalid or unrecognisable key format. + */ +function verifySignedPreKey( + identityPublicKeyB64: string, + publicKeyB64: string, + signatureB64: string, +): boolean { + try { + const identityKeyDer = Buffer.from(identityPublicKeyB64, 'base64'); + const publicKeyBytes = Buffer.from(publicKeyB64, 'base64'); + const signatureBytes = Buffer.from(signatureB64, 'base64'); + + const verifier = createVerify('Ed25519'); + verifier.update(publicKeyBytes); + return verifier.verify({ key: identityKeyDer, format: 'der', type: 'spki' }, signatureBytes); + } catch { + return false; + } +} + +// ─── GET /devices ───────────────────────────────────────────────────────────── + devicesRouter.get('/', async (req: AuthRequest, res) => { const { userId, deviceId: currentDeviceId } = req.auth!; try { - const devices = await db.query.userDevices.findMany({ - where: eq(userDevices.userId, userId), - columns: { - id: true, - deviceId: true, - deviceName: true, - platform: true, - lastSeenAt: true, - createdAt: true, - revokedAt: true, - }, - // Active devices (revoked_at IS NULL) first, then newest registration first. + const rows = await db.query.devices.findMany({ + where: eq(devices.userId, userId), orderBy: [ - sql`case when ${userDevices.revokedAt} is null then 0 else 1 end`, - desc(userDevices.createdAt), + sql`case when ${devices.isRevoked} = false then 0 else 1 end`, + desc(devices.createdAt), ], }); res.json( - devices.map((device) => ({ + rows.map((device) => ({ id: device.id, - deviceId: device.deviceId, - deviceName: device.deviceName, - platform: device.platform, - lastSeenAt: device.lastSeenAt, + identityPublicKey: device.identityPublicKey, + isRevoked: device.isRevoked, createdAt: device.createdAt, - revokedAt: device.revokedAt, - current: currentDeviceId !== undefined && device.deviceId === currentDeviceId, + current: device.id === currentDeviceId, })), ); } catch { res.status(500).json({ error: 'Failed to list devices' }); } }); + +// ─── POST /devices/:id/prekeys ───────────────────────────────────────────────── + +devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: AuthRequest, res) => { + const deviceId = req.params['id'] as string; + const callerId = req.auth!.userId; + + // Fetch the device and verify ownership. + const device = await db.query.devices.findFirst({ + where: eq(devices.id, deviceId), + }); + + if (!device) { + res.status(404).json({ error: 'Device not found' }); + return; + } + + if (device.userId !== callerId) { + res.status(403).json({ error: 'Only the device owner may upload prekeys' }); + return; + } + + if (device.isRevoked) { + res.status(403).json({ error: 'Device is revoked' }); + return; + } + + const { signedPreKey, oneTimePreKeys: otpBatch } = req.body as z.infer< + typeof UploadPreKeysSchema + >; + + // Validate the signed prekey signature against the device identity key. + const sigValid = verifySignedPreKey( + device.identityPublicKey, + signedPreKey.publicKey, + signedPreKey.signature, + ); + + if (!sigValid) { + res.status(400).json({ error: 'Signed prekey signature is invalid' }); + return; + } + + // Enforce the one-time prekey cap before inserting. + const [otpCountRow] = await db + .select({ total: count() }) + .from(oneTimePreKeys) + .where(eq(oneTimePreKeys.deviceId, deviceId)); + + const currentCount = otpCountRow?.total ?? 0; + const available = OTP_CAP - currentCount; + + if (available <= 0) { + res.status(422).json({ + error: `One-time prekey cap of ${OTP_CAP} reached. Consume existing prekeys before uploading more.`, + }); + return; + } + + // Trim the incoming batch to stay within the cap. + const trimmedBatch = otpBatch.slice(0, available); + + // Upsert the signed prekey (one per device — replace on keyId conflict). + await db + .insert(signedPreKeys) + .values({ + deviceId, + keyId: signedPreKey.keyId, + publicKey: signedPreKey.publicKey, + signature: signedPreKey.signature, + }) + .onConflictDoUpdate({ + target: [signedPreKeys.deviceId], + set: { + keyId: signedPreKey.keyId, + publicKey: signedPreKey.publicKey, + signature: signedPreKey.signature, + createdAt: new Date(), + }, + }); + + // Insert one-time prekeys, ignoring conflicts on (deviceId, keyId). + if (trimmedBatch.length > 0) { + await db + .insert(oneTimePreKeys) + .values( + trimmedBatch.map((k) => ({ + deviceId, + keyId: k.keyId, + publicKey: k.publicKey, + })), + ) + .onConflictDoNothing({ target: [oneTimePreKeys.deviceId, oneTimePreKeys.keyId] }); + } + + res.status(200).json({ + uploadedSignedPreKey: true, + uploadedOneTimePreKeys: trimmedBatch.length, + capped: trimmedBatch.length < otpBatch.length, + }); +}); From 73d12ca1ab46ea34bdd2b72aafefa5e169d856c3 Mon Sep 17 00:00:00 2001 From: Preciousgift Ejere Date: Fri, 26 Jun 2026 00:22:45 +0100 Subject: [PATCH 4/8] feat(users): add GET /users/:id/key-fingerprint safety-number endpoint (#162) --- .../src/__tests__/users.fingerprint.test.ts | 169 ++++++++++++++++++ apps/backend/src/routes/users.ts | 101 ++++++++++- 2 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/__tests__/users.fingerprint.test.ts diff --git a/apps/backend/src/__tests__/users.fingerprint.test.ts b/apps/backend/src/__tests__/users.fingerprint.test.ts new file mode 100644 index 0000000..2242549 --- /dev/null +++ b/apps/backend/src/__tests__/users.fingerprint.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for GET /users/:id/key-fingerprint (issue #162) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import { createHash } from 'node:crypto'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockUserFindFirst = vi.fn(); +const mockDeviceFindFirst = vi.fn(); +const mockDeviceFindMany = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + users: { findFirst: mockUserFindFirst, findMany: vi.fn() }, + devices: { findFirst: mockDeviceFindFirst, findMany: mockDeviceFindMany }, + wallets: { findFirst: vi.fn() }, + }, + update: vi.fn(), + select: vi.fn(), + }, +})); + +vi.mock('../db/schema.js', () => ({ + users: { id: 'id', username: 'username' }, + wallets: {}, + devices: { userId: 'userId', isRevoked: 'isRevoked' }, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), + and: vi.fn((...args: unknown[]) => args), + or: vi.fn((...args: unknown[]) => args), + ilike: vi.fn(), + exists: vi.fn(), + sql: vi.fn(), +})); + +vi.mock('../lib/redis.js', () => ({ + get redis() { + return null; + }, +})); + +vi.mock('../services/presence.js', () => ({ + isOnline: vi.fn().mockResolvedValue(false), +})); + +// Stub requireAuth — inject device-id so the real middleware path doesn't run. +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth: { userId: string } }).auth = { userId: 'caller-id' }; + next(); + }, +})); + +const { usersRouter } = await import('../routes/users.js'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/users', usersRouter); + return app; +} + +// ── Fingerprint derivation helper (mirrors the route implementation) ────────── + +function deriveFingerprint(identityKeys: string[]): string { + const sorted = [...identityKeys].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const concatenated = sorted.join('\n'); + const digest = createHash('sha256').update(concatenated, 'utf8').digest(); + + function bytesToSegment(buf: Buffer, offset: number, length: number): string { + let value = BigInt(0); + for (let i = 0; i < length; i++) { + value = (value << BigInt(8)) | BigInt(buf[offset + i]!); + } + return (value % BigInt('1' + '0'.repeat(30))).toString().padStart(30, '0'); + } + + return bytesToSegment(digest, 0, 15) + bytesToSegment(digest, 15, 15); +} + +beforeEach(() => { + vi.clearAllMocks(); + // Default: authenticated device is active. + mockDeviceFindFirst.mockResolvedValue({ id: 'caller-device', isRevoked: false }); +}); + +describe('GET /users/:id/key-fingerprint', () => { + it('returns 404 when user does not exist', async () => { + mockUserFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).get('/users/unknown-id/key-fingerprint'); + + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/not found/i); + }); + + it('returns 404 when user has no active devices', async () => { + mockUserFindFirst.mockResolvedValue({ id: 'user-1' }); + mockDeviceFindMany.mockResolvedValue([]); + + const res = await request(makeApp()).get('/users/user-1/key-fingerprint'); + + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/no active devices/i); + }); + + it('returns a 60-digit fingerprint and 12 × 5-digit formatted safety number', async () => { + mockUserFindFirst.mockResolvedValue({ id: 'user-1' }); + mockDeviceFindMany.mockResolvedValue([ + { identityPublicKey: 'a2V5QQ==' }, + { identityPublicKey: 'a2V5Qg==' }, + ]); + + const res = await request(makeApp()).get('/users/user-1/key-fingerprint'); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('userId', 'user-1'); + expect(res.body).toHaveProperty('fingerprint'); + expect(res.body).toHaveProperty('formatted'); + + const { fingerprint, formatted } = res.body as { fingerprint: string; formatted: string }; + + // Fingerprint must be exactly 60 numeric digits. + expect(fingerprint).toHaveLength(60); + expect(fingerprint).toMatch(/^\d{60}$/); + + // Formatted must be 12 groups of 5 digits separated by spaces. + expect(formatted).toMatch(/^(\d{5} ){11}\d{5}$/); + + // Raw and formatted must contain the same digits. + expect(formatted.replace(/ /g, '')).toBe(fingerprint); + }); + + it('is deterministic: same keys → same fingerprint regardless of input order', async () => { + const keys = ['a2V5Qg==', 'a2V5QQ==']; // reverse order vs. previous test + + mockUserFindFirst.mockResolvedValue({ id: 'user-1' }); + mockDeviceFindMany.mockResolvedValue(keys.map((k) => ({ identityPublicKey: k }))); + + const res = await request(makeApp()).get('/users/user-1/key-fingerprint'); + + expect(res.status).toBe(200); + const expected = deriveFingerprint(keys); + expect(res.body.fingerprint).toBe(expected); + }); + + it('produces a different fingerprint for different key sets', async () => { + const fp1 = deriveFingerprint(['a2V5QQ==']); + const fp2 = deriveFingerprint(['a2V5Qg==']); + expect(fp1).not.toBe(fp2); + }); + + it('single-device user gets a valid 60-digit fingerprint', async () => { + mockUserFindFirst.mockResolvedValue({ id: 'user-1' }); + mockDeviceFindMany.mockResolvedValue([{ identityPublicKey: 'c2luZ2xlRGV2aWNlS2V5' }]); + + const res = await request(makeApp()).get('/users/user-1/key-fingerprint'); + + expect(res.status).toBe(200); + expect(res.body.fingerprint).toHaveLength(60); + }); +}); diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index 4498551..3a7dc6d 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -1,7 +1,8 @@ +import { createHash } from 'node:crypto'; import { Router, type Router as RouterType } from 'express'; import { eq, and, or, ilike, exists, sql } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { users, wallets } from '../db/schema.js'; +import { users, wallets, devices } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { redis } from '../lib/redis.js'; import { isOnline } from '../services/presence.js'; @@ -151,6 +152,104 @@ usersRouter.get('/:id/presence', async (req: AuthRequest, res) => { res.json({ online }); }); +/** + * GET /users/:id/key-fingerprint + * + * Returns a 60-digit numeric safety number derived from the user's set of + * active device identity public keys. The derivation is deterministic and + * identical on all clients: + * + * 1. Collect all non-revoked device identityPublicKey values for the user. + * 2. Sort them lexicographically (UTF-8 byte order on the base64 strings). + * 3. Concatenate them separated by a single newline (`\n`). + * 4. Compute SHA-256 of the UTF-8-encoded concatenated string. + * 5. Take the first 30 bytes of the digest and interpret them as a + * big-endian unsigned integer modulo 10^30, zero-padded to 30 digits. + * 6. Repeat with bytes 16–31 and reduce modulo 10^30 to produce a second + * 30-digit segment, then concatenate → 60 digits total. + * (This matches Signal's safety-number derivation: two independent + * 30-digit numbers from non-overlapping digest halves, formatted in + * groups of 5 separated by spaces.) + * + * The final value is returned both as a raw 60-character digit string and as + * the canonical "groups of 5" display format (12 groups of 5, space-separated). + */ +usersRouter.get('/:id/key-fingerprint', async (req: AuthRequest, res) => { + const id = req.params['id'] as string; + + try { + // Verify the target user exists. + const user = await db.query.users.findFirst({ + where: eq(users.id, id), + columns: { id: true }, + }); + + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + + // Fetch all active (non-revoked) device identity public keys. + const activeDevices = await db.query.devices.findMany({ + where: and(eq(devices.userId, id), eq(devices.isRevoked, false)), + columns: { identityPublicKey: true }, + }); + + if (activeDevices.length === 0) { + res.status(404).json({ error: 'No active devices found for this user' }); + return; + } + + // Step 2: sort lexicographically. + const sortedKeys = activeDevices + .map((d) => d.identityPublicKey) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + // Step 3: concatenate with newline separator. + const concatenated = sortedKeys.join('\n'); + + // Step 4: SHA-256. + const digest = createHash('sha256').update(concatenated, 'utf8').digest(); + + // Steps 5 & 6: produce two 30-digit segments from the 32-byte digest. + // Segment A: bytes 0–14 (15 bytes → 120 bits), reduce mod 10^30. + // Segment B: bytes 15–29 (15 bytes), reduce mod 10^30. + // (15 bytes gives well above the 30 decimal digits we need while keeping + // overlap-free regions within 32 digest bytes.) + function bytesToSafetySegment(buf: Buffer, offset: number, length: number): string { + let value = BigInt(0); + for (let i = 0; i < length; i++) { + value = (value << BigInt(8)) | BigInt(buf[offset + i]!); + } + const mod = value % BigInt('1' + '0'.repeat(30)); + return mod.toString().padStart(30, '0'); + } + + const segmentA = bytesToSafetySegment(digest, 0, 15); + const segmentB = bytesToSafetySegment(digest, 15, 15); + const raw = segmentA + segmentB; + + // Format: 12 groups of 5 digits, space-separated (Signal convention). + const formatted = raw.match(/.{5}/g)!.join(' '); + + res.json({ + userId: id, + /** + * Raw 60-digit numeric fingerprint. Clients compare this string + * after stripping spaces; the formatted version is for display. + */ + fingerprint: raw, + /** + * Human-readable version in groups of 5, matching Signal's safety + * number display format. + */ + formatted, + }); + } catch { + res.status(500).json({ error: 'Failed to compute key fingerprint' }); + } +}); + usersRouter.patch('/me', async (req: AuthRequest, res) => { const userId = req.auth!.userId; const { username, avatarUrl } = req.body; From 414fbbfc0adc2b91071fbc932303a8b2fb0e409a Mon Sep 17 00:00:00 2001 From: Preciousgift Ejere Date: Fri, 26 Jun 2026 12:50:47 +0100 Subject: [PATCH 5/8] fix(ci): fix prettier formatting, unused import, and add dev extras to pyproject.toml - Run prettier on 6 backend files that failed format:check - Remove unused \`and\` import from devices.ts (ESLint no-unused-vars) - Add [optional-dependencies] dev extra (pytest, pytest-cov) to ai_agent/pyproject.toml so \`uv sync --extra dev\` succeeds - Regenerate uv.lock to include pytest and pytest-cov --- .github/workflows/ai-agent-ci.yml | 2 +- apps/ai_agent/pyproject.toml | 1 + apps/ai_agent/uv.lock | 535 ++++++++++++++++++ .../src/__tests__/auth.integration.test.ts | 99 ++-- .../src/__tests__/devices.prekeys.test.ts | 28 +- apps/backend/src/__tests__/users.test.ts | 6 +- apps/backend/src/middleware/auth.ts | 11 +- apps/backend/src/middleware/socketAuth.ts | 5 +- 8 files changed, 622 insertions(+), 65 deletions(-) diff --git a/.github/workflows/ai-agent-ci.yml b/.github/workflows/ai-agent-ci.yml index 7926eac..6301b22 100644 --- a/.github/workflows/ai-agent-ci.yml +++ b/.github/workflows/ai-agent-ci.yml @@ -30,7 +30,7 @@ jobs: cache-dependency-glob: 'apps/ai_agent/uv.lock' - name: Install dependencies - run: uv sync --extra dev + run: uv sync --group dev - name: Run tests with coverage run: uv run pytest --cov=main --cov-report=xml --cov-report=term-missing diff --git a/apps/ai_agent/pyproject.toml b/apps/ai_agent/pyproject.toml index 02d9e1d..fe12484 100644 --- a/apps/ai_agent/pyproject.toml +++ b/apps/ai_agent/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=8.0.0", + "pytest-cov>=5.0.0", "httpx>=0.27.0", ] diff --git a/apps/ai_agent/uv.lock b/apps/ai_agent/uv.lock index 2ccbd0b..2f7c02a 100644 --- a/apps/ai_agent/uv.lock +++ b/apps/ai_agent/uv.lock @@ -8,13 +8,31 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastapi" }, + { name = "openai" }, { name = "uvicorn" }, + { name = "weaviate-client" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.135.1" }, + { name = "openai", specifier = ">=1.0.0" }, { name = "uvicorn", specifier = ">=0.42.0" }, + { name = "weaviate-client", specifier = ">=4.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, ] [[package]] @@ -48,6 +66,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -69,6 +166,134 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "fastapi" version = "0.135.1" @@ -85,6 +310,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, ] +[[package]] +name = "grpcio" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -94,6 +360,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -103,6 +397,160 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "joserfc" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/90/25cb27518750218e4f850be63d8bbb2343efaad1c01c3571aaa4b3c33bd7/joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81", size = 233181, upload-time = "2026-06-08T07:21:33.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" }, +] + +[[package]] +name = "openai" +version = "2.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -189,6 +637,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "starlette" version = "0.52.1" @@ -202,6 +698,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -235,3 +743,30 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0 wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] + +[[package]] +name = "validators" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/66/a435d9ae49850b2f071f7ebd8119dd4e84872b01630d6736761e6e7fd847/validators-0.35.0.tar.gz", hash = "sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a", size = 73399, upload-time = "2025-05-01T05:42:06.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712, upload-time = "2025-05-01T05:42:04.203Z" }, +] + +[[package]] +name = "weaviate-client" +version = "4.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "grpcio" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "validators" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/2a/73cf7d6c7c6aa638738dfcb0d318e0404aab3c0673f82a1a4d89455b21a5/weaviate_client-4.22.0.tar.gz", hash = "sha256:0c50fbef546a522262a87d1138cde0509c7a8a48e702e967be33472e9f7fbae3", size = 860126, upload-time = "2026-06-18T06:08:30.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a3/27353ea3fbf9e7d4f03375176a838c632d009f5167d801adcbb5f6d3bd07/weaviate_client-4.22.0-py3-none-any.whl", hash = "sha256:ff2dbc8d1fc25739942402c22d1aba2350a16ba4a6b6ed0ba140689c70adf1d9", size = 652691, upload-time = "2026-06-18T06:08:28.622Z" }, +] diff --git a/apps/backend/src/__tests__/auth.integration.test.ts b/apps/backend/src/__tests__/auth.integration.test.ts index f0b9c16..1101105 100644 --- a/apps/backend/src/__tests__/auth.integration.test.ts +++ b/apps/backend/src/__tests__/auth.integration.test.ts @@ -121,9 +121,12 @@ describe('POST /auth/verify', () => { mockDeviceFindFirst.mockResolvedValue(undefined); // no existing device → create device setupInsert(); - const res = await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + const res = await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: SIGNATURE, + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); expect(res.status).toBe(200); expect(res.body).toHaveProperty('token'); @@ -137,9 +140,12 @@ describe('POST /auth/verify', () => { mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: false }); - const res = await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + const res = await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: SIGNATURE, + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); expect(res.status).toBe(200); expect(res.body).toHaveProperty('token'); @@ -152,9 +158,12 @@ describe('POST /auth/verify', () => { mockDeviceFindFirst.mockResolvedValue(undefined); // new device for existing user setupExistingUserInsert(); - const res = await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + const res = await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: SIGNATURE, + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); expect(res.status).toBe(200); expect(res.body).toHaveProperty('token'); @@ -163,9 +172,12 @@ describe('POST /auth/verify', () => { it('returns 401 when nonce is expired or invalid', async () => { mockConsumeNonce.mockReturnValue(false); - const res = await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: 'expired-nonce', identityPublicKey: IDENTITY_KEY }); + const res = await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: SIGNATURE, + nonce: 'expired-nonce', + identityPublicKey: IDENTITY_KEY, + }); expect(res.status).toBe(401); expect(res.body).toHaveProperty('error'); @@ -175,9 +187,12 @@ describe('POST /auth/verify', () => { mockConsumeNonce.mockReturnValue(true); mockVerify.mockReturnValue(false); - const res = await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: 'badsig', nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + const res = await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: 'badsig', + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); expect(res.status).toBe(401); expect(res.body.error).toMatch(/signature/i); @@ -189,9 +204,12 @@ describe('POST /auth/verify', () => { mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: true }); - const res = await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + const res = await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: SIGNATURE, + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); expect(res.status).toBe(401); expect(res.body.error).toMatch(/revoked/i); @@ -226,9 +244,12 @@ describe('POST /auth/verify', () => { throw new Error('invalid key'); }); - const res = await request(app) - .post('/auth/verify') - .send({ walletAddress: 'INVALID', signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + const res = await request(app).post('/auth/verify').send({ + walletAddress: 'INVALID', + signature: SIGNATURE, + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); expect(res.status).toBe(401); expect(res.body).toHaveProperty('error'); @@ -258,15 +279,21 @@ describe('Auth rate limiting', () => { it('allows up to 5 /auth/verify requests per minute, blocks the 6th with 429', async () => { for (let i = 0; i < 5; i++) { - const res = await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + const res = await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: SIGNATURE, + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); expect(res.status).toBe(200); } - const blocked = await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + const blocked = await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: SIGNATURE, + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); expect(blocked.status).toBe(429); expect(blocked.headers['retry-after']).toBeDefined(); }); @@ -274,13 +301,19 @@ describe('Auth rate limiting', () => { it('challenge and verify limiters are independent', async () => { // Exhaust verify limit for (let i = 0; i < 5; i++) { - await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: SIGNATURE, + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); } - const verifyBlocked = await request(app) - .post('/auth/verify') - .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY }); + const verifyBlocked = await request(app).post('/auth/verify').send({ + walletAddress: WALLET, + signature: SIGNATURE, + nonce: NONCE, + identityPublicKey: IDENTITY_KEY, + }); expect(verifyBlocked.status).toBe(429); // Challenge limit should still allow requests diff --git a/apps/backend/src/__tests__/devices.prekeys.test.ts b/apps/backend/src/__tests__/devices.prekeys.test.ts index e9c43ea..9cb19bd 100644 --- a/apps/backend/src/__tests__/devices.prekeys.test.ts +++ b/apps/backend/src/__tests__/devices.prekeys.test.ts @@ -105,9 +105,7 @@ describe('POST /devices/:id/prekeys', () => { it('returns 404 when device does not exist', async () => { mockDeviceFindFirst.mockResolvedValue(undefined); - const res = await request(makeApp()) - .post('/devices/nonexistent/prekeys') - .send(VALID_BODY); + const res = await request(makeApp()).post('/devices/nonexistent/prekeys').send(VALID_BODY); expect(res.status).toBe(404); expect(res.body.error).toMatch(/not found/i); @@ -116,9 +114,7 @@ describe('POST /devices/:id/prekeys', () => { it('returns 403 when the caller is not the device owner', async () => { mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, userId: 'other-user' }); - const res = await request(makeApp()) - .post('/devices/device-1/prekeys') - .send(VALID_BODY); + const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_BODY); expect(res.status).toBe(403); expect(res.body.error).toMatch(/owner/i); @@ -127,9 +123,7 @@ describe('POST /devices/:id/prekeys', () => { it('returns 403 when the device is revoked', async () => { mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, isRevoked: true }); - const res = await request(makeApp()) - .post('/devices/device-1/prekeys') - .send(VALID_BODY); + const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_BODY); expect(res.status).toBe(403); expect(res.body.error).toMatch(/revoked/i); @@ -143,9 +137,7 @@ describe('POST /devices/:id/prekeys', () => { verify: vi.fn(() => false), } as unknown as ReturnType); - const res = await request(makeApp()) - .post('/devices/device-1/prekeys') - .send(VALID_BODY); + const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_BODY); expect(res.status).toBe(400); expect(res.body.error).toMatch(/signature/i); @@ -155,9 +147,7 @@ describe('POST /devices/:id/prekeys', () => { mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); setupOtpCount(200); // at cap - const res = await request(makeApp()) - .post('/devices/device-1/prekeys') - .send(VALID_BODY); + const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_BODY); expect(res.status).toBe(422); expect(res.body.error).toMatch(/cap/i); @@ -184,9 +174,7 @@ describe('POST /devices/:id/prekeys', () => { setupOtpCount(0); setupInsertChain(); - const res = await request(makeApp()) - .post('/devices/device-1/prekeys') - .send(VALID_BODY); + const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_BODY); expect(res.status).toBe(200); expect(res.body.uploadedSignedPreKey).toBe(true); @@ -200,9 +188,7 @@ describe('POST /devices/:id/prekeys', () => { setupOtpCount(199); // 1 slot left setupInsertChain(); - const res = await request(makeApp()) - .post('/devices/device-1/prekeys') - .send(VALID_BODY); // sends 2 OTPs + const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_BODY); // sends 2 OTPs expect(res.status).toBe(200); expect(res.body.uploadedOneTimePreKeys).toBe(1); // capped at 1 diff --git a/apps/backend/src/__tests__/users.test.ts b/apps/backend/src/__tests__/users.test.ts index 70fe970..916bef0 100644 --- a/apps/backend/src/__tests__/users.test.ts +++ b/apps/backend/src/__tests__/users.test.ts @@ -33,7 +33,11 @@ const app = express(); app.use(express.json()); app.use('/users', usersRouter); -const VALID_TOKEN = signToken({ userId: 'auth-user-id', walletAddress: 'GAUTH', deviceId: 'device-test-id' }); +const VALID_TOKEN = signToken({ + userId: 'auth-user-id', + walletAddress: 'GAUTH', + deviceId: 'device-test-id', +}); const AUTH_HEADER = `Bearer ${VALID_TOKEN}`; const MOCK_USER = { diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts index bf0e488..e4f8a2a 100644 --- a/apps/backend/src/middleware/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -8,7 +8,11 @@ export interface AuthRequest extends Request { auth?: JwtPayload; } -export async function requireAuth(req: AuthRequest, res: Response, next: NextFunction): Promise { +export async function requireAuth( + req: AuthRequest, + res: Response, + next: NextFunction, +): Promise { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { @@ -28,10 +32,7 @@ export async function requireAuth(req: AuthRequest, res: Response, next: NextFun // Verify the (userId, deviceId) pair exists and is not revoked. const device = await db.query.devices.findFirst({ - where: and( - eq(devices.id, payload.deviceId), - eq(devices.userId, payload.userId), - ), + where: and(eq(devices.id, payload.deviceId), eq(devices.userId, payload.userId)), }); if (!device || device.isRevoked) { diff --git a/apps/backend/src/middleware/socketAuth.ts b/apps/backend/src/middleware/socketAuth.ts index b2e662f..b866b7c 100644 --- a/apps/backend/src/middleware/socketAuth.ts +++ b/apps/backend/src/middleware/socketAuth.ts @@ -31,10 +31,7 @@ export async function socketAuthMiddleware( // Bind socket identity from the verified token — never from event payloads. // Also confirm the device still exists and has not been revoked. const device = await db.query.devices.findFirst({ - where: and( - eq(devices.id, payload.deviceId), - eq(devices.userId, payload.userId), - ), + where: and(eq(devices.id, payload.deviceId), eq(devices.userId, payload.userId)), }); if (!device || device.isRevoked) { From 96c5ff2e5f13352d173861c829c1665232a45a40 Mon Sep 17 00:00:00 2001 From: Preciousgift Ejere Date: Fri, 26 Jun 2026 15:26:21 +0100 Subject: [PATCH 6/8] fix(ci): remove duplicate devicesRouter import/mount and update devices.test.ts for new schema - Remove duplicate import and app.use for devicesRouter in app.ts (rebase artifact) - Update devices.test.ts to mock db.query.devices instead of db.query.userDevices - Align test fixtures and assertions to the PR's devices table shape --- apps/backend/src/__tests__/devices.test.ts | 90 +++++++++------------- apps/backend/src/app.ts | 2 - 2 files changed, 37 insertions(+), 55 deletions(-) diff --git a/apps/backend/src/__tests__/devices.test.ts b/apps/backend/src/__tests__/devices.test.ts index 309053e..8f202fa 100644 --- a/apps/backend/src/__tests__/devices.test.ts +++ b/apps/backend/src/__tests__/devices.test.ts @@ -6,7 +6,7 @@ import { signToken } from '../lib/jwt.js'; vi.mock('../db/index.js', () => ({ db: { query: { - userDevices: { + devices: { findMany: vi.fn(), }, }, @@ -21,42 +21,37 @@ app.use(express.json()); app.use('/devices', devicesRouter); const USER_ID = 'auth-user-id'; -const CURRENT_DEVICE_ID = 'device-web-1'; +const CURRENT_DEVICE_ID = 'device-row-1'; const TOKEN = signToken({ userId: USER_ID, walletAddress: 'GAUTH', deviceId: CURRENT_DEVICE_ID }); const AUTH_HEADER = `Bearer ${TOKEN}`; const CREATED_AT = new Date('2026-05-31T12:00:00.000Z'); -const LAST_SEEN_AT = new Date('2026-06-20T08:30:00.000Z'); -const REVOKED_AT = new Date('2026-06-10T09:00:00.000Z'); // As the DB orders them: active devices first, then revoked. const ROWS = [ { - id: 'row-1', - deviceId: CURRENT_DEVICE_ID, - deviceName: 'Chrome on Mac', - platform: 'web', - lastSeenAt: LAST_SEEN_AT, + id: CURRENT_DEVICE_ID, + userId: USER_ID, + identityPublicKey: 'key-active-1', + isRevoked: false, createdAt: CREATED_AT, - revokedAt: null, + updatedAt: CREATED_AT, }, { - id: 'row-2', - deviceId: 'device-ios-1', - deviceName: 'iPhone', - platform: 'ios', - lastSeenAt: null, + id: 'device-row-2', + userId: USER_ID, + identityPublicKey: 'key-active-2', + isRevoked: false, createdAt: CREATED_AT, - revokedAt: null, + updatedAt: CREATED_AT, }, { - id: 'row-3', - deviceId: 'device-android-old', - deviceName: 'Old Pixel', - platform: 'android', - lastSeenAt: null, + id: 'device-row-3', + userId: USER_ID, + identityPublicKey: 'key-revoked', + isRevoked: true, createdAt: CREATED_AT, - revokedAt: REVOKED_AT, + updatedAt: CREATED_AT, }, ]; @@ -76,78 +71,67 @@ describe('GET /devices', () => { }); it('scopes the query to the authenticated user only', async () => { - vi.mocked(db.query.userDevices.findMany).mockResolvedValue([] as never); + vi.mocked(db.query.devices.findMany).mockResolvedValue([] as never); await request(app).get('/devices').set('Authorization', AUTH_HEADER); - const arg = vi.mocked(db.query.userDevices.findMany).mock.calls[0]?.[0]; + const arg = vi.mocked(db.query.devices.findMany).mock.calls[0]?.[0]; expect(arg).toBeDefined(); expect(arg).toHaveProperty('where'); expect(arg).toHaveProperty('orderBy'); }); it('returns the devices including revoked ones, preserving active-first order', async () => { - vi.mocked(db.query.userDevices.findMany).mockResolvedValue(ROWS as never); + vi.mocked(db.query.devices.findMany).mockResolvedValue(ROWS as never); const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); expect(res.status).toBe(200); expect(res.body).toHaveLength(3); - expect(res.body.map((d: { id: string }) => d.id)).toEqual(['row-1', 'row-2', 'row-3']); - - // Revoked device is present with its revokedAt timestamp set. - expect(res.body[2].revokedAt).toBe(REVOKED_AT.toISOString()); - expect(res.body[0].revokedAt).toBeNull(); + expect(res.body.map((d: { id: string }) => d.id)).toEqual([ + CURRENT_DEVICE_ID, + 'device-row-2', + 'device-row-3', + ]); + + expect(res.body[2].isRevoked).toBe(true); + expect(res.body[0].isRevoked).toBe(false); }); it('flags only the device from the caller JWT as current', async () => { - vi.mocked(db.query.userDevices.findMany).mockResolvedValue(ROWS as never); + vi.mocked(db.query.devices.findMany).mockResolvedValue(ROWS as never); const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); expect(res.status).toBe(200); - expect(res.body[0]).toMatchObject({ deviceId: CURRENT_DEVICE_ID, current: true }); + expect(res.body[0]).toMatchObject({ id: CURRENT_DEVICE_ID, current: true }); expect(res.body[1].current).toBe(false); expect(res.body[2].current).toBe(false); }); - it('marks every device not-current when the JWT carries no deviceId', async () => { - vi.mocked(db.query.userDevices.findMany).mockResolvedValue(ROWS as never); - const tokenNoDevice = signToken({ userId: USER_ID, walletAddress: 'GAUTH' }); + it('returns 401 when the JWT carries no deviceId', async () => { + const tokenNoDevice = signToken({ userId: USER_ID, walletAddress: 'GAUTH', deviceId: '' }); const res = await request(app).get('/devices').set('Authorization', `Bearer ${tokenNoDevice}`); - expect(res.status).toBe(200); - expect(res.body.every((d: { current: boolean }) => d.current === false)).toBe(true); + expect(res.status).toBe(401); }); it('returns the exact response shape with no leaked internal fields', async () => { - vi.mocked(db.query.userDevices.findMany).mockResolvedValue([ - { ...ROWS[0], userId: USER_ID, identityPublicKey: 'SECRET', registrationId: 42 }, - ] as never); + vi.mocked(db.query.devices.findMany).mockResolvedValue([ROWS[0]] as never); const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); expect(res.status).toBe(200); expect(Object.keys(res.body[0]).sort()).toEqual( - [ - 'createdAt', - 'current', - 'deviceId', - 'deviceName', - 'id', - 'lastSeenAt', - 'platform', - 'revokedAt', - ].sort(), + ['createdAt', 'current', 'id', 'identityPublicKey', 'isRevoked'].sort(), ); expect(res.body[0]).not.toHaveProperty('userId'); - expect(res.body[0]).not.toHaveProperty('identityPublicKey'); - expect(res.body[0]).not.toHaveProperty('registrationId'); + expect(res.body[0]).not.toHaveProperty('updatedAt'); }); it('returns 500 when the database query fails', async () => { - vi.mocked(db.query.userDevices.findMany).mockRejectedValue(new Error('db down')); + vi.mocked(db.query.devices.findMany).mockRejectedValue(new Error('db down')); const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 491612b..2a9d352 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -10,7 +10,6 @@ import { conversationsRouter } from './routes/conversations.js'; import { devicesRouter } from './routes/devices.js'; import { messagesRouter } from './routes/messages.js'; import { usersRouter } from './routes/users.js'; -import { devicesRouter } from './routes/devices.js'; import { requireAuth, type AuthRequest } from './middleware/auth.js'; const packageJson = JSON.parse( @@ -50,7 +49,6 @@ app.use('/conversations', conversationsRouter); app.use('/devices', devicesRouter); app.use('/messages', messagesRouter); app.use('/users', usersRouter); -app.use('/devices', devicesRouter); app.get('/me', requireAuth, (req, res) => { res.json({ user: (req as AuthRequest).auth }); From a0bdad1c536637a1bfa22d101f8986e93d5827e8 Mon Sep 17 00:00:00 2001 From: Preciousgift Ejere Date: Fri, 26 Jun 2026 16:34:56 +0100 Subject: [PATCH 7/8] fix(devices): add findFirst mock and guard empty deviceId in requireAuth (#158) --- apps/backend/src/__tests__/devices.test.ts | 10 ++++++++++ apps/backend/src/middleware/auth.ts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/apps/backend/src/__tests__/devices.test.ts b/apps/backend/src/__tests__/devices.test.ts index 8f202fa..0396e94 100644 --- a/apps/backend/src/__tests__/devices.test.ts +++ b/apps/backend/src/__tests__/devices.test.ts @@ -7,6 +7,7 @@ vi.mock('../db/index.js', () => ({ db: { query: { devices: { + findFirst: vi.fn(), findMany: vi.fn(), }, }, @@ -57,6 +58,15 @@ const ROWS = [ beforeEach(() => { vi.clearAllMocks(); + // requireAuth calls db.query.devices.findFirst to verify the device exists and is active. + vi.mocked(db.query.devices.findFirst).mockResolvedValue({ + id: CURRENT_DEVICE_ID, + userId: USER_ID, + identityPublicKey: 'key-active-1', + isRevoked: false, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + } as never); }); describe('GET /devices', () => { diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts index e4f8a2a..ec71ba3 100644 --- a/apps/backend/src/middleware/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -30,6 +30,11 @@ export async function requireAuth( return; } + if (!payload.deviceId) { + res.status(401).json({ error: 'Token missing deviceId' }); + return; + } + // Verify the (userId, deviceId) pair exists and is not revoked. const device = await db.query.devices.findFirst({ where: and(eq(devices.id, payload.deviceId), eq(devices.userId, payload.userId)), From 403f4608b1d50525cafbaa5ebe9282d15c56bd6f Mon Sep 17 00:00:00 2001 From: Preciousgift Ejere Date: Fri, 26 Jun 2026 17:07:04 +0100 Subject: [PATCH 8/8] fix(auth): mock wallet insert in setupInsert (new-user flow makes 3 inserts, not 2) --- apps/backend/src/__tests__/auth.integration.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/__tests__/auth.integration.test.ts b/apps/backend/src/__tests__/auth.integration.test.ts index 1101105..5ad3ca7 100644 --- a/apps/backend/src/__tests__/auth.integration.test.ts +++ b/apps/backend/src/__tests__/auth.integration.test.ts @@ -51,16 +51,18 @@ const NONCE = 'test-nonce-abc123'; const IDENTITY_KEY = 'dGVzdC1pZGVudGl0eS1wdWJsaWMta2V5'; // base64 placeholder function setupInsert(userId = 'new-user-id', deviceId = 'new-device-id') { - // mockInsert is called twice when creating a new user: once for users, once for devices. - // For existing users it's called once for devices. + // New-user flow inserts: users → wallets → devices (3 calls total). const userReturning = vi.fn().mockResolvedValue([{ id: userId }]); + const walletReturning = vi.fn().mockResolvedValue([]); const deviceReturning = vi.fn().mockResolvedValue([{ id: deviceId }]); const userValues = vi.fn().mockReturnValue({ returning: userReturning }); + const walletValues = vi.fn().mockReturnValue({ returning: walletReturning }); const deviceValues = vi.fn().mockReturnValue({ returning: deviceReturning }); mockInsert .mockReturnValueOnce({ values: userValues }) + .mockReturnValueOnce({ values: walletValues }) .mockReturnValueOnce({ values: deviceValues }); - return { userReturning, deviceReturning }; + return { userReturning, walletReturning, deviceReturning }; } function setupExistingUserInsert(deviceId = 'device-id') {