mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-20 09:09:47 -05:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f475da2176 | ||
|
|
8c887a4515 | ||
|
|
ed58a6e3c4 | ||
|
|
07be532f00 | ||
|
|
b4d7955eaa | ||
|
|
0f3a3918c1 | ||
|
|
3466a644ba | ||
|
|
07396abbcb | ||
|
|
c0a960cbe3 | ||
|
|
7cc84b32f5 | ||
|
|
5ef6401e2b | ||
|
|
4260a40bc7 | ||
|
|
95f3acea6b | ||
|
|
e4563e58c7 | ||
|
|
a46cad036f | ||
|
|
e0074e344b | ||
|
|
e509378392 |
No files matched your search
@@ -84,3 +84,54 @@ SPOOLMAN_PORT=7912
|
||||
# SPOOLMAN_CORS_ORIGIN=source1.domain.com:p1, source2.domain.com:p2
|
||||
# to allow from any
|
||||
# SPOOLMAN_CORS_ORIGIN=*
|
||||
|
||||
#
|
||||
# --- Authentication (opt-in) ---
|
||||
#
|
||||
# Spoolman has no authentication unless you switch it on here. With
|
||||
# SPOOLMAN_AUTH_ENABLED unset, every endpoint stays open to anyone who can reach
|
||||
# the port, exactly as before.
|
||||
#
|
||||
|
||||
# Enforce authentication on the API and the web UI.
|
||||
# The first visitor after enabling this claims the instance and becomes the owner,
|
||||
# so enable it and claim the account before exposing the port.
|
||||
# Default: FALSE
|
||||
#SPOOLMAN_AUTH_ENABLED=TRUE
|
||||
|
||||
# Secret key for this instance.
|
||||
# Leave unset and Spoolman generates one in the data directory (secret.key, mode
|
||||
# 0600) on first start. Set it explicitly if you would rather manage it yourself or
|
||||
# need it to be identical across a rebuild.
|
||||
# Use SPOOLMAN_SECRET_KEY_FILE to read it from a file instead, e.g. a docker secret.
|
||||
# Trailing whitespace in the file is ignored.
|
||||
#SPOOLMAN_SECRET_KEY=
|
||||
#SPOOLMAN_SECRET_KEY_FILE=/run/secrets/spoolman_secret_key
|
||||
|
||||
# Reverse proxies whose forwarding headers Spoolman may believe.
|
||||
# Comma-separated IP addresses or CIDR networks. Until a proxy is listed here,
|
||||
# X-Forwarded-For and X-Forwarded-Proto are ignored, which means rate limiting sees
|
||||
# your proxy's address instead of the real client's.
|
||||
# Note this is separate from uvicorn's own FORWARDED_ALLOW_IPS.
|
||||
# Default: empty (trust nothing)
|
||||
#SPOOLMAN_TRUSTED_PROXIES=172.16.0.0/12, 10.0.0.0/8
|
||||
|
||||
# Force the Secure flag on session cookies.
|
||||
# AUTO derives it from the request scheme, which is almost always right. Set TRUE if
|
||||
# you terminate TLS on a proxy that does not send X-Forwarded-Proto. Setting it TRUE
|
||||
# on a plain-HTTP deployment makes the browser discard the cookie and login will
|
||||
# fail silently.
|
||||
# Default: AUTO
|
||||
#SPOOLMAN_COOKIE_SECURE=AUTO
|
||||
|
||||
# Keep the /metrics endpoint reachable without credentials while auth is enabled.
|
||||
# Only consulted when SPOOLMAN_AUTH_ENABLED is set; metrics are public otherwise.
|
||||
# Default: FALSE
|
||||
#SPOOLMAN_METRICS_PUBLIC=TRUE
|
||||
|
||||
# Shared token granting access to /metrics, sent as "Authorization: Bearer <token>"
|
||||
# or "X-Api-Key: <token>". Use SPOOLMAN_METRICS_TOKEN_FILE to read it from a file.
|
||||
# With auth enabled and neither this nor SPOOLMAN_METRICS_PUBLIC set, /metrics
|
||||
# returns 401 to everyone.
|
||||
#SPOOLMAN_METRICS_TOKEN=
|
||||
#SPOOLMAN_METRICS_TOKEN_FILE=/run/secrets/spoolman_metrics_token
|
||||
@@ -277,6 +277,39 @@ jobs:
|
||||
up-flags: "--abort-on-container-exit"
|
||||
down-flags: "--volumes"
|
||||
#
|
||||
# Perform authentication tests
|
||||
#
|
||||
# Deliberately its own job rather than another entry in the matrix above. That matrix
|
||||
# re-runs the whole regression suite per database; adding auth to it would run those
|
||||
# same tests a fifth time to re-prove something none of them are about. Nothing in
|
||||
# spoolman/auth is database-specific, so one SQLite pass over the auth suite is the
|
||||
# whole cost.
|
||||
#
|
||||
test-auth:
|
||||
needs: [build-amd64, build-tester]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download built images
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
with:
|
||||
pattern: spoolman*-image
|
||||
path: /tmp
|
||||
|
||||
- name: Load built images
|
||||
run: |
|
||||
docker load --input /tmp/spoolman-image/spoolman.tar
|
||||
docker load --input /tmp/spoolman-tester-image/spoolman-tester.tar
|
||||
|
||||
- name: Perform authentication tests
|
||||
uses: hoverkraft-tech/compose-action@v2.0.1
|
||||
with:
|
||||
compose-file: "./tests_integration/docker-compose-sqlite-auth.yml"
|
||||
up-flags: "--abort-on-container-exit"
|
||||
down-flags: "--volumes"
|
||||
#
|
||||
# Perform frontend (browser) integration tests against the real image
|
||||
#
|
||||
test-frontend:
|
||||
@@ -429,7 +462,7 @@ jobs:
|
||||
#
|
||||
publish-images:
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
needs: [test, test-frontend, build-amd64, build-arm64, build-armv7, style, build-client]
|
||||
needs: [test, test-auth, test-frontend, build-amd64, build-arm64, build-armv7, style, build-client]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { test, type Page } from '@playwright/test';
|
||||
import { audit, settle } from './audit';
|
||||
|
||||
// Mobile-accessibility audit for the authentication surfaces.
|
||||
//
|
||||
// These live apart from a11y-mobile.spec.ts because they need something that file's
|
||||
// backend does not have: authentication turned on. The sign-in screen never renders
|
||||
// against a default instance, so auditing it there would silently measure the library
|
||||
// page twice and report a pass.
|
||||
//
|
||||
// The file configures itself rather than needing a second Playwright config. It asks
|
||||
// the server what it is, and:
|
||||
//
|
||||
// * skips entirely when authentication is disabled, which is the default and what CI
|
||||
// runs, so `npm run audit:a11y` keeps working unchanged;
|
||||
// * claims an unclaimed instance with the credentials below, so pointing the audit at
|
||||
// a throwaway auth-enabled server needs no setup at all;
|
||||
// * otherwise signs in with AUDIT_AUTH_USERNAME / AUDIT_AUTH_PASSWORD.
|
||||
//
|
||||
// One surface is always missing from any single run, unavoidably: claiming is one-way,
|
||||
// so an instance shows either the claim screen or the sign-in screen, never both. A
|
||||
// fresh instance audits sign-in (the test claims it first); pointing the audit at an
|
||||
// already-claimed instance audits sign-in too. To audit the claim screen, run it once
|
||||
// against a brand-new instance and read the report before the second run.
|
||||
//
|
||||
// Run it against an auth-enabled backend serving the built client with:
|
||||
// npm run build
|
||||
// SPOOLMAN_AUTH_ENABLED=TRUE uv run uvicorn spoolman.main:app --port 8000
|
||||
// AUDIT_BASE_URL=http://localhost:8000 npm run audit:a11y
|
||||
//
|
||||
// Or against `vite dev`, with VITE_APIURL pointing at the backend as usual.
|
||||
|
||||
// Resolved the same way src/lib/api/config.ts resolves it, so the audit works against
|
||||
// both layouts: a `vite dev` server on 5174 talking to a separate backend through an
|
||||
// absolute VITE_APIURL, and the backend serving the built SPA itself, where the API is
|
||||
// same-origin. Getting this wrong is silent — every request 404s and every test skips.
|
||||
const API = process.env.VITE_APIURL?.replace(/\/+$/, '') ?? '/api/v1';
|
||||
|
||||
const USERNAME = process.env.AUDIT_AUTH_USERNAME ?? 'audit-owner';
|
||||
const PASSWORD = process.env.AUDIT_AUTH_PASSWORD ?? 'audit-owner-password';
|
||||
|
||||
// A second account, created by the suite, whose password is one the owner set. It is
|
||||
// what makes the forced-change screen reachable — the flag cannot be set on oneself.
|
||||
const TEMP_USERNAME = 'audit-temp-user';
|
||||
const TEMP_PASSWORD = 'audit-temp-password';
|
||||
|
||||
interface AuthConfig {
|
||||
enabled: boolean;
|
||||
setup_required: boolean;
|
||||
}
|
||||
|
||||
async function authConfig(page: Page): Promise<AuthConfig | null> {
|
||||
try {
|
||||
const response = await page.request.get(`${API}/auth/config`);
|
||||
if (!response.ok()) return null;
|
||||
return (await response.json()) as AuthConfig;
|
||||
} catch {
|
||||
// No backend reachable at all. Skipping beats failing the whole audit run.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the CSRF cookie the server set, which every write must echo in a header. */
|
||||
async function csrfHeaders(page: Page): Promise<Record<string, string>> {
|
||||
const cookies = await page.context().cookies();
|
||||
const csrf = cookies.find((c) => c.name === 'spoolman_csrf');
|
||||
return csrf ? { 'X-CSRF-Token': csrf.value } : {};
|
||||
}
|
||||
|
||||
/** Sign in through the API, leaving the session cookie on the page's context. */
|
||||
async function signIn(page: Page, username: string, password: string): Promise<boolean> {
|
||||
const response = await page.request.post(`${API}/auth/login`, {
|
||||
data: { username, password, remember: false }
|
||||
});
|
||||
return response.ok();
|
||||
}
|
||||
|
||||
/** Put the page in a signed-in state as an administrator, or return false. */
|
||||
async function signInAsOwner(page: Page): Promise<boolean> {
|
||||
const config = await authConfig(page);
|
||||
if (config === null || !config.enabled) return false;
|
||||
|
||||
if (config.setup_required) {
|
||||
const claimed = await page.request.post(`${API}/auth/setup`, {
|
||||
data: { username: USERNAME, password: PASSWORD, display_name: 'Audit Owner' }
|
||||
});
|
||||
if (!claimed.ok()) return false;
|
||||
return true;
|
||||
}
|
||||
return signIn(page, USERNAME, PASSWORD);
|
||||
}
|
||||
|
||||
test.describe('auth surfaces', () => {
|
||||
test('a11y: sign-in screen', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
// Claim the instance if it is unclaimed, rather than skipping: on a fresh server
|
||||
// the sign-in screen does not exist until somebody owns it, and skipping here
|
||||
// would mean the surface is never audited on exactly the throwaway instance this
|
||||
// suite is meant to be pointed at.
|
||||
test.skip(!(await signInAsOwner(page)), 'authentication is not enabled on this server');
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/');
|
||||
await settle(page);
|
||||
await audit(page, testInfo, 'sign-in screen');
|
||||
});
|
||||
|
||||
test('a11y: claim (first-run setup) screen', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
const config = await authConfig(page);
|
||||
test.skip(config === null || !config.enabled, 'authentication is not enabled on this server');
|
||||
test.skip(!config!.setup_required, 'instance is already claimed');
|
||||
|
||||
await settle(page);
|
||||
await audit(page, testInfo, 'claim screen');
|
||||
});
|
||||
|
||||
test('a11y: account page', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
test.skip(!(await signInAsOwner(page)), 'could not sign in');
|
||||
|
||||
await page.goto('/account');
|
||||
await settle(page);
|
||||
await audit(page, testInfo, 'account');
|
||||
|
||||
// The key-creation form is a separate surface: it is the only real form on the
|
||||
// page and none of its controls exist until it is opened.
|
||||
const newKey = page.getByRole('button', { name: /new api key/i }).first();
|
||||
if ((await newKey.count()) > 0) {
|
||||
await newKey.click();
|
||||
await audit(page, testInfo, 'account — new API key form');
|
||||
}
|
||||
});
|
||||
|
||||
test('a11y: users page', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
test.skip(!(await signInAsOwner(page)), 'could not sign in');
|
||||
|
||||
await page.goto('/users');
|
||||
await settle(page);
|
||||
await audit(page, testInfo, 'users');
|
||||
|
||||
const newUser = page.getByRole('button', { name: /new user/i }).first();
|
||||
if ((await newUser.count()) > 0) {
|
||||
await newUser.click();
|
||||
await audit(page, testInfo, 'users — new user form');
|
||||
}
|
||||
});
|
||||
|
||||
test('a11y: audit log', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
test.skip(!(await signInAsOwner(page)), 'could not sign in');
|
||||
|
||||
await page.goto('/audit');
|
||||
await settle(page);
|
||||
await audit(page, testInfo, 'audit log');
|
||||
});
|
||||
|
||||
test('a11y: forced password change', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
test.skip(!(await signInAsOwner(page)), 'could not sign in');
|
||||
|
||||
// Create a throwaway account that must change its password. Re-running the audit
|
||||
// against the same instance is fine: a 409 means it already exists, and the reset
|
||||
// below puts it back into the state this surface needs either way.
|
||||
const headers = await csrfHeaders(page);
|
||||
await page.request.post(`${API}/auth/user`, {
|
||||
headers,
|
||||
data: {
|
||||
username: TEMP_USERNAME,
|
||||
password: TEMP_PASSWORD,
|
||||
level: 'read',
|
||||
is_admin: false,
|
||||
must_change_password: true
|
||||
}
|
||||
});
|
||||
|
||||
const listed = await page.request.get(`${API}/auth/user`);
|
||||
test.skip(!listed.ok(), 'could not list users');
|
||||
const users = (await listed.json()) as { id: number; username: string }[];
|
||||
const temp = users.find((u) => u.username === TEMP_USERNAME);
|
||||
test.skip(temp === undefined, 'could not create the temporary account');
|
||||
|
||||
const reset = await page.request.post(`${API}/auth/user/${temp!.id}/password`, {
|
||||
headers,
|
||||
data: { password: TEMP_PASSWORD, must_change_password: true }
|
||||
});
|
||||
test.skip(!reset.ok(), 'could not reset the temporary account');
|
||||
|
||||
await page.context().clearCookies();
|
||||
test.skip(!(await signIn(page, TEMP_USERNAME, TEMP_PASSWORD)), 'could not sign in as the temporary user');
|
||||
|
||||
await page.goto('/');
|
||||
await settle(page);
|
||||
await audit(page, testInfo, 'forced password change');
|
||||
});
|
||||
});
|
||||
@@ -1,137 +1,13 @@
|
||||
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { SURFACES_DIR, TAP_MIN, type SurfaceResult, type TapTarget } from './report';
|
||||
import { test } from '@playwright/test';
|
||||
import { audit, settle } from './audit';
|
||||
|
||||
// Mobile-accessibility audit for client_v2.
|
||||
// Mobile-accessibility audit for client_v2's main surfaces, under a Pixel 5 mobile
|
||||
// emulation (see playwright.config.ts). The measurements themselves live in audit.ts;
|
||||
// this file is the list of what gets measured.
|
||||
//
|
||||
// Two independent checks run against every surface, under a Pixel 5 mobile
|
||||
// emulation (see playwright.config.ts):
|
||||
//
|
||||
// 1. axe-core — the full WCAG 2.0/2.1 A + AA ruleset PLUS the WCAG 2.2
|
||||
// `target-size` rule (SC 2.5.8, 24x24 CSS px minimum). Real conformance
|
||||
// failures.
|
||||
//
|
||||
// 2. A custom tap-target measurement — flags interactive controls smaller
|
||||
// than TAP_MIN px in either dimension. An *advisory* thumb-comfort check
|
||||
// (Apple HIG 44px / Material 48px), stricter than the WCAG minimum and
|
||||
// catching targets axe considers technically compliant.
|
||||
//
|
||||
// Each surface's findings are written to SURFACES_DIR as JSON; the consolidated
|
||||
// `a11y-report.md` is rendered by globalTeardown (which survives worker
|
||||
// restarts). Raw findings are also attached to the Playwright HTML report.
|
||||
|
||||
let surfaceCount = 0;
|
||||
|
||||
// Let the DOM and any async lists settle. The app holds an open live-sync
|
||||
// WebSocket, so `networkidle` never fires — we bound it and fall through.
|
||||
async function settle(page: Page, ms = 500) {
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {});
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
// Measure every interactive control in the live DOM and return those smaller
|
||||
// than `min` px in either dimension. Runs in the browser.
|
||||
function collectSmallTargets(min: number): TapTarget[] {
|
||||
const SELECTOR = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input:not([type="hidden"])',
|
||||
'select',
|
||||
'textarea',
|
||||
'summary',
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="radio"]',
|
||||
'[role="switch"]',
|
||||
'[role="tab"]',
|
||||
'[role="menuitem"]',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(',');
|
||||
|
||||
const out: TapTarget[] = [];
|
||||
const seen = new Set<Element>();
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll(SELECTOR))) {
|
||||
if (seen.has(el)) continue;
|
||||
seen.add(el);
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
|
||||
if ((el as HTMLButtonElement).disabled) continue;
|
||||
|
||||
// WCAG exempts targets rendered inline within a run of text (e.g. a link
|
||||
// inside a sentence). Skip inline <a> elements to avoid false positives.
|
||||
if (el.tagName === 'A' && style.display.startsWith('inline')) continue;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue; // not rendered
|
||||
if (rect.width >= min && rect.height >= min) continue;
|
||||
|
||||
const id = el.id ? `#${el.id}` : '';
|
||||
const rawCls = (el as HTMLElement).className;
|
||||
const cls =
|
||||
typeof rawCls === 'string' && rawCls.trim()
|
||||
? '.' + rawCls.trim().split(/\s+/).slice(0, 2).join('.')
|
||||
: '';
|
||||
const aria = el.getAttribute('aria-label');
|
||||
|
||||
out.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: (aria || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40),
|
||||
selector: `${el.tagName.toLowerCase()}${id}${cls}`,
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Run both checks against whatever is currently rendered and record the result.
|
||||
async function audit(page: Page, testInfo: TestInfo, label: string) {
|
||||
await settle(page);
|
||||
|
||||
const axeResults = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
|
||||
.analyze();
|
||||
|
||||
const smallTargets = await page.evaluate(collectSmallTargets, TAP_MIN);
|
||||
|
||||
const surface: SurfaceResult = {
|
||||
order: surfaceCount++,
|
||||
label,
|
||||
url: page.url(),
|
||||
axe: axeResults.violations.map((v) => ({
|
||||
id: v.id,
|
||||
impact: v.impact,
|
||||
help: v.help,
|
||||
nodes: v.nodes.length,
|
||||
targets: v.nodes.slice(0, 5).map((n) => n.target.join(' '))
|
||||
})),
|
||||
smallTargets
|
||||
};
|
||||
|
||||
const file = path.join(SURFACES_DIR, `${label.replace(/[^a-z0-9]+/gi, '-')}.json`);
|
||||
await writeFile(file, JSON.stringify(surface, null, 2), 'utf8');
|
||||
|
||||
await testInfo.attach(`${label} — axe violations`, {
|
||||
body: JSON.stringify(axeResults.violations, null, 2),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
await testInfo.attach(`${label} — small tap targets`, {
|
||||
body: JSON.stringify(smallTargets, null, 2),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
|
||||
// Soft assertions: the run completes and reports every surface, but the test
|
||||
// is still marked failed if anything is out of spec — useful as a CI gate.
|
||||
const serious = surface.axe.filter((v) => v.impact === 'serious' || v.impact === 'critical');
|
||||
expect.soft(serious, `${label}: serious/critical axe violations`).toEqual([]);
|
||||
expect.soft(smallTargets, `${label}: tap targets under ${TAP_MIN}px`).toEqual([]);
|
||||
}
|
||||
// These run against the default, authentication-disabled configuration. The sign-in,
|
||||
// account and administration surfaces need an authenticated backend and are audited by
|
||||
// a11y-auth.spec.ts, which skips itself when authentication is off.
|
||||
|
||||
const STATIC_ROUTES: { path: string; label: string }[] = [
|
||||
{ path: '/', label: 'library (home)' },
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, type Page, type TestInfo } from '@playwright/test';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { SURFACES_DIR, TAP_MIN, type SurfaceResult, type TapTarget } from './report';
|
||||
|
||||
// The two measurements the mobile-accessibility audit takes, and the bookkeeping that
|
||||
// turns them into a report. Extracted from a11y-mobile.spec.ts so the auth surfaces —
|
||||
// which need a different backend configuration and therefore live in their own spec —
|
||||
// are measured by exactly the same code rather than a copy of it.
|
||||
//
|
||||
// 1. axe-core, the full WCAG 2.0/2.1 A + AA ruleset plus WCAG 2.2 `target-size`
|
||||
// (SC 2.5.8, 24x24 CSS px minimum). Real conformance failures.
|
||||
//
|
||||
// 2. A custom tap-target measurement flagging interactive controls smaller than
|
||||
// TAP_MIN px in either dimension. Advisory thumb-comfort (Apple HIG 44px /
|
||||
// Material 48px), stricter than WCAG and catching targets axe considers compliant.
|
||||
//
|
||||
// Each surface's findings are written to SURFACES_DIR as JSON; globalTeardown renders
|
||||
// the consolidated a11y-report.md from them.
|
||||
|
||||
let surfaceCount = 0;
|
||||
|
||||
// Let the DOM and any async lists settle. The app holds an open live-sync
|
||||
// WebSocket, so `networkidle` never fires — we bound it and fall through.
|
||||
export async function settle(page: Page, ms = 500) {
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {});
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
// Measure every interactive control in the live DOM and return those smaller
|
||||
// than `min` px in either dimension. Runs in the browser.
|
||||
function collectSmallTargets(min: number): TapTarget[] {
|
||||
const SELECTOR = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input:not([type="hidden"])',
|
||||
'select',
|
||||
'textarea',
|
||||
'summary',
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="radio"]',
|
||||
'[role="switch"]',
|
||||
'[role="tab"]',
|
||||
'[role="menuitem"]',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(',');
|
||||
|
||||
const out: TapTarget[] = [];
|
||||
const seen = new Set<Element>();
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll(SELECTOR))) {
|
||||
if (seen.has(el)) continue;
|
||||
seen.add(el);
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
|
||||
if ((el as HTMLButtonElement).disabled) continue;
|
||||
|
||||
// WCAG exempts targets rendered inline within a run of text (e.g. a link
|
||||
// inside a sentence). Skip inline <a> elements to avoid false positives.
|
||||
if (el.tagName === 'A' && style.display.startsWith('inline')) continue;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue; // not rendered
|
||||
if (rect.width >= min && rect.height >= min) continue;
|
||||
|
||||
const id = el.id ? `#${el.id}` : '';
|
||||
const rawCls = (el as HTMLElement).className;
|
||||
const cls =
|
||||
typeof rawCls === 'string' && rawCls.trim()
|
||||
? '.' + rawCls.trim().split(/\s+/).slice(0, 2).join('.')
|
||||
: '';
|
||||
const aria = el.getAttribute('aria-label');
|
||||
|
||||
out.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: (aria || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40),
|
||||
selector: `${el.tagName.toLowerCase()}${id}${cls}`,
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Run both checks against whatever is currently rendered and record the result.
|
||||
export async function audit(page: Page, testInfo: TestInfo, label: string) {
|
||||
await settle(page);
|
||||
|
||||
const axeResults = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
|
||||
.analyze();
|
||||
|
||||
const smallTargets = await page.evaluate(collectSmallTargets, TAP_MIN);
|
||||
|
||||
const surface: SurfaceResult = {
|
||||
order: surfaceCount++,
|
||||
label,
|
||||
url: page.url(),
|
||||
axe: axeResults.violations.map((v) => ({
|
||||
id: v.id,
|
||||
impact: v.impact,
|
||||
help: v.help,
|
||||
nodes: v.nodes.length,
|
||||
targets: v.nodes.slice(0, 5).map((n) => n.target.join(' '))
|
||||
})),
|
||||
smallTargets
|
||||
};
|
||||
|
||||
const file = path.join(SURFACES_DIR, `${label.replace(/[^a-z0-9]+/gi, '-')}.json`);
|
||||
await writeFile(file, JSON.stringify(surface, null, 2), 'utf8');
|
||||
|
||||
await testInfo.attach(`${label} — axe violations`, {
|
||||
body: JSON.stringify(axeResults.violations, null, 2),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
await testInfo.attach(`${label} — small tap targets`, {
|
||||
body: JSON.stringify(smallTargets, null, 2),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
|
||||
// Soft assertions: the run completes and reports every surface, but the test
|
||||
// is still marked failed if anything is out of spec — useful as a CI gate.
|
||||
const serious = surface.axe.filter((v) => v.impact === 'serious' || v.impact === 'critical');
|
||||
expect.soft(serious, `${label}: serious/critical axe violations`).toEqual([]);
|
||||
expect.soft(smallTargets, `${label}: tap targets under ${TAP_MIN}px`).toEqual([]);
|
||||
}
|
||||
+581
-429
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,98 @@
|
||||
import { deleteJson, getJson, getList, patchJson, postJson } from './http';
|
||||
import type { AuthUserInfo, Level } from './auth';
|
||||
|
||||
// The administrator-only endpoints: /auth/user and /auth/audit. Everything here is
|
||||
// refused with 403 unless the caller is signed in as an administrator, so views built
|
||||
// on these must gate themselves on `auth.isAdmin` — not to enforce anything, but to
|
||||
// avoid rendering a page whose every request will fail.
|
||||
|
||||
export interface UserCreated {
|
||||
user: AuthUserInfo;
|
||||
/** Present only when the server generated the password. Shown once. */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface UserCreateInput {
|
||||
username: string;
|
||||
password?: string | null;
|
||||
display_name?: string | null;
|
||||
level: Level;
|
||||
is_admin: boolean;
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
export interface UserUpdateInput {
|
||||
display_name?: string | null;
|
||||
level?: Level;
|
||||
is_admin?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
/** Users, plus the total the server reports — which may exceed what was returned. */
|
||||
export async function listUsers(signal?: AbortSignal): Promise<AuthUserInfo[]> {
|
||||
const page = await getList('/auth/user', {}, signal);
|
||||
return page.items as AuthUserInfo[];
|
||||
}
|
||||
|
||||
export function createUser(input: UserCreateInput): Promise<UserCreated> {
|
||||
return postJson<UserCreated>('/auth/user', input);
|
||||
}
|
||||
|
||||
export function updateUser(id: number, input: UserUpdateInput): Promise<AuthUserInfo> {
|
||||
return patchJson<AuthUserInfo>(`/auth/user/${id}`, input);
|
||||
}
|
||||
|
||||
export function resetUserPassword(
|
||||
id: number,
|
||||
password: string | null,
|
||||
mustChangePassword: boolean
|
||||
): Promise<UserCreated> {
|
||||
return postJson<UserCreated>(`/auth/user/${id}/password`, {
|
||||
password,
|
||||
must_change_password: mustChangePassword
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeUserSessions(id: number): Promise<{ message: string }> {
|
||||
return postJson<{ message: string }>(`/auth/user/${id}/revoke-sessions`, {});
|
||||
}
|
||||
|
||||
export function deleteUser(id: number): Promise<{ message: string }> {
|
||||
return deleteJson<{ message: string }>(`/auth/user/${id}`);
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
date: string;
|
||||
event: string;
|
||||
actor_user_id: number | null;
|
||||
actor_username: string | null;
|
||||
actor_kind: string;
|
||||
target: string | null;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AuditPage {
|
||||
entries: AuditEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function listAudit(
|
||||
params: { limit: number; offset: number; event?: string },
|
||||
signal?: AbortSignal
|
||||
): Promise<AuditPage> {
|
||||
// getList rather than getJson: the endpoint reports the unpaginated total in
|
||||
// X-Total-Count, which is what drives the pager.
|
||||
const page = await getList(
|
||||
'/auth/audit',
|
||||
{ limit: params.limit, offset: params.offset, event: params.event || undefined },
|
||||
signal
|
||||
);
|
||||
return { entries: page.items as AuditEntry[], total: page.total };
|
||||
}
|
||||
|
||||
export function listAuditEvents(signal?: AbortSignal): Promise<string[]> {
|
||||
return getJson<string[]>('/auth/audit/events', {}, signal);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { deleteResource, getJson, postJson } from './http';
|
||||
|
||||
// The /api/v1/auth endpoints. Every one of these is reachable without being signed in
|
||||
// (except changePassword), because they are how a session is obtained in the first
|
||||
// place. Errors here are handled by the caller rather than by the global 401 handling
|
||||
// in http.ts — a rejected sign-in is an answer, not a sign-out.
|
||||
|
||||
export type Level = 'read' | 'edit' | 'manage';
|
||||
|
||||
export interface AuthOidcConfig {
|
||||
enabled: boolean;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
enabled: boolean;
|
||||
setup_required: boolean;
|
||||
anonymous_read: boolean;
|
||||
oidc: AuthOidcConfig;
|
||||
mtls: boolean;
|
||||
}
|
||||
|
||||
export interface AuthUserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
level: Level;
|
||||
is_admin: boolean;
|
||||
is_owner: boolean;
|
||||
is_active: boolean;
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
export interface AuthSessionInfo {
|
||||
authenticated: boolean;
|
||||
anonymous: boolean;
|
||||
level: Level;
|
||||
is_admin: boolean;
|
||||
is_owner: boolean;
|
||||
user: AuthUserInfo | null;
|
||||
}
|
||||
|
||||
export function getAuthConfig(signal?: AbortSignal): Promise<AuthConfig> {
|
||||
return getJson<AuthConfig>('/auth/config', {}, signal);
|
||||
}
|
||||
|
||||
export function getAuthSession(signal?: AbortSignal): Promise<AuthSessionInfo> {
|
||||
return getJson<AuthSessionInfo>('/auth/session', {}, signal);
|
||||
}
|
||||
|
||||
export function login(username: string, password: string, remember: boolean): Promise<AuthSessionInfo> {
|
||||
return postJson<AuthSessionInfo>('/auth/login', { username, password, remember });
|
||||
}
|
||||
|
||||
export function claimInstance(
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string
|
||||
): Promise<AuthSessionInfo> {
|
||||
return postJson<AuthSessionInfo>('/auth/setup', {
|
||||
username,
|
||||
password,
|
||||
display_name: displayName || null
|
||||
});
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ message: string }> {
|
||||
return postJson<{ message: string }>('/auth/logout', {});
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
|
||||
return postJson<{ message: string }>('/auth/password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: API keys. Every one of these needs a signed-in session — the server
|
||||
// refuses to let a key manage keys — so unlike the endpoints above, a 401 here really
|
||||
// does mean the session went away and the global handling in http.ts should run.
|
||||
|
||||
export interface ApiKeyInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
level: Level;
|
||||
/** What the key can do right now, after capping against its owner's level. */
|
||||
effective_level: Level;
|
||||
prefix: string;
|
||||
created: string;
|
||||
expires: string | null;
|
||||
last_used: string | null;
|
||||
revoked: boolean;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
export interface ApiKeyCreated {
|
||||
/** The complete key. The server returns this once and never again. */
|
||||
key: string;
|
||||
info: ApiKeyInfo;
|
||||
}
|
||||
|
||||
export function listApiKeys(signal?: AbortSignal): Promise<ApiKeyInfo[]> {
|
||||
return getJson<ApiKeyInfo[]>('/auth/apikey', {}, signal);
|
||||
}
|
||||
|
||||
export function createApiKey(name: string, level: Level, expiresDays: number | null): Promise<ApiKeyCreated> {
|
||||
return postJson<ApiKeyCreated>('/auth/apikey', {
|
||||
name,
|
||||
level,
|
||||
expires_days: expiresDays
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeApiKey(id: number): Promise<ApiKeyInfo> {
|
||||
return postJson<ApiKeyInfo>(`/auth/apikey/${id}/revoke`, {});
|
||||
}
|
||||
|
||||
export function deleteApiKey(id: number): Promise<void> {
|
||||
return deleteResource(`/auth/apikey/${id}`);
|
||||
}
|
||||
@@ -42,6 +42,57 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_EXPIRED = 401;
|
||||
const FORBIDDEN = 403;
|
||||
|
||||
const CSRF_COOKIE = 'spoolman_csrf';
|
||||
const CSRF_HEADER = 'X-CSRF-Token';
|
||||
|
||||
/** Paths that own their own error handling — a failed sign-in must not read as a sign-out. */
|
||||
const AUTH_PATH_PREFIX = '/auth/';
|
||||
|
||||
type AuthHandlers = {
|
||||
/** The server says we are not signed in. */
|
||||
unauthorized: () => void;
|
||||
/** The server refused for lack of permission. */
|
||||
forbidden: (detail: string) => void;
|
||||
};
|
||||
|
||||
let authHandlers: AuthHandlers | null = null;
|
||||
|
||||
/**
|
||||
* Register what to do when the server rejects a request on authentication grounds.
|
||||
*
|
||||
* Inverted rather than imported directly: the auth store imports this module, so
|
||||
* reaching back for the store here would be a cycle.
|
||||
*/
|
||||
export function setAuthHandlers(handlers: AuthHandlers): void {
|
||||
authHandlers = handlers;
|
||||
}
|
||||
|
||||
/** Read a cookie by name. Only used for the CSRF token, which is deliberately readable. */
|
||||
function readCookie(name: string): string {
|
||||
const prefix = name + '=';
|
||||
for (const part of document.cookie.split('; ')) {
|
||||
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers for a request that carries a body.
|
||||
*
|
||||
* The session cookie is HttpOnly and travels on its own, but because it is sent
|
||||
* automatically it needs a second factor the browser will not supply cross-site: the
|
||||
* CSRF token, which lives in a readable cookie precisely so it can be echoed here.
|
||||
*/
|
||||
function writeHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||||
const token = readCookie(CSRF_COOKIE);
|
||||
if (token) headers[CSRF_HEADER] = token;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function ensureOk(res: Response, method: string, path: string): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
@@ -50,6 +101,10 @@ async function ensureOk(res: Response, method: string, path: string): Promise<Re
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!path.startsWith(AUTH_PATH_PREFIX)) {
|
||||
if (res.status === SESSION_EXPIRED) authHandlers?.unauthorized();
|
||||
else if (res.status === FORBIDDEN) authHandlers?.forbidden(detail);
|
||||
}
|
||||
throw new HttpError(`${method} ${path} → ${res.status}${detail ? `: ${detail}` : ''}`, res.status);
|
||||
}
|
||||
return res;
|
||||
@@ -71,7 +126,11 @@ export async function getList(
|
||||
params: QueryParams = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<RawPage> {
|
||||
const res = await ensureOk(await fetch(API_BASE + path + queryString(params), { signal }), 'GET', path);
|
||||
const res = await ensureOk(
|
||||
await fetch(API_BASE + path + queryString(params), { signal, credentials: 'include' }),
|
||||
'GET',
|
||||
path
|
||||
);
|
||||
const items = (await res.json()) as unknown[];
|
||||
const header = res.headers.get('x-total-count');
|
||||
const total = header != null && header !== '' ? Number(header) : items.length;
|
||||
@@ -83,7 +142,11 @@ export async function getJson<T = unknown>(
|
||||
params: QueryParams = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const res = await ensureOk(await fetch(API_BASE + path + queryString(params), { signal }), 'GET', path);
|
||||
const res = await ensureOk(
|
||||
await fetch(API_BASE + path + queryString(params), { signal, credentials: 'include' }),
|
||||
'GET',
|
||||
path
|
||||
);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
@@ -91,7 +154,8 @@ export async function patchJson<T = unknown>(path: string, body: unknown): Promi
|
||||
const res = await ensureOk(
|
||||
await fetch(API_BASE + path, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: writeHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
'PATCH',
|
||||
@@ -104,7 +168,8 @@ export async function putJson<T = unknown>(path: string, body: unknown): Promise
|
||||
const res = await ensureOk(
|
||||
await fetch(API_BASE + path, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: writeHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
'PUT',
|
||||
@@ -117,7 +182,8 @@ export async function postJson<T = unknown>(path: string, body: unknown): Promis
|
||||
const res = await ensureOk(
|
||||
await fetch(API_BASE + path, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: writeHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
'POST',
|
||||
@@ -127,10 +193,18 @@ export async function postJson<T = unknown>(path: string, body: unknown): Promis
|
||||
}
|
||||
|
||||
export async function deleteResource(path: string): Promise<void> {
|
||||
await ensureOk(await fetch(API_BASE + path, { method: 'DELETE' }), 'DELETE', path);
|
||||
await ensureOk(
|
||||
await fetch(API_BASE + path, { method: 'DELETE', headers: writeHeaders(), credentials: 'include' }),
|
||||
'DELETE',
|
||||
path
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteJson<T = unknown>(path: string): Promise<T> {
|
||||
const res = await ensureOk(await fetch(API_BASE + path, { method: 'DELETE' }), 'DELETE', path);
|
||||
const res = await ensureOk(
|
||||
await fetch(API_BASE + path, { method: 'DELETE', headers: writeHeaders(), credentials: 'include' }),
|
||||
'DELETE',
|
||||
path
|
||||
);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export interface SubscribeOpts {
|
||||
|
||||
export interface LiveConnection {
|
||||
subscribe(resource: Resource, opts: SubscribeOpts, handler: LiveHandler): () => void;
|
||||
/** Reopen sockets that an auth rejection stopped, after signing back in. */
|
||||
rearm(): void;
|
||||
}
|
||||
|
||||
interface Sub {
|
||||
@@ -39,6 +41,8 @@ interface ResourceSocket {
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
pingTimer: ReturnType<typeof setInterval> | null;
|
||||
closed: boolean;
|
||||
/** Set when the server rejected us on auth grounds; suppresses reconnects until rearm(). */
|
||||
authBlocked: boolean;
|
||||
/** Consecutive failed connects; drives exponential reconnect backoff. */
|
||||
attempts: number;
|
||||
}
|
||||
@@ -52,6 +56,22 @@ interface ResourceSocket {
|
||||
const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
|
||||
// Close codes the server sends after accepting the handshake. 4000 + the HTTP status
|
||||
// they stand in for, matching the backend's convention.
|
||||
const WS_UNAUTHENTICATED = 4401;
|
||||
const WS_FORBIDDEN = 4403;
|
||||
|
||||
let authCloseHandler: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Register what to do when the server closes a socket on authentication grounds.
|
||||
*
|
||||
* Inverted rather than importing the auth store, which would be a cycle.
|
||||
*/
|
||||
export function setLiveAuthCloseHandler(handler: () => void): void {
|
||||
authCloseHandler = handler;
|
||||
}
|
||||
|
||||
class WebSocketLive implements LiveConnection {
|
||||
private sockets = new Map<Resource, ResourceSocket>();
|
||||
|
||||
@@ -84,6 +104,7 @@ class WebSocketLive implements LiveConnection {
|
||||
reconnectTimer: null,
|
||||
pingTimer: null,
|
||||
closed: false,
|
||||
authBlocked: false,
|
||||
attempts: 0
|
||||
};
|
||||
this.sockets.set(resource, sock);
|
||||
@@ -135,10 +156,25 @@ class WebSocketLive implements LiveConnection {
|
||||
}, 25000);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
ws.onclose = (ev) => {
|
||||
if (sock.pingTimer) clearInterval(sock.pingTimer);
|
||||
sock.pingTimer = null;
|
||||
sock.ws = null;
|
||||
|
||||
// An auth rejection is permanent until something changes, unlike a dropped
|
||||
// backend. Retrying it would hammer the server on a backoff loop that can
|
||||
// never succeed, so stop and let the app react. Subscribers are kept, so
|
||||
// rearm() can bring every view back after a sign-in without re-subscribing.
|
||||
//
|
||||
// This only works because the server accepts the handshake before closing.
|
||||
// A close sent before the accept is turned into an HTTP 403 by uvicorn and
|
||||
// the code never arrives — see spoolman/auth/dependencies.py.
|
||||
if (ev.code === WS_UNAUTHENTICATED || ev.code === WS_FORBIDDEN) {
|
||||
sock.authBlocked = true;
|
||||
authCloseHandler?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sock.closed && sock.subs.size > 0) this.scheduleReconnect(resource, sock);
|
||||
};
|
||||
|
||||
@@ -146,7 +182,7 @@ class WebSocketLive implements LiveConnection {
|
||||
}
|
||||
|
||||
private scheduleReconnect(resource: Resource, sock: ResourceSocket) {
|
||||
if (sock.reconnectTimer) return;
|
||||
if (sock.reconnectTimer || sock.authBlocked) return;
|
||||
const delay = this.backoffDelay(sock.attempts);
|
||||
sock.attempts++;
|
||||
sock.reconnectTimer = setTimeout(() => {
|
||||
@@ -166,7 +202,7 @@ class WebSocketLive implements LiveConnection {
|
||||
* waiting out its backoff, and reset the backoff so a fresh drop starts fast. */
|
||||
private reconnectAllNow() {
|
||||
for (const [resource, sock] of this.sockets) {
|
||||
if (sock.closed || sock.subs.size === 0) continue;
|
||||
if (sock.closed || sock.authBlocked || sock.subs.size === 0) continue;
|
||||
const rs = sock.ws?.readyState;
|
||||
if (rs === WebSocket.OPEN || rs === WebSocket.CONNECTING) continue;
|
||||
sock.attempts = 0;
|
||||
@@ -178,6 +214,21 @@ class WebSocketLive implements LiveConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry sockets that were stopped by an auth rejection, after a successful sign-in.
|
||||
*
|
||||
* Distinct from teardown(), which drops the map entry: this keeps every existing
|
||||
* subscriber, so views that were already listening resume without re-subscribing.
|
||||
*/
|
||||
rearm() {
|
||||
for (const [resource, sock] of this.sockets) {
|
||||
if (!sock.authBlocked) continue;
|
||||
sock.authBlocked = false;
|
||||
sock.attempts = 0;
|
||||
if (sock.subs.size > 0) this.open(resource, sock);
|
||||
}
|
||||
}
|
||||
|
||||
private teardown(resource: Resource) {
|
||||
const sock = this.sockets.get(resource);
|
||||
if (!sock) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SearchMatch, SearchResults } from './types';
|
||||
import type { Filament, Spool, Vendor } from '$lib/types';
|
||||
import { API_BASE } from './config';
|
||||
import { getJson } from './http';
|
||||
import { mapFilament, mapSpool, mapVendor } from './map';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
|
||||
@@ -50,10 +50,10 @@ export async function searchAll(
|
||||
const q = query.trim();
|
||||
if (!q) return EMPTY;
|
||||
|
||||
const params = new URLSearchParams({ q, color_similarity_threshold: String(threshold) });
|
||||
const res = await fetch(`${API_BASE}/search?${params.toString()}`, { signal });
|
||||
if (!res.ok) throw new Error(`GET /search → ${res.status}`);
|
||||
const data = (await res.json()) as Json;
|
||||
// Goes through getJson rather than a bare fetch so it picks up credentials, the
|
||||
// CSRF header and 401/403 handling like every other call. A hand-rolled fetch here
|
||||
// would be an authentication hole that silently returns nothing once auth is on.
|
||||
const data = await getJson<Json>('/search', { q, color_similarity_threshold: threshold }, signal);
|
||||
|
||||
return {
|
||||
spools: (data.spools ?? []).map(mapSpoolHit),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
// A checkbox with a 44px tap target and an 18px box.
|
||||
//
|
||||
// A native `<input type="checkbox">` sized to 18px is what the app used, and it is
|
||||
// what the mobile audit flags: the *input* is the tap target, so wrapping it in a
|
||||
// 44px label makes the row comfortable while leaving the control itself at 18px.
|
||||
// `appearance: none` lets the input be 44px and the visible box be drawn at 18px by
|
||||
// a pseudo-element, which satisfies both at once.
|
||||
//
|
||||
// Still a real `<input type="checkbox">` underneath, so it keeps native keyboard
|
||||
// behaviour, form participation and the checkbox role for free.
|
||||
|
||||
let {
|
||||
checked = $bindable(false),
|
||||
id,
|
||||
disabled = false,
|
||||
label
|
||||
}: {
|
||||
checked?: boolean;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<label class="check" class:disabled>
|
||||
<input type="checkbox" {id} bind:checked {disabled} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.check.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
input {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
input::before {
|
||||
content: '';
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 4px;
|
||||
background: var(--input-bg);
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
}
|
||||
|
||||
input:checked::before {
|
||||
border-color: var(--accent);
|
||||
background-color: var(--accent);
|
||||
/* A tick, inlined so the component stays self-contained and needs no asset. */
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M3 8.5l3.5 3.5L13 4.5'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
input:focus-visible::before {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,13 +3,23 @@
|
||||
value: string | number;
|
||||
placeholder?: string;
|
||||
mono?: boolean;
|
||||
/** Read-only presentation, for users without permission to edit. */
|
||||
disabled?: boolean;
|
||||
oninput?: (value: string) => void;
|
||||
}
|
||||
|
||||
let { value, placeholder = '—', mono = false, oninput }: Props = $props();
|
||||
let { value, placeholder = '—', mono = false, disabled = false, oninput }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input class="edit" class:mono {value} {placeholder} oninput={(e) => oninput?.(e.currentTarget.value)} />
|
||||
<input
|
||||
class="edit"
|
||||
class:mono
|
||||
{value}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
readonly={disabled}
|
||||
oninput={(e) => oninput?.(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.edit {
|
||||
@@ -24,4 +34,12 @@
|
||||
.edit:focus {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Read-only: drop the dashed affordance so the field does not invite an edit
|
||||
that the server would refuse. */
|
||||
.edit:disabled {
|
||||
border-bottom-color: transparent;
|
||||
color: var(--text-2);
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -3,13 +3,29 @@
|
||||
import type { Pathname } from '$app/types';
|
||||
import { page } from '$app/stores';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
|
||||
const tabs = [
|
||||
// `show` decides whether a tab appears at all. Absent means always. The auth tabs
|
||||
// are hidden rather than disabled: with auth off there is no account for /account
|
||||
// to describe and no users for /users to list, so the pages would be empty rather
|
||||
// than merely restricted. This is cosmetic — the server gates the endpoints.
|
||||
interface Tab {
|
||||
href: Pathname;
|
||||
label: () => string;
|
||||
show?: () => boolean;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ href: '/', label: m['nav.library'] },
|
||||
{ href: '/locations', label: m['locations.locations'] },
|
||||
{ href: '/labels', label: m['nav.labels'] },
|
||||
{ href: '/settings', label: m['settings.header'] }
|
||||
] satisfies { href: Pathname; label: () => string }[];
|
||||
{ href: '/settings', label: m['settings.header'] },
|
||||
{ href: '/account', label: m['nav.account'], show: () => auth.enabled && auth.authenticated },
|
||||
{ href: '/users', label: m['nav.users'], show: () => auth.enabled && auth.isAdmin },
|
||||
{ href: '/audit', label: m['nav.audit'], show: () => auth.enabled && auth.isAdmin }
|
||||
];
|
||||
|
||||
let visible = $derived(tabs.filter((tab) => tab.show?.() ?? true));
|
||||
|
||||
// The deploy base path, without its trailing slash (resolve('/') === `${base}/`).
|
||||
const basePath = resolve('/').replace(/\/$/, '');
|
||||
@@ -22,7 +38,7 @@
|
||||
</script>
|
||||
|
||||
<nav class="tabs">
|
||||
{#each tabs as tab (tab.href)}
|
||||
{#each visible as tab (tab.href)}
|
||||
<a href={resolve(tab.href)} class="tab" class:active={isActive(tab.href)}>{tab.label()}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
checked: boolean;
|
||||
onchange?: (checked: boolean) => void;
|
||||
ariaLabel: string | undefined;
|
||||
/** Refuse interaction, for a toggle the server would reject anyway. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { checked, onchange, ariaLabel }: Props = $props();
|
||||
let { checked, onchange, ariaLabel, disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="toggle"
|
||||
class:on={checked}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
{disabled}
|
||||
onclick={() => onchange?.(!checked)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
@@ -35,6 +39,10 @@
|
||||
.toggle.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 2.5px;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
import SearchBox from './library/SearchBox.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import ScanLine from '@lucide/svelte/icons/scan-line';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
@@ -52,20 +54,44 @@
|
||||
<button class="scan-btn" onclick={onscan} aria-label={m['scanner.title']()} title={m['scanner.title']()}>
|
||||
<ScanLine size={18} />
|
||||
</button>
|
||||
<button
|
||||
class="add-mobile"
|
||||
onclick={onadd}
|
||||
aria-label={m['topbar.addSpools']()}
|
||||
title={m['topbar.addSpools']()}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
{#if auth.canManage}
|
||||
<button
|
||||
class="add-mobile"
|
||||
onclick={onadd}
|
||||
aria-label={m['topbar.addSpools']()}
|
||||
title={m['topbar.addSpools']()}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
<div class="search-desktop">
|
||||
<SearchBox />
|
||||
</div>
|
||||
<div class="add-desktop">
|
||||
<Button onclick={onadd}><Plus size={15} /> {m['topbar.addSpools']()}</Button>
|
||||
</div>
|
||||
{#if auth.canManage}
|
||||
<div class="add-desktop">
|
||||
<Button onclick={onadd}><Plus size={15} /> {m['topbar.addSpools']()}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Only rendered when the server actually enforces auth, so the default
|
||||
configuration keeps the bar exactly as it was. -->
|
||||
{#if auth.enabled && auth.authenticated}
|
||||
<div class="account">
|
||||
<span class="who" title={m['auth.signedInAs']({ name: auth.displayName })}>
|
||||
{auth.displayName}
|
||||
</span>
|
||||
<button
|
||||
class="signout"
|
||||
onclick={() => auth.signOut()}
|
||||
aria-label={m['auth.signOut']()}
|
||||
title={m['auth.signOut']()}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{:else if auth.enabled && auth.anonymous}
|
||||
<span class="badge">{m['auth.anonymousBadge']()}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile: the expanded search overlays the row so the typed query is visible. -->
|
||||
<div class="search-overlay" bind:this={overlayEl}>
|
||||
@@ -219,4 +245,54 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.who {
|
||||
max-width: 12ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.signout {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* 44px keeps the mobile a11y audit's tap-target check green. */
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signout:hover {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: 8px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.who {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
import Card from '$components/Card.svelte';
|
||||
import Button from '$components/Button.svelte';
|
||||
import SecretReveal from './SecretReveal.svelte';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Ban from '@lucide/svelte/icons/ban';
|
||||
import {
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
type ApiKeyCreated,
|
||||
type ApiKeyInfo,
|
||||
type Level
|
||||
} from '$lib/api/auth';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { formatDateTime } from '$lib/utils/datetime';
|
||||
import { grantableLevels, levelDescription, levelLabel } from '$lib/utils/levels';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// The account page's key manager: list, create, revoke, delete.
|
||||
//
|
||||
// The one thing this screen must get right is that a new key's secret is shown
|
||||
// once. The server hashes it and cannot produce it again, so the reveal panel stays
|
||||
// up until the user explicitly dismisses it rather than closing on its own.
|
||||
|
||||
const EXPIRY_CHOICES: (number | null)[] = [30, 90, 365, null];
|
||||
|
||||
let keys = $state<ApiKeyInfo[]>([]);
|
||||
let loading = $state(true);
|
||||
let failed = $state(false);
|
||||
|
||||
let creating = $state(false);
|
||||
let submitting = $state(false);
|
||||
let name = $state('');
|
||||
let level = $state<Level>('read');
|
||||
let expiresDays = $state<number | null>(90);
|
||||
let formError = $state('');
|
||||
|
||||
// The plaintext key, held only until dismissed. Never written anywhere else.
|
||||
let revealed = $state<ApiKeyCreated | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const controller = new AbortController();
|
||||
load(controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
async function load(signal?: AbortSignal) {
|
||||
loading = true;
|
||||
try {
|
||||
keys = await listApiKeys(signal);
|
||||
failed = false;
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load API keys', e);
|
||||
failed = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openForm() {
|
||||
name = '';
|
||||
// Default to the weakest level rather than the user's own. A key should be as
|
||||
// small as the job needs, and the common case really is a read-only scraper.
|
||||
level = 'read';
|
||||
expiresDays = 90;
|
||||
formError = '';
|
||||
creating = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting) return;
|
||||
if (!name.trim()) {
|
||||
formError = m['account.keyNameRequired']();
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
formError = '';
|
||||
try {
|
||||
revealed = await createApiKey(name.trim(), level, expiresDays);
|
||||
creating = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
console.error('Failed to create API key', e);
|
||||
formError = m['account.keysFailed']();
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(key: ApiKeyInfo) {
|
||||
if (!confirm(m['account.keyRevokeConfirm']({ name: key.name }))) return;
|
||||
try {
|
||||
await revokeApiKey(key.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
console.error('Failed to revoke API key', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(key: ApiKeyInfo) {
|
||||
if (!confirm(m['account.keyDeleteConfirm']({ name: key.name }))) return;
|
||||
try {
|
||||
await deleteApiKey(key.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete API key', e);
|
||||
}
|
||||
}
|
||||
|
||||
function statusOf(key: ApiKeyInfo): string {
|
||||
if (key.revoked) return m['account.keyRevoked']();
|
||||
if (key.expired) return m['account.keyExpired']();
|
||||
return m['account.keyActive']();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if revealed}
|
||||
<SecretReveal
|
||||
title={m['account.keyCreated']()}
|
||||
value={revealed.key}
|
||||
warning={m['account.keyCreatedWarning']()}
|
||||
ondismiss={() => (revealed = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Card divided>
|
||||
{#if loading}
|
||||
<div class="empty">{m['auth.loading']()}…</div>
|
||||
{:else if failed}
|
||||
<div class="empty">{m['account.keysFailed']()}</div>
|
||||
{:else if keys.length === 0}
|
||||
<div class="empty">{m['account.keysEmpty']()}</div>
|
||||
{:else}
|
||||
{#each keys as key (key.id)}
|
||||
<div class="key" class:inactive={key.revoked || key.expired}>
|
||||
<div class="key-main">
|
||||
<div class="key-name">
|
||||
{key.name}
|
||||
<span class="badge" class:dim={key.revoked || key.expired}>{statusOf(key)}</span>
|
||||
</div>
|
||||
<div class="key-meta mono">{key.prefix}</div>
|
||||
<div class="key-meta">
|
||||
{m['account.keyCreatedOn']({ date: formatDateTime(key.created) })}
|
||||
{#if key.last_used}
|
||||
· {m['account.keyLastUsed']({ date: formatDateTime(key.last_used) })}
|
||||
{:else}
|
||||
· {m['account.keyNeverUsed']()}
|
||||
{/if}
|
||||
{#if key.expires}
|
||||
· {m['account.keyExpiresOn']({ date: formatDateTime(key.expires) })}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="key-meta">
|
||||
{levelLabel(key.effective_level)}
|
||||
{#if key.effective_level !== key.level}
|
||||
<!-- The key outranks its owner, so the server is capping it. Saying
|
||||
so beats silently showing a level it does not actually have. -->
|
||||
<span class="capped"
|
||||
>{m['account.keyCappedNotice']({ level: levelLabel(key.effective_level) })}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="key-actions">
|
||||
{#if !key.revoked}
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
onclick={() => revoke(key)}
|
||||
aria-label={m['account.keyRevoke']()}
|
||||
title={m['account.keyRevoke']()}
|
||||
>
|
||||
<Ban size={15} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="icon danger"
|
||||
onclick={() => remove(key)}
|
||||
aria-label={m['account.keyDelete']()}
|
||||
title={m['account.keyDelete']()}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if creating}
|
||||
<Card>
|
||||
<form
|
||||
class="form"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<label for="key-name">{m['account.keyName']()}</label>
|
||||
<input
|
||||
id="key-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={m['account.keyNamePlaceholder']()}
|
||||
disabled={submitting}
|
||||
maxlength="64"
|
||||
/>
|
||||
|
||||
<label for="key-level">{m['account.keyLevel']()}</label>
|
||||
<select id="key-level" bind:value={level} disabled={submitting}>
|
||||
{#each grantableLevels(auth.level) as option (option)}
|
||||
<option value={option}>{levelLabel(option)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="hint">{levelDescription(level)}</p>
|
||||
|
||||
<label for="key-expiry">{m['account.keyExpiry']()}</label>
|
||||
<select id="key-expiry" bind:value={expiresDays} disabled={submitting}>
|
||||
{#each EXPIRY_CHOICES as choice (choice ?? 'never')}
|
||||
<option value={choice}>
|
||||
{choice === null ? m['account.keyExpiryNever']() : m['account.keyExpiryDays']({ count: choice })}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<Button type="submit" variant="primary" disabled={submitting}>
|
||||
{m['account.keyCreate']()}
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={() => (creating = false)}>{m['buttons.cancel']()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="add"><Button variant="outline" onclick={openForm}>{m['account.newKey']()}</Button></div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
padding: 16px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.key.inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.key-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.key-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.key-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.capped {
|
||||
margin-left: 6px;
|
||||
color: var(--warning, var(--text-muted));
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-soft);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge.dim {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.key-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* 44px, matching PasswordInput: the auth surfaces are audited against the
|
||||
advisory thumb-comfort minimum, not just WCAG's 24px. */
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon.danger:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.form label {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form select {
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form input:focus,
|
||||
.form select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 4px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.add {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$components/Logo.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// Shared frame for the sign-in and claim screens. These render in place of the
|
||||
// normal shell, so they bring their own centring rather than inheriting the app
|
||||
// layout's flex row.
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<div class="card">
|
||||
<div class="brand"><Logo /></div>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 24px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import PasswordInput from './PasswordInput.svelte';
|
||||
import Button from '$components/Button.svelte';
|
||||
import { changePassword } from '$lib/api/auth';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { HttpError } from '$lib/api/http';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Shared by the account page and the forced-change screen, which differ only in
|
||||
// their surroundings: the same three fields, the same validation, the same call.
|
||||
|
||||
// Mirrors MIN_PASSWORD_LENGTH in spoolman/api/v1/models.py. Checked here only so
|
||||
// the user hears about it before a round trip; the server is the authority.
|
||||
const MIN_LENGTH = 10;
|
||||
const BAD_REQUEST = 400;
|
||||
|
||||
let { onsuccess }: { onsuccess?: () => void } = $props();
|
||||
|
||||
let current = $state('');
|
||||
let next = $state('');
|
||||
let confirm = $state('');
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let done = $state(false);
|
||||
|
||||
async function submit() {
|
||||
if (submitting) return;
|
||||
error = '';
|
||||
done = false;
|
||||
|
||||
if (next.length < MIN_LENGTH) {
|
||||
error = m['auth.passwordTooShort']({ min: MIN_LENGTH });
|
||||
return;
|
||||
}
|
||||
if (next !== confirm) {
|
||||
error = m['auth.passwordMismatch']();
|
||||
return;
|
||||
}
|
||||
|
||||
submitting = true;
|
||||
try {
|
||||
await changePassword(current, next);
|
||||
// Re-read the session so must_change_password clears and the forced screen
|
||||
// gives way. Nothing else tells the store the flag is gone.
|
||||
await auth.load();
|
||||
current = next = confirm = '';
|
||||
done = true;
|
||||
onsuccess?.();
|
||||
} catch (e) {
|
||||
// 400 is the server's answer for a wrong current password, and it is worth
|
||||
// distinguishing: it is the one failure the user can act on directly.
|
||||
if (e instanceof HttpError && e.status === BAD_REQUEST) error = m['auth.passwordIncorrect']();
|
||||
else error = m['auth.passwordChangeFailed']();
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<label for="pw-current">{m['auth.currentPassword']()}</label>
|
||||
<PasswordInput id="pw-current" bind:value={current} autocomplete="current-password" disabled={submitting} />
|
||||
|
||||
<label for="pw-new">{m['auth.newPassword']()}</label>
|
||||
<PasswordInput id="pw-new" bind:value={next} autocomplete="new-password" disabled={submitting} />
|
||||
|
||||
<label for="pw-confirm">{m['auth.confirmPassword']()}</label>
|
||||
<PasswordInput id="pw-confirm" bind:value={confirm} autocomplete="new-password" disabled={submitting} />
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{:else if done}
|
||||
<p class="ok" role="status">{m['auth.passwordChanged']()}</p>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" variant="primary" disabled={submitting}>
|
||||
{m['auth.changePassword']()}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 4px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
form :global(button[type='submit']) {
|
||||
margin-top: 12px;
|
||||
align-self: flex-start;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import AuthCard from './AuthCard.svelte';
|
||||
import ChangePasswordForm from './ChangePasswordForm.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Shown in place of the app when the signed-in user's password was set by somebody
|
||||
// else. Phase 1 could set must_change_password from the CLI and clear it through
|
||||
// POST /auth/password, but nothing ever made the user go through it, so the flag
|
||||
// was advisory. This is what makes it mean something.
|
||||
//
|
||||
// There is deliberately no way past this screen other than changing the password or
|
||||
// signing out. A "remind me later" would defeat the purpose: the administrator who
|
||||
// set the temporary password knows it.
|
||||
</script>
|
||||
|
||||
<AuthCard>
|
||||
<h1>{m['auth.mustChangeTitle']()}</h1>
|
||||
<p class="sub">{m['auth.mustChangeIntro']()}</p>
|
||||
|
||||
<ChangePasswordForm />
|
||||
|
||||
<button class="signout" type="button" onclick={() => auth.signOut()}>
|
||||
{m['auth.signOut']()}
|
||||
</button>
|
||||
</AuthCard>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signout {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-top: 12px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signout:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import AuthCard from './AuthCard.svelte';
|
||||
import PasswordInput from './PasswordInput.svelte';
|
||||
import Button from '$components/Button.svelte';
|
||||
import Checkbox from '$components/Checkbox.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { HttpError } from '$lib/api/http';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// A real <form> with a submit button, unlike the rest of the app, which is
|
||||
// change-on-blur auto-save. That is what gives Enter-to-submit and lets password
|
||||
// managers recognise and offer to save the credentials.
|
||||
|
||||
const UNAUTHORIZED = 401;
|
||||
const TOO_MANY_REQUESTS = 429;
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let remember = $state(false);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function submit() {
|
||||
if (submitting) return;
|
||||
error = '';
|
||||
if (!username.trim()) {
|
||||
error = m['auth.usernameRequired']();
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
try {
|
||||
await auth.signIn(username, password, remember);
|
||||
password = '';
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError && e.status === UNAUTHORIZED) error = m['auth.invalidCredentials']();
|
||||
else if (e instanceof HttpError && e.status === TOO_MANY_REQUESTS) error = m['auth.tooManyAttempts']();
|
||||
else error = m['auth.signInFailed']();
|
||||
password = '';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AuthCard>
|
||||
<h1>{m['auth.loginTitle']()}</h1>
|
||||
<p class="sub">{m['auth.loginSubtitle']()}</p>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<label for="login-username">{m['auth.username']()}</label>
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
autocomplete="username"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<label for="login-password">{m['auth.password']()}</label>
|
||||
<PasswordInput id="login-password" bind:value={password} disabled={submitting} />
|
||||
|
||||
<Checkbox bind:checked={remember} disabled={submitting} label={m['auth.rememberMe']()} />
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" variant="primary" disabled={submitting}>
|
||||
{submitting ? m['auth.signingIn']() + '…' : m['auth.signIn']()}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthCard>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0 0 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
label {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type='text']:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 4px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
form :global(button[type='submit']) {
|
||||
margin-top: 12px;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import Eye from '@lucide/svelte/icons/eye';
|
||||
import EyeOff from '@lucide/svelte/icons/eye-off';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// The app has no password field anywhere else — everything else is change-on-blur
|
||||
// auto-save, so this is the first real form control. The reveal toggle is a plain
|
||||
// button rather than an icon-only affordance so it carries a label for screen
|
||||
// readers, and it is sized to the 44px minimum the mobile a11y audit enforces.
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
id,
|
||||
autocomplete = 'current-password',
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
invalid = false
|
||||
}: {
|
||||
value?: string;
|
||||
id: string;
|
||||
autocomplete?: 'current-password' | 'new-password';
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
} = $props();
|
||||
|
||||
let revealed = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="wrap" class:invalid>
|
||||
{#if revealed}
|
||||
<input
|
||||
{id}
|
||||
type="text"
|
||||
bind:value
|
||||
{autocomplete}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
aria-invalid={invalid}
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
{:else}
|
||||
<input {id} type="password" bind:value {autocomplete} {placeholder} {disabled} aria-invalid={invalid} />
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="reveal"
|
||||
onclick={() => (revealed = !revealed)}
|
||||
aria-label={revealed ? m['auth.hidePassword']() : m['auth.showPassword']()}
|
||||
aria-pressed={revealed}
|
||||
{disabled}
|
||||
>
|
||||
{#if revealed}
|
||||
<EyeOff size={16} />
|
||||
{:else}
|
||||
<Eye size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wrap:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wrap.invalid {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
flex: 0 0 44px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reveal:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.reveal:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import Card from '$components/Card.svelte';
|
||||
import Button from '$components/Button.svelte';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Shows a value the server will never send again — a new API key, or a generated
|
||||
// password. Two rules follow from that and are the reason this is a component
|
||||
// rather than a toast:
|
||||
//
|
||||
// It never auto-dismisses. A toast that times out while the user is fetching their
|
||||
// password manager loses the only copy of the secret.
|
||||
//
|
||||
// It is never logged or stored. The value lives in the caller's state until
|
||||
// dismissed and nowhere else.
|
||||
|
||||
let {
|
||||
title,
|
||||
value,
|
||||
warning,
|
||||
ondismiss
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
warning: string;
|
||||
ondismiss: () => void;
|
||||
} = $props();
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toasts.success(m['account.keyCopied']());
|
||||
} catch {
|
||||
// The clipboard API is unavailable outside a secure context, which is exactly
|
||||
// the plain-HTTP homelab this feature serves. Say so rather than doing
|
||||
// nothing; the value is selectable, so manual copying still works.
|
||||
toasts.error(m['account.keyCopyFailed']());
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<div class="reveal">
|
||||
<div class="reveal-title">{title}</div>
|
||||
<p class="warn">{warning}</p>
|
||||
<div class="secret">
|
||||
<code>{value}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
onclick={copy}
|
||||
aria-label={m['account.keyCopy']()}
|
||||
title={m['account.keyCopy']()}
|
||||
>
|
||||
<Copy size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="outline" onclick={ondismiss}>{m['account.keyDone']()}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.reveal {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.reveal-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warn {
|
||||
margin: 4px 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--warning, var(--text-2));
|
||||
}
|
||||
|
||||
.secret {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--input-bg);
|
||||
}
|
||||
|
||||
.secret code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import AuthCard from './AuthCard.svelte';
|
||||
import PasswordInput from './PasswordInput.svelte';
|
||||
import Button from '$components/Button.svelte';
|
||||
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { HttpError } from '$lib/api/http';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Shown while the instance is unclaimed. Whoever submits this becomes the owner, so
|
||||
// the warning is prominent rather than tucked away — an instance left in this state
|
||||
// on a reachable network is claimable by anyone who finds it.
|
||||
|
||||
// Must match MIN_PASSWORD_LENGTH in spoolman/api/v1/models.py, which is what
|
||||
// actually enforces this; checking here only avoids a pointless round trip.
|
||||
const MIN_PASSWORD = 10;
|
||||
const CONFLICT = 409;
|
||||
|
||||
let username = $state('');
|
||||
let displayName = $state('');
|
||||
let password = $state('');
|
||||
let confirm = $state('');
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function submit() {
|
||||
if (submitting) return;
|
||||
error = '';
|
||||
if (!username.trim()) {
|
||||
error = m['auth.usernameRequired']();
|
||||
return;
|
||||
}
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
error = m['auth.passwordTooShort']({ min: MIN_PASSWORD });
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
error = m['auth.passwordMismatch']();
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
try {
|
||||
await auth.claim(username, password, displayName.trim() || undefined);
|
||||
password = '';
|
||||
confirm = '';
|
||||
} catch (e) {
|
||||
error =
|
||||
e instanceof HttpError && e.status === CONFLICT
|
||||
? m['auth.setupAlreadyClaimed']()
|
||||
: m['auth.setupFailed']();
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AuthCard>
|
||||
<h1>{m['auth.setupTitle']()}</h1>
|
||||
<p class="sub">{m['auth.setupIntro']()}</p>
|
||||
|
||||
<p class="warn">
|
||||
<TriangleAlert size={16} />
|
||||
<span>{m['auth.setupWarning']()}</span>
|
||||
</p>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<label for="setup-username">{m['auth.username']()}</label>
|
||||
<input
|
||||
id="setup-username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
autocomplete="username"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<label for="setup-display">{m['auth.displayNameOptional']()}</label>
|
||||
<input
|
||||
id="setup-display"
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
autocomplete="name"
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<label for="setup-password">{m['auth.password']()}</label>
|
||||
<PasswordInput
|
||||
id="setup-password"
|
||||
bind:value={password}
|
||||
autocomplete="new-password"
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<label for="setup-confirm">{m['auth.confirmPassword']()}</label>
|
||||
<PasswordInput
|
||||
id="setup-confirm"
|
||||
bind:value={confirm}
|
||||
autocomplete="new-password"
|
||||
disabled={submitting}
|
||||
invalid={confirm.length > 0 && confirm !== password}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" variant="primary" disabled={submitting}>
|
||||
{submitting ? m['auth.signingIn']() + '…' : m['auth.setupClaim']()}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthCard>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0 0 14px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.warn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
margin: 0 0 16px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.warn :global(svg) {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type='text']:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 4px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
form :global(button[type='submit']) {
|
||||
margin-top: 12px;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
@@ -28,6 +28,7 @@
|
||||
import { makeSaver, makeExtraSaver } from '$lib/utils/saver';
|
||||
import { trackSave } from '$lib/utils/autosave';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { filament }: { filament: Filament } = $props();
|
||||
|
||||
@@ -189,10 +190,11 @@
|
||||
<SectionLabel>{m['inspector.specs']()}</SectionLabel>
|
||||
<FieldGrid>
|
||||
<Field label={m['filament.fields.name']()}>
|
||||
<EditableField value={filament.name} oninput={(v) => set({ name: v })} />
|
||||
<EditableField disabled={!auth.canEdit} value={filament.name} oninput={(v) => set({ name: v })} />
|
||||
</Field>
|
||||
<Field label={m['filament.fields.material']()}>
|
||||
<Combobox
|
||||
disabled={!auth.canEdit}
|
||||
value={filament.material}
|
||||
options={materialNames}
|
||||
underline
|
||||
@@ -201,15 +203,20 @@
|
||||
</Field>
|
||||
<Field label={m['filament.fields.colorHex']()}>
|
||||
{#key filament.id}
|
||||
<ColorEditor
|
||||
colors={filament.colors}
|
||||
direction={filament.multiColorDirection}
|
||||
onchange={(v) => set({ colors: v.colors, multiColorDirection: v.direction })}
|
||||
/>
|
||||
<!-- ColorEditor has no disabled prop of its own; inert takes the whole
|
||||
subtree out of interaction without having to add one. -->
|
||||
<div class="gate" inert={!auth.canEdit}>
|
||||
<ColorEditor
|
||||
colors={filament.colors}
|
||||
direction={filament.multiColorDirection}
|
||||
onchange={(v) => set({ colors: v.colors, multiColorDirection: v.direction })}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
</Field>
|
||||
<Field label={m['filament.fields.diameter']()}>
|
||||
<NumberInput
|
||||
disabled={!auth.canEdit}
|
||||
dense
|
||||
width="200px"
|
||||
unit="mm"
|
||||
@@ -221,6 +228,7 @@
|
||||
</Field>
|
||||
<Field label={m['filament.fields.density']()}>
|
||||
<NumberInput
|
||||
disabled={!auth.canEdit}
|
||||
dense
|
||||
width="200px"
|
||||
unit="g/cm³"
|
||||
@@ -232,6 +240,7 @@
|
||||
</Field>
|
||||
<Field label={m['filament.fields.weight']()} help={m['filament.fieldsHelp.weight']()}>
|
||||
<NumberInput
|
||||
disabled={!auth.canEdit}
|
||||
dense
|
||||
width="200px"
|
||||
unit="g"
|
||||
@@ -243,6 +252,7 @@
|
||||
</Field>
|
||||
<Field label={m['filament.fields.spoolWeight']()} help={m['filament.fieldsHelp.spoolWeight']()}>
|
||||
<NumberInput
|
||||
disabled={!auth.canEdit}
|
||||
dense
|
||||
width="200px"
|
||||
unit="g"
|
||||
@@ -256,6 +266,7 @@
|
||||
</Field>
|
||||
<Field label={m['filament.fields.settingsExtruderTemp']()}>
|
||||
<NumberInput
|
||||
disabled={!auth.canEdit}
|
||||
dense
|
||||
width="200px"
|
||||
unit="°C"
|
||||
@@ -267,6 +278,7 @@
|
||||
</Field>
|
||||
<Field label={m['filament.fields.settingsBedTemp']()}>
|
||||
<NumberInput
|
||||
disabled={!auth.canEdit}
|
||||
dense
|
||||
width="200px"
|
||||
unit="°C"
|
||||
@@ -278,6 +290,7 @@
|
||||
</Field>
|
||||
<Field label={m['filament.fields.price']()}>
|
||||
<NumberInput
|
||||
disabled={!auth.canEdit}
|
||||
dense
|
||||
width="200px"
|
||||
unit={settings.currency}
|
||||
@@ -289,6 +302,7 @@
|
||||
</Field>
|
||||
<Field label={m['filament.fields.articleNumber']()} help={m['filament.fieldsHelp.articleNumber']()}>
|
||||
<EditableField
|
||||
disabled={!auth.canEdit}
|
||||
value={filament.articleNumber ?? ''}
|
||||
mono
|
||||
oninput={(v) => set({ articleNumber: v })}
|
||||
@@ -296,11 +310,20 @@
|
||||
</Field>
|
||||
<Field label={m['filament.fields.registered']()}>{filament.registeredLabel}</Field>
|
||||
<Field label={m['filament.fields.comment']()}>
|
||||
<EditableField value={filament.comment} oninput={(v) => set({ comment: v })} />
|
||||
<EditableField
|
||||
disabled={!auth.canEdit}
|
||||
value={filament.comment}
|
||||
oninput={(v) => set({ comment: v })}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGrid>
|
||||
|
||||
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={extraSaver.change} />
|
||||
<ExtraFieldsSection
|
||||
readonly={!auth.canEdit}
|
||||
entity="filament"
|
||||
extra={filament.extra}
|
||||
onchange={extraSaver.change}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,4 +452,9 @@
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wrapper exists only to carry `inert`; it must not affect layout. */
|
||||
.gate {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
@@ -26,6 +26,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { spool }: { spool: Spool } = $props();
|
||||
|
||||
@@ -265,7 +266,7 @@
|
||||
bind:value={adjustVal}
|
||||
unit={adjustInfo.unit}
|
||||
step={0.01}
|
||||
disabled={adjustBusy}
|
||||
disabled={adjustBusy || !auth.canEdit}
|
||||
width="130px"
|
||||
/>
|
||||
<Button onclick={applyAdjust} disabled={adjustBusy}
|
||||
@@ -287,6 +288,7 @@
|
||||
<FieldGrid>
|
||||
<Field label={m['spool.fields.location']()} help={m['spool.fieldsHelp.location']()}>
|
||||
<Combobox
|
||||
disabled={!auth.canEdit}
|
||||
value={spool.location}
|
||||
placeholder={m['library.noLocation']()}
|
||||
options={locationOptions}
|
||||
@@ -295,10 +297,11 @@
|
||||
/>
|
||||
</Field>
|
||||
<Field label={m['spool.fields.lotNr']()} help={m['spool.fieldsHelp.lotNr']()}>
|
||||
<EditableField value={spool.lot} mono oninput={(v) => set({ lot: v })} />
|
||||
<EditableField disabled={!auth.canEdit} value={spool.lot} mono oninput={(v) => set({ lot: v })} />
|
||||
</Field>
|
||||
<Field label={m['spool.fields.price']()} help={m['spool.fieldsHelp.price']()}>
|
||||
<NumberInput
|
||||
disabled={!auth.canEdit}
|
||||
dense
|
||||
width="240px"
|
||||
unit={settings.currencySymbol}
|
||||
@@ -319,11 +322,20 @@
|
||||
<DateTimeField value={spool.lastUsed} oninput={(iso) => set({ lastUsed: iso })} />
|
||||
</Field>
|
||||
<Field label={m['spool.fields.comment']()}>
|
||||
<EditableField value={spool.comment} oninput={(v) => set({ comment: v })} />
|
||||
<EditableField
|
||||
disabled={!auth.canEdit}
|
||||
value={spool.comment}
|
||||
oninput={(v) => set({ comment: v })}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGrid>
|
||||
|
||||
<ExtraFieldsSection entity="spool" extra={spool.extra} onchange={extraSaver.change} />
|
||||
<ExtraFieldsSection
|
||||
readonly={!auth.canEdit}
|
||||
entity="spool"
|
||||
extra={spool.extra}
|
||||
onchange={extraSaver.change}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
@@ -364,6 +376,8 @@
|
||||
{/if}
|
||||
</FieldGrid>
|
||||
|
||||
<!-- Already read-only regardless of permission: this shows the parent filament's
|
||||
fields from inside the spool inspector, which are edited on the filament. -->
|
||||
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={() => {}} readonly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { makeSaver, makeExtraSaver } from '$lib/utils/saver';
|
||||
import { trackSave } from '$lib/utils/autosave';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { vendor }: { vendor: Vendor } = $props();
|
||||
|
||||
@@ -81,10 +82,11 @@
|
||||
<SectionLabel>{m['filament.fields.vendor']()}</SectionLabel>
|
||||
<FieldGrid labelWidth="140px">
|
||||
<Field label={m['vendor.fields.name']()}>
|
||||
<EditableField value={vendor.name} oninput={(v) => set({ name: v })} />
|
||||
<EditableField disabled={!auth.canEdit} value={vendor.name} oninput={(v) => set({ name: v })} />
|
||||
</Field>
|
||||
<Field label={m['vendor.fields.emptySpoolWeight']()} help={m['vendor.fieldsHelp.emptySpoolWeight']()}>
|
||||
<NumberInput
|
||||
disabled={!auth.canEdit}
|
||||
dense
|
||||
unit="g"
|
||||
step={10}
|
||||
@@ -96,11 +98,20 @@
|
||||
</Field>
|
||||
<Field label={m['vendor.fields.registered']()}>{vendor.registeredLabel}</Field>
|
||||
<Field label={m['vendor.fields.comment']()}>
|
||||
<EditableField value={vendor.comment} oninput={(v) => set({ comment: v })} />
|
||||
<EditableField
|
||||
disabled={!auth.canEdit}
|
||||
value={vendor.comment}
|
||||
oninput={(v) => set({ comment: v })}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGrid>
|
||||
|
||||
<ExtraFieldsSection entity="vendor" extra={vendor.extra} onchange={extraSaver.change} />
|
||||
<ExtraFieldsSection
|
||||
readonly={!auth.canEdit}
|
||||
entity="vendor"
|
||||
extra={vendor.extra}
|
||||
onchange={extraSaver.change}
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<SectionLabel>{m['filament.filament']()}</SectionLabel>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
claimInstance,
|
||||
getAuthConfig,
|
||||
getAuthSession,
|
||||
login,
|
||||
logout,
|
||||
type AuthUserInfo,
|
||||
type Level
|
||||
} from '$lib/api/auth';
|
||||
import { setAuthHandlers } from '$lib/api/http';
|
||||
import { setLiveAuthCloseHandler } from '$lib/api/live';
|
||||
import { toasts } from './toasts.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Who is signed in, and what they may do.
|
||||
//
|
||||
// The important property: when the server reports authentication is disabled, every
|
||||
// capability getter answers true. That is what keeps the default configuration
|
||||
// pixel-identical to an instance built before auth existed — no call site needs to know
|
||||
// whether auth is on, and nothing has to be conditionally rendered for the common case.
|
||||
//
|
||||
// Gating here is cosmetic. The server is the authority; this only avoids showing people
|
||||
// buttons that would fail.
|
||||
|
||||
const RANKS: Record<Level, number> = { read: 0, edit: 1, manage: 2 };
|
||||
|
||||
class Auth {
|
||||
/** Whether the server enforces authentication at all. */
|
||||
enabled = $state(false);
|
||||
/** Whether the first load has finished. Nothing should render until this is true. */
|
||||
ready = $state(false);
|
||||
/** Whether the instance is unclaimed and awaiting an owner. */
|
||||
setupRequired = $state(false);
|
||||
/** Whether the server grants unauthenticated read access. */
|
||||
anonymousRead = $state(false);
|
||||
|
||||
authenticated = $state(false);
|
||||
anonymous = $state(false);
|
||||
level = $state<Level>('manage');
|
||||
isAdmin = $state(true);
|
||||
isOwner = $state(true);
|
||||
user = $state<AuthUserInfo | null>(null);
|
||||
|
||||
// Unlike the other stores, load() can be re-entered — by a sign-in, a sign-out, or a
|
||||
// 401 arriving mid-flight — so concurrent calls share one request.
|
||||
#inflight: Promise<void> | null = null;
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.#inflight ??= this.#load().finally(() => {
|
||||
this.#inflight = null;
|
||||
});
|
||||
return this.#inflight;
|
||||
}
|
||||
|
||||
async #load(): Promise<void> {
|
||||
try {
|
||||
const config = await getAuthConfig();
|
||||
this.enabled = config.enabled;
|
||||
this.setupRequired = config.setup_required;
|
||||
this.anonymousRead = config.anonymous_read;
|
||||
|
||||
if (!config.enabled) {
|
||||
this.#applyDisabled();
|
||||
return;
|
||||
}
|
||||
this.#apply(await getAuthSession());
|
||||
} catch (e) {
|
||||
// Reaching neither endpoint means we cannot know. Assume the restrictive
|
||||
// answer rather than rendering an interface the server will refuse.
|
||||
console.error('Failed to load authentication state', e);
|
||||
this.#applySignedOut();
|
||||
} finally {
|
||||
this.ready = true;
|
||||
}
|
||||
}
|
||||
|
||||
#applyDisabled(): void {
|
||||
this.authenticated = true;
|
||||
this.anonymous = false;
|
||||
this.level = 'manage';
|
||||
this.isAdmin = true;
|
||||
this.isOwner = true;
|
||||
this.user = null;
|
||||
}
|
||||
|
||||
#applySignedOut(): void {
|
||||
this.authenticated = false;
|
||||
this.anonymous = false;
|
||||
this.level = 'read';
|
||||
this.isAdmin = false;
|
||||
this.isOwner = false;
|
||||
this.user = null;
|
||||
}
|
||||
|
||||
#apply(session: {
|
||||
authenticated: boolean;
|
||||
anonymous: boolean;
|
||||
level: Level;
|
||||
is_admin: boolean;
|
||||
is_owner: boolean;
|
||||
user: AuthUserInfo | null;
|
||||
}): void {
|
||||
this.authenticated = session.authenticated;
|
||||
this.anonymous = session.anonymous;
|
||||
this.level = session.level;
|
||||
this.isAdmin = session.is_admin;
|
||||
this.isOwner = session.is_owner;
|
||||
this.user = session.user;
|
||||
}
|
||||
|
||||
async signIn(username: string, password: string, remember: boolean): Promise<void> {
|
||||
this.#apply(await login(username, password, remember));
|
||||
this.setupRequired = false;
|
||||
}
|
||||
|
||||
async claim(username: string, password: string, displayName?: string): Promise<void> {
|
||||
this.#apply(await claimInstance(username, password, displayName));
|
||||
this.setupRequired = false;
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
try {
|
||||
await logout();
|
||||
} catch (e) {
|
||||
// The session may already be gone server-side, which is not a failure worth
|
||||
// blocking on — the local state still has to be cleared either way.
|
||||
console.error('Sign-out request failed', e);
|
||||
}
|
||||
this.markSignedOut();
|
||||
}
|
||||
|
||||
/** Drop local session state, e.g. after the server answered 401. */
|
||||
markSignedOut(): void {
|
||||
if (!this.enabled) return;
|
||||
this.#applySignedOut();
|
||||
}
|
||||
|
||||
get canRead(): boolean {
|
||||
return !this.enabled || this.authenticated || this.anonymous;
|
||||
}
|
||||
|
||||
get canEdit(): boolean {
|
||||
return !this.enabled || (this.authenticated && RANKS[this.level] >= RANKS.edit);
|
||||
}
|
||||
|
||||
get canManage(): boolean {
|
||||
return !this.enabled || (this.authenticated && RANKS[this.level] >= RANKS.manage);
|
||||
}
|
||||
|
||||
/** True when the instance is unclaimed and the setup screen should be shown. */
|
||||
get needsSetup(): boolean {
|
||||
return this.enabled && this.setupRequired;
|
||||
}
|
||||
|
||||
/** True when a signed-in user must change their password before continuing. */
|
||||
get mustChangePassword(): boolean {
|
||||
return this.user?.must_change_password ?? false;
|
||||
}
|
||||
|
||||
/** How to address the signed-in user in the interface. */
|
||||
get displayName(): string {
|
||||
return this.user?.display_name || this.user?.username || '';
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new Auth();
|
||||
|
||||
// Wire the API layer's rejection handling back to this store. Registered here rather
|
||||
// than imported there, because http.ts and live.ts must not depend on a store.
|
||||
setAuthHandlers({
|
||||
unauthorized: () => auth.markSignedOut(),
|
||||
forbidden: () => toasts.error(m['auth.forbidden']())
|
||||
});
|
||||
|
||||
setLiveAuthCloseHandler(() => auth.markSignedOut());
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Level } from '$lib/api/auth';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Permission levels, for the interface. The ranking mirrors spoolman/auth/levels.py,
|
||||
// and is duplicated here rather than fetched because a client that has to ask the
|
||||
// server what "edit" outranks cannot render a form until it has.
|
||||
|
||||
export const LEVELS: Level[] = ['read', 'edit', 'manage'];
|
||||
|
||||
const RANKS: Record<Level, number> = { read: 0, edit: 1, manage: 2 };
|
||||
|
||||
export function levelLabel(level: Level): string {
|
||||
switch (level) {
|
||||
case 'read':
|
||||
return m['level.read']();
|
||||
case 'edit':
|
||||
return m['level.edit']();
|
||||
case 'manage':
|
||||
return m['level.manage']();
|
||||
}
|
||||
}
|
||||
|
||||
export function levelDescription(level: Level): string {
|
||||
switch (level) {
|
||||
case 'read':
|
||||
return m['level.readDesc']();
|
||||
case 'edit':
|
||||
return m['level.editDesc']();
|
||||
case 'manage':
|
||||
return m['level.manageDesc']();
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `actual` is at least as permissive as `required`. */
|
||||
export function levelCovers(actual: Level, required: Level): boolean {
|
||||
return RANKS[actual] >= RANKS[required];
|
||||
}
|
||||
|
||||
/** The levels a holder of `own` may grant — never above themselves, as the server enforces. */
|
||||
export function grantableLevels(own: Level): Level[] {
|
||||
return LEVELS.filter((level) => levelCovers(own, level));
|
||||
}
|
||||
@@ -5,12 +5,18 @@
|
||||
import AddSpoolModal from '$components/AddSpoolModal.svelte';
|
||||
import QrScannerModal from '$components/QrScannerModal.svelte';
|
||||
import Toaster from '$components/Toaster.svelte';
|
||||
import LoginScreen from '$components/auth/LoginScreen.svelte';
|
||||
import SetupScreen from '$components/auth/SetupScreen.svelte';
|
||||
import ForcePasswordChange from '$components/auth/ForcePasswordChange.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { serverInfo } from '$lib/stores/serverInfo.svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { startLiveSync } from '$lib/api/liveSync';
|
||||
import { live } from '$lib/api/live';
|
||||
import { getLocale, getTextDirection } from '$lib/paraglide/runtime';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
@@ -31,31 +37,64 @@
|
||||
document.documentElement.dir = getTextDirection();
|
||||
});
|
||||
|
||||
// Find out whether anyone needs to sign in before rendering anything. With auth
|
||||
// disabled this resolves immediately and reports full capability, so the default
|
||||
// configuration behaves exactly as it did before auth existed.
|
||||
$effect(() => {
|
||||
auth.load();
|
||||
});
|
||||
|
||||
// Load server settings and start central live-sync (keeps the reactive cache,
|
||||
// and thus every view that reads it, up to date with WebSocket events).
|
||||
//
|
||||
// Split from the effect above and gated on being able to read: starting live-sync
|
||||
// while signed out would open websockets the server immediately closes. Because
|
||||
// canRead is reactive, signing in re-runs this and everything starts for real; the
|
||||
// returned teardown runs on sign-out.
|
||||
$effect(() => {
|
||||
if (!auth.ready || !auth.canRead || auth.mustChangePassword) return;
|
||||
|
||||
settings.load();
|
||||
serverInfo.load();
|
||||
// Sockets stopped by an auth rejection stay stopped until told otherwise.
|
||||
live.rearm();
|
||||
|
||||
return startLiveSync();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
<TopBar onadd={() => ui.openAddModal()} onscan={() => ui.openScanner()} />
|
||||
{#if !auth.ready}
|
||||
<!-- Nothing renders until we know: showing the app and then yanking it away
|
||||
would flash the interface at someone who cannot use it. -->
|
||||
<div class="boot">{m['auth.loading']()}…</div>
|
||||
{:else if auth.needsSetup}
|
||||
<div class="app"><SetupScreen /></div>
|
||||
{:else if !auth.canRead}
|
||||
<div class="app"><LoginScreen /></div>
|
||||
{:else if auth.mustChangePassword}
|
||||
<!-- Ahead of the app, not inside it. A temporary password set by an administrator
|
||||
is one they know, so browsing on it should not be an option. -->
|
||||
<div class="app"><ForcePasswordChange /></div>
|
||||
{:else}
|
||||
<div class="app">
|
||||
<TopBar onadd={() => ui.openAddModal()} onscan={() => ui.openScanner()} />
|
||||
|
||||
<main>{@render children()}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<main>{@render children()}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<AddSpoolModal
|
||||
open={ui.addModalOpen}
|
||||
presetFilamentId={ui.addModalFilamentId}
|
||||
onclose={() => ui.closeAddModal()}
|
||||
/>
|
||||
{#if auth.canManage}
|
||||
<AddSpoolModal
|
||||
open={ui.addModalOpen}
|
||||
presetFilamentId={ui.addModalFilamentId}
|
||||
onclose={() => ui.closeAddModal()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<QrScannerModal open={ui.scannerOpen} onclose={() => ui.closeScanner()} />
|
||||
<QrScannerModal open={ui.scannerOpen} onclose={() => ui.closeScanner()} />
|
||||
{/if}
|
||||
|
||||
<!-- Outside the branches: sign-in errors need somewhere to surface too. -->
|
||||
<Toaster />
|
||||
|
||||
<style>
|
||||
@@ -73,4 +112,14 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.boot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import Card from '$components/Card.svelte';
|
||||
import SettingRow from '$components/settings/SettingRow.svelte';
|
||||
import ChangePasswordForm from '$components/auth/ChangePasswordForm.svelte';
|
||||
import ApiKeyList from '$components/auth/ApiKeyList.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { levelLabel } from '$lib/utils/levels';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// The signed-in user's own account. Unlike /users this needs no admin rights, and
|
||||
// unlike the rest of the app it is only reachable at all when auth is enabled —
|
||||
// with auth off there is no account to be the subject of any of it.
|
||||
|
||||
function roleLabel(): string {
|
||||
if (auth.isOwner) return m['account.roleOwner']();
|
||||
if (auth.isAdmin) return m['account.roleAdmin']();
|
||||
return m['account.roleUser']();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m['documentTitle.account']()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page scroll-y">
|
||||
<div class="wrap">
|
||||
<div class="title">{m['account.header']()}</div>
|
||||
|
||||
{#if !auth.enabled || !auth.authenticated}
|
||||
<!-- With auth off there is no account to describe, and no password or key
|
||||
endpoint that would answer. Reachable only by typing the URL. -->
|
||||
<p class="notice">{m['auth.forbidden']()}</p>
|
||||
{:else}
|
||||
<div class="sec-label">{m['account.profile']()}</div>
|
||||
<Card divided>
|
||||
<SettingRow title={m['auth.username']()}>
|
||||
<span class="value mono">{auth.user?.username ?? ''}</span>
|
||||
</SettingRow>
|
||||
{#if auth.user?.display_name}
|
||||
<SettingRow title={m['auth.displayName']()}>
|
||||
<span class="value">{auth.user.display_name}</span>
|
||||
</SettingRow>
|
||||
{/if}
|
||||
<SettingRow title={m['account.level']()}>
|
||||
<span class="value">{levelLabel(auth.level)}</span>
|
||||
</SettingRow>
|
||||
<SettingRow title={m['account.role']()}>
|
||||
<span class="value">{roleLabel()}</span>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
<div class="sec-label">{m['account.passwordSection']()}</div>
|
||||
<p class="desc">{m['account.passwordDesc']()}</p>
|
||||
<Card>
|
||||
<div class="pad"><ChangePasswordForm /></div>
|
||||
</Card>
|
||||
|
||||
<div class="sec-label">{m['account.keysSection']()}</div>
|
||||
<p class="desc">{m['account.keysDesc']()}</p>
|
||||
<ApiKeyList />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 22px 24px 48px;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.sec-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
padding: 22px 0 8px;
|
||||
}
|
||||
.desc {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.value {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
.pad {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.notice {
|
||||
margin: 12px 0 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,330 @@
|
||||
<script lang="ts">
|
||||
import Card from '$components/Card.svelte';
|
||||
import Pagination from '$components/Pagination.svelte';
|
||||
import { listAudit, listAuditEvents, type AuditEntry } from '$lib/api/admin';
|
||||
import { getSettings, parseSetting, setSetting } from '$lib/api/settings';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { formatDateTime } from '$lib/utils/datetime';
|
||||
import { trackSave } from '$lib/utils/autosave';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Administrator-only, read-only. There is no way to write or delete an entry from
|
||||
// here by design — the server offers none. Entries appear as a side effect of what
|
||||
// they record and leave when the retention window passes over them.
|
||||
|
||||
let pageSize = $state(50);
|
||||
let entries = $state<AuditEntry[]>([]);
|
||||
let total = $state(0);
|
||||
let page = $state(0);
|
||||
let event = $state('');
|
||||
let events = $state<string[]>([]);
|
||||
let loading = $state(true);
|
||||
let failed = $state(false);
|
||||
let retention = $state(90);
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.enabled || !auth.isAdmin) return;
|
||||
// Reading these here is what subscribes the effect to them, so changing any one
|
||||
// of them refetches.
|
||||
const limit = pageSize;
|
||||
const offset = page * pageSize;
|
||||
const filter = event;
|
||||
const controller = new AbortController();
|
||||
load(limit, offset, filter, controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.enabled || !auth.isAdmin) return;
|
||||
const controller = new AbortController();
|
||||
loadMeta(controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
async function load(limit: number, offset: number, filter: string, signal: AbortSignal) {
|
||||
loading = true;
|
||||
try {
|
||||
const result = await listAudit({ limit, offset, event: filter }, signal);
|
||||
entries = result.entries;
|
||||
total = result.total;
|
||||
failed = false;
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load the audit log', e);
|
||||
failed = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta(signal: AbortSignal) {
|
||||
try {
|
||||
events = await listAuditEvents(signal);
|
||||
const settings = await getSettings(signal);
|
||||
retention = parseSetting(settings.auth_audit_retention_days, 90);
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load audit metadata', e);
|
||||
}
|
||||
}
|
||||
|
||||
function saveRetention(value: string) {
|
||||
const days = Math.max(0, Math.floor(Number(value)));
|
||||
if (!Number.isFinite(days)) return;
|
||||
retention = days;
|
||||
trackSave(setSetting('auth_audit_retention_days', days));
|
||||
}
|
||||
|
||||
/** A readable name for an event, falling back to the raw identifier. */
|
||||
function eventLabel(name: string): string {
|
||||
const key = `audit.events.${name}`;
|
||||
const messages = m as unknown as Record<string, (() => string) | undefined>;
|
||||
return messages[key]?.() ?? name;
|
||||
}
|
||||
|
||||
function actorLabel(entry: AuditEntry): string {
|
||||
if (entry.actor_username) return entry.actor_username;
|
||||
if (entry.actor_user_id !== null) return m['audit.deletedActor']();
|
||||
return m['audit.anonymousActor']();
|
||||
}
|
||||
|
||||
/** Render the detail object as `key: value` pairs, since its shape varies by event. */
|
||||
function detailText(entry: AuditEntry): string {
|
||||
if (!entry.detail) return '';
|
||||
return Object.entries(entry.detail)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m['documentTitle.audit']()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page scroll-y">
|
||||
<div class="wrap">
|
||||
<div class="title">{m['audit.header']()}</div>
|
||||
<p class="desc">{m['audit.desc']()}</p>
|
||||
|
||||
{#if !auth.enabled || !auth.isAdmin}
|
||||
<!-- Reachable by typing the URL. Say so rather than firing requests the
|
||||
server will refuse. -->
|
||||
<p class="notice">{m['auth.forbidden']()}</p>
|
||||
{:else}
|
||||
<div class="bar">
|
||||
<label class="field">
|
||||
<span class="field-label">{m['audit.event']()}</span>
|
||||
<select
|
||||
bind:value={event}
|
||||
onchange={() => (page = 0)}
|
||||
aria-label={m['audit.event']()}
|
||||
class="filter"
|
||||
>
|
||||
<option value="">{m['audit.allEvents']()}</option>
|
||||
{#each events as name (name)}
|
||||
<option value={name}>{eventLabel(name)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">{m['audit.retention']()}</span>
|
||||
<span class="retention">
|
||||
<input
|
||||
class="num"
|
||||
type="number"
|
||||
min="0"
|
||||
value={retention}
|
||||
aria-label={m['audit.retention']()}
|
||||
onchange={(e) => saveRetention(e.currentTarget.value)}
|
||||
/>
|
||||
<span class="unit">{m['audit.retentionDays']()}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="desc small">{m['audit.retentionDesc']()}</p>
|
||||
|
||||
<Card divided>
|
||||
{#if loading}
|
||||
<div class="empty">{m['auth.loading']()}…</div>
|
||||
{:else if failed}
|
||||
<div class="empty">{m['audit.loadFailed']()}</div>
|
||||
{:else if entries.length === 0}
|
||||
<div class="empty">{m['audit.empty']()}</div>
|
||||
{:else}
|
||||
{#each entries as entry (entry.id)}
|
||||
<div class="entry">
|
||||
<div class="when">{formatDateTime(entry.date)}</div>
|
||||
<div class="what">
|
||||
<div class="event">
|
||||
{eventLabel(entry.event)}
|
||||
{#if entry.target}<span class="target">{entry.target}</span>{/if}
|
||||
</div>
|
||||
<div class="meta">
|
||||
{actorLabel(entry)}
|
||||
{#if entry.ip}· {entry.ip}{/if}
|
||||
</div>
|
||||
{#if entry.detail}
|
||||
<div class="meta detail">{detailText(entry)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if total > 0}
|
||||
<div class="pager">
|
||||
<Pagination
|
||||
page={page + 1}
|
||||
{pageSize}
|
||||
{total}
|
||||
onpage={(p) => (page = p - 1)}
|
||||
onpagesize={(size) => {
|
||||
pageSize = size;
|
||||
page = 0;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 22px 24px 48px;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.desc {
|
||||
margin: 3px 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.desc.small {
|
||||
margin: 6px 0 14px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.retention {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.unit {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.filter,
|
||||
.num {
|
||||
height: 44px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
}
|
||||
.filter {
|
||||
min-width: 180px;
|
||||
}
|
||||
.num {
|
||||
width: 76px;
|
||||
text-align: right;
|
||||
}
|
||||
.filter:focus,
|
||||
.num:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.when {
|
||||
flex: 0 0 150px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.what {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.event {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.target {
|
||||
margin-left: 6px;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta.detail {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.entry {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.when {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@
|
||||
import { theme, type ThemePref } from '$lib/stores/theme.svelte';
|
||||
import { locales, getLocale, setLocale, isLocale } from '$lib/paraglide/runtime.js';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { languages } from '$lib/i18n/languages';
|
||||
import { trackSave } from '$lib/utils/autosave';
|
||||
|
||||
@@ -40,99 +41,116 @@
|
||||
<div class="wrap">
|
||||
<div class="title">{m['settings.header']()}</div>
|
||||
|
||||
<div class="sec-label">{m['settings.appearance.tab']()}</div>
|
||||
<Card divided>
|
||||
<SettingRow title={m['settings.appearance.theme.label']()} desc={m['settings.appearance.theme.desc']()}>
|
||||
<div class="seg" role="group" aria-label={m['settings.appearance.theme.label']()}>
|
||||
{#each themeOptions as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg-btn"
|
||||
class:active={theme.pref === opt.value}
|
||||
aria-pressed={theme.pref === opt.value}
|
||||
onclick={() => theme.setPref(opt.value)}
|
||||
>
|
||||
{opt.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow title={m['settings.language.label']()} desc={m['settings.language.desc']()}>
|
||||
<select
|
||||
class="lang"
|
||||
aria-label={m['settings.language.label']()}
|
||||
value={getLocale()}
|
||||
onchange={(e) => {
|
||||
if (isLocale(e.currentTarget.value)) setLocale(e.currentTarget.value);
|
||||
}}
|
||||
{#if !auth.canManage}
|
||||
<p class="notice">{m['auth.readOnlyNotice']()}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Every control below writes through POST /setting/{key}, which the server
|
||||
gates at manage. `inert` removes the whole subtree from interaction and from
|
||||
the accessibility tree with one attribute, which beats threading a `disabled`
|
||||
prop through a dozen components that were never built to take one. This is
|
||||
cosmetic; the server is the authority. -->
|
||||
<div class="sections" inert={!auth.canManage}>
|
||||
<div class="sec-label">{m['settings.appearance.tab']()}</div>
|
||||
<Card divided>
|
||||
<SettingRow
|
||||
title={m['settings.appearance.theme.label']()}
|
||||
desc={m['settings.appearance.theme.desc']()}
|
||||
>
|
||||
{#each localeData as locale (locale.code)}
|
||||
<option value={locale.code}>{locale.langData.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
<div class="seg" role="group" aria-label={m['settings.appearance.theme.label']()}>
|
||||
{#each themeOptions as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg-btn"
|
||||
class:active={theme.pref === opt.value}
|
||||
aria-pressed={theme.pref === opt.value}
|
||||
onclick={() => theme.setPref(opt.value)}
|
||||
>
|
||||
{opt.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow title={m['settings.language.label']()} desc={m['settings.language.desc']()}>
|
||||
<select
|
||||
class="lang"
|
||||
aria-label={m['settings.language.label']()}
|
||||
value={getLocale()}
|
||||
onchange={(e) => {
|
||||
if (isLocale(e.currentTarget.value)) setLocale(e.currentTarget.value);
|
||||
}}
|
||||
>
|
||||
{#each localeData as locale (locale.code)}
|
||||
<option value={locale.code}>{locale.langData.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
<div class="sec-label">{m['settings.general.tab']()}</div>
|
||||
<Card divided>
|
||||
<SettingRow title={m['settings.general.currency.label']()} desc={m['settings.general.currency.desc']()}>
|
||||
<input
|
||||
class="code mono"
|
||||
aria-label={m['settings.general.currency.label']()}
|
||||
value={settings.currency}
|
||||
maxlength="3"
|
||||
onchange={(e) => saveCurrency(e.currentTarget.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title={m['settings.general.roundPrices.label']()}
|
||||
desc={m['settings.general.roundPrices.tooltip']()}
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.roundPrices}
|
||||
onchange={(v) => trackSave(settings.setRoundPrices(v))}
|
||||
ariaLabel={m['settings.general.roundPrices.label']()}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title={m['settings.general.baseUrl.label']()}
|
||||
desc={m['settings.general.baseUrl.tooltip']()}
|
||||
>
|
||||
<input
|
||||
class="url"
|
||||
aria-label={m['settings.general.baseUrl.label']()}
|
||||
value={settings.baseUrl}
|
||||
placeholder="https://spoolman.example.com"
|
||||
onchange={(e) => saveBaseUrl(e.currentTarget.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
<div class="sec-label">{m['settings.general.tab']()}</div>
|
||||
<Card divided>
|
||||
<SettingRow
|
||||
title={m['settings.general.currency.label']()}
|
||||
desc={m['settings.general.currency.desc']()}
|
||||
>
|
||||
<input
|
||||
class="code mono"
|
||||
aria-label={m['settings.general.currency.label']()}
|
||||
value={settings.currency}
|
||||
maxlength="3"
|
||||
onchange={(e) => saveCurrency(e.currentTarget.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title={m['settings.general.roundPrices.label']()}
|
||||
desc={m['settings.general.roundPrices.tooltip']()}
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.roundPrices}
|
||||
onchange={(v) => trackSave(settings.setRoundPrices(v))}
|
||||
ariaLabel={m['settings.general.roundPrices.label']()}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title={m['settings.general.baseUrl.label']()}
|
||||
desc={m['settings.general.baseUrl.tooltip']()}
|
||||
>
|
||||
<input
|
||||
class="url"
|
||||
aria-label={m['settings.general.baseUrl.label']()}
|
||||
value={settings.baseUrl}
|
||||
placeholder="https://spoolman.example.com"
|
||||
onchange={(e) => saveBaseUrl(e.currentTarget.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
<div class="sec-label">{m['settings.library.tab']()}</div>
|
||||
<Card divided>
|
||||
<SettingRow
|
||||
title={m['settings.library.lowThreshold.label']()}
|
||||
desc={m['settings.library.lowThreshold.desc']()}
|
||||
>
|
||||
<input
|
||||
class="num mono"
|
||||
type="number"
|
||||
aria-label={m['settings.library.lowThreshold.label']()}
|
||||
value={settings.lowThreshold}
|
||||
oninput={(e) => settings.setLowThreshold(Number(e.currentTarget.value) || 0)}
|
||||
/>
|
||||
<span class="unit">g</span>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
<div class="sec-label">{m['settings.library.tab']()}</div>
|
||||
<Card divided>
|
||||
<SettingRow
|
||||
title={m['settings.library.lowThreshold.label']()}
|
||||
desc={m['settings.library.lowThreshold.desc']()}
|
||||
>
|
||||
<input
|
||||
class="num mono"
|
||||
type="number"
|
||||
aria-label={m['settings.library.lowThreshold.label']()}
|
||||
value={settings.lowThreshold}
|
||||
oninput={(e) => settings.setLowThreshold(Number(e.currentTarget.value) || 0)}
|
||||
/>
|
||||
<span class="unit">g</span>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
<div class="sec-label">{m['settings.extraFields.tab']()}</div>
|
||||
<div class="subtitle sub2">
|
||||
<p>{m['settings.extraFields.description.intro']()}</p>
|
||||
<p>{m['settings.extraFields.description.constraints']()}</p>
|
||||
<p>{m['settings.extraFields.description.keyUsage']()}</p>
|
||||
<p>{m['settings.extraFields.description.tableViews']()}</p>
|
||||
<div class="sec-label">{m['settings.extraFields.tab']()}</div>
|
||||
<div class="subtitle sub2">
|
||||
<p>{m['settings.extraFields.description.intro']()}</p>
|
||||
<p>{m['settings.extraFields.description.constraints']()}</p>
|
||||
<p>{m['settings.extraFields.description.keyUsage']()}</p>
|
||||
<p>{m['settings.extraFields.description.tableViews']()}</p>
|
||||
</div>
|
||||
<ExtraFieldsManager />
|
||||
</div>
|
||||
<ExtraFieldsManager />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,4 +240,14 @@
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<script lang="ts">
|
||||
import Card from '$components/Card.svelte';
|
||||
import Button from '$components/Button.svelte';
|
||||
import Toggle from '$components/Toggle.svelte';
|
||||
import Checkbox from '$components/Checkbox.svelte';
|
||||
import PasswordInput from '$components/auth/PasswordInput.svelte';
|
||||
import SecretReveal from '$components/auth/SecretReveal.svelte';
|
||||
import KeyRound from '@lucide/svelte/icons/key-round';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
revokeUserSessions,
|
||||
updateUser
|
||||
} from '$lib/api/admin';
|
||||
import type { AuthUserInfo, Level } from '$lib/api/auth';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
import { HttpError, isAbortError } from '$lib/api/http';
|
||||
import { LEVELS, levelLabel } from '$lib/utils/levels';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Administrator-only. The server refuses every request here without admin rights,
|
||||
// so the page gates itself to avoid rendering an interface that cannot work.
|
||||
//
|
||||
// Every control that the server would refuse for a *specific* account — the owner's
|
||||
// level, an administrator's own active flag — is disabled with the reason shown,
|
||||
// rather than left enabled to fail. The rules are duplicated from
|
||||
// spoolman/api/v1/users.py::_protected; the server remains the authority.
|
||||
|
||||
let users = $state<AuthUserInfo[]>([]);
|
||||
let loading = $state(true);
|
||||
let failed = $state(false);
|
||||
let busy = $state<number | null>(null);
|
||||
|
||||
let creating = $state(false);
|
||||
let submitting = $state(false);
|
||||
let newUsername = $state('');
|
||||
let newPassword = $state('');
|
||||
let newDisplayName = $state('');
|
||||
let newLevel = $state<Level>('read');
|
||||
let newIsAdmin = $state(false);
|
||||
let newMustChange = $state(true);
|
||||
let formError = $state('');
|
||||
|
||||
// A generated password, held until dismissed. The server returns it once.
|
||||
let secret = $state<{ title: string; value: string } | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
// Nothing to fetch without admin rights; the guard in the markup explains why.
|
||||
if (!auth.enabled || !auth.isAdmin) return;
|
||||
const controller = new AbortController();
|
||||
load(controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
async function load(signal?: AbortSignal) {
|
||||
loading = true;
|
||||
try {
|
||||
users = await listUsers(signal);
|
||||
failed = false;
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load users', e);
|
||||
failed = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Why a destructive change to this account is refused, or '' when it is allowed. */
|
||||
function protectedReason(user: AuthUserInfo): string {
|
||||
if (user.is_owner) return m['users.protectedOwner']();
|
||||
if (user.id === auth.user?.id) return m['users.protectedSelf']();
|
||||
return '';
|
||||
}
|
||||
|
||||
async function patch(user: AuthUserInfo, change: Parameters<typeof updateUser>[1]) {
|
||||
busy = user.id;
|
||||
try {
|
||||
await updateUser(user.id, change);
|
||||
await load();
|
||||
toasts.success(m['users.saved']());
|
||||
} catch (e) {
|
||||
console.error('Failed to update user', e);
|
||||
toasts.error(e instanceof HttpError ? e.message : m['users.loadFailed']());
|
||||
// Reload regardless: the row on screen may now disagree with the server.
|
||||
await load();
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (submitting) return;
|
||||
if (!newUsername.trim()) {
|
||||
formError = m['auth.usernameRequired']();
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
formError = '';
|
||||
try {
|
||||
const created = await createUser({
|
||||
username: newUsername.trim(),
|
||||
password: newPassword ? newPassword : null,
|
||||
display_name: newDisplayName.trim() || null,
|
||||
level: newLevel,
|
||||
is_admin: newIsAdmin,
|
||||
must_change_password: newMustChange
|
||||
});
|
||||
creating = false;
|
||||
newUsername = newPassword = newDisplayName = '';
|
||||
if (created.password) {
|
||||
secret = { title: m['users.generatedPassword'](), value: created.password };
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
formError = e instanceof HttpError && e.status === 409 ? e.message : m['users.loadFailed']();
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(user: AuthUserInfo) {
|
||||
if (!confirm(m['users.resetWarning']())) return;
|
||||
busy = user.id;
|
||||
try {
|
||||
const result = await resetUserPassword(user.id, null, true);
|
||||
if (result.password) {
|
||||
secret = { title: m['users.generatedPassword'](), value: result.password };
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
console.error('Failed to reset password', e);
|
||||
toasts.error(m['users.loadFailed']());
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutEverywhere(user: AuthUserInfo) {
|
||||
busy = user.id;
|
||||
try {
|
||||
await revokeUserSessions(user.id);
|
||||
toasts.success(m['users.sessionsRevoked']());
|
||||
} catch (e) {
|
||||
console.error('Failed to revoke sessions', e);
|
||||
toasts.error(m['users.loadFailed']());
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(user: AuthUserInfo) {
|
||||
if (!confirm(m['users.deleteConfirm']({ name: user.username }))) return;
|
||||
busy = user.id;
|
||||
try {
|
||||
await deleteUser(user.id);
|
||||
toasts.success(m['users.deleted']());
|
||||
await load();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete user', e);
|
||||
toasts.error(e instanceof HttpError ? e.message : m['users.loadFailed']());
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m['documentTitle.users']()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page scroll-y">
|
||||
<div class="wrap">
|
||||
<div class="title">{m['users.header']()}</div>
|
||||
<p class="desc">{m['users.desc']()}</p>
|
||||
|
||||
{#if !auth.enabled || !auth.isAdmin}
|
||||
<!-- Reachable by typing the URL. Say so rather than firing a page of
|
||||
requests the server will refuse. -->
|
||||
<p class="notice">{m['auth.forbidden']()}</p>
|
||||
{:else}
|
||||
{#if secret}
|
||||
<SecretReveal
|
||||
title={secret.title}
|
||||
value={secret.value}
|
||||
warning={m['users.generatedPasswordWarning']()}
|
||||
ondismiss={() => (secret = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Card divided>
|
||||
{#if loading}
|
||||
<div class="empty">{m['auth.loading']()}…</div>
|
||||
{:else if failed}
|
||||
<div class="empty">{m['users.loadFailed']()}</div>
|
||||
{:else}
|
||||
{#each users as user (user.id)}
|
||||
{@const reason = protectedReason(user)}
|
||||
<div class="user" class:disabled={!user.is_active}>
|
||||
<div class="user-main">
|
||||
<div class="user-name">
|
||||
{user.username}
|
||||
{#if user.is_owner}<span class="badge">{m['account.roleOwner']()}</span>{/if}
|
||||
{#if user.id === auth.user?.id}<span class="badge dim">{m['users.you']()}</span>{/if}
|
||||
</div>
|
||||
{#if user.display_name}<div class="user-meta">{user.display_name}</div>{/if}
|
||||
<div class="user-meta">
|
||||
{#if !user.is_active}
|
||||
{m['users.statusDisabled']()} ·
|
||||
{/if}
|
||||
{#if user.must_change_password}
|
||||
{m['users.statusMustChange']()}
|
||||
{/if}
|
||||
</div>
|
||||
{#if reason}<div class="user-meta hint">{reason}</div>{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label class="ctl">
|
||||
<span class="ctl-label">{m['users.level']()}</span>
|
||||
<select
|
||||
value={user.level}
|
||||
disabled={!!reason || busy === user.id}
|
||||
aria-label={m['users.level']()}
|
||||
onchange={(e) => patch(user, { level: e.currentTarget.value as Level })}
|
||||
>
|
||||
{#each LEVELS as option (option)}
|
||||
<option value={option}>{levelLabel(option)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{m['users.admin']()}</span>
|
||||
<Toggle
|
||||
checked={user.is_admin}
|
||||
disabled={(!!reason && user.is_admin) || busy === user.id}
|
||||
ariaLabel={m['users.admin']()}
|
||||
onchange={(v) => patch(user, { is_admin: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{m['users.active']()}</span>
|
||||
<Toggle
|
||||
checked={user.is_active}
|
||||
disabled={(!!reason && user.is_active) || busy === user.id}
|
||||
ariaLabel={m['users.active']()}
|
||||
onchange={(v) => patch(user, { is_active: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="acts">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
disabled={busy === user.id}
|
||||
onclick={() => resetPassword(user)}
|
||||
aria-label={m['users.resetPassword']()}
|
||||
title={m['users.resetPassword']()}
|
||||
>
|
||||
<KeyRound size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
disabled={busy === user.id}
|
||||
onclick={() => signOutEverywhere(user)}
|
||||
aria-label={m['users.signOutEverywhere']()}
|
||||
title={m['users.signOutEverywhere']()}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon danger"
|
||||
disabled={!!reason || busy === user.id}
|
||||
onclick={() => remove(user)}
|
||||
aria-label={m['users.delete']()}
|
||||
title={reason || m['users.delete']()}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if creating}
|
||||
<Card>
|
||||
<form
|
||||
class="form"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitCreate();
|
||||
}}
|
||||
>
|
||||
<label for="nu-username">{m['users.username']()}</label>
|
||||
<input
|
||||
id="nu-username"
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
disabled={submitting}
|
||||
maxlength="64"
|
||||
/>
|
||||
|
||||
<label for="nu-display">{m['auth.displayNameOptional']()}</label>
|
||||
<input
|
||||
id="nu-display"
|
||||
type="text"
|
||||
bind:value={newDisplayName}
|
||||
disabled={submitting}
|
||||
maxlength="128"
|
||||
/>
|
||||
|
||||
<label for="nu-password">{m['users.passwordOptional']()}</label>
|
||||
<PasswordInput
|
||||
id="nu-password"
|
||||
bind:value={newPassword}
|
||||
autocomplete="new-password"
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<label for="nu-level">{m['users.level']()}</label>
|
||||
<select id="nu-level" bind:value={newLevel} disabled={submitting}>
|
||||
{#each LEVELS as option (option)}
|
||||
<option value={option}>{levelLabel(option)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<Checkbox bind:checked={newIsAdmin} disabled={submitting} label={m['users.admin']()} />
|
||||
<Checkbox
|
||||
bind:checked={newMustChange}
|
||||
disabled={submitting}
|
||||
label={m['users.mustChangePassword']()}
|
||||
/>
|
||||
|
||||
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<Button type="submit" variant="primary" disabled={submitting}>{m['users.create']()}</Button>
|
||||
<Button variant="ghost" onclick={() => (creating = false)}>{m['buttons.cancel']()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="add">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
formError = '';
|
||||
creating = true;
|
||||
}}>{m['users.newUser']()}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 22px 24px 48px;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.desc {
|
||||
margin: 3px 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.empty {
|
||||
padding: 16px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.user.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.user-main {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
.user-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.user-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.user-meta.hint {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-soft);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.badge.dim {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.ctl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ctl-label {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
select {
|
||||
height: 44px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* 44px, matching PasswordInput and the rest of the auth surfaces. */
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon:hover:not(:disabled) {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text);
|
||||
}
|
||||
.icon.danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
}
|
||||
.icon:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.form label {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.form input[type='text'],
|
||||
.form select {
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
}
|
||||
.form input[type='text']:focus,
|
||||
.form select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.error {
|
||||
margin: 4px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.add {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Auth tables.
|
||||
|
||||
Revision ID: 6e496ae15523
|
||||
Revises: 415a8f855e14
|
||||
Create Date: 2026-07-27 20:13:56.370544
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "6e496ae15523"
|
||||
down_revision = "415a8f855e14"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Perform the upgrade."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"auth_user",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("username", sa.String(length=64), nullable=False, comment="Stored lowercased."),
|
||||
sa.Column("display_name", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"email", sa.String(length=256), nullable=True, comment="Used for OIDC matching only, never delivery."
|
||||
),
|
||||
sa.Column("password_hash", sa.Text(), nullable=True, comment="Null for OIDC- or certificate-only accounts."),
|
||||
sa.Column(
|
||||
"level", sa.String(length=16), nullable=False, comment="read, edit or manage. See spoolman.auth.levels."
|
||||
),
|
||||
sa.Column("is_admin", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_owner", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("must_change_password", sa.Boolean(), nullable=False),
|
||||
sa.Column("totp_secret", sa.Text(), nullable=True, comment="Fernet-encrypted. Phase 4."),
|
||||
sa.Column("totp_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("oidc_issuer", sa.String(length=256), nullable=True, comment="Phase 3."),
|
||||
sa.Column("oidc_subject", sa.String(length=256), nullable=True, comment="Phase 3."),
|
||||
sa.Column("failed_logins", sa.Integer(), nullable=False),
|
||||
sa.Column("locked_until", sa.DateTime(), nullable=True),
|
||||
sa.Column("registered", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_login", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_auth_user_id"), "auth_user", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_auth_user_username"), "auth_user", ["username"], unique=True)
|
||||
op.create_table(
|
||||
"auth_api_key",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("level", sa.String(length=16), nullable=False),
|
||||
sa.Column("prefix", sa.String(length=16), nullable=False, comment="Indexes the row at lookup."),
|
||||
sa.Column("key_hash", sa.Text(), nullable=False),
|
||||
sa.Column("created", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_used", sa.DateTime(), nullable=True),
|
||||
sa.Column("revoked", sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["auth_user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_auth_api_key_id"), "auth_api_key", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_auth_api_key_prefix"), "auth_api_key", ["prefix"], unique=True)
|
||||
op.create_index(op.f("ix_auth_api_key_user_id"), "auth_api_key", ["user_id"], unique=False)
|
||||
op.create_table(
|
||||
"auth_audit_log",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("date", sa.DateTime(), nullable=False),
|
||||
sa.Column("event", sa.String(length=64), nullable=False),
|
||||
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("actor_kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("target", sa.String(length=128), nullable=True),
|
||||
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||
sa.Column("user_agent", sa.String(length=256), nullable=True),
|
||||
sa.Column("detail", sa.Text(), nullable=True, comment="JSON object."),
|
||||
sa.ForeignKeyConstraint(
|
||||
["actor_user_id"],
|
||||
["auth_user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_auth_audit_log_date"), "auth_audit_log", ["date"], unique=False)
|
||||
op.create_index(op.f("ix_auth_audit_log_id"), "auth_audit_log", ["id"], unique=False)
|
||||
op.create_table(
|
||||
"auth_certificate",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"fingerprint_sha256",
|
||||
sa.String(length=95),
|
||||
nullable=False,
|
||||
comment="Colon-separated uppercase hex, 95 chars.",
|
||||
),
|
||||
sa.Column("subject_dn", sa.String(length=512), nullable=False),
|
||||
sa.Column("created", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_used", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["auth_user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_auth_certificate_fingerprint_sha256"), "auth_certificate", ["fingerprint_sha256"], unique=True
|
||||
)
|
||||
op.create_index(op.f("ix_auth_certificate_id"), "auth_certificate", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_auth_certificate_user_id"), "auth_certificate", ["user_id"], unique=False)
|
||||
op.create_table(
|
||||
"auth_session",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False, comment="SHA-256 hex of the token."),
|
||||
sa.Column("created", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_seen", sa.DateTime(), nullable=False),
|
||||
sa.Column("remember", sa.Boolean(), nullable=False),
|
||||
sa.Column("user_agent", sa.String(length=256), nullable=True),
|
||||
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["auth_user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_auth_session_id"), "auth_session", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_auth_session_token_hash"), "auth_session", ["token_hash"], unique=True)
|
||||
op.create_index(op.f("ix_auth_session_user_id"), "auth_session", ["user_id"], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Perform the downgrade."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_auth_session_user_id"), table_name="auth_session")
|
||||
op.drop_index(op.f("ix_auth_session_token_hash"), table_name="auth_session")
|
||||
op.drop_index(op.f("ix_auth_session_id"), table_name="auth_session")
|
||||
op.drop_table("auth_session")
|
||||
op.drop_index(op.f("ix_auth_certificate_user_id"), table_name="auth_certificate")
|
||||
op.drop_index(op.f("ix_auth_certificate_id"), table_name="auth_certificate")
|
||||
op.drop_index(op.f("ix_auth_certificate_fingerprint_sha256"), table_name="auth_certificate")
|
||||
op.drop_table("auth_certificate")
|
||||
op.drop_index(op.f("ix_auth_audit_log_id"), table_name="auth_audit_log")
|
||||
op.drop_index(op.f("ix_auth_audit_log_date"), table_name="auth_audit_log")
|
||||
op.drop_table("auth_audit_log")
|
||||
op.drop_index(op.f("ix_auth_api_key_user_id"), table_name="auth_api_key")
|
||||
op.drop_index(op.f("ix_auth_api_key_prefix"), table_name="auth_api_key")
|
||||
op.drop_index(op.f("ix_auth_api_key_id"), table_name="auth_api_key")
|
||||
op.drop_table("auth_api_key")
|
||||
op.drop_index(op.f("ix_auth_user_username"), table_name="auth_user")
|
||||
op.drop_index(op.f("ix_auth_user_id"), table_name="auth_user")
|
||||
op.drop_table("auth_user")
|
||||
# ### end Alembic commands ###
|
||||
+15
-3
@@ -25,7 +25,7 @@ dependencies = [
|
||||
"hishel~=0.1",
|
||||
"aiomysql[rsa]~=0.3",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -69,7 +69,7 @@ replace = 'version = "{new_version}"'
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
target-version = "py314"
|
||||
extend-exclude = ["client_v2"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
@@ -92,6 +92,16 @@ ignore = [
|
||||
"S104",
|
||||
"S603",
|
||||
"SIM108",
|
||||
# TC001/2/3 want type-only imports moved into `if TYPE_CHECKING:` blocks. That is
|
||||
# wrong here: FastAPI resolves `Annotated[AsyncSession, Depends(...)]` and websocket
|
||||
# parameters at runtime, Pydantic resolves field types at runtime, and SQLAlchemy
|
||||
# resolves `Mapped[...]` at mapper-configuration time. A TYPE_CHECKING-only import is
|
||||
# absent from the runtime namespace, so applying these breaks dependency injection.
|
||||
# Ruff marks all of them as unsafe fixes for exactly this reason. They only began
|
||||
# firing when target-version moved to py314 (PEP 649 deferred annotations).
|
||||
"TC001",
|
||||
"TC002",
|
||||
"TC003",
|
||||
"TD002",
|
||||
"TD003",
|
||||
"TRY003",
|
||||
@@ -99,5 +109,7 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests*/*" = ["ANN201", "ASYNC210", "S101", "PLR2004", "D103", "TID252"]
|
||||
# S105/S106 flag string literals that look like passwords. Authentication tests have to
|
||||
# name the credentials they sign in with, so the rule only produces noise there.
|
||||
"tests*/*" = ["ANN201", "ASYNC210", "S101", "S105", "S106", "PLR2004", "D103", "TID252"]
|
||||
"migrations/versions/*" = ["N999"]
|
||||
@@ -0,0 +1,218 @@
|
||||
"""API key endpoints.
|
||||
|
||||
A key is a machine credential: something a printer, a script or a scrape job presents on
|
||||
every request, with no way to sign in and no browser to redirect. It belongs to a user
|
||||
and can never outrank them.
|
||||
|
||||
Every route here is gated by ``require_user``, so a key cannot manage keys -- not even
|
||||
its own. See that gate's docstring for why.
|
||||
"""
|
||||
|
||||
# ruff: noqa: D103
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import Response
|
||||
|
||||
from spoolman.auth.audit import AuditEvent, record
|
||||
from spoolman.auth.dependencies import require_user
|
||||
from spoolman.auth.levels import covers, parse_level
|
||||
from spoolman.auth.principal import Principal
|
||||
from spoolman.database import auth_api_key as auth_api_key_db
|
||||
from spoolman.database import auth_user as auth_user_db
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
|
||||
from . import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/auth/apikey",
|
||||
tags=["auth"],
|
||||
)
|
||||
|
||||
BAD_REQUEST = 400
|
||||
FORBIDDEN = 403
|
||||
NOT_FOUND = 404
|
||||
CREATED = 201
|
||||
|
||||
# Keys a single account may hold at once. Not a security boundary; a stop on the one
|
||||
# unbounded write an ordinary user can perform through this API.
|
||||
MAX_KEYS_PER_USER = 50
|
||||
|
||||
|
||||
def _message(status_code: int, message: str) -> JSONResponse:
|
||||
"""Build an error response in the shape the rest of this API uses."""
|
||||
return JSONResponse(status_code=status_code, content={"message": message})
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
name="List own API keys",
|
||||
description=(
|
||||
"List the calling user's API keys. Secrets are never included: the complete key "
|
||||
"is returned only once, by the request that created it. Revoked and expired keys "
|
||||
"are listed too, so that a key which stopped working can be told apart from one "
|
||||
"that was never made."
|
||||
),
|
||||
response_model=list[models.AuthApiKeyInfo],
|
||||
)
|
||||
async def find(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
principal: Annotated[Principal, Depends(require_user())],
|
||||
) -> Response:
|
||||
if principal.user_id is None:
|
||||
# Only reachable with authentication disabled, where there is no account to own
|
||||
# a key. Answering with an empty list rather than an error keeps the account
|
||||
# page renderable on an unauthenticated instance.
|
||||
return JSONResponse(content=[], headers={"x-total-count": "0"})
|
||||
|
||||
user = await auth_user_db.get_by_id(db, principal.user_id)
|
||||
keys = await auth_api_key_db.find_for_user(db=db, user_id=principal.user_id)
|
||||
return JSONResponse(
|
||||
content=jsonable_encoder(
|
||||
[models.AuthApiKeyInfo.from_db(key, user.level) for key in keys],
|
||||
exclude_none=True,
|
||||
),
|
||||
headers={"x-total-count": str(len(keys))},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
name="Create an API key",
|
||||
description=(
|
||||
"Issue a new API key. The complete key is in the response and is never "
|
||||
"retrievable again. Present it in an `X-API-Key` header, or as a bearer token."
|
||||
),
|
||||
response_model=models.AuthApiKeyCreated,
|
||||
responses={400: {"model": models.Message}, 403: {"model": models.Message}},
|
||||
status_code=201,
|
||||
)
|
||||
async def create(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(require_user())],
|
||||
body: models.ApiKeyCreateRequest,
|
||||
) -> Response:
|
||||
if principal.user_id is None:
|
||||
return _message(FORBIDDEN, "API keys require an account, and authentication is not enabled.")
|
||||
|
||||
try:
|
||||
level = parse_level(body.level)
|
||||
except ValueError as exc:
|
||||
return _message(BAD_REQUEST, str(exc))
|
||||
|
||||
# Refused rather than silently capped. A key quietly issued weaker than asked for is
|
||||
# a support ticket six months later when a script starts failing; an error now is
|
||||
# read immediately.
|
||||
if not covers(principal.level, level):
|
||||
return _message(FORBIDDEN, f"You cannot issue a key at the '{level}' level, which is above your own.")
|
||||
|
||||
if await auth_api_key_db.count_for_user(db, principal.user_id) >= MAX_KEYS_PER_USER:
|
||||
return _message(
|
||||
BAD_REQUEST,
|
||||
f"You already have {MAX_KEYS_PER_USER} API keys. Delete one before creating another.",
|
||||
)
|
||||
|
||||
user = await auth_user_db.get_by_id(db, principal.user_id)
|
||||
key, plaintext = await auth_api_key_db.create(
|
||||
db=db,
|
||||
user_id=principal.user_id,
|
||||
name=body.name,
|
||||
level=level,
|
||||
expires_days=body.expires_days,
|
||||
)
|
||||
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.APIKEY_CREATED,
|
||||
principal=principal,
|
||||
target=key.name,
|
||||
detail={"prefix": key.prefix, "level": str(level), "expires_days": body.expires_days},
|
||||
)
|
||||
|
||||
created = models.AuthApiKeyCreated(
|
||||
key=plaintext,
|
||||
info=models.AuthApiKeyInfo.from_db(key, user.level),
|
||||
)
|
||||
return JSONResponse(status_code=CREATED, content=jsonable_encoder(created, exclude_none=True))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{key_id}/revoke",
|
||||
name="Revoke an API key",
|
||||
description=(
|
||||
"Stop a key from authenticating while keeping it listed. Prefer this to deleting "
|
||||
"when you want the record of the key to survive."
|
||||
),
|
||||
response_model=models.AuthApiKeyInfo,
|
||||
responses={403: {"model": models.Message}, 404: {"model": models.Message}},
|
||||
)
|
||||
async def revoke(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(require_user())],
|
||||
key_id: int,
|
||||
) -> Response:
|
||||
try:
|
||||
key = await auth_api_key_db.get_by_id(db, key_id)
|
||||
except ItemNotFoundError as exc:
|
||||
return _message(NOT_FOUND, str(exc))
|
||||
|
||||
if key.user_id != principal.user_id:
|
||||
# 404 rather than 403. Answering "forbidden" would confirm that a key with this
|
||||
# ID exists and belongs to somebody else, which is more than a caller who does
|
||||
# not own it needs to know.
|
||||
return _message(NOT_FOUND, f"No API key with ID {key_id} found.")
|
||||
|
||||
await auth_api_key_db.revoke(db=db, key=key)
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.APIKEY_REVOKED,
|
||||
principal=principal,
|
||||
target=key.name,
|
||||
detail={"prefix": key.prefix},
|
||||
)
|
||||
|
||||
user = await auth_user_db.get_by_id(db, key.user_id)
|
||||
return JSONResponse(content=jsonable_encoder(models.AuthApiKeyInfo.from_db(key, user.level), exclude_none=True))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{key_id}",
|
||||
name="Delete an API key",
|
||||
description="Remove a key entirely. The key stops working immediately.",
|
||||
response_model=models.Message,
|
||||
responses={403: {"model": models.Message}, 404: {"model": models.Message}},
|
||||
)
|
||||
async def delete(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(require_user())],
|
||||
key_id: int,
|
||||
) -> Response:
|
||||
try:
|
||||
key = await auth_api_key_db.get_by_id(db, key_id)
|
||||
except ItemNotFoundError as exc:
|
||||
return _message(NOT_FOUND, str(exc))
|
||||
|
||||
if key.user_id != principal.user_id:
|
||||
return _message(NOT_FOUND, f"No API key with ID {key_id} found.")
|
||||
|
||||
name, prefix = key.name, key.prefix
|
||||
await auth_api_key_db.delete_key(db=db, key=key)
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.APIKEY_DELETED,
|
||||
principal=principal,
|
||||
target=name,
|
||||
detail={"prefix": prefix},
|
||||
)
|
||||
return JSONResponse(content={"message": "API key deleted."})
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Audit log endpoint.
|
||||
|
||||
Read-only, and administrators only. The log records addresses, user agents and which
|
||||
accounts exist, so exposing it at the ``read`` level would hand an anonymous reader a
|
||||
map of the instance's users and a record of every sign-in attempt against them.
|
||||
|
||||
There is deliberately no way to write an entry through the API, and no way to delete
|
||||
one. Entries appear as a side effect of the operations that produce them, and leave only
|
||||
when the retention window in :data:`spoolman.auth.audit.RETENTION_SETTING` passes over
|
||||
them. A log that its subjects can edit is not evidence of anything.
|
||||
"""
|
||||
|
||||
# ruff: noqa: D103
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import Response
|
||||
|
||||
from spoolman.auth.audit import AuditEvent
|
||||
from spoolman.auth.dependencies import require_admin
|
||||
from spoolman.auth.principal import Principal
|
||||
from spoolman.database import auth_audit as auth_audit_db
|
||||
from spoolman.database import auth_user as auth_user_db
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
|
||||
from . import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/auth/audit",
|
||||
tags=["auth"],
|
||||
)
|
||||
|
||||
# Cap on a single page. The table is the one in the schema that grows without an upper
|
||||
# bound, so an unbounded list endpoint over it is an easy way to exhaust memory.
|
||||
MAX_LIMIT = 500
|
||||
DEFAULT_LIMIT = 100
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
name="Read the audit log",
|
||||
description=(
|
||||
"List audit entries, newest first. Administrators only. Entries cannot be "
|
||||
"created or deleted through the API; they appear as a side effect of the "
|
||||
"operations they record and expire with the retention setting."
|
||||
),
|
||||
response_model=list[models.AuthAuditEntry],
|
||||
)
|
||||
async def find(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
_principal: Annotated[Principal, Depends(require_admin())],
|
||||
limit: Annotated[int, Query(ge=1, le=MAX_LIMIT, description="Maximum number of entries to return.")] = (
|
||||
DEFAULT_LIMIT
|
||||
),
|
||||
offset: Annotated[int, Query(ge=0, description="How many entries to skip.")] = 0,
|
||||
event: Annotated[str | None, Query(description="Only return entries for this event.")] = None,
|
||||
actor_user_id: Annotated[int | None, Query(description="Only return entries by this user.")] = None,
|
||||
) -> Response:
|
||||
entries, total = await auth_audit_db.find(
|
||||
db=db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
event=event,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
# Resolve the usernames in one pass rather than per row. A page of a hundred entries
|
||||
# is typically a handful of distinct actors, and the alternative is a hundred
|
||||
# queries against a database that may be a single SQLite file.
|
||||
usernames: dict[int, str] = {}
|
||||
for actor_id in {entry.actor_user_id for entry in entries if entry.actor_user_id is not None}:
|
||||
try:
|
||||
usernames[actor_id] = (await auth_user_db.get_by_id(db, actor_id)).username
|
||||
except ItemNotFoundError:
|
||||
# The account was deleted between the log write and now. The entry stays,
|
||||
# unattributed, which is the point of keeping history past its subject.
|
||||
logger.debug("Audit entry references user %d, which no longer exists.", actor_id)
|
||||
|
||||
return JSONResponse(
|
||||
content=jsonable_encoder(
|
||||
[
|
||||
models.AuthAuditEntry.from_db(
|
||||
entry,
|
||||
usernames.get(entry.actor_user_id) if entry.actor_user_id is not None else None,
|
||||
)
|
||||
for entry in entries
|
||||
],
|
||||
exclude_none=True,
|
||||
),
|
||||
headers={"x-total-count": str(total)},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events",
|
||||
name="List audit event types",
|
||||
description=(
|
||||
"List every event name this server can record, so a client can offer a filter "
|
||||
"without hardcoding the vocabulary."
|
||||
),
|
||||
response_model=list[str],
|
||||
)
|
||||
async def events(
|
||||
_principal: Annotated[Principal, Depends(require_admin())],
|
||||
) -> Response:
|
||||
return JSONResponse(content=[str(event) for event in AuditEvent])
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Sign-in, sign-out and account endpoints.
|
||||
|
||||
Every endpoint here returns a JSON body, never a bare 204. The client's fetch helpers
|
||||
parse a body unconditionally, so an empty response would surface as a parse failure
|
||||
rather than a success.
|
||||
|
||||
These routes are reachable without credentials by necessity -- they are how a credential
|
||||
is obtained -- so each does its own checking rather than relying on a permission gate.
|
||||
"""
|
||||
|
||||
# ruff: noqa: D103
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman import env
|
||||
from spoolman.auth import cookies
|
||||
from spoolman.auth.audit import AuditEvent, record
|
||||
from spoolman.auth.dependencies import anonymous_read_enabled, require_user, resolve_principal
|
||||
from spoolman.auth.hashing import (
|
||||
dummy_verify_async,
|
||||
hash_password_async,
|
||||
needs_rehash,
|
||||
new_token,
|
||||
verify_password_async,
|
||||
)
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.auth.principal import Principal, PrincipalKind
|
||||
from spoolman.auth.ratelimit import client_ip, ip_window
|
||||
from spoolman.database import auth_session as auth_session_db
|
||||
from spoolman.database import auth_user as auth_user_db
|
||||
from spoolman.database import models as db_models
|
||||
from spoolman.database.database import get_db_session
|
||||
|
||||
from . import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
)
|
||||
|
||||
# One message for every failed sign-in. Distinguishing "no such user" from "wrong
|
||||
# password" would let anyone enumerate accounts; the equal-time dummy verify below
|
||||
# closes the same leak in the timing channel.
|
||||
INVALID_CREDENTIALS = "Incorrect username or password."
|
||||
|
||||
BAD_REQUEST = 400
|
||||
UNAUTHORIZED = 401
|
||||
FORBIDDEN = 403
|
||||
CONFLICT = 409
|
||||
TOO_MANY_REQUESTS = 429
|
||||
CREATED = 201
|
||||
|
||||
USER_AGENT_HEADER = "user-agent"
|
||||
|
||||
|
||||
def _message(status_code: int, message: str, headers: dict[str, str] | None = None) -> JSONResponse:
|
||||
"""Build an error response in the shape the rest of this API uses."""
|
||||
return JSONResponse(status_code=status_code, content={"message": message}, headers=headers)
|
||||
|
||||
|
||||
def _session_info(principal: Principal | None, user: models.AuthUserInfo | None = None) -> models.AuthSessionInfo:
|
||||
"""Describe the caller's standing."""
|
||||
if principal is None:
|
||||
return models.AuthSessionInfo(
|
||||
authenticated=False,
|
||||
anonymous=False,
|
||||
level=str(Level.READ),
|
||||
is_admin=False,
|
||||
is_owner=False,
|
||||
)
|
||||
return models.AuthSessionInfo(
|
||||
authenticated=principal.kind in {PrincipalKind.USER, PrincipalKind.DISABLED},
|
||||
anonymous=principal.kind is PrincipalKind.ANONYMOUS,
|
||||
level=str(principal.level),
|
||||
is_admin=principal.is_admin,
|
||||
is_owner=principal.is_owner,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def _failure_reason(user: db_models.AuthUser | None) -> str:
|
||||
"""Say why a sign-in failed before a password was even checked, for the audit log."""
|
||||
if user is None:
|
||||
return "no_such_user"
|
||||
if not user.is_active:
|
||||
return "account_disabled"
|
||||
return "no_password_set"
|
||||
|
||||
|
||||
def _throttled(address: str) -> JSONResponse | None:
|
||||
"""Check the per-address sign-in throttle, returning a 429 response if it has tripped.
|
||||
|
||||
Checked before any password hashing, so a flood of attempts cannot be turned into
|
||||
CPU exhaustion against the single server process. Per-account throttling is separate
|
||||
and lives on the user row; see :func:`spoolman.database.auth_user.lockout_for`.
|
||||
"""
|
||||
if not address:
|
||||
return None
|
||||
decision = ip_window.check(address)
|
||||
if decision.allowed:
|
||||
return None
|
||||
return _message(
|
||||
TOO_MANY_REQUESTS,
|
||||
"Too many sign-in attempts. Try again shortly.",
|
||||
{"Retry-After": str(decision.retry_after)},
|
||||
)
|
||||
|
||||
|
||||
async def _start_session(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
request: Request,
|
||||
user_id: int,
|
||||
remember: bool,
|
||||
) -> tuple[str, str]:
|
||||
"""Create a session row and return the tokens the cookies should carry."""
|
||||
_, token = await auth_session_db.create(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
remember=remember,
|
||||
user_agent=request.headers.get(USER_AGENT_HEADER),
|
||||
ip=client_ip(request) or None,
|
||||
)
|
||||
return token, new_token()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/config",
|
||||
name="Get authentication configuration",
|
||||
description=(
|
||||
"Report whether authentication is enabled and how to sign in. Always reachable "
|
||||
"without credentials, since a client needs this before it can present any."
|
||||
),
|
||||
response_model=models.AuthConfig,
|
||||
)
|
||||
async def get_config(db: Annotated[AsyncSession, Depends(get_db_session)]) -> models.AuthConfig:
|
||||
if not env.is_auth_enabled():
|
||||
return models.AuthConfig(enabled=False, setup_required=False, anonymous_read=False)
|
||||
return models.AuthConfig(
|
||||
enabled=True,
|
||||
setup_required=await auth_user_db.count(db) == 0,
|
||||
anonymous_read=await anonymous_read_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/setup",
|
||||
name="Claim this instance",
|
||||
description=(
|
||||
"Create the owner account. Only possible while no account exists; the owner "
|
||||
"administers everyone else. The caller is signed in as the new owner."
|
||||
),
|
||||
response_model=models.AuthSessionInfo,
|
||||
responses={403: {"model": models.Message}, 409: {"model": models.Message}},
|
||||
status_code=201,
|
||||
)
|
||||
async def setup(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
body: models.SetupRequest,
|
||||
) -> Response:
|
||||
if not env.is_auth_enabled():
|
||||
return _message(FORBIDDEN, "Authentication is not enabled on this instance.")
|
||||
if await auth_user_db.count(db) > 0:
|
||||
return _message(CONFLICT, "This instance has already been claimed.")
|
||||
|
||||
password_hash = await hash_password_async(body.password)
|
||||
try:
|
||||
user = await auth_user_db.create(
|
||||
db=db,
|
||||
username=body.username,
|
||||
password_hash=password_hash,
|
||||
level=Level.MANAGE,
|
||||
is_admin=True,
|
||||
is_owner=True,
|
||||
display_name=body.display_name,
|
||||
)
|
||||
except IntegrityError:
|
||||
# Two callers raced for an unclaimed instance; the unique index on username
|
||||
# decided it. The loser is told the same thing as a late arrival.
|
||||
await db.rollback()
|
||||
return _message(CONFLICT, "This instance has already been claimed.")
|
||||
|
||||
# Loud on purpose: whoever reached the instance first now owns it, and the operator
|
||||
# should be able to see from the log whether that was them.
|
||||
logger.warning(
|
||||
"Instance ownership claimed by %r from %s.",
|
||||
user.username,
|
||||
client_ip(request) or "an unknown address",
|
||||
)
|
||||
|
||||
await record(request, AuditEvent.INSTANCE_CLAIMED, actor_user_id=user.id, target=user.username)
|
||||
|
||||
token, csrf = await _start_session(db=db, request=request, user_id=user.id, remember=False)
|
||||
info = _session_info(
|
||||
Principal(
|
||||
kind=PrincipalKind.USER,
|
||||
level=Level.MANAGE,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
is_admin=True,
|
||||
is_owner=True,
|
||||
),
|
||||
models.AuthUserInfo.from_db(user),
|
||||
)
|
||||
response = JSONResponse(status_code=CREATED, content=info.model_dump())
|
||||
cookies.set_session_cookies(response, request, token=token, csrf=csrf, remember=False)
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
name="Sign in",
|
||||
description="Exchange a username and password for a session cookie.",
|
||||
response_model=models.AuthSessionInfo,
|
||||
responses={
|
||||
401: {"model": models.Message},
|
||||
403: {"model": models.Message},
|
||||
429: {"model": models.Message},
|
||||
},
|
||||
)
|
||||
async def login(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
body: models.LoginRequest,
|
||||
) -> Response:
|
||||
if not env.is_auth_enabled():
|
||||
return _message(FORBIDDEN, "Authentication is not enabled on this instance.")
|
||||
|
||||
username = auth_user_db.normalize_username(body.username)
|
||||
address = client_ip(request)
|
||||
|
||||
throttled = _throttled(address)
|
||||
if throttled is not None:
|
||||
return throttled
|
||||
|
||||
user = await auth_user_db.get_by_username(db, username)
|
||||
|
||||
if user is None or not user.is_active or user.password_hash is None:
|
||||
# Spend a verification's worth of time anyway, so that a missing, disabled or
|
||||
# password-less account is indistinguishable from a wrong password.
|
||||
await dummy_verify_async()
|
||||
if address:
|
||||
ip_window.hit(address)
|
||||
# The audit entry does distinguish these cases, unlike the response. Its readers
|
||||
# are administrators who already know which accounts exist, and "somebody is
|
||||
# trying to sign in to the account you disabled" is exactly what a log is for.
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
actor_user_id=user.id if user is not None else None,
|
||||
target=username,
|
||||
detail={"reason": _failure_reason(user)},
|
||||
)
|
||||
return _message(UNAUTHORIZED, INVALID_CREDENTIALS)
|
||||
|
||||
if auth_user_db.is_locked(user) and user.locked_until is not None:
|
||||
# Deliberately not spending a hash here: the answer is already decided, and
|
||||
# burning CPU on locked accounts is a self-inflicted denial of service.
|
||||
retry_after = max(int((user.locked_until - datetime.utcnow()).total_seconds()), 1)
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.LOGIN_LOCKED,
|
||||
actor_user_id=user.id,
|
||||
target=username,
|
||||
detail={"retry_after": retry_after},
|
||||
)
|
||||
return _message(
|
||||
TOO_MANY_REQUESTS,
|
||||
"Too many sign-in attempts. Try again shortly.",
|
||||
{"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
if not await verify_password_async(body.password, user.password_hash):
|
||||
await auth_user_db.record_login_failure(db=db, user=user)
|
||||
if address:
|
||||
ip_window.hit(address)
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
actor_user_id=user.id,
|
||||
target=username,
|
||||
detail={"reason": "bad_password", "failed_logins": user.failed_logins},
|
||||
)
|
||||
return _message(UNAUTHORIZED, INVALID_CREDENTIALS)
|
||||
|
||||
await auth_user_db.record_login_success(db=db, user=user)
|
||||
await record(request, AuditEvent.LOGIN_SUCCESS, actor_user_id=user.id, target=user.username)
|
||||
|
||||
# Upgrade the stored hash if the cost parameters have been raised since it was made.
|
||||
if needs_rehash(user.password_hash):
|
||||
await auth_user_db.rehash_password(
|
||||
db=db,
|
||||
user=user,
|
||||
password_hash=await hash_password_async(body.password),
|
||||
)
|
||||
|
||||
token, csrf = await _start_session(db=db, request=request, user_id=user.id, remember=body.remember)
|
||||
info = _session_info(
|
||||
Principal(
|
||||
kind=PrincipalKind.USER,
|
||||
level=Level(user.level),
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
is_admin=user.is_admin,
|
||||
is_owner=user.is_owner,
|
||||
),
|
||||
models.AuthUserInfo.from_db(user),
|
||||
)
|
||||
response = JSONResponse(content=info.model_dump())
|
||||
cookies.set_session_cookies(response, request, token=token, csrf=csrf, remember=body.remember)
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
name="Sign out",
|
||||
description="End the current session. Succeeds even if there was no session to end.",
|
||||
response_model=models.Message,
|
||||
)
|
||||
async def logout(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
) -> Response:
|
||||
# Idempotent, and reachable without a gate: signing out has to work even when the
|
||||
# session has already expired server-side, which is exactly when a client most wants
|
||||
# its cookies cleared.
|
||||
token = request.cookies.get(cookies.SESSION_COOKIE, "")
|
||||
if token:
|
||||
# Resolved before the revoke, while the token still identifies somebody. A
|
||||
# sign-out that cannot say who signed out is not worth recording.
|
||||
principal = await resolve_principal(request)
|
||||
await auth_session_db.revoke_by_token(db, token)
|
||||
if principal is not None and principal.user_id is not None:
|
||||
await record(request, AuditEvent.LOGOUT, principal=principal, target=principal.username)
|
||||
response = JSONResponse(content={"message": "Signed out."})
|
||||
cookies.clear_session_cookies(response, request)
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/session",
|
||||
name="Get the current session",
|
||||
description=(
|
||||
"Describe the caller's current standing. Answers for signed-out callers too, "
|
||||
"so a client can distinguish having no session from being unable to reach the server."
|
||||
),
|
||||
response_model=models.AuthSessionInfo,
|
||||
)
|
||||
async def get_session(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
) -> models.AuthSessionInfo:
|
||||
if not env.is_auth_enabled():
|
||||
return models.AuthSessionInfo(
|
||||
authenticated=True,
|
||||
anonymous=False,
|
||||
level=str(Level.MANAGE),
|
||||
is_admin=True,
|
||||
is_owner=True,
|
||||
)
|
||||
|
||||
principal = await resolve_principal(request)
|
||||
user_info = None
|
||||
if principal is not None and principal.user_id is not None:
|
||||
user_info = models.AuthUserInfo.from_db(await auth_user_db.get_by_id(db, principal.user_id))
|
||||
return _session_info(principal, user_info)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/password",
|
||||
name="Change own password",
|
||||
description="Replace one's own password. Requires the current password.",
|
||||
response_model=models.Message,
|
||||
responses={400: {"model": models.Message}, 401: {"model": models.Message}},
|
||||
)
|
||||
async def change_password(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(require_user())],
|
||||
body: models.PasswordChangeRequest,
|
||||
) -> Response:
|
||||
if principal.user_id is None:
|
||||
return _message(UNAUTHORIZED, "Authentication required.")
|
||||
|
||||
user = await auth_user_db.get_by_id(db, principal.user_id)
|
||||
if user.password_hash is None or not await verify_password_async(body.current_password, user.password_hash):
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
principal=principal,
|
||||
target=user.username,
|
||||
detail={"reason": "bad_password", "during": "password_change"},
|
||||
)
|
||||
return _message(BAD_REQUEST, "The current password is incorrect.")
|
||||
|
||||
await auth_user_db.set_password(
|
||||
db=db,
|
||||
user=user,
|
||||
password_hash=await hash_password_async(body.new_password),
|
||||
)
|
||||
# Other sessions are deliberately left alone. Someone changing their password
|
||||
# routinely does not expect their phone to be signed out; an administrator who
|
||||
# suspects a compromise has "sign out everywhere" for that, and a reset performed
|
||||
# for them revokes everything.
|
||||
await record(request, AuditEvent.PASSWORD_CHANGED, principal=principal, target=user.username)
|
||||
return JSONResponse(content={"message": "Password changed."})
|
||||
@@ -8,6 +8,8 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.auth.dependencies import require_level
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database import filament, spool, vendor
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.models import Base
|
||||
@@ -29,6 +31,7 @@ class ExportFormat(Enum):
|
||||
"/spools",
|
||||
name="Export spools",
|
||||
description="Export the list of spools in various formats. Filament and vendor data is included.",
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def export_spools(
|
||||
*,
|
||||
@@ -47,6 +50,7 @@ async def export_spools(
|
||||
"/filaments",
|
||||
name="Export filaments",
|
||||
description="Export the list of filaments in various formats. Vendor data is included.",
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def export_filaments(
|
||||
*,
|
||||
@@ -61,6 +65,7 @@ async def export_filaments(
|
||||
"/vendors",
|
||||
name="Export vendors",
|
||||
description="Export the list of vendors in various formats.",
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def export_vendors(
|
||||
*,
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from spoolman.auth.dependencies import require_level
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.externaldb import (
|
||||
ExternalFilament,
|
||||
ExternalMaterial,
|
||||
@@ -28,6 +30,7 @@ logger = logging.getLogger(__name__)
|
||||
name="Get all external filaments",
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[ExternalFilament],
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def filaments() -> FileResponse:
|
||||
"""Get all external filaments."""
|
||||
@@ -38,6 +41,7 @@ async def filaments() -> FileResponse:
|
||||
"/filament/search",
|
||||
name="Search external filaments",
|
||||
response_model_exclude_none=True,
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def search_external_filaments(
|
||||
query: Annotated[
|
||||
@@ -65,6 +69,7 @@ async def search_external_filaments(
|
||||
name="Get all external materials",
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[ExternalMaterial],
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def materials() -> FileResponse:
|
||||
"""Get all external materials."""
|
||||
|
||||
@@ -8,6 +8,8 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.api.v1.models import Message
|
||||
from spoolman.auth.dependencies import require_level
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.extra_field_query import find_extra_field_values
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
@@ -35,6 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
name="Get extra fields",
|
||||
description="Get all extra fields for a specific entity type.",
|
||||
response_model_exclude_none=True,
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def get(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -54,6 +57,7 @@ async def get(
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[str],
|
||||
responses={404: {"model": Message}},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def get_values(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -76,6 +80,7 @@ async def get_values(
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[ExtraField],
|
||||
responses={400: {"model": Message}},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def update(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -105,6 +110,7 @@ async def update(
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[ExtraField],
|
||||
responses={404: {"model": Message}},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def delete(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
|
||||
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.api.v1.models import Filament, FilamentEvent, Message, MultiColorDirection
|
||||
from spoolman.auth.dependencies import require_level, ws_authenticated
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database import filament
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.utils import SortOrder
|
||||
@@ -158,7 +160,7 @@ class FilamentParameters(BaseModel):
|
||||
return v
|
||||
|
||||
@model_validator(mode="after") # type: ignore[]
|
||||
def validate(self) -> "FilamentParameters":
|
||||
def validate(self) -> FilamentParameters:
|
||||
"""Validate the model."""
|
||||
if self.color_hex and self.multi_color_hexes:
|
||||
raise ValueError("Cannot specify both color_hex and multi_color_hexes.")
|
||||
@@ -178,7 +180,7 @@ class FilamentUpdateParameters(FilamentParameters):
|
||||
|
||||
@field_validator("density", "diameter")
|
||||
@classmethod
|
||||
def prevent_none(cls: type["FilamentUpdateParameters"], v: float | None) -> float | None:
|
||||
def prevent_none(cls: type[FilamentUpdateParameters], v: float | None) -> float | None:
|
||||
"""Prevent density and diameter from being None."""
|
||||
if v is None:
|
||||
raise ValueError("Value must not be None.")
|
||||
@@ -198,6 +200,7 @@ class FilamentUpdateParameters(FilamentParameters):
|
||||
200: {"model": list[Filament]},
|
||||
299: {"model": FilamentEvent, "description": "Websocket message"},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def find(
|
||||
*,
|
||||
@@ -382,10 +385,10 @@ async def find(
|
||||
"",
|
||||
name="Listen to filament changes",
|
||||
)
|
||||
@ws_authenticated(Level.READ)
|
||||
async def notify_any(
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
await websocket.accept()
|
||||
websocket_manager.connect(("filament",), websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -405,6 +408,7 @@ async def notify_any(
|
||||
),
|
||||
response_model_exclude_none=True,
|
||||
responses={404: {"model": Message}, 299: {"model": FilamentEvent, "description": "Websocket message"}},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def get(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -418,11 +422,11 @@ async def get(
|
||||
"/{filament_id}",
|
||||
name="Listen to filament changes",
|
||||
)
|
||||
@ws_authenticated(Level.READ)
|
||||
async def notify(
|
||||
websocket: WebSocket,
|
||||
filament_id: int,
|
||||
) -> None:
|
||||
await websocket.accept()
|
||||
websocket_manager.connect(("filament", str(filament_id)), websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -440,6 +444,7 @@ async def notify(
|
||||
response_model_exclude_none=True,
|
||||
response_model=Filament,
|
||||
responses={400: {"model": Message}},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def create( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -489,6 +494,7 @@ async def create( # noqa: ANN201
|
||||
400: {"model": Message},
|
||||
404: {"model": Message},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.EDIT))],
|
||||
)
|
||||
async def update( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -522,6 +528,7 @@ async def update( # noqa: ANN201
|
||||
403: {"model": Message},
|
||||
404: {"model": Message},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def delete( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
|
||||
+287
-6
@@ -1,11 +1,14 @@
|
||||
"""Pydantic data models for typing the FastAPI request/responses."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, PlainSerializer
|
||||
|
||||
from spoolman.auth import apikey
|
||||
from spoolman.auth.levels import parse_level
|
||||
from spoolman.database import models
|
||||
from spoolman.math import length_from_weight
|
||||
from spoolman.settings import SettingDefinition, SettingType
|
||||
@@ -14,7 +17,7 @@ from spoolman.settings import SettingDefinition, SettingType
|
||||
def datetime_to_str(dt: datetime) -> str:
|
||||
"""Convert a datetime object to a string."""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@@ -51,7 +54,7 @@ class SettingKV(BaseModel):
|
||||
setting: SettingResponse = Field(description="Setting value.")
|
||||
|
||||
@staticmethod
|
||||
def from_db(definition: SettingDefinition, set_value: str | None) -> "SettingKV":
|
||||
def from_db(definition: SettingDefinition, set_value: str | None) -> SettingKV:
|
||||
"""Create a new Pydantic vendor object from a database vendor object."""
|
||||
return SettingKV(
|
||||
key=definition.key,
|
||||
@@ -92,7 +95,7 @@ class Vendor(BaseModel):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_db(item: models.Vendor) -> "Vendor":
|
||||
def from_db(item: models.Vendor) -> Vendor:
|
||||
"""Create a new Pydantic vendor object from a database vendor object."""
|
||||
return Vendor(
|
||||
id=item.id,
|
||||
@@ -208,7 +211,7 @@ class Filament(BaseModel):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_db(item: models.Filament) -> "Filament":
|
||||
def from_db(item: models.Filament) -> Filament:
|
||||
"""Create a new Pydantic filament object from a database filament object."""
|
||||
return Filament(
|
||||
id=item.id,
|
||||
@@ -317,7 +320,7 @@ class Spool(BaseModel):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_db(item: models.Spool) -> "Spool":
|
||||
def from_db(item: models.Spool) -> Spool:
|
||||
"""Create a new Pydantic spool object from a database spool object."""
|
||||
filament = Filament.from_db(item.filament)
|
||||
|
||||
@@ -533,3 +536,281 @@ class SettingEvent(Event):
|
||||
|
||||
payload: SettingKV = Field(description="Updated setting.")
|
||||
resource: Literal["setting"] = Field(description="Resource type.")
|
||||
|
||||
|
||||
#
|
||||
# Authentication.
|
||||
#
|
||||
# These describe the /auth endpoints. They are additive: nothing above changes shape,
|
||||
# and none of this is reachable in a meaningful way unless SPOOLMAN_AUTH_ENABLED is set.
|
||||
#
|
||||
|
||||
# Short enough to stay usable for a homelab instance, long enough that the lockout in
|
||||
# spoolman/database/auth_user.py is the binding constraint rather than the search space.
|
||||
MIN_PASSWORD_LENGTH = 10
|
||||
MAX_PASSWORD_LENGTH = 1024
|
||||
MAX_USERNAME_LENGTH = 64
|
||||
MAX_DISPLAY_NAME_LENGTH = 128
|
||||
|
||||
|
||||
class AuthOidcConfig(BaseModel):
|
||||
"""OIDC provider advertisement. Phase 3; always absent for now."""
|
||||
|
||||
enabled: bool = Field(default=False, description="Whether an OIDC provider is configured.")
|
||||
name: str | None = Field(default=None, description="Display name for the sign-in button.")
|
||||
|
||||
|
||||
class AuthConfig(BaseModel):
|
||||
"""What a client needs to know before anyone has signed in."""
|
||||
|
||||
enabled: bool = Field(description="Whether authentication is enforced on this instance.")
|
||||
setup_required: bool = Field(
|
||||
description="Whether this instance is unclaimed. When true, anyone may claim ownership.",
|
||||
)
|
||||
anonymous_read: bool = Field(description="Whether unauthenticated clients may read.")
|
||||
oidc: AuthOidcConfig = Field(default_factory=AuthOidcConfig, description="OIDC provider info.")
|
||||
mtls: bool = Field(default=False, description="Whether client certificates are accepted. Phase 5.")
|
||||
|
||||
|
||||
class AuthUserInfo(BaseModel):
|
||||
"""A user account, as shown to itself or an administrator."""
|
||||
|
||||
id: int = Field(description="Unique internal ID of this user.")
|
||||
username: str = Field(description="Username, stored lowercased.")
|
||||
display_name: str | None = Field(default=None, description="Friendly name, if set.")
|
||||
level: str = Field(description="Permission level: read, edit or manage.", examples=["edit"])
|
||||
is_admin: bool = Field(description="Whether this user administers other users.")
|
||||
is_owner: bool = Field(description="Whether this user owns the instance.")
|
||||
is_active: bool = Field(default=True, description="Whether the account may sign in at all.")
|
||||
must_change_password: bool = Field(description="Whether a password change is required.")
|
||||
|
||||
@staticmethod
|
||||
def from_db(user: models.AuthUser) -> AuthUserInfo:
|
||||
"""Create a Pydantic user object from a database user object."""
|
||||
return AuthUserInfo(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
level=user.level,
|
||||
is_admin=user.is_admin,
|
||||
is_owner=user.is_owner,
|
||||
is_active=user.is_active,
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
class AuthSessionInfo(BaseModel):
|
||||
"""The caller's current standing.
|
||||
|
||||
Always answers rather than rejecting, so a signed-out client can tell "no session"
|
||||
apart from "server unreachable" without treating a 401 as an error.
|
||||
"""
|
||||
|
||||
authenticated: bool = Field(description="Whether a user is signed in.")
|
||||
anonymous: bool = Field(description="Whether access comes from the anonymous-read setting.")
|
||||
level: str = Field(description="Effective permission level. 'manage' when auth is disabled.")
|
||||
is_admin: bool = Field(description="Whether the caller administers other users.")
|
||||
is_owner: bool = Field(description="Whether the caller owns the instance.")
|
||||
user: AuthUserInfo | None = Field(default=None, description="The signed-in user, if any.")
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Credentials for a sign-in attempt."""
|
||||
|
||||
username: str = Field(min_length=1, max_length=MAX_USERNAME_LENGTH, description="Username.")
|
||||
password: str = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH, description="Password.")
|
||||
remember: bool = Field(default=False, description="Whether the session should outlive the browser session.")
|
||||
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
"""The owner account to create when claiming an unclaimed instance."""
|
||||
|
||||
username: str = Field(min_length=1, max_length=MAX_USERNAME_LENGTH, description="Username for the owner.")
|
||||
password: str = Field(
|
||||
min_length=MIN_PASSWORD_LENGTH,
|
||||
max_length=MAX_PASSWORD_LENGTH,
|
||||
description="Password for the owner.",
|
||||
)
|
||||
display_name: str | None = Field(default=None, max_length=MAX_DISPLAY_NAME_LENGTH, description="Friendly name.")
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
"""A request to replace one's own password."""
|
||||
|
||||
current_password: str = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH, description="The current password.")
|
||||
new_password: str = Field(
|
||||
min_length=MIN_PASSWORD_LENGTH,
|
||||
max_length=MAX_PASSWORD_LENGTH,
|
||||
description="The new password.",
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Authentication, phase 2: API keys, user administration and the audit log.
|
||||
#
|
||||
|
||||
MAX_KEY_NAME_LENGTH = 64
|
||||
MAX_AUDIT_EVENT_LENGTH = 64
|
||||
|
||||
# The longest an API key may be issued for. Mirrors
|
||||
# spoolman.database.auth_api_key.MAX_EXPIRY_DAYS; validated here so a bad value is a 422
|
||||
# from the schema rather than a database error.
|
||||
MAX_KEY_EXPIRY_DAYS = 3650
|
||||
|
||||
|
||||
class AuthApiKeyInfo(BaseModel):
|
||||
"""An API key, as shown to its owner.
|
||||
|
||||
Never carries the secret. The complete key exists in a readable form exactly once,
|
||||
in the response to the request that created it.
|
||||
"""
|
||||
|
||||
id: int = Field(description="Unique internal ID of this key.")
|
||||
name: str = Field(description="The label its owner gave it.")
|
||||
level: str = Field(description="Permission level the key was issued with.", examples=["edit"])
|
||||
effective_level: str = Field(
|
||||
description="What the key can actually do right now, after capping against its owner's level.",
|
||||
examples=["read"],
|
||||
)
|
||||
prefix: str = Field(description="The public half of the key, enough to recognise it in a log.")
|
||||
created: SpoolmanDateTime = Field(description="When the key was issued.")
|
||||
expires: SpoolmanDateTime | None = Field(default=None, description="When the key stops working, if ever.")
|
||||
last_used: SpoolmanDateTime | None = Field(
|
||||
default=None,
|
||||
description="When the key was last accepted. Updated at most once every few minutes.",
|
||||
)
|
||||
revoked: bool = Field(description="Whether the key has been withdrawn.")
|
||||
expired: bool = Field(description="Whether the key's expiry has passed.")
|
||||
|
||||
@staticmethod
|
||||
def from_db(key: models.AuthApiKey, owner_level: str) -> AuthApiKeyInfo:
|
||||
"""Create a Pydantic API key object from a database API key object."""
|
||||
return AuthApiKeyInfo(
|
||||
id=key.id,
|
||||
name=key.name,
|
||||
level=key.level,
|
||||
effective_level=str(apikey.effective_level(parse_level(key.level), parse_level(owner_level))),
|
||||
prefix=key.prefix,
|
||||
created=key.created,
|
||||
expires=key.expires,
|
||||
last_used=key.last_used,
|
||||
revoked=key.revoked,
|
||||
expired=key.expires is not None and key.expires <= datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
class AuthApiKeyCreated(BaseModel):
|
||||
"""A newly issued API key, including the secret."""
|
||||
|
||||
key: str = Field(description="The complete key. Shown once and never again; store it now.")
|
||||
info: AuthApiKeyInfo = Field(description="The key's metadata, as it will appear in listings.")
|
||||
|
||||
|
||||
class ApiKeyCreateRequest(BaseModel):
|
||||
"""A request to issue an API key."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=MAX_KEY_NAME_LENGTH, description="A label, to tell keys apart.")
|
||||
level: str = Field(
|
||||
description="Permission level to issue at. May not exceed the creator's own level.",
|
||||
examples=["edit"],
|
||||
)
|
||||
expires_days: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=MAX_KEY_EXPIRY_DAYS,
|
||||
description="How many days the key should live for. Omit for a key that never expires.",
|
||||
)
|
||||
|
||||
|
||||
class AuthUserCreateRequest(BaseModel):
|
||||
"""An account for an administrator to create."""
|
||||
|
||||
username: str = Field(min_length=1, max_length=MAX_USERNAME_LENGTH, description="Username, stored lowercased.")
|
||||
password: str | None = Field(
|
||||
default=None,
|
||||
min_length=MIN_PASSWORD_LENGTH,
|
||||
max_length=MAX_PASSWORD_LENGTH,
|
||||
description="Initial password. Omit to have one generated and returned once.",
|
||||
)
|
||||
display_name: str | None = Field(default=None, max_length=MAX_DISPLAY_NAME_LENGTH, description="Friendly name.")
|
||||
level: str = Field(default="read", description="Permission level to grant.", examples=["edit"])
|
||||
is_admin: bool = Field(default=False, description="Whether the account administers other users.")
|
||||
must_change_password: bool = Field(
|
||||
default=True,
|
||||
description="Whether the user must choose a new password at next sign-in.",
|
||||
)
|
||||
|
||||
|
||||
class AuthUserUpdateRequest(BaseModel):
|
||||
"""Changes to an existing account. Omitted fields are left alone."""
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=MAX_DISPLAY_NAME_LENGTH, description="Friendly name.")
|
||||
level: str | None = Field(default=None, description="Permission level to grant.", examples=["manage"])
|
||||
is_admin: bool | None = Field(default=None, description="Whether the account administers other users.")
|
||||
is_active: bool | None = Field(default=None, description="Whether the account may sign in at all.")
|
||||
|
||||
|
||||
class AuthPasswordResetRequest(BaseModel):
|
||||
"""An administrator's replacement of somebody else's password."""
|
||||
|
||||
password: str | None = Field(
|
||||
default=None,
|
||||
min_length=MIN_PASSWORD_LENGTH,
|
||||
max_length=MAX_PASSWORD_LENGTH,
|
||||
description="The new password. Omit to have one generated and returned once.",
|
||||
)
|
||||
must_change_password: bool = Field(
|
||||
default=True,
|
||||
description="Whether the user must choose a new password at next sign-in.",
|
||||
)
|
||||
|
||||
|
||||
class AuthUserCreated(BaseModel):
|
||||
"""A newly created account, with its password if one was generated."""
|
||||
|
||||
user: AuthUserInfo = Field(description="The account.")
|
||||
password: str | None = Field(
|
||||
default=None,
|
||||
description="The generated password, present only when the caller did not supply one. Shown once.",
|
||||
)
|
||||
|
||||
|
||||
class AuthAuditEntry(BaseModel):
|
||||
"""One line of the audit trail."""
|
||||
|
||||
id: int = Field(description="Unique internal ID of this entry.")
|
||||
date: SpoolmanDateTime = Field(description="When it happened, in UTC.")
|
||||
event: str = Field(description="What happened.", examples=["login.success"])
|
||||
actor_user_id: int | None = Field(default=None, description="Which account acted, if it still exists.")
|
||||
actor_username: str | None = Field(default=None, description="That account's username, resolved for display.")
|
||||
actor_kind: str = Field(description="How the actor was authenticated.", examples=["user"])
|
||||
target: str | None = Field(default=None, description="What was acted on.")
|
||||
ip: str | None = Field(default=None, description="Where the request came from.")
|
||||
user_agent: str | None = Field(default=None, description="What the request identified itself as.")
|
||||
detail: dict | None = Field(default=None, description="Anything else worth keeping.")
|
||||
|
||||
@staticmethod
|
||||
def from_db(entry: models.AuthAuditLog, actor_username: str | None = None) -> AuthAuditEntry:
|
||||
"""Create a Pydantic audit entry from a database audit entry."""
|
||||
detail = None
|
||||
if entry.detail:
|
||||
try:
|
||||
parsed = json.loads(entry.detail)
|
||||
except ValueError:
|
||||
# A row written by a future version, or corrupted. Showing the raw text
|
||||
# beats dropping the entry: the point of an audit log is that entries do
|
||||
# not silently vanish.
|
||||
parsed = {"raw": entry.detail}
|
||||
detail = parsed if isinstance(parsed, dict) else {"value": parsed}
|
||||
return AuthAuditEntry(
|
||||
id=entry.id,
|
||||
date=entry.date,
|
||||
event=entry.event,
|
||||
actor_user_id=entry.actor_user_id,
|
||||
actor_username=actor_username,
|
||||
actor_kind=entry.actor_kind,
|
||||
target=entry.target,
|
||||
ip=entry.ip,
|
||||
user_agent=entry.user_agent,
|
||||
detail=detail,
|
||||
)
|
||||
@@ -7,6 +7,8 @@ from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.auth.dependencies import require_level
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database import filament, spool
|
||||
from spoolman.database.database import get_db_session
|
||||
|
||||
@@ -39,6 +41,7 @@ router = APIRouter(
|
||||
},
|
||||
},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def find_materials(
|
||||
*,
|
||||
@@ -65,6 +68,7 @@ async def find_materials(
|
||||
},
|
||||
},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def find_article_numbers(
|
||||
*,
|
||||
@@ -91,6 +95,7 @@ async def find_article_numbers(
|
||||
},
|
||||
},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def find_lot_numbers(
|
||||
*,
|
||||
@@ -118,6 +123,7 @@ async def find_lot_numbers(
|
||||
},
|
||||
},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def find_locations(
|
||||
*,
|
||||
@@ -136,6 +142,7 @@ class RenameLocationBody(BaseModel):
|
||||
description="Rename a spool location. All spools in this location will be moved to the new location.",
|
||||
response_model_exclude_none=True,
|
||||
response_model=RootModel[str],
|
||||
dependencies=[Depends(require_level(Level.EDIT))],
|
||||
)
|
||||
async def rename_location(
|
||||
location: str,
|
||||
|
||||
@@ -5,18 +5,36 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from spoolman import env
|
||||
from spoolman.auth.coverage import assert_routes_covered
|
||||
from spoolman.auth.dependencies import require_level, ws_authenticated
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database.database import backup_global_db
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
from spoolman.exceptions import AuthenticationRequiredError, ItemNotFoundError, PermissionDeniedError
|
||||
from spoolman.externaldb import get_external_db_name
|
||||
from spoolman.ws import websocket_manager
|
||||
|
||||
from . import export, externaldb, field, filament, models, other, search, setting, spool, vendor
|
||||
from . import (
|
||||
apikey,
|
||||
audit,
|
||||
auth,
|
||||
export,
|
||||
externaldb,
|
||||
field,
|
||||
filament,
|
||||
models,
|
||||
other,
|
||||
search,
|
||||
setting,
|
||||
spool,
|
||||
users,
|
||||
vendor,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,8 +62,33 @@ async def itemnotfounderror_exception_handler(_request: Request, exc: ItemNotFou
|
||||
)
|
||||
|
||||
|
||||
# The body shape matches models.Message, the same as every other error this API returns,
|
||||
# rather than FastAPI's default {"detail": ...}. Handlers have to be registered on this
|
||||
# app and not the outer one: Starlette resolves them from the mounted app that owns the
|
||||
# route, which is why the handler above lives here too.
|
||||
@app.exception_handler(AuthenticationRequiredError)
|
||||
async def authenticationrequirederror_exception_handler(
|
||||
_request: Request,
|
||||
exc: AuthenticationRequiredError,
|
||||
) -> Response:
|
||||
logger.debug(exc)
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"message": exc.args[0]},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(PermissionDeniedError)
|
||||
async def permissiondeniederror_exception_handler(_request: Request, exc: PermissionDeniedError) -> Response:
|
||||
logger.debug(exc)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"message": exc.args[0]},
|
||||
)
|
||||
|
||||
|
||||
# Add a general info endpoint
|
||||
@app.get("/info")
|
||||
@app.get("/info", dependencies=[Depends(require_level(Level.READ))])
|
||||
async def info() -> models.Info:
|
||||
"""Return general info about the API."""
|
||||
return models.Info(
|
||||
@@ -75,6 +118,7 @@ async def health() -> models.HealthCheck:
|
||||
description="Trigger a database backup. Only applicable for SQLite databases.",
|
||||
response_model=models.BackupResponse,
|
||||
responses={500: {"model": models.Message}},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def backup(): # noqa: ANN201
|
||||
"""Trigger a database backup."""
|
||||
@@ -91,10 +135,10 @@ async def backup(): # noqa: ANN201
|
||||
"/",
|
||||
name="Listen to any changes",
|
||||
)
|
||||
@ws_authenticated(Level.READ)
|
||||
async def notify(
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
await websocket.accept()
|
||||
websocket_manager.connect((), websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -106,6 +150,10 @@ async def notify(
|
||||
|
||||
|
||||
# Add routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(apikey.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(filament.router)
|
||||
app.include_router(spool.router)
|
||||
app.include_router(vendor.router)
|
||||
@@ -115,3 +163,31 @@ app.include_router(other.router)
|
||||
app.include_router(externaldb.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
|
||||
# Routes that must stay reachable without credentials.
|
||||
#
|
||||
# /health is polled by container healthchecks and by the frontend test harness, and is
|
||||
# the one endpoint that has to answer before anyone can possibly be signed in. The auth
|
||||
# endpoints bootstrap a session, so requiring one would be circular; each performs its
|
||||
# own checking. Everything else is gated.
|
||||
PUBLIC_ROUTES = frozenset(
|
||||
{
|
||||
("GET", "/health"),
|
||||
# Signing in is how a credential is obtained, so requiring one would be
|
||||
# circular. Each of these checks for itself: /setup refuses once an account
|
||||
# exists, /login throttles and verifies, /logout and /session are read-only
|
||||
# about the caller and answer honestly when there is no session.
|
||||
("GET", "/auth/config"),
|
||||
("POST", "/auth/setup"),
|
||||
("POST", "/auth/login"),
|
||||
("POST", "/auth/logout"),
|
||||
("GET", "/auth/session"),
|
||||
},
|
||||
)
|
||||
|
||||
# Proving coverage here, at import, is the point: a route added without a gate stops the
|
||||
# process immediately rather than quietly serving unauthenticated traffic. This runs
|
||||
# before uvicorn binds a port and before migrations, and equally when the module is
|
||||
# imported by the docs generator or a test.
|
||||
ROUTE_LEVELS = assert_routes_covered(app, PUBLIC_ROUTES)
|
||||
@@ -15,6 +15,8 @@ from spoolman.api.v1.models import (
|
||||
Spool,
|
||||
Vendor,
|
||||
)
|
||||
from spoolman.auth.dependencies import require_level
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database import search
|
||||
from spoolman.database.database import get_db_session
|
||||
|
||||
@@ -43,6 +45,7 @@ router = APIRouter(
|
||||
"reports which field matched."
|
||||
),
|
||||
response_model_exclude_none=True,
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def search_endpoint(
|
||||
*,
|
||||
|
||||
@@ -9,6 +9,8 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.api.v1.models import Message, SettingEvent, SettingResponse
|
||||
from spoolman.auth.dependencies import require_level, ws_authenticated
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database import setting
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
@@ -29,10 +31,10 @@ logger = logging.getLogger(__name__)
|
||||
"",
|
||||
name="Listen to setting changes",
|
||||
)
|
||||
@ws_authenticated(Level.READ)
|
||||
async def notify_any(
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
await websocket.accept()
|
||||
websocket_manager.connect(("setting",), websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -54,6 +56,7 @@ async def notify_any(
|
||||
response_model_exclude_none=True,
|
||||
response_model=SettingResponse,
|
||||
responses={404: {"model": Message}, 299: {"model": SettingEvent, "description": "Websocket message"}},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def get(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -85,6 +88,7 @@ async def get(
|
||||
description=("Get all settings, set or not. If the setting has not been set, 'value' will be the default value."),
|
||||
response_model_exclude_none=True,
|
||||
response_model=dict[str, SettingResponse],
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def find(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -121,6 +125,7 @@ async def find(
|
||||
"/{key}",
|
||||
name="Listen to setting changes",
|
||||
)
|
||||
@ws_authenticated(Level.READ)
|
||||
async def notify(
|
||||
websocket: WebSocket,
|
||||
key: str,
|
||||
@@ -131,7 +136,6 @@ async def notify(
|
||||
await websocket.close(code=4040, reason=str(e))
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
websocket_manager.connect(("setting", str(key)), websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -153,6 +157,7 @@ async def notify(
|
||||
response_model_exclude_none=True,
|
||||
response_model=SettingResponse,
|
||||
responses={404: {"model": Message}},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def update(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
|
||||
@@ -13,6 +13,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.datastructures import QueryParams
|
||||
|
||||
from spoolman.api.v1.models import Filament, Message, Spool, SpoolEvent, SpoolGroup, Vendor
|
||||
from spoolman.auth.dependencies import require_level, ws_authenticated
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database import spool
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.utils import SortOrder
|
||||
@@ -117,7 +119,7 @@ class SpoolUpdateParameters(SpoolParameters):
|
||||
|
||||
@field_validator("filament_id")
|
||||
@classmethod
|
||||
def prevent_none(cls: type["SpoolUpdateParameters"], v: int | None) -> int | None:
|
||||
def prevent_none(cls: type[SpoolUpdateParameters], v: int | None) -> int | None:
|
||||
"""Prevent filament_id from being None."""
|
||||
if v is None:
|
||||
raise ValueError("Value must not be None.")
|
||||
@@ -146,6 +148,7 @@ class SpoolMeasureParameters(BaseModel):
|
||||
200: {"model": list[Spool]},
|
||||
299: {"model": SpoolEvent, "description": "Websocket message"},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def find(
|
||||
*,
|
||||
@@ -361,10 +364,10 @@ async def find(
|
||||
"",
|
||||
name="Listen to spool changes",
|
||||
)
|
||||
@ws_authenticated(Level.READ)
|
||||
async def notify_any(
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
await websocket.accept()
|
||||
websocket_manager.connect(("spool",), websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -390,6 +393,7 @@ async def notify_any(
|
||||
200: {"model": list[SpoolGroup]},
|
||||
400: {"model": Message},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def find_groups(
|
||||
*,
|
||||
@@ -551,6 +555,7 @@ async def find_groups(
|
||||
),
|
||||
response_model_exclude_none=True,
|
||||
responses={404: {"model": Message}, 299: {"model": SpoolEvent, "description": "Websocket message"}},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def get(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -564,11 +569,11 @@ async def get(
|
||||
"/{spool_id}",
|
||||
name="Listen to spool changes",
|
||||
)
|
||||
@ws_authenticated(Level.READ)
|
||||
async def notify(
|
||||
websocket: WebSocket,
|
||||
spool_id: int,
|
||||
) -> None:
|
||||
await websocket.accept()
|
||||
websocket_manager.connect(("spool", str(spool_id)), websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -592,6 +597,7 @@ async def notify(
|
||||
responses={
|
||||
400: {"model": Message},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def create( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -651,6 +657,7 @@ async def create( # noqa: ANN201
|
||||
400: {"model": Message},
|
||||
404: {"model": Message},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.EDIT))],
|
||||
)
|
||||
async def update( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -693,6 +700,7 @@ async def update( # noqa: ANN201
|
||||
name="Delete spool",
|
||||
description="Delete a spool.",
|
||||
responses={404: {"model": Message}},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def delete(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -714,6 +722,7 @@ async def delete(
|
||||
400: {"model": Message},
|
||||
404: {"model": Message},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.EDIT))],
|
||||
)
|
||||
async def use( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -750,6 +759,7 @@ async def use( # noqa: ANN201
|
||||
400: {"model": Message},
|
||||
404: {"model": Message},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.EDIT))],
|
||||
)
|
||||
async def measure( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"""User administration endpoints.
|
||||
|
||||
Everything here needs ``require_admin``, which in turn needs a signed-in session -- an
|
||||
API key never carries administrative rights, so a leaked printer credential cannot be
|
||||
used to create a second account and entrench itself.
|
||||
|
||||
Two accounts are protected from the operations below, and both protections exist to stop
|
||||
an instance being administered into a state nobody can recover from without the command
|
||||
line:
|
||||
|
||||
* The owner cannot be demoted, disabled or deleted by anyone, themselves included. It is
|
||||
the account that can always fix the others.
|
||||
* An administrator cannot demote, disable or delete themselves. Someone else with the
|
||||
rights has to do it, which guarantees a second pair of hands still exists afterwards.
|
||||
|
||||
Neither protection is a security boundary. Both are recoverable with
|
||||
``python -m spoolman.cli``, which is the escape hatch when there is no working account
|
||||
left at all.
|
||||
"""
|
||||
|
||||
# ruff: noqa: D103
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import Response
|
||||
|
||||
from spoolman.auth.audit import AuditEvent, record
|
||||
from spoolman.auth.dependencies import require_admin
|
||||
from spoolman.auth.hashing import hash_password_async
|
||||
from spoolman.auth.levels import parse_level
|
||||
from spoolman.auth.principal import Principal
|
||||
from spoolman.database import auth_api_key as auth_api_key_db
|
||||
from spoolman.database import auth_audit as auth_audit_db
|
||||
from spoolman.database import auth_session as auth_session_db
|
||||
from spoolman.database import auth_user as auth_user_db
|
||||
from spoolman.database import models as db_models
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
|
||||
from . import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/auth/user",
|
||||
tags=["auth"],
|
||||
)
|
||||
|
||||
BAD_REQUEST = 400
|
||||
FORBIDDEN = 403
|
||||
NOT_FOUND = 404
|
||||
CONFLICT = 409
|
||||
CREATED = 201
|
||||
|
||||
# Length of a generated password, in bytes of entropy before base64. 16 bytes is 128
|
||||
# bits, which is far past anything the account lockout would ever let be guessed; the
|
||||
# length is chosen to still be transcribable rather than for the strength.
|
||||
GENERATED_PASSWORD_BYTES = 16
|
||||
|
||||
|
||||
def _message(status_code: int, message: str) -> JSONResponse:
|
||||
"""Build an error response in the shape the rest of this API uses."""
|
||||
return JSONResponse(status_code=status_code, content={"message": message})
|
||||
|
||||
|
||||
def _generate_password() -> str:
|
||||
"""Make up a password for an account whose creator did not supply one."""
|
||||
return secrets.token_urlsafe(GENERATED_PASSWORD_BYTES)
|
||||
|
||||
|
||||
def _protected(principal: Principal, user: db_models.AuthUser) -> str | None:
|
||||
"""Check whether an account is shielded from a destructive change.
|
||||
|
||||
Args:
|
||||
principal: Who is asking.
|
||||
user: The account being changed.
|
||||
|
||||
Returns:
|
||||
Optional[str]: A refusal message, or None if the change may proceed.
|
||||
|
||||
"""
|
||||
if user.is_owner:
|
||||
return "The instance owner cannot be demoted, disabled or deleted."
|
||||
if user.id == principal.user_id:
|
||||
return "You cannot demote, disable or delete your own account. Ask another administrator."
|
||||
return None
|
||||
|
||||
|
||||
class _Refused(Exception): # noqa: N818
|
||||
"""A change the protections above will not allow.
|
||||
|
||||
An exception rather than a returned value so that :func:`_collect_changes` can stay
|
||||
a straight-line description of the four fields instead of threading a refusal back
|
||||
through every branch. Caught immediately by the one handler that raises it, and
|
||||
never escapes this module.
|
||||
"""
|
||||
|
||||
def __init__(self, status_code: int, message: str) -> None:
|
||||
"""Record the refusal."""
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _collect_changes(
|
||||
principal: Principal,
|
||||
user: db_models.AuthUser,
|
||||
body: models.AuthUserUpdateRequest,
|
||||
) -> dict[str, object]:
|
||||
"""Work out what an update actually changes, refusing protected changes.
|
||||
|
||||
Fields that are omitted, or that already hold the requested value, produce no
|
||||
change. That matters for the protections: re-sending an unchanged account from a
|
||||
form must not be refused just because the form included the owner's own level.
|
||||
|
||||
Args:
|
||||
principal: Who is asking.
|
||||
user: The account being changed.
|
||||
body: The requested changes.
|
||||
|
||||
Raises:
|
||||
_Refused: If a requested change is not allowed.
|
||||
|
||||
Returns:
|
||||
dict: The fields to change and their new values.
|
||||
|
||||
"""
|
||||
changes: dict[str, object] = {}
|
||||
|
||||
def guard() -> None:
|
||||
refusal = _protected(principal, user)
|
||||
if refusal is not None:
|
||||
raise _Refused(FORBIDDEN, refusal)
|
||||
|
||||
if body.level is not None and body.level != user.level:
|
||||
try:
|
||||
level = parse_level(body.level)
|
||||
except ValueError as exc:
|
||||
raise _Refused(BAD_REQUEST, str(exc)) from exc
|
||||
guard()
|
||||
changes["level"] = str(level)
|
||||
|
||||
# Granting administrator rights is unrestricted; removing them is what the
|
||||
# protections are about, so they are only consulted when something is taken away.
|
||||
if body.is_admin is not None and body.is_admin != user.is_admin:
|
||||
if not body.is_admin:
|
||||
guard()
|
||||
changes["is_admin"] = body.is_admin
|
||||
|
||||
if body.is_active is not None and body.is_active != user.is_active:
|
||||
if not body.is_active:
|
||||
guard()
|
||||
changes["is_active"] = body.is_active
|
||||
|
||||
if body.display_name is not None and body.display_name != user.display_name:
|
||||
changes["display_name"] = body.display_name
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
name="List users",
|
||||
description="List the accounts on this instance. Administrators only.",
|
||||
response_model=list[models.AuthUserInfo],
|
||||
)
|
||||
async def find(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
_principal: Annotated[Principal, Depends(require_admin())],
|
||||
limit: Annotated[int | None, Query(description="Maximum number of accounts to return.")] = None,
|
||||
offset: Annotated[int, Query(description="How many accounts to skip.")] = 0,
|
||||
) -> Response:
|
||||
users, total = await auth_user_db.find(db=db, limit=limit, offset=offset)
|
||||
return JSONResponse(
|
||||
content=jsonable_encoder(
|
||||
[models.AuthUserInfo.from_db(user) for user in users],
|
||||
exclude_none=True,
|
||||
),
|
||||
headers={"x-total-count": str(total)},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
name="Create a user",
|
||||
description=(
|
||||
"Create an account. If no password is supplied one is generated and returned "
|
||||
"once, which is the only time it exists in a readable form."
|
||||
),
|
||||
response_model=models.AuthUserCreated,
|
||||
responses={400: {"model": models.Message}, 409: {"model": models.Message}},
|
||||
status_code=201,
|
||||
)
|
||||
async def create(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(require_admin())],
|
||||
body: models.AuthUserCreateRequest,
|
||||
) -> Response:
|
||||
try:
|
||||
level = parse_level(body.level)
|
||||
except ValueError as exc:
|
||||
return _message(BAD_REQUEST, str(exc))
|
||||
|
||||
username = auth_user_db.normalize_username(body.username)
|
||||
if not username:
|
||||
return _message(BAD_REQUEST, "A username is required.")
|
||||
if await auth_user_db.get_by_username(db, username) is not None:
|
||||
return _message(CONFLICT, f"A user named '{username}' already exists.")
|
||||
|
||||
generated = body.password is None
|
||||
password = body.password or _generate_password()
|
||||
|
||||
try:
|
||||
user = await auth_user_db.create(
|
||||
db=db,
|
||||
username=username,
|
||||
password_hash=await hash_password_async(password),
|
||||
level=level,
|
||||
is_admin=body.is_admin,
|
||||
display_name=body.display_name,
|
||||
must_change_password=body.must_change_password,
|
||||
)
|
||||
except IntegrityError:
|
||||
# Two administrators created the same username at once; the unique index decided.
|
||||
await db.rollback()
|
||||
return _message(CONFLICT, f"A user named '{username}' already exists.")
|
||||
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.USER_CREATED,
|
||||
principal=principal,
|
||||
target=user.username,
|
||||
detail={"level": str(level), "is_admin": body.is_admin},
|
||||
)
|
||||
|
||||
created = models.AuthUserCreated(
|
||||
user=models.AuthUserInfo.from_db(user),
|
||||
password=password if generated else None,
|
||||
)
|
||||
return JSONResponse(status_code=CREATED, content=jsonable_encoder(created, exclude_none=True))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}",
|
||||
name="Get a user",
|
||||
description="Get one account. Administrators only.",
|
||||
response_model=models.AuthUserInfo,
|
||||
responses={404: {"model": models.Message}},
|
||||
)
|
||||
async def get(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
_principal: Annotated[Principal, Depends(require_admin())],
|
||||
user_id: int,
|
||||
) -> Response:
|
||||
try:
|
||||
user = await auth_user_db.get_by_id(db, user_id)
|
||||
except ItemNotFoundError as exc:
|
||||
return _message(NOT_FOUND, str(exc))
|
||||
return JSONResponse(content=jsonable_encoder(models.AuthUserInfo.from_db(user), exclude_none=True))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
name="Update a user",
|
||||
description=(
|
||||
"Change an account's level, administrator flag, active flag or display name. "
|
||||
"Omitted fields are left alone. Disabling an account also ends its sessions."
|
||||
),
|
||||
response_model=models.AuthUserInfo,
|
||||
responses={400: {"model": models.Message}, 403: {"model": models.Message}, 404: {"model": models.Message}},
|
||||
)
|
||||
async def update(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(require_admin())],
|
||||
user_id: int,
|
||||
body: models.AuthUserUpdateRequest,
|
||||
) -> Response:
|
||||
try:
|
||||
user = await auth_user_db.get_by_id(db, user_id)
|
||||
except ItemNotFoundError as exc:
|
||||
return _message(NOT_FOUND, str(exc))
|
||||
|
||||
try:
|
||||
changes = _collect_changes(principal, user, body)
|
||||
except _Refused as refusal:
|
||||
return _message(refusal.status_code, refusal.message)
|
||||
|
||||
if not changes:
|
||||
return JSONResponse(content=jsonable_encoder(models.AuthUserInfo.from_db(user), exclude_none=True))
|
||||
|
||||
await auth_user_db.update(db=db, user=user, changes=changes)
|
||||
|
||||
if changes.get("is_active") is False:
|
||||
# A disabled account whose cookie still works is not disabled. resolve_principal
|
||||
# already refuses an inactive user, but ending the sessions makes the state on
|
||||
# disk agree with the state in the browser.
|
||||
await auth_session_db.revoke_all_for_user(db, user.id)
|
||||
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.USER_UPDATED,
|
||||
principal=principal,
|
||||
target=user.username,
|
||||
detail={key: value for key, value in changes.items() if key != "display_name"},
|
||||
)
|
||||
return JSONResponse(content=jsonable_encoder(models.AuthUserInfo.from_db(user), exclude_none=True))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/password",
|
||||
name="Reset a user's password",
|
||||
description=(
|
||||
"Set somebody else's password and end all their sessions. If no password is "
|
||||
"supplied one is generated and returned once. The current password is not "
|
||||
"required -- this is the path for a user who has forgotten theirs."
|
||||
),
|
||||
response_model=models.AuthUserCreated,
|
||||
responses={404: {"model": models.Message}},
|
||||
)
|
||||
async def reset_password(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(require_admin())],
|
||||
user_id: int,
|
||||
body: models.AuthPasswordResetRequest,
|
||||
) -> Response:
|
||||
try:
|
||||
user = await auth_user_db.get_by_id(db, user_id)
|
||||
except ItemNotFoundError as exc:
|
||||
return _message(NOT_FOUND, str(exc))
|
||||
|
||||
generated = body.password is None
|
||||
password = body.password or _generate_password()
|
||||
|
||||
await auth_user_db.set_password(
|
||||
db=db,
|
||||
user=user,
|
||||
password_hash=await hash_password_async(password),
|
||||
must_change=body.must_change_password,
|
||||
)
|
||||
# Whoever knew the old password may be exactly who this reset is defending against,
|
||||
# and their cookie would otherwise outlive the credential it was obtained with.
|
||||
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
|
||||
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.PASSWORD_RESET,
|
||||
principal=principal,
|
||||
target=user.username,
|
||||
detail={"sessions_revoked": revoked, "must_change_password": body.must_change_password},
|
||||
)
|
||||
|
||||
result = models.AuthUserCreated(
|
||||
user=models.AuthUserInfo.from_db(user),
|
||||
password=password if generated else None,
|
||||
)
|
||||
return JSONResponse(content=jsonable_encoder(result, exclude_none=True))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/revoke-sessions",
|
||||
name="Sign a user out everywhere",
|
||||
description="End every login session belonging to an account, without changing their password.",
|
||||
response_model=models.Message,
|
||||
responses={404: {"model": models.Message}},
|
||||
)
|
||||
async def revoke_sessions(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(require_admin())],
|
||||
user_id: int,
|
||||
) -> Response:
|
||||
try:
|
||||
user = await auth_user_db.get_by_id(db, user_id)
|
||||
except ItemNotFoundError as exc:
|
||||
return _message(NOT_FOUND, str(exc))
|
||||
|
||||
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
|
||||
await record(
|
||||
request,
|
||||
AuditEvent.SESSIONS_REVOKED,
|
||||
principal=principal,
|
||||
target=user.username,
|
||||
detail={"sessions_revoked": revoked},
|
||||
)
|
||||
return JSONResponse(content={"message": f"Ended {revoked} session(s)."})
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}",
|
||||
name="Delete a user",
|
||||
description=(
|
||||
"Remove an account, its sessions and its API keys. Its audit log entries are "
|
||||
"kept but stop pointing at the account."
|
||||
),
|
||||
response_model=models.Message,
|
||||
responses={403: {"model": models.Message}, 404: {"model": models.Message}},
|
||||
)
|
||||
async def delete(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(require_admin())],
|
||||
user_id: int,
|
||||
) -> Response:
|
||||
try:
|
||||
user = await auth_user_db.get_by_id(db, user_id)
|
||||
except ItemNotFoundError as exc:
|
||||
return _message(NOT_FOUND, str(exc))
|
||||
|
||||
refusal = _protected(principal, user)
|
||||
if refusal is not None:
|
||||
return _message(FORBIDDEN, refusal)
|
||||
|
||||
username = user.username
|
||||
await auth_session_db.revoke_all_for_user(db, user.id)
|
||||
await auth_api_key_db.delete_all_for_user(db, user.id)
|
||||
await auth_audit_db.clear_actor(db, user.id)
|
||||
await auth_user_db.delete_user(db=db, user=user)
|
||||
|
||||
await record(request, AuditEvent.USER_DELETED, principal=principal, target=username)
|
||||
return JSONResponse(content={"message": f"Deleted user '{username}'."})
|
||||
@@ -10,6 +10,8 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.api.v1.models import Message, Vendor, VendorEvent
|
||||
from spoolman.auth.dependencies import require_level, ws_authenticated
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database import vendor
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.utils import SortOrder
|
||||
@@ -57,7 +59,7 @@ class VendorUpdateParameters(VendorParameters):
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def prevent_none(cls: type["VendorUpdateParameters"], v: str | None) -> str | None:
|
||||
def prevent_none(cls: type[VendorUpdateParameters], v: str | None) -> str | None:
|
||||
"""Prevent name from being None."""
|
||||
if v is None:
|
||||
raise ValueError("Value must not be None.")
|
||||
@@ -77,6 +79,7 @@ class VendorUpdateParameters(VendorParameters):
|
||||
200: {"model": list[Vendor]},
|
||||
299: {"model": VendorEvent, "description": "Websocket message"},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def find(
|
||||
request: Request,
|
||||
@@ -160,10 +163,10 @@ async def find(
|
||||
"",
|
||||
name="Listen to vendor changes",
|
||||
)
|
||||
@ws_authenticated(Level.READ)
|
||||
async def notify_any(
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
await websocket.accept()
|
||||
websocket_manager.connect(("vendor",), websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -183,6 +186,7 @@ async def notify_any(
|
||||
),
|
||||
response_model_exclude_none=True,
|
||||
responses={404: {"model": Message}, 299: {"model": VendorEvent, "description": "Websocket message"}},
|
||||
dependencies=[Depends(require_level(Level.READ))],
|
||||
)
|
||||
async def get(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -196,11 +200,11 @@ async def get(
|
||||
"/{vendor_id}",
|
||||
name="Listen to vendor changes",
|
||||
)
|
||||
@ws_authenticated(Level.READ)
|
||||
async def notify(
|
||||
websocket: WebSocket,
|
||||
vendor_id: int,
|
||||
) -> None:
|
||||
await websocket.accept()
|
||||
websocket_manager.connect(("vendor", str(vendor_id)), websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -218,6 +222,7 @@ async def notify(
|
||||
response_model_exclude_none=True,
|
||||
response_model=Vendor,
|
||||
responses={400: {"model": Message}},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def create( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -255,6 +260,7 @@ async def create( # noqa: ANN201
|
||||
400: {"model": Message},
|
||||
404: {"model": Message},
|
||||
},
|
||||
dependencies=[Depends(require_level(Level.EDIT))],
|
||||
)
|
||||
async def update( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
@@ -286,6 +292,7 @@ async def update( # noqa: ANN201
|
||||
"Delete a vendor. The vendor attribute of any filaments who refer to the deleted vendor will be cleared."
|
||||
),
|
||||
responses={404: {"model": Message}},
|
||||
dependencies=[Depends(require_level(Level.MANAGE))],
|
||||
)
|
||||
async def delete(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Authentication and authorization.
|
||||
|
||||
Everything in this package is inert unless ``SPOOLMAN_AUTH_ENABLED`` is set. With it
|
||||
unset, permission gates short-circuit to an unrestricted principal before touching the
|
||||
database, so an instance behaves exactly as it did before authentication existed.
|
||||
|
||||
The permission model has two orthogonal axes:
|
||||
|
||||
* **Level** -- an ordered ``read < edit < manage`` scale carried by users and, from
|
||||
phase 2, by API keys. ``read`` covers every GET, ``edit`` adds modification of
|
||||
existing records, and ``manage`` adds creation and deletion.
|
||||
* **Flags** -- ``is_admin`` (administers other users) and ``is_owner`` (exactly one per
|
||||
instance; promotes admins and cannot be demoted by anyone else). Both imply
|
||||
``manage``.
|
||||
"""
|
||||
@@ -0,0 +1,139 @@
|
||||
"""API key format, generation and presentation.
|
||||
|
||||
A key is presented as ``spoolman_<prefix>.<secret>``. The marker makes a leaked key
|
||||
recognisable in a log or a paste -- secret scanners key off exactly this kind of fixed
|
||||
prefix -- and the separated prefix is what makes the lookup a single indexed query
|
||||
rather than a scan that verifies every stored hash in turn.
|
||||
|
||||
The separator is a full stop rather than an underscore because both halves are url-safe
|
||||
base64, whose alphabet already contains ``-`` and ``_``; splitting on one of those would
|
||||
cut the key in an unpredictable place.
|
||||
|
||||
Keys are hashed for storage with the same plain SHA-256 used for session tokens, and for
|
||||
the same reason: the secret half is 256 bits from a cryptographic random source, so
|
||||
there is no dictionary to attack and a work factor would buy nothing while costing tens
|
||||
of milliseconds on the hot path of every machine request. See
|
||||
:func:`spoolman.auth.hashing.hash_token`.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from typing import Final
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from spoolman.auth.levels import Level, covers
|
||||
|
||||
MARKER: Final = "spoolman_"
|
||||
SEPARATOR: Final = "."
|
||||
|
||||
# 9 bytes is 12 base64 characters, which fits the 16 character prefix column with room
|
||||
# to spare and leaves 72 bits of collision resistance on a value that only has to be
|
||||
# unique, not unguessable.
|
||||
PREFIX_BYTES: Final = 9
|
||||
SECRET_BYTES: Final = 32
|
||||
|
||||
# Both headers are accepted. X-API-Key is what Spoolman's own metrics gate already reads,
|
||||
# and bearer is what most HTTP clients and Prometheus scrape configs express natively.
|
||||
API_KEY_HEADER: Final = "x-api-key"
|
||||
AUTHORIZATION_HEADER: Final = "authorization"
|
||||
BEARER_SCHEME: Final = "bearer"
|
||||
|
||||
# Longest key we will even look at. A key is a fixed ~57 characters; anything much
|
||||
# larger is either not a key or an attempt to make us hash a megabyte per request.
|
||||
MAX_KEY_LENGTH: Final = 256
|
||||
|
||||
|
||||
def new_prefix() -> str:
|
||||
"""Generate the public half of a key.
|
||||
|
||||
Returns:
|
||||
str: A 12 character url-safe identifier.
|
||||
|
||||
"""
|
||||
return secrets.token_urlsafe(PREFIX_BYTES)
|
||||
|
||||
|
||||
def format_key(prefix: str, secret: str) -> str:
|
||||
"""Assemble the string a caller presents.
|
||||
|
||||
Args:
|
||||
prefix: The public identifier.
|
||||
secret: The secret half.
|
||||
|
||||
Returns:
|
||||
str: The complete key.
|
||||
|
||||
"""
|
||||
return f"{MARKER}{prefix}{SEPARATOR}{secret}"
|
||||
|
||||
|
||||
def generate() -> tuple[str, str]:
|
||||
"""Generate a new key.
|
||||
|
||||
Returns:
|
||||
tuple: The prefix to store, and the complete key, which is the only time the
|
||||
secret exists in a readable form.
|
||||
|
||||
"""
|
||||
prefix = new_prefix()
|
||||
return prefix, format_key(prefix, secrets.token_urlsafe(SECRET_BYTES))
|
||||
|
||||
|
||||
def prefix_of(presented: str) -> str | None:
|
||||
"""Extract the prefix from a presented key.
|
||||
|
||||
Args:
|
||||
presented: The string a caller sent.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The prefix, or None if the string is not shaped like a key. A
|
||||
None here means no database query happens at all, which is what keeps a flood
|
||||
of junk credentials from becoming a flood of lookups.
|
||||
|
||||
"""
|
||||
if not presented or len(presented) > MAX_KEY_LENGTH or not presented.startswith(MARKER):
|
||||
return None
|
||||
head, separator, tail = presented.partition(SEPARATOR)
|
||||
if not separator or not tail:
|
||||
return None
|
||||
prefix = head[len(MARKER) :]
|
||||
return prefix or None
|
||||
|
||||
|
||||
def presented_key(conn: Request | WebSocket) -> str:
|
||||
"""Read the API key a request carries, if any.
|
||||
|
||||
Args:
|
||||
conn: The request or websocket.
|
||||
|
||||
Returns:
|
||||
str: The presented key, or an empty string.
|
||||
|
||||
"""
|
||||
presented = conn.headers.get(API_KEY_HEADER, "").strip()
|
||||
if presented:
|
||||
return presented
|
||||
scheme, _, value = conn.headers.get(AUTHORIZATION_HEADER, "").partition(" ")
|
||||
if scheme.lower() == BEARER_SCHEME:
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def effective_level(key_level: Level, user_level: Level) -> Level:
|
||||
"""Work out what a key may actually do.
|
||||
|
||||
A key never exceeds its owner. The cap is applied here, on every request, rather
|
||||
than being baked into the stored level at creation time, so that demoting a user
|
||||
weakens their existing keys immediately instead of leaving credentials in the wild
|
||||
that outrank the account they belong to.
|
||||
|
||||
Args:
|
||||
key_level: The level the key was issued with.
|
||||
user_level: The level its owner currently holds.
|
||||
|
||||
Returns:
|
||||
Level: The weaker of the two.
|
||||
|
||||
"""
|
||||
return user_level if covers(key_level, user_level) else key_level
|
||||
@@ -0,0 +1,207 @@
|
||||
"""The audit trail.
|
||||
|
||||
What gets recorded is anything that changes who can do what, plus every sign-in attempt.
|
||||
Ordinary data changes -- creating a spool, using filament -- are deliberately not
|
||||
recorded here. Those are already visible in the data itself, and mixing them in would
|
||||
bury the handful of security-relevant events under thousands of routine ones, which is
|
||||
how audit logs stop being read.
|
||||
|
||||
Writing is best effort. :func:`record` never raises and never blocks the operation it is
|
||||
describing: an instance whose audit table is somehow unwritable should still let people
|
||||
sign in. The failure is logged instead, which is the one place an operator will see it.
|
||||
|
||||
Each write uses its own database session rather than the caller's. Login attempts have
|
||||
already committed by the time they are audited, several call sites have no session in
|
||||
hand at all, and a shared session would mean an audit write could roll back the
|
||||
operation it was recording. The cost is one extra short transaction on paths that
|
||||
already do several.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from enum import StrEnum
|
||||
from typing import Any, Final
|
||||
|
||||
from scheduler.asyncio.scheduler import Scheduler
|
||||
from starlette.requests import Request
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from spoolman import env
|
||||
from spoolman.auth.principal import Principal, PrincipalKind
|
||||
from spoolman.auth.ratelimit import client_ip
|
||||
from spoolman.database import auth_audit as auth_audit_db
|
||||
from spoolman.settings import parse_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_AGENT_HEADER: Final = "user-agent"
|
||||
|
||||
RETENTION_SETTING: Final = "auth_audit_retention_days"
|
||||
|
||||
# Used when the retention setting cannot be read at all. Matches the setting's own
|
||||
# default, so a database that is briefly unavailable does not silently switch the
|
||||
# instance to keeping entries forever.
|
||||
DEFAULT_RETENTION_DAYS: Final = 90
|
||||
|
||||
|
||||
class AuditEvent(StrEnum):
|
||||
"""Everything the audit log records.
|
||||
|
||||
The values are stable identifiers, stored in the database and matched against by the
|
||||
listing endpoint's filter. Renaming one orphans the history that used the old name,
|
||||
so add rather than rename.
|
||||
"""
|
||||
|
||||
LOGIN_SUCCESS = "login.success"
|
||||
LOGIN_FAILURE = "login.failure"
|
||||
LOGIN_LOCKED = "login.locked"
|
||||
LOGOUT = "logout"
|
||||
INSTANCE_CLAIMED = "instance.claimed"
|
||||
|
||||
# Event names, not credentials. Ruff's hardcoded-password heuristic only sees the
|
||||
# word "password" in the member name.
|
||||
PASSWORD_CHANGED = "password.changed" # noqa: S105
|
||||
PASSWORD_RESET = "password.reset" # noqa: S105
|
||||
|
||||
USER_CREATED = "user.created"
|
||||
USER_UPDATED = "user.updated"
|
||||
USER_DELETED = "user.deleted"
|
||||
|
||||
APIKEY_CREATED = "apikey.created"
|
||||
APIKEY_REVOKED = "apikey.revoked"
|
||||
APIKEY_DELETED = "apikey.deleted"
|
||||
|
||||
SESSIONS_REVOKED = "sessions.revoked"
|
||||
|
||||
|
||||
def _actor_kind(principal: Principal | None) -> str:
|
||||
"""Describe how the actor was authenticated."""
|
||||
if principal is None:
|
||||
return str(PrincipalKind.ANONYMOUS)
|
||||
return str(principal.kind)
|
||||
|
||||
|
||||
async def record(
|
||||
conn: Request | WebSocket | None,
|
||||
event: AuditEvent,
|
||||
*,
|
||||
principal: Principal | None = None,
|
||||
actor_user_id: int | None = None,
|
||||
target: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Record an event, if auditing applies.
|
||||
|
||||
Does nothing when authentication is disabled. An instance that has never turned auth
|
||||
on has no accounts, no sessions and no keys, so there is nothing meaningful to
|
||||
attribute an event to, and writing rows to a table nothing reads would just grow the
|
||||
database of every existing user.
|
||||
|
||||
Args:
|
||||
conn: The request or websocket the event happened on, for the address and user
|
||||
agent. May be None for events that did not arise from a request.
|
||||
event: What happened.
|
||||
principal: Who did it, if a principal had been resolved.
|
||||
actor_user_id: Who did it, when the user is known but no principal exists --
|
||||
a failed sign-in against a real account, for instance.
|
||||
target: What was acted on.
|
||||
detail: Anything else worth keeping. Stored as a JSON object.
|
||||
|
||||
"""
|
||||
if not env.is_auth_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
# Imported here rather than at module scope: this module is reachable from the
|
||||
# command line tool, and get_db_session belongs to a module that assumes a
|
||||
# configured database.
|
||||
from spoolman.database.database import get_db_session # noqa: PLC0415
|
||||
|
||||
if actor_user_id is None and principal is not None:
|
||||
actor_user_id = principal.user_id
|
||||
|
||||
generator = get_db_session()
|
||||
db = await anext(generator)
|
||||
try:
|
||||
await auth_audit_db.record(
|
||||
db=db,
|
||||
event=str(event),
|
||||
actor_kind=_actor_kind(principal),
|
||||
actor_user_id=actor_user_id,
|
||||
target=target,
|
||||
ip=client_ip(conn) if conn is not None else None,
|
||||
user_agent=conn.headers.get(USER_AGENT_HEADER) if conn is not None else None,
|
||||
detail=json.dumps(detail) if detail else None,
|
||||
)
|
||||
finally:
|
||||
await generator.aclose()
|
||||
except Exception:
|
||||
# Never propagates. See the module docstring.
|
||||
logger.exception("Failed to write audit log entry for %s.", event)
|
||||
|
||||
|
||||
async def retention_days() -> int:
|
||||
"""Read how long audit entries are kept.
|
||||
|
||||
Returns:
|
||||
int: The retention window in days. Zero means keep everything.
|
||||
|
||||
"""
|
||||
from spoolman.database import setting as setting_db # noqa: PLC0415
|
||||
from spoolman.database.database import get_db_session # noqa: PLC0415
|
||||
from spoolman.exceptions import ItemNotFoundError # noqa: PLC0415
|
||||
|
||||
definition = parse_setting(RETENTION_SETTING)
|
||||
try:
|
||||
generator = get_db_session()
|
||||
db = await anext(generator)
|
||||
try:
|
||||
try:
|
||||
row = await setting_db.get(db, definition)
|
||||
except ItemNotFoundError:
|
||||
return int(json.loads(definition.default))
|
||||
return int(json.loads(row.value))
|
||||
finally:
|
||||
await generator.aclose()
|
||||
except Exception:
|
||||
logger.exception("Failed to read the %s setting, keeping the default.", RETENTION_SETTING)
|
||||
return DEFAULT_RETENTION_DAYS
|
||||
|
||||
|
||||
async def _prune_task() -> None:
|
||||
"""Apply the configured retention window to the audit log."""
|
||||
from spoolman.database.database import get_db_session # noqa: PLC0415
|
||||
|
||||
days = await retention_days()
|
||||
if days <= 0:
|
||||
return
|
||||
|
||||
cutoff = datetime.datetime.utcnow().replace(microsecond=0) - datetime.timedelta(days=days)
|
||||
generator = get_db_session()
|
||||
db = await anext(generator)
|
||||
try:
|
||||
removed = await auth_audit_db.prune_older_than(db, cutoff)
|
||||
finally:
|
||||
await generator.aclose()
|
||||
|
||||
if removed:
|
||||
logger.info("Pruned %d audit log entries older than %d days.", removed, days)
|
||||
|
||||
|
||||
def schedule_tasks(scheduler: Scheduler) -> None:
|
||||
"""Schedule the audit log retention sweep.
|
||||
|
||||
Only scheduled when authentication is enabled, since nothing writes to the table
|
||||
otherwise and the job would be a nightly no-op on every existing instance.
|
||||
|
||||
Args:
|
||||
scheduler: The scheduler to use.
|
||||
|
||||
"""
|
||||
if not env.is_auth_enabled():
|
||||
return
|
||||
logger.info("Scheduling automatic audit log pruning.")
|
||||
# Half an hour after the nightly backup rather than alongside it, so the two do not
|
||||
# contend for SQLite's single write lock.
|
||||
scheduler.daily(datetime.time(hour=0, minute=30, second=0), _prune_task) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Session cookie and CSRF handling.
|
||||
|
||||
The session cookie holds an opaque token and is ``HttpOnly``, so script cannot read it.
|
||||
Because it is sent automatically on same-origin requests it is an ambient credential and
|
||||
needs CSRF protection, which is done by double submit: a second, script-readable cookie
|
||||
carries a random value that the client must echo in a request header. An attacker's page
|
||||
can cause the browser to send the cookies but cannot read them to construct the header.
|
||||
|
||||
API keys, once they arrive in phase 2, are exempt -- they are not ambient, so a
|
||||
cross-site page has no way to attach one.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from spoolman import env
|
||||
from spoolman.auth import ratelimit
|
||||
from spoolman.auth.hashing import tokens_equal
|
||||
|
||||
SESSION_COOKIE: Final = "spoolman_session"
|
||||
CSRF_COOKIE: Final = "spoolman_csrf"
|
||||
CSRF_HEADER: Final = "X-CSRF-Token"
|
||||
|
||||
# Sessions without "remember me" expire this long after they were last used.
|
||||
IDLE_LIFETIME: Final = timedelta(hours=12)
|
||||
|
||||
# Sessions with "remember me" last this long, sliding forward as they are used.
|
||||
REMEMBER_LIFETIME: Final = timedelta(days=90)
|
||||
|
||||
# Methods that do not change state, and so do not require a CSRF token.
|
||||
SAFE_METHODS: Final = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
|
||||
def cookie_path() -> str:
|
||||
"""Get the path cookies are scoped to.
|
||||
|
||||
Returns:
|
||||
str: The deployment's base path with a trailing slash.
|
||||
|
||||
"""
|
||||
return env.get_base_path() + "/"
|
||||
|
||||
|
||||
def cookie_secure(conn: Request | WebSocket) -> bool:
|
||||
"""Decide whether cookies should carry the Secure flag.
|
||||
|
||||
Setting Secure on a plain-HTTP deployment makes the browser silently discard the
|
||||
cookie, which presents as a login that appears to succeed and then does nothing. The
|
||||
automatic path therefore only concludes HTTPS from positive evidence: either the
|
||||
request arrived over TLS, or a proxy we have been told to trust says it did.
|
||||
|
||||
Args:
|
||||
conn: The request or websocket.
|
||||
|
||||
Returns:
|
||||
bool: Whether to set the Secure flag.
|
||||
|
||||
"""
|
||||
override = env.get_cookie_secure_override()
|
||||
if override is not None:
|
||||
return override
|
||||
if conn.url.scheme in {"https", "wss"}:
|
||||
return True
|
||||
return ratelimit.forwarded_proto(conn) == "https"
|
||||
|
||||
|
||||
def set_session_cookies(
|
||||
response: Response,
|
||||
conn: Request,
|
||||
*,
|
||||
token: str,
|
||||
csrf: str,
|
||||
remember: bool,
|
||||
) -> None:
|
||||
"""Attach the session and CSRF cookies to a response.
|
||||
|
||||
Args:
|
||||
response: The response to modify.
|
||||
conn: The request being answered, used to derive the Secure flag.
|
||||
token: The plaintext session token.
|
||||
csrf: The plaintext CSRF token.
|
||||
remember: Whether the session should outlive the browser session.
|
||||
|
||||
"""
|
||||
secure = cookie_secure(conn)
|
||||
path = cookie_path()
|
||||
max_age = int(REMEMBER_LIFETIME.total_seconds()) if remember else None
|
||||
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
max_age=max_age,
|
||||
path=path,
|
||||
secure=secure,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
# Readable by script on purpose: the client has to copy it into a request header.
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE,
|
||||
csrf,
|
||||
max_age=max_age,
|
||||
path=path,
|
||||
secure=secure,
|
||||
httponly=False,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookies(response: Response, conn: Request) -> None:
|
||||
"""Remove the session and CSRF cookies.
|
||||
|
||||
Args:
|
||||
response: The response to modify.
|
||||
conn: The request being answered, used to derive the Secure flag.
|
||||
|
||||
"""
|
||||
secure = cookie_secure(conn)
|
||||
path = cookie_path()
|
||||
for name in (SESSION_COOKIE, CSRF_COOKIE):
|
||||
response.delete_cookie(name, path=path, secure=secure, httponly=name == SESSION_COOKIE, samesite="lax")
|
||||
|
||||
|
||||
def verify_csrf(request: Request) -> bool:
|
||||
"""Check the CSRF double-submit for a state-changing request.
|
||||
|
||||
Args:
|
||||
request: The request to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the request may proceed.
|
||||
|
||||
"""
|
||||
if request.method.upper() in SAFE_METHODS:
|
||||
return True
|
||||
cookie = request.cookies.get(CSRF_COOKIE, "")
|
||||
header = request.headers.get(CSRF_HEADER, "")
|
||||
if not cookie or not header:
|
||||
return False
|
||||
return tokens_equal(cookie, header)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Proof that every API route carries a permission gate.
|
||||
|
||||
Gating routes one at a time is fail-open: forget one and it silently serves
|
||||
unauthenticated traffic, and nothing tells you until someone notices. This module
|
||||
inverts that. It walks the route table and raises if any route lacks a gate, so a
|
||||
forgotten one stops the process at import rather than becoming a hole.
|
||||
|
||||
It runs at import of :mod:`spoolman.api.v1.router`, which is the earliest possible
|
||||
moment -- nothing registers routes after that -- and means the failure happens before
|
||||
uvicorn binds a port, before migrations run, and equally when the module is imported by
|
||||
the docs generator or a test.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from typing import Final
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.dependencies.models import Dependant
|
||||
from fastapi.routing import APIRoute, APIWebSocketRoute
|
||||
from starlette.routing import BaseRoute
|
||||
|
||||
from spoolman.auth.dependencies import AUTH_LEVEL_ATTR
|
||||
from spoolman.auth.levels import Level
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WEBSOCKET_METHOD: Final = "WEBSOCKET"
|
||||
|
||||
|
||||
def _walk(routes: list[BaseRoute]) -> Iterator[BaseRoute]:
|
||||
"""Yield every route, descending into included routers.
|
||||
|
||||
FastAPI does not flatten ``include_router`` into the parent's route list; it stores
|
||||
a wrapper object holding the original router. Iterating ``app.routes`` alone
|
||||
therefore sees only the handful of routes declared directly on the app and would
|
||||
declare everything else covered.
|
||||
|
||||
Args:
|
||||
routes: The route list to walk.
|
||||
|
||||
Yields:
|
||||
BaseRoute: Each concrete route.
|
||||
|
||||
"""
|
||||
for route in routes:
|
||||
nested = getattr(route, "original_router", None)
|
||||
if nested is not None:
|
||||
yield from _walk(nested.routes)
|
||||
else:
|
||||
yield route
|
||||
|
||||
|
||||
def _gate_level(dependant: Dependant) -> Level | None:
|
||||
"""Find the permission level a route's dependencies enforce.
|
||||
|
||||
Reads ``dependant.dependencies`` rather than the route's raw ``dependencies``
|
||||
argument, so that a gate declared as a handler parameter counts too.
|
||||
|
||||
Args:
|
||||
dependant: The route's resolved dependency tree.
|
||||
|
||||
Returns:
|
||||
Optional[Level]: The level enforced, or None if no gate is present.
|
||||
|
||||
"""
|
||||
for sub in dependant.dependencies:
|
||||
level = getattr(sub.call, AUTH_LEVEL_ATTR, None)
|
||||
if level is not None:
|
||||
return level
|
||||
nested = _gate_level(sub)
|
||||
if nested is not None:
|
||||
return nested
|
||||
return None
|
||||
|
||||
|
||||
def find_uncovered(app: FastAPI, allow: frozenset[tuple[str, str]]) -> tuple[list[str], dict[str, Level]]:
|
||||
"""Sort an app's routes into gated and ungated.
|
||||
|
||||
Args:
|
||||
app: The application to inspect.
|
||||
allow: Method and path pairs that are intentionally public.
|
||||
|
||||
Returns:
|
||||
tuple: The ungated route descriptions, and the level of each gated route.
|
||||
|
||||
"""
|
||||
missing: list[str] = []
|
||||
covered: dict[str, Level] = {}
|
||||
|
||||
for route in _walk(app.routes):
|
||||
if isinstance(route, APIWebSocketRoute):
|
||||
methods = (WEBSOCKET_METHOD,)
|
||||
level = getattr(route.endpoint, AUTH_LEVEL_ATTR, None)
|
||||
elif isinstance(route, APIRoute):
|
||||
# HEAD is added implicitly alongside GET and shares its gate.
|
||||
methods = tuple(sorted(method for method in route.methods if method != "HEAD"))
|
||||
level = _gate_level(route.dependant)
|
||||
else:
|
||||
# Starlette's own routes: /openapi.json, /docs, /docs/oauth2-redirect and
|
||||
# /redoc. They serve the schema, not data.
|
||||
continue
|
||||
|
||||
for method in methods:
|
||||
if (method, route.path) in allow:
|
||||
continue
|
||||
if level is None:
|
||||
missing.append(f"{method} {route.path}")
|
||||
else:
|
||||
covered[f"{method} {route.path}"] = level
|
||||
|
||||
return missing, covered
|
||||
|
||||
|
||||
def assert_routes_covered(app: FastAPI, allow: frozenset[tuple[str, str]]) -> dict[str, Level]:
|
||||
"""Raise unless every route on an app carries a permission gate.
|
||||
|
||||
Args:
|
||||
app: The application to inspect.
|
||||
allow: Method and path pairs that are intentionally public.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If any route lacks a gate.
|
||||
|
||||
Returns:
|
||||
dict: The level enforced on each gated route.
|
||||
|
||||
"""
|
||||
missing, covered = find_uncovered(app, allow)
|
||||
if missing:
|
||||
listed = "\n ".join(sorted(missing))
|
||||
raise RuntimeError(
|
||||
"Some API routes have no authentication gate, which would serve them "
|
||||
"unauthenticated whenever authentication is enabled. Add a "
|
||||
"require_level dependency, or a ws_authenticated decorator for websockets, "
|
||||
f"or list the route as intentionally public:\n {listed}",
|
||||
)
|
||||
return covered
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Permission gates for API routes.
|
||||
|
||||
HTTP routes are gated by adding ``dependencies=[Depends(require_level(Level.X))]`` to
|
||||
the route decorator. Websocket routes cannot use that mechanism -- see
|
||||
:func:`ws_authenticated` for why -- and are gated with a decorator instead.
|
||||
|
||||
Both mechanisms tag the callable they produce with :data:`AUTH_LEVEL_ATTR`, which is
|
||||
what :mod:`spoolman.auth.coverage` reads to prove at import time that no route was left
|
||||
ungated.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Final, ParamSpec, TypeVar
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.requests import Request
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from spoolman import env
|
||||
from spoolman.auth import apikey, cookies
|
||||
from spoolman.auth.levels import Level, parse_level
|
||||
from spoolman.auth.principal import ANONYMOUS_READER, UNRESTRICTED, Principal, PrincipalKind
|
||||
from spoolman.database import auth_api_key as auth_api_key_db
|
||||
from spoolman.database import auth_session as auth_session_db
|
||||
from spoolman.database import auth_user as auth_user_db
|
||||
from spoolman.database import models
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.exceptions import AuthenticationRequiredError, ItemNotFoundError, PermissionDeniedError
|
||||
from spoolman.settings import parse_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTH_LEVEL_ATTR: Final = "__spoolman_auth_level__"
|
||||
|
||||
# Websocket close codes. The codebase's convention is 4000 plus the HTTP status it
|
||||
# stands in for, following the existing 4040 in spoolman/api/v1/setting.py.
|
||||
WS_UNAUTHENTICATED: Final = 4401
|
||||
WS_FORBIDDEN: Final = 4403
|
||||
|
||||
# Where a resolved principal is cached, so several gates on one request cost one lookup.
|
||||
REQUEST_STATE_ATTR: Final = "spoolman_principal"
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
ANONYMOUS_READ_SETTING: Final = "auth_anonymous_read"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _db_session() -> AsyncIterator[AsyncSession]:
|
||||
"""Open a database session outside FastAPI's dependency system.
|
||||
|
||||
``get_db_session`` is an async generator. Driving it with ``async for`` and then
|
||||
breaking or returning leaves it suspended at its yield, so the ``finally`` that
|
||||
closes the session never runs on the event loop -- the garbage collector reaches it
|
||||
later and SQLAlchemy raises out of a context where it cannot await. Closing the
|
||||
generator explicitly keeps that deterministic.
|
||||
|
||||
Note this skips the generator's own trailing commit, which is deliberate: callers
|
||||
here commit explicitly where they mean to write.
|
||||
|
||||
Yields:
|
||||
AsyncSession: The database session.
|
||||
|
||||
"""
|
||||
generator = get_db_session()
|
||||
session = await anext(generator)
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await generator.aclose()
|
||||
|
||||
|
||||
async def anonymous_read_enabled() -> bool:
|
||||
"""Check the setting that grants unauthenticated read access.
|
||||
|
||||
Fails closed. Any problem reaching the setting -- the database being unavailable,
|
||||
the row holding something that is not a boolean -- denies access rather than
|
||||
granting it, because the failure mode of guessing wrong here is serving the whole
|
||||
database to anonymous callers.
|
||||
"""
|
||||
# Imported lazily: spoolman.database.setting pulls in the websocket manager, and at
|
||||
# module scope that would drag it into every import of this package, including the
|
||||
# command line tool.
|
||||
from spoolman.database import setting as setting_db # noqa: PLC0415
|
||||
|
||||
definition = parse_setting(ANONYMOUS_READ_SETTING)
|
||||
try:
|
||||
async with _db_session() as db:
|
||||
try:
|
||||
row = await setting_db.get(db, definition)
|
||||
except ItemNotFoundError:
|
||||
# Never set, so it holds its default of false.
|
||||
return False
|
||||
return json.loads(row.value) is True
|
||||
except Exception:
|
||||
logger.exception("Failed to read the %s setting, denying anonymous access.", ANONYMOUS_READ_SETTING)
|
||||
return False
|
||||
|
||||
|
||||
def _principal_for_user(user: models.AuthUser, session_id: int) -> Principal:
|
||||
"""Build a principal from a user row and the session that authenticated it."""
|
||||
return Principal(
|
||||
kind=PrincipalKind.USER,
|
||||
level=parse_level(user.level),
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
is_admin=user.is_admin,
|
||||
is_owner=user.is_owner,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
|
||||
def _principal_for_api_key(key: models.AuthApiKey, user: models.AuthUser) -> Principal:
|
||||
"""Build a principal from an API key and the user it belongs to.
|
||||
|
||||
A key never carries administrative rights, however senior its owner. Everything
|
||||
behind :func:`require_admin` -- creating users, reading the audit log, minting
|
||||
further keys -- therefore needs a signed-in session. That means a leaked key cannot
|
||||
be used to entrench itself by creating another account or another key, and it keeps
|
||||
the blast radius of the credential that lives in a printer's config file to the data
|
||||
it was issued for.
|
||||
"""
|
||||
return Principal(
|
||||
kind=PrincipalKind.APIKEY,
|
||||
level=apikey.effective_level(parse_level(key.level), parse_level(user.level)),
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
is_admin=False,
|
||||
is_owner=False,
|
||||
api_key_id=key.id,
|
||||
)
|
||||
|
||||
|
||||
async def api_key_principal(conn: Request | WebSocket) -> Principal | None:
|
||||
"""Resolve an API key, ignoring every other kind of credential.
|
||||
|
||||
Separate from :func:`resolve_principal` because /metrics needs exactly this and
|
||||
nothing else: a scraper presents a key or a shared token, never a cookie, and
|
||||
honouring a session there would quietly make the endpoint readable by any browser
|
||||
tab that happens to be signed in.
|
||||
|
||||
Args:
|
||||
conn: The request or websocket being authenticated.
|
||||
|
||||
Returns:
|
||||
Optional[Principal]: The principal, or None if no usable key was presented.
|
||||
|
||||
"""
|
||||
presented = apikey.presented_key(conn)
|
||||
if not presented:
|
||||
return None
|
||||
async with _db_session() as db:
|
||||
key = await auth_api_key_db.resolve(db, presented)
|
||||
if key is None:
|
||||
return None
|
||||
user = await auth_user_db.get_by_id(db, key.user_id)
|
||||
if not user.is_active:
|
||||
return None
|
||||
return _principal_for_api_key(key, user)
|
||||
|
||||
|
||||
async def resolve_principal(conn: Request | WebSocket) -> Principal | None:
|
||||
"""Work out who is making a request.
|
||||
|
||||
Resolution order is the API key header, then the session cookie, then anonymous read
|
||||
if the setting allows it. Certificates join this list in phase 5.
|
||||
|
||||
An explicitly presented key wins over the cookie a browser attaches by itself. The
|
||||
ordering only matters when both are present, which in practice is a signed-in
|
||||
operator testing a key from the browser's console; resolving to the key is what makes
|
||||
that test tell them the truth about what the key can do.
|
||||
|
||||
This opens its own short-lived database session rather than depending on
|
||||
``get_db_session``. A route-level dependency is solved before the handler's own
|
||||
parameters, so declaring one here would open a session on every request -- including
|
||||
/health and /info, which currently need no database at all and would start failing
|
||||
when the database is down.
|
||||
|
||||
Args:
|
||||
conn: The request or websocket being authenticated.
|
||||
|
||||
Returns:
|
||||
Optional[Principal]: The principal, or None if no credential was accepted.
|
||||
|
||||
"""
|
||||
cached = getattr(conn.state, REQUEST_STATE_ATTR, None) if hasattr(conn, "state") else None
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
principal: Principal | None = await api_key_principal(conn)
|
||||
|
||||
if principal is None:
|
||||
token = conn.cookies.get(cookies.SESSION_COOKIE, "")
|
||||
if token:
|
||||
async with _db_session() as db:
|
||||
session = await auth_session_db.resolve(db, token)
|
||||
if session is not None:
|
||||
user = await auth_user_db.get_by_id(db, session.user_id)
|
||||
if user.is_active:
|
||||
principal = _principal_for_user(user, session.id)
|
||||
|
||||
if principal is None and await anonymous_read_enabled():
|
||||
principal = ANONYMOUS_READER
|
||||
|
||||
if principal is not None and hasattr(conn, "state"):
|
||||
setattr(conn.state, REQUEST_STATE_ATTR, principal)
|
||||
return principal
|
||||
|
||||
|
||||
def require_level(
|
||||
level: Level,
|
||||
*,
|
||||
allow_anonymous: bool = True,
|
||||
) -> Callable[[Request], Awaitable[Principal]]:
|
||||
"""Build a dependency that admits requests at or above a permission level.
|
||||
|
||||
Args:
|
||||
level: The level required.
|
||||
allow_anonymous: Whether the anonymous-read setting can satisfy this route.
|
||||
Turned off for endpoints that expose the database in bulk.
|
||||
|
||||
Returns:
|
||||
Callable: A FastAPI dependency yielding the principal.
|
||||
|
||||
"""
|
||||
|
||||
async def gate(request: Request) -> Principal:
|
||||
if not env.is_auth_enabled():
|
||||
return UNRESTRICTED
|
||||
|
||||
principal = await resolve_principal(request)
|
||||
if principal is None:
|
||||
raise AuthenticationRequiredError("Authentication required.")
|
||||
if principal.kind is PrincipalKind.ANONYMOUS and not allow_anonymous:
|
||||
raise AuthenticationRequiredError("Authentication required.")
|
||||
if not principal.covers(level):
|
||||
raise PermissionDeniedError(f"This operation requires the '{level}' permission level.")
|
||||
if principal.kind is PrincipalKind.USER and not cookies.verify_csrf(request):
|
||||
# 403 rather than 401 on purpose: the credential is valid, so telling the
|
||||
# client it is unauthenticated would sign the user out over what is usually
|
||||
# a stale tab rather than an attack.
|
||||
#
|
||||
# Only USER is checked. A session cookie is ambient -- the browser attaches
|
||||
# it to a cross-site form post without being asked -- which is the entire
|
||||
# premise of CSRF. An API key is not: it lives in a header that a page on
|
||||
# another origin has no way to set, so demanding a second factor of a
|
||||
# machine credential would only break every non-browser client for nothing.
|
||||
raise PermissionDeniedError("CSRF token missing or invalid.")
|
||||
return principal
|
||||
|
||||
setattr(gate, AUTH_LEVEL_ATTR, level)
|
||||
return gate
|
||||
|
||||
|
||||
def require_user() -> Callable[[Request], Awaitable[Principal]]:
|
||||
"""Build a dependency that requires a real signed-in user.
|
||||
|
||||
This is the gate for anything that acts on the account itself -- changing its
|
||||
password, minting or revoking its API keys. An API key is refused even though it
|
||||
belongs to a user, because a credential that can mint another credential can never
|
||||
really be revoked: whoever holds a leaked key would simply issue themselves a fresh
|
||||
one before the original was withdrawn.
|
||||
|
||||
Returns:
|
||||
Callable: A FastAPI dependency yielding the principal.
|
||||
|
||||
"""
|
||||
inner = require_level(Level.READ, allow_anonymous=False)
|
||||
|
||||
async def gate(request: Request) -> Principal:
|
||||
principal = await inner(request)
|
||||
if principal.kind is PrincipalKind.ANONYMOUS:
|
||||
raise AuthenticationRequiredError("Authentication required.")
|
||||
if principal.kind is PrincipalKind.APIKEY:
|
||||
# 403, not 401: the key is a perfectly valid credential, it is simply the
|
||||
# wrong kind for this. Answering 401 would tell the client to go and get a
|
||||
# credential it already has.
|
||||
raise PermissionDeniedError("This operation requires a signed-in user, not an API key.")
|
||||
return principal
|
||||
|
||||
setattr(gate, AUTH_LEVEL_ATTR, Level.READ)
|
||||
return gate
|
||||
|
||||
|
||||
def require_admin() -> Callable[[Request], Awaitable[Principal]]:
|
||||
"""Build a dependency that requires administrator rights.
|
||||
|
||||
Returns:
|
||||
Callable: A FastAPI dependency yielding the principal.
|
||||
|
||||
"""
|
||||
inner = require_level(Level.MANAGE, allow_anonymous=False)
|
||||
|
||||
async def gate(request: Request) -> Principal:
|
||||
principal = await inner(request)
|
||||
if not env.is_auth_enabled():
|
||||
return principal
|
||||
if not principal.is_admin:
|
||||
raise PermissionDeniedError("This operation requires administrator rights.")
|
||||
return principal
|
||||
|
||||
setattr(gate, AUTH_LEVEL_ATTR, Level.MANAGE)
|
||||
return gate
|
||||
|
||||
|
||||
def ws_authenticated(level: Level) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
||||
"""Gate a websocket handler.
|
||||
|
||||
The handshake is accepted *before* any rejection close, which looks wrong and is
|
||||
deliberate. uvicorn translates a close sent before the accept into an HTTP 403
|
||||
handshake rejection and discards the close code entirely, and Starlette routes
|
||||
WebSocketException raised from a dependency through that same path. Neither can
|
||||
deliver 4401 or 4403 to the client. Accepting first and then closing is the only
|
||||
way the browser observes the real code, which is what lets the client tell "you are
|
||||
signed out, stop retrying" apart from "the network blipped, back off and retry".
|
||||
|
||||
A socket that is accepted and immediately closed never joins the websocket manager,
|
||||
so it costs nothing.
|
||||
|
||||
Decorated handlers must not call ``websocket.accept()`` themselves.
|
||||
|
||||
Args:
|
||||
level: The level required to subscribe.
|
||||
|
||||
Returns:
|
||||
Callable: The decorator.
|
||||
|
||||
"""
|
||||
|
||||
def decorate(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
|
||||
websocket = kwargs["websocket"]
|
||||
assert isinstance(websocket, WebSocket) # noqa: S101
|
||||
await websocket.accept()
|
||||
|
||||
if env.is_auth_enabled():
|
||||
principal = await resolve_principal(websocket)
|
||||
if principal is None:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED, reason="Authentication required.")
|
||||
return None
|
||||
if not principal.covers(level):
|
||||
await websocket.close(code=WS_FORBIDDEN, reason="Insufficient permission level.")
|
||||
return None
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
setattr(wrapper, AUTH_LEVEL_ATTR, level)
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorate
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Password and token hashing.
|
||||
|
||||
Passwords use :func:`hashlib.scrypt`, a memory-hard KDF in the standard library.
|
||||
|
||||
The obvious alternative is to depend on a hashing library, and that was considered and
|
||||
declined for phase 1. ``argon2-cffi`` is the strongest option but its bindings have no
|
||||
armv7 wheel, so that image would compile a C extension under QEMU in CI. ``bcrypt`` 5.0
|
||||
does now publish armv7 wheels, but truncates passwords at 72 bytes. ``passlib`` has had
|
||||
no release since 2020. None of them addresses the part of login that actually leaks
|
||||
information -- see :func:`dummy_verify` -- which is application-level either way.
|
||||
|
||||
What a library would replace is the encoding and parsing below. It is deliberately kept
|
||||
behind this module's interface so the decision can be revisited without touching
|
||||
callers.
|
||||
|
||||
Every hash records the parameters it was produced with, so the cost can be raised later
|
||||
without a migration: :func:`verify_password` re-derives using the parameters embedded in
|
||||
the stored string, and :func:`needs_rehash` reports when a stored hash predates a change
|
||||
so the login path can opportunistically upgrade it.
|
||||
|
||||
Deriving a hash blocks for tens to hundreds of milliseconds depending on hardware
|
||||
(measured at 49 ms on a desktop, several times that on a Raspberry Pi). Spoolman runs as
|
||||
a single process, so a synchronous call in a request handler would stall every other
|
||||
request and every websocket ping for that whole time. scrypt releases the GIL, so the
|
||||
``_async`` wrappers below genuinely offload to a worker thread; request handlers must
|
||||
use those and never the synchronous forms.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from typing import Final
|
||||
|
||||
import anyio.to_thread
|
||||
|
||||
# Cost parameters. n is the CPU/memory cost, r the block size, p the parallelisation.
|
||||
# Memory use is roughly 128 * r * n bytes, so this costs ~16 MiB and ~50 ms per hash on
|
||||
# a desktop. OWASP's headline scrypt recommendation is n=2**17, which is eight times
|
||||
# this and would be multi-second on the low-power ARM boards that Spoolman commonly runs
|
||||
# on -- unacceptable for an interactive login. Their memory-constrained alternative
|
||||
# raises p instead, which costs the same wall-clock time without the memory hardness.
|
||||
SCRYPT_N: Final = 2**14
|
||||
SCRYPT_R: Final = 8
|
||||
SCRYPT_P: Final = 1
|
||||
SCRYPT_DKLEN: Final = 32
|
||||
SALT_BYTES: Final = 16
|
||||
|
||||
# OpenSSL caps scrypt's allocation at 32 MiB when maxmem is left at 0, which n=2**14
|
||||
# fits under. It is passed explicitly so that raising SCRYPT_N later fails loudly at the
|
||||
# parameter change rather than obscurely inside OpenSSL.
|
||||
SCRYPT_MAXMEM: Final = 64 * 1024 * 1024
|
||||
|
||||
ALGORITHM: Final = "scrypt"
|
||||
TOKEN_BYTES: Final = 32
|
||||
|
||||
|
||||
def _b64encode(raw: bytes) -> str:
|
||||
"""Encode bytes as unpadded url-safe base64."""
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
def _b64decode(value: str) -> bytes:
|
||||
"""Decode unpadded url-safe base64."""
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(value + padding)
|
||||
|
||||
|
||||
def _derive(password: str, salt: bytes, n: int, r: int, p: int, dklen: int) -> bytes:
|
||||
"""Run scrypt with explicit parameters."""
|
||||
return hashlib.scrypt(
|
||||
password.encode(),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
dklen=dklen,
|
||||
maxmem=SCRYPT_MAXMEM,
|
||||
)
|
||||
|
||||
|
||||
def _format(salt: bytes, derived: bytes, n: int, r: int, p: int) -> str:
|
||||
"""Build the stored representation of a hash."""
|
||||
return f"{ALGORITHM}${n}${r}${p}${_b64encode(salt)}${_b64encode(derived)}"
|
||||
|
||||
|
||||
def _parse(encoded: str) -> tuple[int, int, int, bytes, bytes]:
|
||||
"""Split a stored hash into its parameters, salt and digest.
|
||||
|
||||
Args:
|
||||
encoded: The stored hash string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the string is not a well-formed scrypt hash.
|
||||
|
||||
Returns:
|
||||
tuple: n, r, p, salt and the expected digest.
|
||||
|
||||
"""
|
||||
parts = encoded.split("$")
|
||||
expected_parts = 6
|
||||
if len(parts) != expected_parts or parts[0] != ALGORITHM:
|
||||
raise ValueError("Malformed password hash.")
|
||||
try:
|
||||
n, r, p = int(parts[1]), int(parts[2]), int(parts[3])
|
||||
return n, r, p, _b64decode(parts[4]), _b64decode(parts[5])
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Malformed password hash.") from exc
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password for storage.
|
||||
|
||||
Args:
|
||||
password: The plaintext password.
|
||||
|
||||
Returns:
|
||||
str: The encoded hash, including the parameters used.
|
||||
|
||||
"""
|
||||
salt = secrets.token_bytes(SALT_BYTES)
|
||||
derived = _derive(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P, SCRYPT_DKLEN)
|
||||
return _format(salt, derived, SCRYPT_N, SCRYPT_R, SCRYPT_P)
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
"""Check a password against a stored hash.
|
||||
|
||||
Args:
|
||||
password: The plaintext password to check.
|
||||
encoded: The stored hash.
|
||||
|
||||
Returns:
|
||||
bool: True if the password matches. False if it does not, and also if the stored
|
||||
hash is unreadable -- a corrupt hash must never authenticate anyone.
|
||||
|
||||
"""
|
||||
try:
|
||||
n, r, p, salt, expected = _parse(encoded)
|
||||
except ValueError:
|
||||
return False
|
||||
derived = _derive(password, salt, n, r, p, len(expected))
|
||||
return hmac.compare_digest(derived, expected)
|
||||
|
||||
|
||||
def needs_rehash(encoded: str) -> bool:
|
||||
"""Check whether a stored hash was produced with outdated parameters.
|
||||
|
||||
Args:
|
||||
encoded: The stored hash.
|
||||
|
||||
Returns:
|
||||
bool: True if the hash should be recomputed on the next successful login.
|
||||
|
||||
"""
|
||||
try:
|
||||
n, r, p, _, expected = _parse(encoded)
|
||||
except ValueError:
|
||||
return True
|
||||
return (n, r, p, len(expected)) != (SCRYPT_N, SCRYPT_R, SCRYPT_P, SCRYPT_DKLEN)
|
||||
|
||||
|
||||
# A real hash of a random password, derived once at import. Verifying against this costs
|
||||
# the same as verifying a real account, which is what lets the login path spend equal
|
||||
# time on unknown and known usernames.
|
||||
_DUMMY_ENCODED: Final = hash_password(secrets.token_urlsafe(32))
|
||||
|
||||
|
||||
def dummy_verify() -> None:
|
||||
"""Spend the same time a real verification would.
|
||||
|
||||
Called on login paths that fail before reaching a password check -- unknown
|
||||
username, disabled account, or an account with no password set -- so that response
|
||||
timing does not reveal which accounts exist.
|
||||
"""
|
||||
verify_password("", _DUMMY_ENCODED)
|
||||
|
||||
|
||||
async def hash_password_async(password: str) -> str:
|
||||
"""Hash a password on a worker thread.
|
||||
|
||||
Args:
|
||||
password: The plaintext password.
|
||||
|
||||
Returns:
|
||||
str: The encoded hash.
|
||||
|
||||
"""
|
||||
return await anyio.to_thread.run_sync(hash_password, password)
|
||||
|
||||
|
||||
async def verify_password_async(password: str, encoded: str) -> bool:
|
||||
"""Check a password on a worker thread.
|
||||
|
||||
Args:
|
||||
password: The plaintext password to check.
|
||||
encoded: The stored hash.
|
||||
|
||||
Returns:
|
||||
bool: True if the password matches.
|
||||
|
||||
"""
|
||||
return await anyio.to_thread.run_sync(verify_password, password, encoded)
|
||||
|
||||
|
||||
async def dummy_verify_async() -> None:
|
||||
"""Spend a verification's worth of time on a worker thread."""
|
||||
await anyio.to_thread.run_sync(dummy_verify)
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
"""Generate an opaque credential.
|
||||
|
||||
Returns:
|
||||
str: A url-safe 256-bit random token.
|
||||
|
||||
"""
|
||||
return secrets.token_urlsafe(TOKEN_BYTES)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash a token for storage.
|
||||
|
||||
A plain SHA-256 with no salt and no stretching, unlike passwords. Tokens are 256
|
||||
bits of output from a cryptographic random source, so there is no dictionary to
|
||||
attack and nothing for a work factor to buy; the digest is here so that a database
|
||||
leak does not hand over usable session cookies. The fixed 64-character output also
|
||||
lets the column be indexed for lookup.
|
||||
|
||||
Args:
|
||||
token: The plaintext token.
|
||||
|
||||
Returns:
|
||||
str: The hex digest.
|
||||
|
||||
"""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def tokens_equal(left: str, right: str) -> bool:
|
||||
"""Compare two tokens without leaking their contents through timing.
|
||||
|
||||
Args:
|
||||
left: One token.
|
||||
right: The other.
|
||||
|
||||
Returns:
|
||||
bool: True if equal.
|
||||
|
||||
"""
|
||||
return hmac.compare_digest(left, right)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""The permission level scale.
|
||||
|
||||
Three ordered levels gate the API: ``read`` covers every GET and websocket
|
||||
subscription, ``edit`` adds modification of existing records, and ``manage`` adds
|
||||
creation and deletion.
|
||||
|
||||
``PUT /spool/{id}/use`` deliberately sits at ``edit`` rather than ``manage``: it is
|
||||
Moonraker's and OctoPrint's hot path, so a printer can be given an ``edit`` credential
|
||||
that tracks filament usage but cannot delete anything.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class Level(StrEnum):
|
||||
"""A permission level."""
|
||||
|
||||
READ = "read"
|
||||
EDIT = "edit"
|
||||
MANAGE = "manage"
|
||||
|
||||
|
||||
# Ranks are kept in a separate mapping rather than expressed with comparison operators
|
||||
# on Level itself. StrEnum inherits str's operators, so defining __lt__/__ge__ would
|
||||
# silently shadow string ordering and give "edit" < "manage" a second, alphabetical
|
||||
# meaning that happens to agree here and would stop agreeing the moment a level is
|
||||
# renamed. Callers use covers() instead.
|
||||
_RANKS: dict[Level, int] = {
|
||||
Level.READ: 0,
|
||||
Level.EDIT: 1,
|
||||
Level.MANAGE: 2,
|
||||
}
|
||||
|
||||
|
||||
def covers(actual: Level, required: Level) -> bool:
|
||||
"""Check whether a level satisfies a requirement.
|
||||
|
||||
Args:
|
||||
actual: The level held.
|
||||
required: The level demanded.
|
||||
|
||||
Returns:
|
||||
bool: True if `actual` is at least as permissive as `required`.
|
||||
|
||||
"""
|
||||
return _RANKS[actual] >= _RANKS[required]
|
||||
|
||||
|
||||
def parse_level(value: str) -> Level:
|
||||
"""Parse a level name.
|
||||
|
||||
Args:
|
||||
value: The level name, case-insensitive.
|
||||
|
||||
Raises:
|
||||
ValueError: If the name is not a known level.
|
||||
|
||||
Returns:
|
||||
Level: The parsed level.
|
||||
|
||||
"""
|
||||
try:
|
||||
return Level(value.strip().lower())
|
||||
except ValueError as exc:
|
||||
known = ", ".join(level.value for level in Level)
|
||||
raise ValueError(f"Unknown permission level '{value}'. Expected one of: {known}.") from exc
|
||||
@@ -0,0 +1,66 @@
|
||||
"""The authenticated identity behind a request."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Final
|
||||
|
||||
from spoolman.auth.levels import Level, covers
|
||||
|
||||
|
||||
class PrincipalKind(StrEnum):
|
||||
"""How a principal was established."""
|
||||
|
||||
USER = "user"
|
||||
APIKEY = "apikey"
|
||||
CERT = "cert"
|
||||
ANONYMOUS = "anonymous"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
"""Who is making a request, and what they may do.
|
||||
|
||||
The absence of a credential is represented by ``None`` rather than by a Principal,
|
||||
so a caller can never accidentally treat "nobody" as a permission holder.
|
||||
"""
|
||||
|
||||
kind: PrincipalKind
|
||||
level: Level
|
||||
user_id: int | None = None
|
||||
username: str | None = None
|
||||
is_admin: bool = False
|
||||
is_owner: bool = False
|
||||
session_id: int | None = None
|
||||
api_key_id: int | None = None
|
||||
# Multi-tenancy seam (see AUTHENTICATION_PLAN.md). Always None today. It exists now
|
||||
# so that every auth database function already threads a tenant through its
|
||||
# signature, and adding tenancy later becomes a filter inside those functions
|
||||
# rather than a change to forty routers.
|
||||
tenant_id: int | None = None
|
||||
|
||||
def covers(self, required: Level) -> bool:
|
||||
"""Check whether this principal satisfies a level requirement.
|
||||
|
||||
Args:
|
||||
required: The level demanded.
|
||||
|
||||
Returns:
|
||||
bool: True if permitted.
|
||||
|
||||
"""
|
||||
return covers(self.level, required)
|
||||
|
||||
|
||||
# Returned by every gate when SPOOLMAN_AUTH_ENABLED is unset, before any database access
|
||||
# happens. This is what keeps the auth-disabled request path identical to an instance
|
||||
# built before authentication existed.
|
||||
UNRESTRICTED: Final = Principal(
|
||||
kind=PrincipalKind.DISABLED,
|
||||
level=Level.MANAGE,
|
||||
is_admin=True,
|
||||
is_owner=True,
|
||||
)
|
||||
|
||||
# Granted when the auth_anonymous_read setting is on and no credential was presented.
|
||||
ANONYMOUS_READER: Final = Principal(kind=PrincipalKind.ANONYMOUS, level=Level.READ)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Login throttling and client address resolution.
|
||||
|
||||
The windows are in-process dictionaries. That is sound here because Spoolman's
|
||||
deployment contract is exactly one process -- the same assumption the websocket manager
|
||||
and the scheduler already make -- but it does mean the counters reset on restart. Account
|
||||
lockout is therefore also persisted on ``auth_user``; this module only provides the
|
||||
cheap first line of defence that never touches the database.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from ipaddress import IPv4Network, IPv6Network, ip_address, ip_network
|
||||
from typing import Final
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from spoolman import env
|
||||
|
||||
FORWARDED_FOR: Final = "x-forwarded-for"
|
||||
FORWARDED_PROTO: Final = "x-forwarded-proto"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Decision:
|
||||
"""The outcome of a rate limit check."""
|
||||
|
||||
allowed: bool
|
||||
retry_after: int = 0
|
||||
|
||||
|
||||
class SlidingWindow:
|
||||
"""Counts events per key over a moving time window."""
|
||||
|
||||
def __init__(self, *, limit: int, window: timedelta) -> None:
|
||||
"""Create a window.
|
||||
|
||||
Args:
|
||||
limit: How many events are allowed within the window.
|
||||
window: How far back the window reaches.
|
||||
|
||||
"""
|
||||
self._limit = limit
|
||||
self._window = window.total_seconds()
|
||||
self._events: dict[str, deque[float]] = {}
|
||||
|
||||
def _prune(self, key: str, now: float) -> deque[float] | None:
|
||||
"""Drop expired events for a key, forgetting the key entirely once empty."""
|
||||
events = self._events.get(key)
|
||||
if events is None:
|
||||
return None
|
||||
cutoff = now - self._window
|
||||
while events and events[0] <= cutoff:
|
||||
events.popleft()
|
||||
if not events:
|
||||
del self._events[key]
|
||||
return None
|
||||
return events
|
||||
|
||||
def check(self, key: str) -> Decision:
|
||||
"""Test whether an event would be allowed, without recording it.
|
||||
|
||||
Args:
|
||||
key: The identity being limited.
|
||||
|
||||
Returns:
|
||||
Decision: Whether to allow, and how long to wait if not.
|
||||
|
||||
"""
|
||||
now = time.monotonic()
|
||||
events = self._prune(key, now)
|
||||
if events is None or len(events) < self._limit:
|
||||
return Decision(allowed=True)
|
||||
retry_after = int(events[0] + self._window - now) + 1
|
||||
return Decision(allowed=False, retry_after=max(retry_after, 1))
|
||||
|
||||
def hit(self, key: str) -> None:
|
||||
"""Record an event against a key.
|
||||
|
||||
Args:
|
||||
key: The identity being limited.
|
||||
|
||||
"""
|
||||
now = time.monotonic()
|
||||
self._prune(key, now)
|
||||
self._events.setdefault(key, deque()).append(now)
|
||||
|
||||
def clear(self, key: str) -> None:
|
||||
"""Forget a key's history, called after a successful login.
|
||||
|
||||
Args:
|
||||
key: The identity to reset.
|
||||
|
||||
"""
|
||||
self._events.pop(key, None)
|
||||
|
||||
|
||||
# Keyed by client address, to blunt spraying across many usernames from one source.
|
||||
#
|
||||
# There is deliberately no per-username window here. Per-account throttling is the
|
||||
# persisted failed_logins/locked_until pair on auth_user, which is graduated (a minute,
|
||||
# doubling to a quarter of an hour) and survives a restart. A second in-memory window
|
||||
# keyed by username would shadow that schedule with a flat one and make the graduation
|
||||
# unreachable. This window exists for the case the persisted counter cannot see: guesses
|
||||
# spread across many usernames, including ones that do not exist.
|
||||
ip_window: Final = SlidingWindow(limit=20, window=timedelta(minutes=15))
|
||||
|
||||
|
||||
def _parse_network(entry: str) -> IPv4Network | IPv6Network | None:
|
||||
"""Parse one trusted-proxy entry, returning None if it is not an address or network."""
|
||||
try:
|
||||
return ip_network(entry, strict=False)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _trusted_networks() -> list[IPv4Network | IPv6Network]:
|
||||
"""Parse SPOOLMAN_TRUSTED_PROXIES, skipping entries that are not addresses."""
|
||||
parsed = (_parse_network(entry) for entry in env.get_trusted_proxies())
|
||||
return [network for network in parsed if network is not None]
|
||||
|
||||
|
||||
def is_trusted_peer(peer: str | None) -> bool:
|
||||
"""Check whether forwarding headers from an address may be believed.
|
||||
|
||||
Args:
|
||||
peer: The address the connection actually came from.
|
||||
|
||||
Returns:
|
||||
bool: True if the address is covered by SPOOLMAN_TRUSTED_PROXIES.
|
||||
|
||||
"""
|
||||
if not peer:
|
||||
return False
|
||||
try:
|
||||
address = ip_address(peer)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(address in network for network in _trusted_networks())
|
||||
|
||||
|
||||
def client_ip(conn: Request | WebSocket) -> str:
|
||||
"""Determine the address a request came from.
|
||||
|
||||
X-Forwarded-For is honoured only when the immediate peer is a configured trusted
|
||||
proxy. Without that check any client could spoof the header and evade rate limiting
|
||||
by varying it.
|
||||
|
||||
Args:
|
||||
conn: The request or websocket.
|
||||
|
||||
Returns:
|
||||
str: The client address, or an empty string if it cannot be determined.
|
||||
|
||||
"""
|
||||
peer = conn.client.host if conn.client else ""
|
||||
if is_trusted_peer(peer):
|
||||
forwarded = conn.headers.get(FORWARDED_FOR)
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return peer
|
||||
|
||||
|
||||
def forwarded_proto(conn: Request | WebSocket) -> str | None:
|
||||
"""Read the originating scheme reported by a trusted proxy.
|
||||
|
||||
Args:
|
||||
conn: The request or websocket.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The scheme, or None if there is no trustworthy header.
|
||||
|
||||
"""
|
||||
if not is_trusted_peer(conn.client.host if conn.client else ""):
|
||||
return None
|
||||
proto = conn.headers.get(FORWARDED_PROTO)
|
||||
if not proto:
|
||||
return None
|
||||
return proto.split(",")[0].strip().lower()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""The instance secret key.
|
||||
|
||||
Resolution order is ``SPOOLMAN_SECRET_KEY_FILE``, then ``SPOOLMAN_SECRET_KEY``, then an
|
||||
auto-generated ``secret.key`` in the data directory. The generated file is created with
|
||||
mode 0600 via an exclusive open, rather than a write followed by a chmod, so its
|
||||
contents are never briefly readable under a permissive umask.
|
||||
|
||||
Phase 1 does not use the key for anything cryptographic: session tokens are opaque
|
||||
256-bit random values stored as SHA-256 digests, and CSRF protection is a double-submit
|
||||
cookie comparison. The key exists now because it is consumed from phase 3 onward (OIDC
|
||||
state signing) and phase 4 (Fernet encryption of TOTP secrets), and introducing it later
|
||||
would force every phase-1 adopter to reconfigure. Resolving it at startup also proves,
|
||||
at a point where the error message is actionable, that the data directory is writable
|
||||
and that any configured key file exists and is readable.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from spoolman import env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEY_FILENAME = "secret.key"
|
||||
KEY_BYTES = 32
|
||||
|
||||
__key: bytes | None = None
|
||||
|
||||
|
||||
def _generate(path: Path) -> bytes:
|
||||
"""Create a new key file with owner-only permissions.
|
||||
|
||||
Args:
|
||||
path: Where to write the key.
|
||||
|
||||
Returns:
|
||||
bytes: The generated key.
|
||||
|
||||
"""
|
||||
value = secrets.token_hex(KEY_BYTES)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(value)
|
||||
except BaseException:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
logger.info("Generated a new secret key at %s.", path)
|
||||
return value.encode()
|
||||
|
||||
|
||||
def _warn_if_readable(path: Path) -> None:
|
||||
"""Warn when an existing key file is readable by anyone but its owner.
|
||||
|
||||
Deliberately a warning rather than an error: some bind-mounted volumes cannot
|
||||
represent 0600, and refusing to start would be worse than the exposure.
|
||||
|
||||
Args:
|
||||
path: The key file to inspect.
|
||||
|
||||
"""
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
except OSError:
|
||||
return
|
||||
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||
logger.warning(
|
||||
"Secret key file %s is readable by users other than its owner. Restrict it with 'chmod 600 %s'.",
|
||||
path,
|
||||
path,
|
||||
)
|
||||
|
||||
|
||||
def get_secret_key() -> bytes:
|
||||
"""Get the instance secret key, generating one if needed.
|
||||
|
||||
The result is memoised, so the file is read or created at most once per process.
|
||||
|
||||
Raises:
|
||||
ValueError: If a configured key is empty.
|
||||
|
||||
Returns:
|
||||
bytes: The secret key.
|
||||
|
||||
"""
|
||||
global __key # noqa: PLW0603
|
||||
if __key is not None:
|
||||
return __key
|
||||
|
||||
configured = env.get_secret_key()
|
||||
if configured is not None:
|
||||
if not configured:
|
||||
raise ValueError("Failed to parse SPOOLMAN_SECRET_KEY variable: The key is empty.")
|
||||
__key = configured.encode()
|
||||
return __key
|
||||
|
||||
path = env.get_data_dir() / KEY_FILENAME
|
||||
if path.exists():
|
||||
_warn_if_readable(path)
|
||||
existing = path.read_text(encoding="utf-8").strip()
|
||||
if existing:
|
||||
__key = existing.encode()
|
||||
return __key
|
||||
logger.warning("Secret key file %s is empty, generating a new key.", path)
|
||||
path.unlink()
|
||||
|
||||
__key = _generate(path)
|
||||
return __key
|
||||
|
||||
|
||||
def ensure_secret_key() -> None:
|
||||
"""Resolve the secret key early so configuration problems surface at startup.
|
||||
|
||||
Called from the server's startup sequence when authentication is enabled.
|
||||
"""
|
||||
get_secret_key()
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Forget the memoised key.
|
||||
|
||||
Only useful to tests, which change the environment between cases.
|
||||
"""
|
||||
global __key # noqa: PLW0603
|
||||
__key = None
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
"""Command line administration for authentication.
|
||||
|
||||
Run as ``python -m spoolman.cli``, rather than as an installed console script, because
|
||||
the Docker image runs Spoolman from source:
|
||||
|
||||
docker exec -it spoolman python -m spoolman.cli auth status
|
||||
|
||||
This is the way back in when nobody can sign in -- a forgotten owner password, a
|
||||
disabled account, an instance whose owner has left. It is the reason no per-user
|
||||
recovery codes exist.
|
||||
|
||||
It talks to the database directly using the same environment configuration as the
|
||||
server, and never runs migrations: if the schema is missing it says so and stops, since
|
||||
the fix is to start the server once and let it migrate.
|
||||
|
||||
It also works while SPOOLMAN_AUTH_ENABLED is unset, which is deliberate -- the intended
|
||||
first run is to create an owner and only then turn authentication on.
|
||||
"""
|
||||
|
||||
# ruff: noqa: T201
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import secrets
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.auth.hashing import hash_password
|
||||
from spoolman.auth.levels import Level, parse_level
|
||||
from spoolman.database import auth_session as auth_session_db
|
||||
from spoolman.database import auth_user as auth_user_db
|
||||
from spoolman.database import database, models
|
||||
|
||||
# Long enough that a generated password is not worth attacking, short enough to retype
|
||||
# from a terminal into a browser.
|
||||
GENERATED_PASSWORD_BYTES = 12
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_ERROR = 1
|
||||
EXIT_NO_SCHEMA = 2
|
||||
|
||||
|
||||
async def _require_schema() -> None:
|
||||
"""Stop unless the auth tables exist."""
|
||||
|
||||
def _tables(connection: sqlalchemy.Connection) -> list[str]:
|
||||
return sqlalchemy.inspect(connection).get_table_names()
|
||||
|
||||
async with database.get_engine().connect() as connection:
|
||||
tables = await connection.run_sync(_tables)
|
||||
|
||||
if "auth_user" not in tables:
|
||||
print(
|
||||
"Spoolman's authentication tables are missing. Start the Spoolman server "
|
||||
"once so it can migrate the database, then run this command again.",
|
||||
)
|
||||
raise SystemExit(EXIT_NO_SCHEMA)
|
||||
|
||||
|
||||
def _generate_password() -> str:
|
||||
"""Make a password that is safe to hand to someone once."""
|
||||
return secrets.token_urlsafe(GENERATED_PASSWORD_BYTES)
|
||||
|
||||
|
||||
def _describe(user: models.AuthUser) -> str:
|
||||
"""Format a user as one line for `user list`."""
|
||||
flags = []
|
||||
if user.is_owner:
|
||||
flags.append("owner")
|
||||
if user.is_admin:
|
||||
flags.append("admin")
|
||||
if not user.is_active:
|
||||
flags.append("disabled")
|
||||
if user.must_change_password:
|
||||
flags.append("must-change-password")
|
||||
if user.locked_until is not None and user.locked_until > datetime.utcnow():
|
||||
flags.append(f"locked until {user.locked_until:%Y-%m-%d %H:%M} UTC")
|
||||
last = f"{user.last_login:%Y-%m-%d %H:%M}" if user.last_login else "never"
|
||||
return f" {user.username:<24} {user.level:<7} last login: {last:<17} {' '.join(flags)}"
|
||||
|
||||
|
||||
async def _cmd_auth_status(args: argparse.Namespace, db: AsyncSession) -> int: # noqa: ARG001
|
||||
"""Report the state of authentication on this instance."""
|
||||
from spoolman import env # noqa: PLC0415
|
||||
|
||||
users, total = await auth_user_db.find(db=db)
|
||||
owners = [u for u in users if u.is_owner]
|
||||
|
||||
print(f"Authentication enabled : {'yes' if env.is_auth_enabled() else 'no (SPOOLMAN_AUTH_ENABLED is not set)'}")
|
||||
print(f"User accounts : {total}")
|
||||
print(f"Owner : {owners[0].username if owners else 'none - the instance is unclaimed'}")
|
||||
if total == 0:
|
||||
print()
|
||||
print("Nobody can sign in yet. Either create an account here, or start the server")
|
||||
print("with authentication enabled and claim it from the web interface.")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
async def _cmd_user_list(args: argparse.Namespace, db: AsyncSession) -> int: # noqa: ARG001
|
||||
"""List every account."""
|
||||
users, total = await auth_user_db.find(db=db)
|
||||
if total == 0:
|
||||
print("No user accounts exist.")
|
||||
return EXIT_OK
|
||||
print(f"{total} user account(s):")
|
||||
for user in users:
|
||||
print(_describe(user))
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
async def _cmd_user_create(args: argparse.Namespace, db: AsyncSession) -> int:
|
||||
"""Create an account and print its generated password."""
|
||||
existing = await auth_user_db.get_by_username(db, args.username)
|
||||
if existing is not None:
|
||||
print(f"A user named {existing.username!r} already exists.")
|
||||
return EXIT_ERROR
|
||||
|
||||
if args.owner:
|
||||
existing_users, _ = await auth_user_db.find(db=db)
|
||||
owners = [u for u in existing_users if u.is_owner]
|
||||
if owners:
|
||||
print(
|
||||
f"This instance is already owned by {owners[0].username!r}. There is exactly one owner; "
|
||||
"transfer ownership from the web interface instead.",
|
||||
)
|
||||
return EXIT_ERROR
|
||||
|
||||
# Administering other users implies being able to manage data, so an owner or admin
|
||||
# is always at manage. Without this, "user create <name> --owner" would take the
|
||||
# default level and produce an owner who cannot change anything.
|
||||
is_admin = args.admin or args.owner
|
||||
level = parse_level(args.level) if args.level is not None else Level.READ
|
||||
if is_admin and level is not Level.MANAGE:
|
||||
if args.level is not None:
|
||||
print(f"Ignoring --level {level}: an admin or owner is always at {Level.MANAGE}.")
|
||||
level = Level.MANAGE
|
||||
|
||||
password = _generate_password()
|
||||
user = await auth_user_db.create(
|
||||
db=db,
|
||||
username=args.username,
|
||||
password_hash=hash_password(password),
|
||||
level=level,
|
||||
is_admin=is_admin,
|
||||
is_owner=args.owner,
|
||||
display_name=args.display_name,
|
||||
)
|
||||
print(f"Created {user.username!r} with level {user.level}.")
|
||||
print(f" Password: {password}")
|
||||
print(" This is shown once. Give it to the user and have them change it.")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
async def _cmd_user_reset_password(args: argparse.Namespace, db: AsyncSession) -> int:
|
||||
"""Give an account a new one-time password."""
|
||||
user = await auth_user_db.get_by_username(db, args.username)
|
||||
if user is None:
|
||||
print(f"No user named {args.username!r}.")
|
||||
return EXIT_ERROR
|
||||
|
||||
password = _generate_password()
|
||||
await auth_user_db.set_password(
|
||||
db=db,
|
||||
user=user,
|
||||
password_hash=hash_password(password),
|
||||
must_change=True,
|
||||
)
|
||||
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
|
||||
print(f"Reset the password for {user.username!r}.")
|
||||
print(f" Password: {password}")
|
||||
print(f" They must change it at next sign-in. {revoked} existing session(s) were ended.")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
async def _cmd_user_set_level(args: argparse.Namespace, db: AsyncSession) -> int:
|
||||
"""Change an account's permission level."""
|
||||
user = await auth_user_db.get_by_username(db, args.username)
|
||||
if user is None:
|
||||
print(f"No user named {args.username!r}.")
|
||||
return EXIT_ERROR
|
||||
level = parse_level(args.level)
|
||||
user.level = str(level)
|
||||
await db.commit()
|
||||
print(f"{user.username!r} is now at level {level}.")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
async def _cmd_user_enable(args: argparse.Namespace, db: AsyncSession) -> int:
|
||||
"""Re-enable an account and clear any lockout."""
|
||||
user = await auth_user_db.get_by_username(db, args.username)
|
||||
if user is None:
|
||||
print(f"No user named {args.username!r}.")
|
||||
return EXIT_ERROR
|
||||
user.is_active = True
|
||||
user.failed_logins = 0
|
||||
user.locked_until = None
|
||||
await db.commit()
|
||||
print(f"{user.username!r} is enabled, and any lockout has been cleared.")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
async def _cmd_user_disable(args: argparse.Namespace, db: AsyncSession) -> int:
|
||||
"""Disable an account and end its sessions."""
|
||||
user = await auth_user_db.get_by_username(db, args.username)
|
||||
if user is None:
|
||||
print(f"No user named {args.username!r}.")
|
||||
return EXIT_ERROR
|
||||
if user.is_owner:
|
||||
print("The owner cannot be disabled. Transfer ownership first.")
|
||||
return EXIT_ERROR
|
||||
user.is_active = False
|
||||
await db.commit()
|
||||
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
|
||||
print(f"{user.username!r} is disabled. {revoked} session(s) were ended.")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
async def _cmd_session_revoke_all(args: argparse.Namespace, db: AsyncSession) -> int:
|
||||
"""Sign out one user, or everyone."""
|
||||
if args.user:
|
||||
user = await auth_user_db.get_by_username(db, args.user)
|
||||
if user is None:
|
||||
print(f"No user named {args.user!r}.")
|
||||
return EXIT_ERROR
|
||||
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
|
||||
print(f"Ended {revoked} session(s) for {user.username!r}.")
|
||||
return EXIT_OK
|
||||
|
||||
users, _ = await auth_user_db.find(db=db)
|
||||
revoked = 0
|
||||
for user in users:
|
||||
revoked += await auth_session_db.revoke_all_for_user(db, user.id)
|
||||
print(f"Ended {revoked} session(s) across all users.")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argument parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m spoolman.cli",
|
||||
description=(
|
||||
"Administer Spoolman authentication from the host. Use this to create the "
|
||||
"first account, or to get back in when nobody can sign in."
|
||||
),
|
||||
)
|
||||
commands = parser.add_subparsers(dest="group", required=True)
|
||||
|
||||
auth = commands.add_parser("auth", help="Inspect authentication state.")
|
||||
auth_sub = auth.add_subparsers(dest="command", required=True)
|
||||
auth_sub.add_parser("status", help="Show whether auth is on, and who owns the instance.").set_defaults(
|
||||
func=_cmd_auth_status,
|
||||
)
|
||||
|
||||
user = commands.add_parser("user", help="Manage user accounts.")
|
||||
user_sub = user.add_subparsers(dest="command", required=True)
|
||||
|
||||
user_sub.add_parser("list", help="List all accounts.").set_defaults(func=_cmd_user_list)
|
||||
|
||||
create = user_sub.add_parser("create", help="Create an account and print a generated password.")
|
||||
create.add_argument("username")
|
||||
create.add_argument(
|
||||
"--level",
|
||||
default=None,
|
||||
choices=[str(level) for level in Level],
|
||||
help=f"Permission level. Defaults to {Level.READ}, or {Level.MANAGE} with --admin or --owner.",
|
||||
)
|
||||
create.add_argument("--admin", action="store_true", help="Let this user administer other users.")
|
||||
create.add_argument("--owner", action="store_true", help="Make this user the instance owner. Implies --admin.")
|
||||
create.add_argument("--display-name", default=None)
|
||||
create.set_defaults(func=_cmd_user_create)
|
||||
|
||||
reset = user_sub.add_parser("reset-password", help="Set a new one-time password and end their sessions.")
|
||||
reset.add_argument("username")
|
||||
reset.set_defaults(func=_cmd_user_reset_password)
|
||||
|
||||
set_level = user_sub.add_parser("set-level", help="Change an account's permission level.")
|
||||
set_level.add_argument("username")
|
||||
set_level.add_argument("level", choices=[str(level) for level in Level])
|
||||
set_level.set_defaults(func=_cmd_user_set_level)
|
||||
|
||||
enable = user_sub.add_parser("enable", help="Re-enable an account and clear any lockout.")
|
||||
enable.add_argument("username")
|
||||
enable.set_defaults(func=_cmd_user_enable)
|
||||
|
||||
disable = user_sub.add_parser("disable", help="Disable an account and end its sessions.")
|
||||
disable.add_argument("username")
|
||||
disable.set_defaults(func=_cmd_user_disable)
|
||||
|
||||
session = commands.add_parser("session", help="Manage login sessions.")
|
||||
session_sub = session.add_subparsers(dest="command", required=True)
|
||||
revoke = session_sub.add_parser("revoke-all", help="Sign out everyone, or one user.")
|
||||
revoke.add_argument("--user", default=None, help="Limit to this username.")
|
||||
revoke.set_defaults(func=_cmd_session_revoke_all)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
async def _run(args: argparse.Namespace) -> int:
|
||||
"""Connect to the database and dispatch the command."""
|
||||
database.setup_db(database.get_connection_url())
|
||||
await _require_schema()
|
||||
|
||||
generator = database.get_db_session()
|
||||
db = await anext(generator)
|
||||
try:
|
||||
return await args.func(args, db)
|
||||
finally:
|
||||
await generator.aclose()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the command line interface."""
|
||||
args = _build_parser().parse_args()
|
||||
try:
|
||||
sys.exit(asyncio.run(_run(args)))
|
||||
except SystemExit:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}")
|
||||
sys.exit(EXIT_ERROR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Helper functions for interacting with API key database objects.
|
||||
|
||||
As with :mod:`spoolman.database.auth_user`, nothing here emits a websocket event.
|
||||
``websocket_manager.send`` fans out to every subscriber, so broadcasting key activity
|
||||
would tell every connected client when and how often each machine credential is used,
|
||||
and the creation event would carry the prefix that identifies it. Do not add a notifier
|
||||
here.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.auth import apikey
|
||||
from spoolman.auth.hashing import hash_token, tokens_equal
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database import models
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How stale last_used must be before a request bothers to update it.
|
||||
#
|
||||
# The same reasoning as auth_session.RENEW_THRESHOLD, and it matters more here: an API
|
||||
# key is what a printer polls with, several times a minute, forever. Recording every one
|
||||
# of those would turn a read-only request into a write on a database whose default is a
|
||||
# single SQLite file behind one write lock. "Last used" to the nearest five minutes is
|
||||
# all this field is ever read for.
|
||||
TOUCH_THRESHOLD = timedelta(minutes=5)
|
||||
|
||||
# Longest a key may be issued for, in days. Not a security boundary -- an expiry is a
|
||||
# convenience, and a key can always be revoked -- but an unbounded value would let a
|
||||
# typo produce a date SQLite stores and other backends reject.
|
||||
MAX_EXPIRY_DAYS = 3650
|
||||
|
||||
|
||||
async def create(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
name: str,
|
||||
level: Level,
|
||||
expires_days: int | None = None,
|
||||
) -> tuple[models.AuthApiKey, str]:
|
||||
"""Issue a new API key.
|
||||
|
||||
Unlike passwords, the secret is generated here rather than passed in. There is
|
||||
nothing for a caller to supply -- a key is not chosen, it is minted -- and hashing
|
||||
it is a single SHA-256 rather than the tens of milliseconds a password costs, so
|
||||
none of the offloading that :mod:`spoolman.auth.hashing` exists for applies. This
|
||||
mirrors :func:`spoolman.database.auth_session.create`.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user_id: Who the key belongs to.
|
||||
name: A human label, so the owner can tell their keys apart.
|
||||
level: The level to issue at. The caller is responsible for refusing to issue
|
||||
above its own; the cap against the owner's current level is applied at
|
||||
every request instead, in :func:`spoolman.auth.apikey.effective_level`.
|
||||
expires_days: How long the key should live, or None for no expiry.
|
||||
|
||||
Returns:
|
||||
tuple: The stored key and the complete plaintext key, which is the only time
|
||||
the secret exists in a readable form.
|
||||
|
||||
"""
|
||||
prefix, plaintext = apikey.generate()
|
||||
now = datetime.utcnow().replace(microsecond=0)
|
||||
expires = now + timedelta(days=expires_days) if expires_days else None
|
||||
key = models.AuthApiKey(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
level=str(level),
|
||||
prefix=prefix,
|
||||
key_hash=hash_token(plaintext),
|
||||
created=now,
|
||||
expires=expires,
|
||||
last_used=None,
|
||||
revoked=False,
|
||||
)
|
||||
db.add(key)
|
||||
await db.commit()
|
||||
return key, plaintext
|
||||
|
||||
|
||||
async def resolve(db: AsyncSession, presented: str) -> models.AuthApiKey | None:
|
||||
"""Look up a live key by the string a caller presented.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
presented: The complete key from the request header.
|
||||
|
||||
Returns:
|
||||
Optional[models.AuthApiKey]: The key, or None if it is unknown, revoked or
|
||||
expired.
|
||||
|
||||
"""
|
||||
prefix = apikey.prefix_of(presented)
|
||||
if prefix is None:
|
||||
return None
|
||||
|
||||
stmt = select(models.AuthApiKey).where(models.AuthApiKey.prefix == prefix)
|
||||
key = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if key is None:
|
||||
return None
|
||||
|
||||
# Constant time even though both sides are hex digests of the same length: the
|
||||
# comparison is cheap and a timing oracle on the stored hash is not worth leaving
|
||||
# open for the sake of an early exit.
|
||||
if not tokens_equal(hash_token(presented), key.key_hash):
|
||||
return None
|
||||
if key.revoked:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow().replace(microsecond=0)
|
||||
if key.expires is not None and key.expires <= now:
|
||||
# Left in place rather than deleted, unlike an expired session. A key is
|
||||
# something a person made and named, and having it disappear from the list
|
||||
# instead of showing as expired reads as data loss.
|
||||
return None
|
||||
|
||||
if key.last_used is None or now - key.last_used >= TOUCH_THRESHOLD:
|
||||
key.last_used = now
|
||||
await db.commit()
|
||||
|
||||
return key
|
||||
|
||||
|
||||
async def get_by_id(db: AsyncSession, key_id: int) -> models.AuthApiKey:
|
||||
"""Get an API key by its unique ID.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
key_id: The key ID.
|
||||
|
||||
Raises:
|
||||
ItemNotFoundError: If no such key exists.
|
||||
|
||||
Returns:
|
||||
models.AuthApiKey: The key.
|
||||
|
||||
"""
|
||||
key = await db.get(models.AuthApiKey, key_id)
|
||||
if key is None:
|
||||
raise ItemNotFoundError(f"No API key with ID {key_id} found.")
|
||||
return key
|
||||
|
||||
|
||||
async def find_for_user(*, db: AsyncSession, user_id: int) -> list[models.AuthApiKey]:
|
||||
"""List a user's keys, newest first.
|
||||
|
||||
Revoked and expired keys are included. The owner needs to see that a key they
|
||||
revoked is in fact revoked, and a key that stopped working needs to be
|
||||
distinguishable from one that was never created.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user_id: Whose keys to list.
|
||||
|
||||
Returns:
|
||||
list[models.AuthApiKey]: The keys.
|
||||
|
||||
"""
|
||||
stmt = (
|
||||
select(models.AuthApiKey)
|
||||
.where(models.AuthApiKey.user_id == user_id)
|
||||
.order_by(models.AuthApiKey.created.desc(), models.AuthApiKey.id.desc())
|
||||
)
|
||||
return list((await db.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def count_for_user(db: AsyncSession, user_id: int) -> int:
|
||||
"""Count the keys a user holds, revoked ones included.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user_id: Whose keys to count.
|
||||
|
||||
Returns:
|
||||
int: The number of keys.
|
||||
|
||||
"""
|
||||
stmt = select(func.count()).select_from(models.AuthApiKey).where(models.AuthApiKey.user_id == user_id)
|
||||
return (await db.execute(stmt)).scalar_one()
|
||||
|
||||
|
||||
async def revoke(*, db: AsyncSession, key: models.AuthApiKey) -> None:
|
||||
"""Stop a key from authenticating, keeping the row.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
key: The key to revoke.
|
||||
|
||||
"""
|
||||
key.revoked = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_key(*, db: AsyncSession, key: models.AuthApiKey) -> None:
|
||||
"""Remove a key entirely.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
key: The key to delete.
|
||||
|
||||
"""
|
||||
await db.delete(key)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_all_for_user(db: AsyncSession, user_id: int) -> int:
|
||||
"""Remove every key belonging to a user.
|
||||
|
||||
Used when the user itself is deleted: the foreign key would otherwise leave rows
|
||||
pointing at an account that no longer exists.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user_id: Whose keys to remove.
|
||||
|
||||
Returns:
|
||||
int: How many keys were removed.
|
||||
|
||||
"""
|
||||
result = await db.execute(delete(models.AuthApiKey).where(models.AuthApiKey.user_id == user_id))
|
||||
await db.commit()
|
||||
return result.rowcount or 0
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Helper functions for interacting with audit log database objects.
|
||||
|
||||
As with the other ``auth_`` modules, nothing here emits a websocket event. The audit log
|
||||
is the most sensitive table in the schema -- it records who signed in, from where, and
|
||||
what they changed -- and ``websocket_manager.send`` fans out to every subscriber
|
||||
including anonymous readers. Do not add a notifier here.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from sqlalchemy import Select, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.database import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Caps on the free-form columns, so a hostile user agent or a long username cannot
|
||||
# overflow the column and fail the insert -- which, on the login path, would turn an
|
||||
# audit write into a failed sign-in.
|
||||
TARGET_MAX = 128
|
||||
USER_AGENT_MAX = 256
|
||||
IP_MAX = 64
|
||||
EVENT_MAX = 64
|
||||
|
||||
|
||||
def _clip(value: str | None, limit: int) -> str | None:
|
||||
"""Truncate a value to fit its column, treating the empty string as absent."""
|
||||
if not value:
|
||||
return None
|
||||
return value[:limit]
|
||||
|
||||
|
||||
async def record(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
event: str,
|
||||
actor_kind: str,
|
||||
actor_user_id: int | None = None,
|
||||
target: str | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
detail: str | None = None,
|
||||
) -> models.AuthAuditLog:
|
||||
"""Append an entry to the audit log.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
event: What happened. See :class:`spoolman.auth.audit.AuditEvent`.
|
||||
actor_kind: How the actor was authenticated, or "anonymous" if not at all.
|
||||
actor_user_id: Which account acted, if the actor was a known user.
|
||||
target: What was acted on -- a username, a key name, a session count.
|
||||
ip: Where the request came from.
|
||||
user_agent: What the request identified itself as.
|
||||
detail: A JSON object with anything else worth keeping.
|
||||
|
||||
Returns:
|
||||
models.AuthAuditLog: The stored entry.
|
||||
|
||||
"""
|
||||
entry = models.AuthAuditLog(
|
||||
date=datetime.datetime.utcnow().replace(microsecond=0),
|
||||
event=event[:EVENT_MAX],
|
||||
actor_user_id=actor_user_id,
|
||||
actor_kind=actor_kind,
|
||||
target=_clip(target, TARGET_MAX),
|
||||
ip=_clip(ip, IP_MAX),
|
||||
user_agent=_clip(user_agent, USER_AGENT_MAX),
|
||||
detail=detail,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
return entry
|
||||
|
||||
|
||||
def _filtered(stmt: Select, *, event: str | None, actor_user_id: int | None) -> Select:
|
||||
"""Apply the optional filters shared by the listing and its count."""
|
||||
if event:
|
||||
stmt = stmt.where(models.AuthAuditLog.event == event)
|
||||
if actor_user_id is not None:
|
||||
stmt = stmt.where(models.AuthAuditLog.actor_user_id == actor_user_id)
|
||||
return stmt
|
||||
|
||||
|
||||
async def find(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
event: str | None = None,
|
||||
actor_user_id: int | None = None,
|
||||
) -> tuple[list[models.AuthAuditLog], int]:
|
||||
"""List audit entries, newest first.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
limit: The maximum number to return.
|
||||
offset: How many to skip.
|
||||
event: Only return entries for this event.
|
||||
actor_user_id: Only return entries by this user.
|
||||
|
||||
Returns:
|
||||
tuple: The entries, and the total number matching the filters.
|
||||
|
||||
"""
|
||||
stmt = _filtered(select(models.AuthAuditLog), event=event, actor_user_id=actor_user_id)
|
||||
# Ordered by id as well as date: the timestamp is stored to the second, so several
|
||||
# entries from one request would otherwise come back in an arbitrary order and
|
||||
# paginate inconsistently.
|
||||
stmt = stmt.order_by(models.AuthAuditLog.date.desc(), models.AuthAuditLog.id.desc())
|
||||
|
||||
count_stmt = _filtered(
|
||||
select(func.count()).select_from(models.AuthAuditLog),
|
||||
event=event,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
total = (await db.execute(count_stmt)).scalar_one()
|
||||
|
||||
if offset:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
rows = list((await db.execute(stmt)).scalars().all())
|
||||
return rows, total
|
||||
|
||||
|
||||
async def prune_older_than(db: AsyncSession, cutoff: datetime.datetime) -> int:
|
||||
"""Delete audit entries older than a point in time.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
cutoff: Entries dated before this are removed.
|
||||
|
||||
Returns:
|
||||
int: How many entries were removed.
|
||||
|
||||
"""
|
||||
result = await db.execute(delete(models.AuthAuditLog).where(models.AuthAuditLog.date < cutoff))
|
||||
await db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def clear_actor(db: AsyncSession, user_id: int) -> int:
|
||||
"""Detach a user's audit entries from their account.
|
||||
|
||||
Called when the account is deleted. The entries themselves are kept -- an audit log
|
||||
that loses its record of what an account did the moment that account is removed is
|
||||
not an audit log -- but the foreign key has to stop pointing at a row that no longer
|
||||
exists. The username is already recorded in ``target`` on the entries where it
|
||||
matters, so the trail stays readable.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user_id: Whose entries to detach.
|
||||
|
||||
Returns:
|
||||
int: How many entries were detached.
|
||||
|
||||
"""
|
||||
rows = (
|
||||
(await db.execute(select(models.AuthAuditLog).where(models.AuthAuditLog.actor_user_id == user_id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
row.actor_user_id = None
|
||||
await db.commit()
|
||||
return len(rows)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Helper functions for interacting with login session database objects.
|
||||
|
||||
As with :mod:`spoolman.database.auth_user`, nothing here emits a websocket event -- the
|
||||
subscriber pool is every connected client, and session activity is nobody else's
|
||||
business.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.auth.cookies import IDLE_LIFETIME, REMEMBER_LIFETIME
|
||||
from spoolman.auth.hashing import hash_token, new_token
|
||||
from spoolman.database import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How stale last_seen must be before a request bothers to slide the session forward.
|
||||
#
|
||||
# Without this, every authenticated request becomes a database write. On SQLite -- the
|
||||
# default, a single file behind one write lock -- that serialises the whole application
|
||||
# behind session bookkeeping and would undo the list-page latency work that the
|
||||
# selectin loading in models.py exists for. A minute of imprecision against a twelve
|
||||
# hour idle window is not worth a write per request.
|
||||
RENEW_THRESHOLD = timedelta(minutes=1)
|
||||
|
||||
# Cap on how much of a user agent string is retained for the session list.
|
||||
USER_AGENT_MAX = 256
|
||||
|
||||
|
||||
def lifetime_for(*, remember: bool) -> timedelta:
|
||||
"""Get how long a session should live from now.
|
||||
|
||||
Args:
|
||||
remember: Whether the user asked to stay signed in.
|
||||
|
||||
Returns:
|
||||
timedelta: The session lifetime.
|
||||
|
||||
"""
|
||||
return REMEMBER_LIFETIME if remember else IDLE_LIFETIME
|
||||
|
||||
|
||||
async def create(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
remember: bool,
|
||||
user_agent: str | None = None,
|
||||
ip: str | None = None,
|
||||
) -> tuple[models.AuthSession, str]:
|
||||
"""Start a new session.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user_id: Who the session belongs to.
|
||||
remember: Whether the session should outlive the browser session.
|
||||
user_agent: The client's user agent, for the session list.
|
||||
ip: The client's address, for the session list.
|
||||
|
||||
Returns:
|
||||
tuple: The stored session and the plaintext token, which is the only time the
|
||||
token exists in a readable form.
|
||||
|
||||
"""
|
||||
token = new_token()
|
||||
now = datetime.utcnow().replace(microsecond=0)
|
||||
session = models.AuthSession(
|
||||
user_id=user_id,
|
||||
token_hash=hash_token(token),
|
||||
created=now,
|
||||
expires=now + lifetime_for(remember=remember),
|
||||
last_seen=now,
|
||||
remember=remember,
|
||||
user_agent=user_agent[:USER_AGENT_MAX] if user_agent else None,
|
||||
ip=ip or None,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
return session, token
|
||||
|
||||
|
||||
async def resolve(db: AsyncSession, token: str) -> models.AuthSession | None:
|
||||
"""Look up a live session by its token, sliding its expiry forward.
|
||||
|
||||
An expired session is deleted as it is encountered, which keeps the table tidy
|
||||
without needing a scheduled sweep.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
token: The plaintext token from the cookie.
|
||||
|
||||
Returns:
|
||||
Optional[models.AuthSession]: The session, or None if unknown or expired.
|
||||
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
stmt = select(models.AuthSession).where(models.AuthSession.token_hash == hash_token(token))
|
||||
session = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if session is None:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow().replace(microsecond=0)
|
||||
if session.expires <= now:
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
if now - session.last_seen >= RENEW_THRESHOLD:
|
||||
session.last_seen = now
|
||||
session.expires = now + lifetime_for(remember=session.remember)
|
||||
await db.commit()
|
||||
|
||||
return session
|
||||
|
||||
|
||||
async def revoke(db: AsyncSession, session_id: int) -> None:
|
||||
"""End one session.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
session_id: Which session to end.
|
||||
|
||||
"""
|
||||
await db.execute(delete(models.AuthSession).where(models.AuthSession.id == session_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def revoke_by_token(db: AsyncSession, token: str) -> None:
|
||||
"""End the session identified by a token.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
token: The plaintext token from the cookie.
|
||||
|
||||
"""
|
||||
if not token:
|
||||
return
|
||||
await db.execute(delete(models.AuthSession).where(models.AuthSession.token_hash == hash_token(token)))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def revoke_all_for_user(db: AsyncSession, user_id: int) -> int:
|
||||
"""End every session belonging to a user.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user_id: Whose sessions to end.
|
||||
|
||||
Returns:
|
||||
int: How many sessions were ended.
|
||||
|
||||
"""
|
||||
result = await db.execute(delete(models.AuthSession).where(models.AuthSession.user_id == user_id))
|
||||
await db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def find_for_user(*, db: AsyncSession, user_id: int) -> list[models.AuthSession]:
|
||||
"""List a user's live sessions, most recently used first.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user_id: Whose sessions to list.
|
||||
|
||||
Returns:
|
||||
list[models.AuthSession]: The sessions.
|
||||
|
||||
"""
|
||||
stmt = (
|
||||
select(models.AuthSession)
|
||||
.where(models.AuthSession.user_id == user_id)
|
||||
.order_by(models.AuthSession.last_seen.desc())
|
||||
)
|
||||
return list((await db.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def prune_expired(db: AsyncSession) -> int:
|
||||
"""Delete every session that has expired.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
|
||||
Returns:
|
||||
int: How many sessions were removed.
|
||||
|
||||
"""
|
||||
now = datetime.utcnow().replace(microsecond=0)
|
||||
result = await db.execute(delete(models.AuthSession).where(models.AuthSession.expires <= now))
|
||||
await db.commit()
|
||||
return result.rowcount or 0
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Helper functions for interacting with user database objects.
|
||||
|
||||
Unlike the vendor, filament and spool modules, nothing here emits a websocket event.
|
||||
``websocket_manager.send`` fans out to every subscriber, so broadcasting account changes
|
||||
would hand every connected client the details of every other account. Account state is
|
||||
read back over the authenticated REST endpoints instead. Do not add a notifier here.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.database import models
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Failed logins allowed before an account starts locking.
|
||||
LOCKOUT_THRESHOLD = 5
|
||||
|
||||
# The first lockout, doubling for each subsequent failure.
|
||||
LOCKOUT_BASE = timedelta(minutes=1)
|
||||
|
||||
# The longest an account is ever locked for. A lockout that grows without bound turns a
|
||||
# password guess into a denial of service against the real owner.
|
||||
LOCKOUT_MAX = timedelta(minutes=15)
|
||||
|
||||
|
||||
def normalize_username(username: str) -> str:
|
||||
"""Reduce a username to its stored form.
|
||||
|
||||
Args:
|
||||
username: The username as typed.
|
||||
|
||||
Returns:
|
||||
str: The trimmed, lowercased username.
|
||||
|
||||
"""
|
||||
return username.strip().lower()
|
||||
|
||||
|
||||
async def create(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
username: str,
|
||||
password_hash: str | None,
|
||||
level: Level,
|
||||
is_admin: bool = False,
|
||||
is_owner: bool = False,
|
||||
display_name: str | None = None,
|
||||
must_change_password: bool = False,
|
||||
) -> models.AuthUser:
|
||||
"""Add a new user to the database.
|
||||
|
||||
Takes an already-derived hash rather than a plaintext password. Deriving one blocks
|
||||
for tens of milliseconds, and this module is called from async request handlers
|
||||
where that would stall the whole process; callers pick
|
||||
:func:`spoolman.auth.hashing.hash_password_async` or its synchronous twin according
|
||||
to where they run.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
username: The username, stored lowercased.
|
||||
password_hash: The encoded password hash, or None for an account that cannot
|
||||
sign in with a password.
|
||||
level: The permission level to grant.
|
||||
is_admin: Whether the user administers other users.
|
||||
is_owner: Whether the user owns the instance.
|
||||
display_name: An optional friendly name.
|
||||
must_change_password: Whether to force a password change at next sign-in.
|
||||
|
||||
Returns:
|
||||
models.AuthUser: The created user.
|
||||
|
||||
"""
|
||||
user = models.AuthUser(
|
||||
username=normalize_username(username),
|
||||
display_name=display_name,
|
||||
email=None,
|
||||
password_hash=password_hash,
|
||||
level=str(level),
|
||||
is_admin=is_admin,
|
||||
is_owner=is_owner,
|
||||
is_active=True,
|
||||
must_change_password=must_change_password,
|
||||
totp_secret=None,
|
||||
totp_enabled=False,
|
||||
oidc_issuer=None,
|
||||
oidc_subject=None,
|
||||
failed_logins=0,
|
||||
locked_until=None,
|
||||
registered=datetime.utcnow().replace(microsecond=0),
|
||||
last_login=None,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def get_by_id(db: AsyncSession, user_id: int) -> models.AuthUser:
|
||||
"""Get a user from the database by the unique ID.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user_id: The user ID.
|
||||
|
||||
Raises:
|
||||
ItemNotFoundError: If no such user exists.
|
||||
|
||||
Returns:
|
||||
models.AuthUser: The user.
|
||||
|
||||
"""
|
||||
user = await db.get(models.AuthUser, user_id)
|
||||
if user is None:
|
||||
raise ItemNotFoundError(f"No user with ID {user_id} found.")
|
||||
return user
|
||||
|
||||
|
||||
async def get_by_username(db: AsyncSession, username: str) -> models.AuthUser | None:
|
||||
"""Get a user from the database by username.
|
||||
|
||||
Returns None rather than raising, because the caller is usually the login path and a
|
||||
missing user there is an expected outcome, not an error.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
username: The username, in any case.
|
||||
|
||||
Returns:
|
||||
Optional[models.AuthUser]: The user, or None.
|
||||
|
||||
"""
|
||||
stmt = select(models.AuthUser).where(models.AuthUser.username == normalize_username(username))
|
||||
return (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def count(db: AsyncSession) -> int:
|
||||
"""Count the user accounts that exist.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
|
||||
Returns:
|
||||
int: The number of accounts.
|
||||
|
||||
"""
|
||||
stmt = select(func.count()).select_from(models.AuthUser)
|
||||
return (await db.execute(stmt)).scalar_one()
|
||||
|
||||
|
||||
async def find(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[models.AuthUser], int]:
|
||||
"""List user accounts.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
limit: The maximum number to return.
|
||||
offset: How many to skip.
|
||||
|
||||
Returns:
|
||||
tuple: The users, and the total number that exist.
|
||||
|
||||
"""
|
||||
stmt = select(models.AuthUser).order_by(models.AuthUser.username)
|
||||
total = await count(db)
|
||||
if offset:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
rows = list((await db.execute(stmt)).scalars().all())
|
||||
return rows, total
|
||||
|
||||
|
||||
# Fields an administrator may change through :func:`update`. A whitelist rather than
|
||||
# open setattr, so that a bug or a crafted request in the API layer cannot reach
|
||||
# password_hash, is_owner, or the lockout counters.
|
||||
UPDATABLE_FIELDS = frozenset({"level", "is_admin", "is_active", "display_name"})
|
||||
|
||||
|
||||
async def update(*, db: AsyncSession, user: models.AuthUser, changes: dict[str, object]) -> models.AuthUser:
|
||||
"""Apply administrative changes to an account.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user: The user to modify.
|
||||
changes: Field names and their new values.
|
||||
|
||||
Raises:
|
||||
ValueError: If a field outside :data:`UPDATABLE_FIELDS` is named.
|
||||
|
||||
Returns:
|
||||
models.AuthUser: The modified user.
|
||||
|
||||
"""
|
||||
unknown = set(changes) - UPDATABLE_FIELDS
|
||||
if unknown:
|
||||
raise ValueError(f"Cannot update field(s): {', '.join(sorted(unknown))}.")
|
||||
for field, value in changes.items():
|
||||
setattr(user, field, value)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def delete_user(*, db: AsyncSession, user: models.AuthUser) -> None:
|
||||
"""Remove an account.
|
||||
|
||||
The caller is responsible for clearing what references it first -- sessions, API
|
||||
keys and audit entries all carry a foreign key to this row. Deliberately not done
|
||||
here: the audit log's rows are kept and merely detached, which is a policy decision
|
||||
that belongs with the caller rather than buried in a delete.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user: The user to remove.
|
||||
|
||||
"""
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_password(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user: models.AuthUser,
|
||||
password_hash: str,
|
||||
must_change: bool = False,
|
||||
) -> None:
|
||||
"""Replace a user's password.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user: The user to modify.
|
||||
password_hash: The encoded hash of the new password.
|
||||
must_change: Whether to force another change at next sign-in.
|
||||
|
||||
"""
|
||||
user.password_hash = password_hash
|
||||
user.must_change_password = must_change
|
||||
# A password change resets the lockout: the credential being guessed no longer
|
||||
# exists, so continuing to hold the account shut serves no purpose.
|
||||
user.failed_logins = 0
|
||||
user.locked_until = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def rehash_password(*, db: AsyncSession, user: models.AuthUser, password_hash: str) -> None:
|
||||
"""Store a re-derived hash of the same password.
|
||||
|
||||
Used by the login path when the cost parameters have been raised since the stored
|
||||
hash was produced. Deliberately leaves must_change_password, failed_logins and
|
||||
locked_until alone: nothing about the user's credential has changed.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user: The user to modify.
|
||||
password_hash: The newly encoded hash.
|
||||
|
||||
"""
|
||||
user.password_hash = password_hash
|
||||
await db.commit()
|
||||
|
||||
|
||||
def lockout_for(failed_logins: int) -> timedelta | None:
|
||||
"""Work out how long an account should be locked after a number of failures.
|
||||
|
||||
Args:
|
||||
failed_logins: The running count of consecutive failures.
|
||||
|
||||
Returns:
|
||||
Optional[timedelta]: How long to lock for, or None if below the threshold.
|
||||
|
||||
"""
|
||||
if failed_logins < LOCKOUT_THRESHOLD:
|
||||
return None
|
||||
doublings = failed_logins - LOCKOUT_THRESHOLD
|
||||
return min(LOCKOUT_BASE * (2**doublings), LOCKOUT_MAX)
|
||||
|
||||
|
||||
async def record_login_failure(*, db: AsyncSession, user: models.AuthUser) -> None:
|
||||
"""Note a failed sign-in, locking the account once failures pile up.
|
||||
|
||||
Persisted rather than kept only in the in-memory window so that a lockout survives a
|
||||
restart.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user: The user who failed to sign in.
|
||||
|
||||
"""
|
||||
user.failed_logins += 1
|
||||
lockout = lockout_for(user.failed_logins)
|
||||
if lockout is not None:
|
||||
user.locked_until = datetime.utcnow().replace(microsecond=0) + lockout
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def record_login_success(*, db: AsyncSession, user: models.AuthUser) -> None:
|
||||
"""Note a successful sign-in and clear any lockout.
|
||||
|
||||
Args:
|
||||
db: The database session.
|
||||
user: The user who signed in.
|
||||
|
||||
"""
|
||||
user.failed_logins = 0
|
||||
user.locked_until = None
|
||||
user.last_login = datetime.utcnow().replace(microsecond=0)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def is_locked(user: models.AuthUser) -> bool:
|
||||
"""Check whether an account is currently locked out.
|
||||
|
||||
Args:
|
||||
user: The user to check.
|
||||
|
||||
Returns:
|
||||
bool: True if sign-in should be refused.
|
||||
|
||||
"""
|
||||
if user.locked_until is None:
|
||||
return False
|
||||
return user.locked_until > datetime.utcnow()
|
||||
@@ -169,6 +169,21 @@ def setup_db(connection_url: URL) -> None:
|
||||
__db.connect()
|
||||
|
||||
|
||||
def get_engine() -> AsyncEngine:
|
||||
"""Get the connected engine.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the database has not been set up.
|
||||
|
||||
Returns:
|
||||
AsyncEngine: The engine.
|
||||
|
||||
"""
|
||||
if __db is None or __db.engine is None:
|
||||
raise RuntimeError("DB is not setup.")
|
||||
return __db.engine
|
||||
|
||||
|
||||
async def backup_global_db(num_backups: int = 5) -> Path | None:
|
||||
"""Backup the database and rotate existing backups.
|
||||
|
||||
@@ -219,7 +234,7 @@ def schedule_tasks(scheduler: Scheduler) -> None:
|
||||
scheduler.daily(datetime.time(hour=0, minute=0, second=0), _backup_task) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession]:
|
||||
"""Get a DB session to be used with FastAPI's dependency system.
|
||||
|
||||
Yields:
|
||||
|
||||
+128
-11
@@ -1,7 +1,6 @@
|
||||
"""SQLAlchemy data models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
@@ -33,9 +32,9 @@ class Vendor(Base):
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
empty_spool_weight: Mapped[float | None] = mapped_column(comment="The weight of an empty spool.")
|
||||
comment: Mapped[str | None] = mapped_column(String(1024))
|
||||
filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor")
|
||||
filaments: Mapped[list[Filament]] = relationship(back_populates="vendor")
|
||||
external_id: Mapped[str | None] = mapped_column(String(256))
|
||||
extra: Mapped[list["VendorField"]] = relationship(
|
||||
extra: Mapped[list[VendorField]] = relationship(
|
||||
back_populates="vendor",
|
||||
cascade="save-update, merge, delete, delete-orphan",
|
||||
lazy="selectin",
|
||||
@@ -49,8 +48,8 @@ class Filament(Base):
|
||||
registered: Mapped[datetime] = mapped_column()
|
||||
name: Mapped[str | None] = mapped_column(String(64))
|
||||
vendor_id: Mapped[int | None] = mapped_column(ForeignKey("vendor.id"))
|
||||
vendor: Mapped[Optional["Vendor"]] = relationship(back_populates="filaments")
|
||||
spools: Mapped[list["Spool"]] = relationship(back_populates="filament")
|
||||
vendor: Mapped[Vendor | None] = relationship(back_populates="filaments")
|
||||
spools: Mapped[list[Spool]] = relationship(back_populates="filament")
|
||||
material: Mapped[str | None] = mapped_column(String(64))
|
||||
price: Mapped[float | None] = mapped_column()
|
||||
density: Mapped[float] = mapped_column()
|
||||
@@ -65,7 +64,7 @@ class Filament(Base):
|
||||
multi_color_hexes: Mapped[str | None] = mapped_column(String(128))
|
||||
multi_color_direction: Mapped[str | None] = mapped_column(String(16))
|
||||
external_id: Mapped[str | None] = mapped_column(String(256))
|
||||
extra: Mapped[list["FilamentField"]] = relationship(
|
||||
extra: Mapped[list[FilamentField]] = relationship(
|
||||
back_populates="filament",
|
||||
cascade="save-update, merge, delete, delete-orphan",
|
||||
lazy="selectin",
|
||||
@@ -81,7 +80,7 @@ class Spool(Base):
|
||||
last_used: Mapped[datetime | None] = mapped_column()
|
||||
price: Mapped[float | None] = mapped_column()
|
||||
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"))
|
||||
filament: Mapped["Filament"] = relationship(back_populates="spools")
|
||||
filament: Mapped[Filament] = relationship(back_populates="spools")
|
||||
initial_weight: Mapped[float | None] = mapped_column()
|
||||
spool_weight: Mapped[float | None] = mapped_column()
|
||||
used_weight: Mapped[float] = mapped_column()
|
||||
@@ -89,7 +88,7 @@ class Spool(Base):
|
||||
lot_nr: Mapped[str | None] = mapped_column(String(64))
|
||||
comment: Mapped[str | None] = mapped_column(String(1024))
|
||||
archived: Mapped[bool | None] = mapped_column()
|
||||
extra: Mapped[list["SpoolField"]] = relationship(
|
||||
extra: Mapped[list[SpoolField]] = relationship(
|
||||
back_populates="spool",
|
||||
cascade="save-update, merge, delete, delete-orphan",
|
||||
lazy="selectin",
|
||||
@@ -108,7 +107,7 @@ class VendorField(Base):
|
||||
__tablename__ = "vendor_field"
|
||||
|
||||
vendor_id: Mapped[int] = mapped_column(ForeignKey("vendor.id"), primary_key=True, index=True)
|
||||
vendor: Mapped["Vendor"] = relationship(back_populates="extra")
|
||||
vendor: Mapped[Vendor] = relationship(back_populates="extra")
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text())
|
||||
|
||||
@@ -117,7 +116,7 @@ class FilamentField(Base):
|
||||
__tablename__ = "filament_field"
|
||||
|
||||
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"), primary_key=True, index=True)
|
||||
filament: Mapped["Filament"] = relationship(back_populates="extra")
|
||||
filament: Mapped[Filament] = relationship(back_populates="extra")
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text())
|
||||
|
||||
@@ -126,6 +125,124 @@ class SpoolField(Base):
|
||||
__tablename__ = "spool_field"
|
||||
|
||||
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), primary_key=True, index=True)
|
||||
spool: Mapped["Spool"] = relationship(back_populates="extra")
|
||||
spool: Mapped[Spool] = relationship(back_populates="extra")
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text())
|
||||
|
||||
|
||||
# The authentication tables below are all prefixed `auth_`, because `user` and `session`
|
||||
# are reserved words in PostgreSQL and MySQL and quoting them at every call site is a
|
||||
# footgun not worth taking.
|
||||
#
|
||||
# None of them declare a relationship(). Auth lookups are single-row queries by an
|
||||
# indexed column, so there is nothing to eager-load; a `sessions` collection on AuthUser
|
||||
# would fetch every session row on every login for no benefit, and lazy-loading one
|
||||
# under asyncio raises MissingGreenlet. Related rows are queried and deleted explicitly.
|
||||
#
|
||||
# Enum-valued columns are stored as String, matching `multi_color_direction` above --
|
||||
# there is not one sa.Enum in this schema, and adding one would create a native
|
||||
# PostgreSQL type and a MySQL ENUM(...) with three different ALTER dialects.
|
||||
#
|
||||
# Multi-tenancy seam (see AUTHENTICATION_PLAN.md): every table uses an integer surrogate
|
||||
# primary key so a tenant_id foreign key is a pure add-column later. Uniqueness is
|
||||
# enforced by unique *indexes* rather than named UNIQUE constraints, so
|
||||
# `auth_user.username` can become a composite (tenant_id, username) index without having
|
||||
# to drop a constraint whose generated name differs per dialect. `auth_api_key.prefix`
|
||||
# stays globally unique even then: it is a lookup key, not a name.
|
||||
|
||||
|
||||
class AuthUser(Base):
|
||||
"""A user account. Used from phase 1."""
|
||||
|
||||
__tablename__ = "auth_user"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(64), index=True, unique=True, comment="Stored lowercased.")
|
||||
display_name: Mapped[str | None] = mapped_column(String(128))
|
||||
email: Mapped[str | None] = mapped_column(String(256), comment="Used for OIDC matching only, never delivery.")
|
||||
password_hash: Mapped[str | None] = mapped_column(Text(), comment="Null for OIDC- or certificate-only accounts.")
|
||||
level: Mapped[str] = mapped_column(String(16), comment="read, edit or manage. See spoolman.auth.levels.")
|
||||
is_admin: Mapped[bool] = mapped_column()
|
||||
is_owner: Mapped[bool] = mapped_column()
|
||||
is_active: Mapped[bool] = mapped_column()
|
||||
must_change_password: Mapped[bool] = mapped_column()
|
||||
totp_secret: Mapped[str | None] = mapped_column(Text(), comment="Fernet-encrypted. Phase 4.")
|
||||
totp_enabled: Mapped[bool] = mapped_column()
|
||||
oidc_issuer: Mapped[str | None] = mapped_column(String(256), comment="Phase 3.")
|
||||
oidc_subject: Mapped[str | None] = mapped_column(String(256), comment="Phase 3.")
|
||||
failed_logins: Mapped[int] = mapped_column()
|
||||
locked_until: Mapped[datetime | None] = mapped_column()
|
||||
registered: Mapped[datetime] = mapped_column()
|
||||
last_login: Mapped[datetime | None] = mapped_column()
|
||||
|
||||
|
||||
class AuthSession(Base):
|
||||
"""A server-side login session. Used from phase 1.
|
||||
|
||||
Sessions are opaque random tokens stored as their SHA-256 digest rather than JWTs,
|
||||
so that they can be listed and revoked.
|
||||
"""
|
||||
|
||||
__tablename__ = "auth_session"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("auth_user.id"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), index=True, unique=True, comment="SHA-256 hex of the token.")
|
||||
created: Mapped[datetime] = mapped_column()
|
||||
expires: Mapped[datetime] = mapped_column()
|
||||
last_seen: Mapped[datetime] = mapped_column()
|
||||
remember: Mapped[bool] = mapped_column()
|
||||
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||
ip: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class AuthApiKey(Base):
|
||||
"""A machine credential. Table created in phase 1, used from phase 2."""
|
||||
|
||||
__tablename__ = "auth_api_key"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("auth_user.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
level: Mapped[str] = mapped_column(String(16))
|
||||
prefix: Mapped[str] = mapped_column(String(16), index=True, unique=True, comment="Indexes the row at lookup.")
|
||||
key_hash: Mapped[str] = mapped_column(Text())
|
||||
created: Mapped[datetime] = mapped_column()
|
||||
expires: Mapped[datetime | None] = mapped_column()
|
||||
last_used: Mapped[datetime | None] = mapped_column()
|
||||
revoked: Mapped[bool] = mapped_column()
|
||||
|
||||
|
||||
class AuthAuditLog(Base):
|
||||
"""An audit trail entry. Table created in phase 1, used from phase 2."""
|
||||
|
||||
__tablename__ = "auth_audit_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
date: Mapped[datetime] = mapped_column(index=True)
|
||||
event: Mapped[str] = mapped_column(String(64))
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("auth_user.id"))
|
||||
actor_kind: Mapped[str] = mapped_column(String(16))
|
||||
target: Mapped[str | None] = mapped_column(String(128))
|
||||
ip: Mapped[str | None] = mapped_column(String(64))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||
detail: Mapped[str | None] = mapped_column(Text(), comment="JSON object.")
|
||||
|
||||
|
||||
class AuthCertificate(Base):
|
||||
"""An mTLS client certificate mapped to a user. Table created in phase 1, used from phase 5."""
|
||||
|
||||
__tablename__ = "auth_certificate"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("auth_user.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
fingerprint_sha256: Mapped[str] = mapped_column(
|
||||
String(95),
|
||||
index=True,
|
||||
unique=True,
|
||||
comment="Colon-separated uppercase hex, 95 chars.",
|
||||
)
|
||||
subject_dn: Mapped[str] = mapped_column(String(512))
|
||||
created: Mapped[datetime] = mapped_column()
|
||||
last_used: Mapped[datetime | None] = mapped_column()
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import case, func
|
||||
@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def utc_timezone_naive(dt: datetime) -> datetime:
|
||||
"""Convert a datetime object to UTC and remove timezone info."""
|
||||
return dt.astimezone(tz=timezone.utc).replace(tzinfo=None)
|
||||
return dt.astimezone(tz=UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
async def create(
|
||||
|
||||
+159
@@ -486,3 +486,162 @@ def is_legacy_client_enabled() -> bool:
|
||||
raise ValueError(
|
||||
f"Failed to parse SPOOLMAN_LEGACY_CLIENT variable: Unknown value '{legacy_client}'.",
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Authentication.
|
||||
#
|
||||
# All of the below are opt-in: with SPOOLMAN_AUTH_ENABLED unset, the request path is
|
||||
# identical to an instance built before authentication existed.
|
||||
#
|
||||
|
||||
|
||||
def _parse_bool(name: str, default: str) -> bool:
|
||||
"""Parse a TRUE/FALSE/1/0 environment variable.
|
||||
|
||||
The older boolean accessors in this module each inline this logic with a bespoke
|
||||
error message that users quote in bug reports, so they are deliberately left alone.
|
||||
New variables use this helper.
|
||||
|
||||
Args:
|
||||
name: The environment variable to read.
|
||||
default: The value to assume when the variable is unset.
|
||||
|
||||
Raises:
|
||||
ValueError: If the variable is set to something other than TRUE/FALSE/1/0.
|
||||
|
||||
Returns:
|
||||
bool: The parsed value.
|
||||
|
||||
"""
|
||||
value = os.getenv(name, default).upper()
|
||||
if value in {"FALSE", "0"}:
|
||||
return False
|
||||
if value in {"TRUE", "1"}:
|
||||
return True
|
||||
raise ValueError(f"Failed to parse {name} variable: Unknown value '{value}'.")
|
||||
|
||||
|
||||
def _read_env_or_file(name: str) -> str | None:
|
||||
"""Read a secret from ``<name>_FILE`` if set, falling back to ``<name>``.
|
||||
|
||||
Follows the same two-step convention as :func:`get_password`, with one deliberate
|
||||
difference: the file contents are stripped. A key or token file is almost always
|
||||
produced by a shell redirect, which appends a newline, and an unstripped trailing
|
||||
newline would silently yield a different secret than the one the operator wrote.
|
||||
|
||||
Args:
|
||||
name: The base environment variable name, without the ``_FILE`` suffix.
|
||||
|
||||
Raises:
|
||||
ValueError: If the file is missing or cannot be read.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The secret, or None if neither variable is set.
|
||||
|
||||
"""
|
||||
file_path = os.getenv(name + "_FILE")
|
||||
if file_path is not None:
|
||||
file = Path(file_path)
|
||||
if not file.exists() or not file.is_file():
|
||||
raise ValueError(
|
||||
f'Failed to parse {name}_FILE variable: File "{file_path}" does not exist.',
|
||||
)
|
||||
try:
|
||||
with file.open(encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except OSError as exc:
|
||||
raise ValueError(
|
||||
f'Failed to parse {name}_FILE variable: Failed to read from file "{file_path}": {exc!s}.',
|
||||
) from exc
|
||||
|
||||
return os.getenv(name)
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Get whether authentication is enforced.
|
||||
|
||||
Returns False if no environment variable was set, which keeps zero-config boot and
|
||||
every existing integration working untouched.
|
||||
|
||||
Returns:
|
||||
bool: Whether authentication is enabled.
|
||||
|
||||
"""
|
||||
return _parse_bool("SPOOLMAN_AUTH_ENABLED", "FALSE")
|
||||
|
||||
|
||||
def get_secret_key() -> str | None:
|
||||
"""Get the configured server secret key.
|
||||
|
||||
Returns None when neither SPOOLMAN_SECRET_KEY nor SPOOLMAN_SECRET_KEY_FILE is set,
|
||||
in which case a key is generated and persisted in the data directory instead. See
|
||||
:mod:`spoolman.auth.secret`.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The secret key, or None if unset.
|
||||
|
||||
"""
|
||||
return _read_env_or_file("SPOOLMAN_SECRET_KEY")
|
||||
|
||||
|
||||
def get_trusted_proxies() -> list[str]:
|
||||
"""Get the reverse proxies whose forwarding headers may be believed.
|
||||
|
||||
Accepts a comma-separated list of IP addresses or CIDR networks. Empty by default,
|
||||
which means X-Forwarded-For and X-Forwarded-Proto are never trusted.
|
||||
|
||||
Returns:
|
||||
list[str]: The trusted proxy addresses or networks.
|
||||
|
||||
"""
|
||||
proxies = os.getenv("SPOOLMAN_TRUSTED_PROXIES", "")
|
||||
return [entry.strip() for entry in proxies.split(",") if entry.strip()]
|
||||
|
||||
|
||||
def is_metrics_public() -> bool:
|
||||
"""Get whether the metrics endpoint stays reachable without credentials.
|
||||
|
||||
Only consulted when authentication is enabled; with authentication off, metrics are
|
||||
public regardless.
|
||||
|
||||
Returns:
|
||||
bool: Whether metrics are public.
|
||||
|
||||
"""
|
||||
return _parse_bool("SPOOLMAN_METRICS_PUBLIC", "FALSE")
|
||||
|
||||
|
||||
def get_metrics_token() -> str | None:
|
||||
"""Get the shared token that grants access to the metrics endpoint.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The metrics token, or None if unset.
|
||||
|
||||
"""
|
||||
return _read_env_or_file("SPOOLMAN_METRICS_TOKEN")
|
||||
|
||||
|
||||
def get_cookie_secure_override() -> bool | None:
|
||||
"""Get the operator's override for the Secure flag on session cookies.
|
||||
|
||||
Defaults to AUTO, which derives the flag from the request scheme. An override is
|
||||
needed only for proxies that do not set X-Forwarded-Proto. Guessing True on a
|
||||
plain-HTTP LAN deployment would make the browser discard the cookie and break login
|
||||
with no visible error, so the automatic path never assumes HTTPS.
|
||||
|
||||
Raises:
|
||||
ValueError: If set to something other than AUTO/TRUE/FALSE/1/0.
|
||||
|
||||
Returns:
|
||||
Optional[bool]: True or False to force the flag, None to derive it.
|
||||
|
||||
"""
|
||||
value = os.getenv("SPOOLMAN_COOKIE_SECURE", "AUTO").upper()
|
||||
if value == "AUTO":
|
||||
return None
|
||||
if value in {"FALSE", "0"}:
|
||||
return False
|
||||
if value in {"TRUE", "1"}:
|
||||
return True
|
||||
raise ValueError(f"Failed to parse SPOOLMAN_COOKIE_SECURE variable: Unknown value '{value}'.")
|
||||
@@ -15,3 +15,11 @@ class ItemCreateError(Exception):
|
||||
|
||||
class SpoolMeasureError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationRequiredError(Exception):
|
||||
"""No usable credential was presented. Mapped to HTTP 401."""
|
||||
|
||||
|
||||
class PermissionDeniedError(Exception):
|
||||
"""A credential was presented but does not permit the operation. Mapped to HTTP 403."""
|
||||
+2
-2
@@ -38,7 +38,7 @@ async def flatten_sqlalchemy_object(obj: models.Base, parent_key: str = "", sep:
|
||||
return fields
|
||||
|
||||
|
||||
async def dump_as_csv(sqlalchemy_objects: Iterable[models.Base], writer: "SupportsWrite[str]") -> None:
|
||||
async def dump_as_csv(sqlalchemy_objects: Iterable[models.Base], writer: SupportsWrite[str]) -> None:
|
||||
"""Export a list of objects as CSV to a writer. Nested objects are flattened with dot-separated keys."""
|
||||
# Flatten each object and get all column names
|
||||
all_flattened = await asyncio.gather(*[flatten_sqlalchemy_object(obj) for obj in sqlalchemy_objects])
|
||||
@@ -57,7 +57,7 @@ async def dump_as_csv(sqlalchemy_objects: Iterable[models.Base], writer: "Suppor
|
||||
csv_writer.writerow(flattened_obj)
|
||||
|
||||
|
||||
async def dump_as_json(sqlalchemy_objects: Iterable[models.Base], writer: "SupportsWrite[str]") -> None:
|
||||
async def dump_as_json(sqlalchemy_objects: Iterable[models.Base], writer: SupportsWrite[str]) -> None:
|
||||
"""Export a list of objects as JSON to a writer. Nested objects are flattened with dot-separated keys."""
|
||||
# Flatten each object and get all column names
|
||||
all_flattened = await asyncio.gather(*[flatten_sqlalchemy_object(obj) for obj in sqlalchemy_objects])
|
||||
|
||||
+112
-7
@@ -1,12 +1,13 @@
|
||||
"""Main entrypoint to the server."""
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import subprocess
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import PlainTextResponse, RedirectResponse, Response
|
||||
@@ -15,6 +16,8 @@ from scheduler.asyncio.scheduler import Scheduler
|
||||
|
||||
from spoolman import env, externaldb
|
||||
from spoolman.api.v1.router import app as v1_app
|
||||
from spoolman.auth import audit, secret
|
||||
from spoolman.auth.levels import Level
|
||||
from spoolman.client import SinglePageApplication
|
||||
from spoolman.database import database
|
||||
from spoolman.prometheus.metrics import registry
|
||||
@@ -57,6 +60,54 @@ app.add_middleware(GZipMiddleware)
|
||||
app.mount(env.get_base_path() + "/api/v1", v1_app)
|
||||
|
||||
|
||||
async def metrics_access_permitted(request: Request) -> bool:
|
||||
"""Check whether a caller may read the metrics endpoint.
|
||||
|
||||
Metrics stay public while authentication is off, so existing Prometheus scrape
|
||||
configs keep working untouched. With authentication on, a caller needs either the
|
||||
shared SPOOLMAN_METRICS_TOKEN or an API key, unless the operator opts back out with
|
||||
SPOOLMAN_METRICS_PUBLIC.
|
||||
|
||||
Both, rather than one or the other. The shared token is a deployment-level secret
|
||||
that needs no database and no account, which is what an operator scraping their own
|
||||
instance wants and what survives the database being down. An API key is attributable
|
||||
and revocable through the interface, which is what you want when the scraper belongs
|
||||
to somebody else. Neither is checked before the other for any security reason; the
|
||||
token is simply free to check.
|
||||
|
||||
Only API keys are accepted, not session cookies, so that a signed-in browser tab
|
||||
does not silently make the endpoint readable. See
|
||||
:func:`spoolman.auth.dependencies.api_key_principal`.
|
||||
|
||||
Args:
|
||||
request: The incoming request.
|
||||
|
||||
Returns:
|
||||
bool: Whether to serve the metrics.
|
||||
|
||||
"""
|
||||
if not env.is_auth_enabled() or env.is_metrics_public():
|
||||
return True
|
||||
|
||||
presented = request.headers.get("x-api-key", "")
|
||||
if not presented:
|
||||
authorization = request.headers.get("authorization", "")
|
||||
scheme, _, value = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer":
|
||||
presented = value.strip()
|
||||
if not presented:
|
||||
return False
|
||||
|
||||
expected = env.get_metrics_token()
|
||||
if expected and hmac.compare_digest(presented, expected):
|
||||
return True
|
||||
|
||||
from spoolman.auth.dependencies import api_key_principal # noqa: PLC0415
|
||||
|
||||
principal = await api_key_principal(request)
|
||||
return principal is not None and principal.covers(Level.READ)
|
||||
|
||||
|
||||
# WA for prometheus /metrics bind with SinglePageApp at root
|
||||
@app.get(
|
||||
env.get_base_path() + "/metrics",
|
||||
@@ -66,9 +117,11 @@ app.mount(env.get_base_path() + "/api/v1", v1_app)
|
||||
"Get app metrics for prometheusIf enabled SPOOLMAN_METRICS_ENABLED returned metrics by Spools and Filaments"
|
||||
),
|
||||
)
|
||||
def get_metrics() -> bytes:
|
||||
async def get_metrics(request: Request) -> Response:
|
||||
"""Return prometheus metrics."""
|
||||
return generate_latest(registry)
|
||||
if not await metrics_access_permitted(request):
|
||||
return PlainTextResponse("Unauthorized", status_code=401)
|
||||
return PlainTextResponse(generate_latest(registry))
|
||||
|
||||
|
||||
base_path = env.get_base_path()
|
||||
@@ -126,10 +179,19 @@ else:
|
||||
|
||||
def add_cors_middleware() -> None:
|
||||
"""Add CORS middleware to the FastAPI app based on environment settings."""
|
||||
origins = []
|
||||
origins: list[str] = []
|
||||
origin_regex: str | None = None
|
||||
if env.is_debug_mode():
|
||||
logger.warning("Running in debug mode, allowing all origins.")
|
||||
origins = ["*"]
|
||||
# Reflect whatever Origin asked, rather than sending a literal "*".
|
||||
#
|
||||
# allow_credentials=True below makes Starlette emit "*" verbatim, and browsers
|
||||
# reject that outright for any request carrying credentials. Nothing noticed
|
||||
# while the API was anonymous, but it silently breaks cookie-authenticated
|
||||
# requests from the Vite dev server on port 5174 to the backend on 8000, which
|
||||
# is the whole point of debug mode. Echoing the origin is what the wildcard was
|
||||
# already trying to express.
|
||||
logger.warning("Running in debug mode, reflecting all origins.")
|
||||
origin_regex = ".*"
|
||||
elif env.is_cors_defined():
|
||||
cors_origins = env.get_cors_origin()
|
||||
if cors_origins:
|
||||
@@ -138,12 +200,13 @@ def add_cors_middleware() -> None:
|
||||
else:
|
||||
logger.warning("CORS origins are not defined, no CORS will be applied.")
|
||||
|
||||
if not origins:
|
||||
if not origins and origin_regex is None:
|
||||
return
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_origin_regex=origin_regex,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -154,6 +217,39 @@ def add_cors_middleware() -> None:
|
||||
add_cors_middleware()
|
||||
|
||||
|
||||
def log_auth_status() -> None:
|
||||
"""Report the authentication configuration, and warn about combinations that surprise."""
|
||||
if not env.is_auth_enabled():
|
||||
return
|
||||
|
||||
from collections import Counter # noqa: PLC0415
|
||||
|
||||
from spoolman.api.v1.router import ROUTE_LEVELS # noqa: PLC0415
|
||||
|
||||
counts = Counter(str(level) for level in ROUTE_LEVELS.values())
|
||||
logger.info(
|
||||
"Authentication is enabled. %d API routes are gated (read: %d, edit: %d, manage: %d).",
|
||||
len(ROUTE_LEVELS),
|
||||
counts.get("read", 0),
|
||||
counts.get("edit", 0),
|
||||
counts.get("manage", 0),
|
||||
)
|
||||
|
||||
if env.is_legacy_client_enabled():
|
||||
# The React client has no sign-in screen and no cookie or CSRF handling, so it
|
||||
# will fail every request with no way for the user to authenticate.
|
||||
logger.warning(
|
||||
"SPOOLMAN_LEGACY_CLIENT is set together with authentication. The legacy client "
|
||||
"does not support signing in and will not work. Unset one of the two.",
|
||||
)
|
||||
|
||||
if env.is_metrics_enabled() and not env.is_metrics_public() and not env.get_metrics_token():
|
||||
logger.warning(
|
||||
"Metrics are enabled but no SPOOLMAN_METRICS_TOKEN is set, so /metrics will "
|
||||
"reject every scrape. Set a token, or set SPOOLMAN_METRICS_PUBLIC=TRUE.",
|
||||
)
|
||||
|
||||
|
||||
def add_file_logging() -> None:
|
||||
"""Add file logging to the root logger."""
|
||||
# Define a file logger with log rotation
|
||||
@@ -188,6 +284,12 @@ async def startup() -> None:
|
||||
logger.info("Using logs directory: %s", env.get_logs_dir().resolve())
|
||||
logger.info("Using backups directory: %s", env.get_backups_dir().resolve())
|
||||
|
||||
# Resolve the secret key before the database work. It needs a writable data
|
||||
# directory but no schema, so failing here gives an actionable error ahead of the
|
||||
# migration subprocess. Guarded so an auth-disabled instance never grows the file.
|
||||
if env.is_auth_enabled():
|
||||
secret.ensure_secret_key()
|
||||
|
||||
logger.info("Setting up database...")
|
||||
database.setup_db(database.get_connection_url())
|
||||
|
||||
@@ -202,6 +304,9 @@ async def startup() -> None:
|
||||
schedule = Scheduler()
|
||||
database.schedule_tasks(schedule)
|
||||
externaldb.schedule_tasks(schedule)
|
||||
audit.schedule_tasks(schedule)
|
||||
|
||||
log_auth_status()
|
||||
|
||||
logger.info("Startup complete.")
|
||||
|
||||
|
||||
@@ -73,3 +73,15 @@ register_setting("base_url", SettingType.STRING, json.dumps(""))
|
||||
|
||||
register_setting("locations", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
|
||||
|
||||
# Never store a secret in a setting. Settings are readable through GET /setting/ and
|
||||
# every write is broadcast to all websocket subscribers, so anything put here is public
|
||||
# to every client that can read the API. Deployment secrets belong in environment
|
||||
# variables; see spoolman/env.py.
|
||||
register_setting("auth_anonymous_read", SettingType.BOOLEAN, json.dumps(obj=False))
|
||||
|
||||
# How many days of audit log to keep. Zero keeps everything. A setting rather than an
|
||||
# environment variable because retention is a policy an administrator revises, not a
|
||||
# deployment parameter, and changing it should not need a restart. Not a secret: it says
|
||||
# nothing about what is in the log.
|
||||
register_setting("auth_audit_retention_days", SettingType.NUMBER, json.dumps(90))
|
||||
@@ -0,0 +1,24 @@
|
||||
version: '3.8'
|
||||
# The authentication suite. Deliberately a single SQLite variant rather than another
|
||||
# pass over all four databases: nothing in spoolman/auth is database-specific, so
|
||||
# re-running the whole regression suite with authentication on would quadruple the CI
|
||||
# bill to re-prove the same thing.
|
||||
#
|
||||
# The tester mounts ./tests_auth -- not ./tests -- at the path the image's fixed
|
||||
# entrypoint collects from, so this compose file runs the auth suite and only the auth
|
||||
# suite, and the regression runs can never pick these tests up.
|
||||
services:
|
||||
spoolman:
|
||||
image: donkie/spoolman:test
|
||||
environment:
|
||||
- SPOOLMAN_LOGGING_LEVEL=INFO
|
||||
- TZ=Europe/Stockholm
|
||||
- SPOOLMAN_AUTH_ENABLED=TRUE
|
||||
tester:
|
||||
image: donkie/spoolman-tester:latest
|
||||
volumes:
|
||||
- ./tests_auth:/tester/tests
|
||||
environment:
|
||||
- DB_TYPE=sqlite
|
||||
depends_on:
|
||||
- spoolman
|
||||
@@ -21,12 +21,17 @@ if __name__ == "__main__":
|
||||
print("Failed to build Spoolman tester!")
|
||||
sys.exit(1)
|
||||
|
||||
# Support input arguments for running only specific tests
|
||||
# Support input arguments for running only specific tests.
|
||||
#
|
||||
# "sqlite-auth" is runnable but not in the default set. It runs a different suite
|
||||
# (tests_integration/tests_auth) against a server with SPOOLMAN_AUTH_ENABLED, and
|
||||
# nothing in spoolman/auth is database-specific, so there is no reason to pay for it
|
||||
# on every run of the four-database regression sweep.
|
||||
if len(sys.argv) > 1:
|
||||
targets = sys.argv[1:]
|
||||
# Check that all targets are valid
|
||||
for target in targets:
|
||||
if target not in ["postgres", "sqlite", "mariadb", "cockroachdb"]:
|
||||
if target not in ["postgres", "sqlite", "mariadb", "cockroachdb", "sqlite-auth"]:
|
||||
print(f"Unknown target: {target}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
|
||||
@@ -46,7 +46,7 @@ def pytest_sessionstart(session): # noqa: ARG001, ANN001
|
||||
response = httpx.get(URL, timeout=1)
|
||||
response.raise_for_status()
|
||||
print("pytest: Spoolman now seems to be up!") # noqa: T201
|
||||
except httpx.HTTPError: # noqa: PERF203
|
||||
except httpx.HTTPError:
|
||||
if time.time() - start_time > TIMEOUT:
|
||||
raise
|
||||
time.sleep(0.5)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Integration tests for the custom extra fields system."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -136,7 +136,7 @@ def test_add_datetime_field():
|
||||
json={
|
||||
"name": "My datetime field",
|
||||
"field_type": "datetime",
|
||||
"default_value": json.dumps(datetime.now(timezone.utc).isoformat()),
|
||||
"default_value": json.dumps(datetime.now(UTC).isoformat()),
|
||||
},
|
||||
)
|
||||
assert_httpx_success(result)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Integration tests for the Filament API endpoint."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -70,7 +70,7 @@ def test_add_filament(random_vendor: dict[str, Any]):
|
||||
)
|
||||
|
||||
# Verify that registered happened almost now (within 1 minute)
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(filament["registered"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(filament["registered"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Clean up
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Integration tests for the Spool API endpoint."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -69,7 +69,7 @@ def test_add_spool_remaining_weight(random_filament: dict[str, Any]):
|
||||
)
|
||||
|
||||
# Verify that registered happened almost now (within 1 minute)
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["registered"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Clean up
|
||||
@@ -261,7 +261,7 @@ def test_add_spool_initial_weight(random_filament: dict[str, Any]):
|
||||
)
|
||||
|
||||
# Verify that registered happened almost now (within 1 minute)
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["registered"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Clean up
|
||||
@@ -332,7 +332,7 @@ def test_add_spool_spool_weight(random_filament: dict[str, Any]):
|
||||
)
|
||||
|
||||
# Verify that registered happened almost now (within 1 minute)
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["registered"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Clean up
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Integration tests for the Spool API endpoint."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -45,11 +45,11 @@ def test_measure_spool(random_filament: dict[str, Any], measurement: float):
|
||||
expected_remaining = max(measurement - spool_weight, 0)
|
||||
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
|
||||
# Verify that first_used has been updated
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["first_used"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Verify that last_used has been updated
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Clean up
|
||||
@@ -143,11 +143,11 @@ def test_measure_spool_sequence(random_filament: dict[str, Any], measurements: l
|
||||
expected_remaining = max(m - spool_weight, 0)
|
||||
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
|
||||
# Verify that first_used has been updated
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["first_used"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Verify that last_used has been updated
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
current_weight = current_weight - expected_use
|
||||
@@ -195,11 +195,11 @@ def test_measure_spool_empty(random_empty_filament: dict[str, Any], measurements
|
||||
expected_remaining = max(m - spool_weight, 0)
|
||||
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
|
||||
# Verify that first_used has been updated
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["first_used"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Verify that last_used has been updated
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
current_weight = current_weight - expected_use
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -43,11 +43,11 @@ def test_use_spool_weight(random_filament: dict[str, Any], use_weight: float):
|
||||
assert spool["remaining_weight"] == pytest.approx(min(max(start_weight - use_weight, 0), filament_net_weight))
|
||||
|
||||
# Verify that first_used has been updated
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["first_used"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Verify that last_used has been updated
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Clean up
|
||||
@@ -93,7 +93,7 @@ def test_use_spool_length(random_filament: dict[str, Any], use_length: float):
|
||||
assert spool["first_used"] == "2023-01-01T00:00:00Z"
|
||||
|
||||
# Verify that last_used has been updated
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Clean up
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
"""Integration tests for the Vendor API endpoint."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_add_vendor():
|
||||
)
|
||||
|
||||
# Verify that registered happened almost now (within 1 minute)
|
||||
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(vendor["registered"])).total_seconds())
|
||||
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(vendor["registered"])).total_seconds())
|
||||
assert diff < 60
|
||||
|
||||
# Clean up
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,289 @@
|
||||
"""Fixtures for the authentication integration tests.
|
||||
|
||||
This suite is deliberately a sibling of ``tests_integration/tests`` rather than a
|
||||
subdirectory of it. The tester image's entrypoint is fixed to ``pytest --exitfirst
|
||||
tests``, and the auth compose file mounts *this* directory at ``/tester/tests``. That
|
||||
means the regression suite and the auth suite can never be collected in the same run:
|
||||
the regression suite talks to a server with authentication off and would fail wholesale
|
||||
against an authenticated one.
|
||||
|
||||
The URL/DbType/session-start block below is duplicated from ``tests/conftest.py`` on
|
||||
purpose. Nothing there can be reused: every fixture in that file drives bare ``httpx``
|
||||
module functions with no credentials, which against an authenticated server is exactly
|
||||
the thing under test.
|
||||
|
||||
Two pieces of shared state matter for the whole run:
|
||||
|
||||
* The instance is claimed exactly once, by the ``setup_response`` fixture. Any test that
|
||||
needs a signed-in caller depends on it, so ``test_setup.py`` can assert on the *first*
|
||||
call's response no matter which file pytest happens to collect first.
|
||||
* The sign-in throttle in ``spoolman.auth.ratelimit`` allows 20 failed attempts per
|
||||
client address per 15 minutes, and every test here shares one address. The suite spends
|
||||
roughly 8. Keep new failing-login assertions well under the remaining budget or later
|
||||
tests will start seeing 429 where they expect 401.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from enum import StrEnum
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
TIMEOUT = 30
|
||||
|
||||
HOST = "spoolman:" + os.environ.get("SPOOLMAN_PORT", "8000")
|
||||
URL = f"http://{HOST}"
|
||||
API_URL = f"{URL}/api/v1"
|
||||
WS_API_URL = f"ws://{HOST}/api/v1"
|
||||
|
||||
SESSION_COOKIE = "spoolman_session"
|
||||
CSRF_COOKIE = "spoolman_csrf"
|
||||
CSRF_HEADER = "X-CSRF-Token"
|
||||
|
||||
# Deliberately mixed case: usernames are normalised to lowercase on the way in, and
|
||||
# test_setup asserts that the stored form came back lowercased.
|
||||
OWNER_USERNAME = "Owner"
|
||||
OWNER_USERNAME_STORED = "owner"
|
||||
OWNER_PASSWORD = "correct-horse-battery-staple"
|
||||
OWNER_DISPLAY_NAME = "The Owner"
|
||||
|
||||
# Every failed sign-in answers with this exact text, whatever went wrong.
|
||||
INVALID_CREDENTIALS = "Incorrect username or password."
|
||||
|
||||
# Password for accounts the tests create for themselves. Long enough to clear the
|
||||
# server's ten character minimum.
|
||||
READER_PASSWORD = "reader-password-1"
|
||||
|
||||
# Websocket close codes, from spoolman.auth.dependencies.
|
||||
WS_UNAUTHENTICATED = 4401
|
||||
WS_FORBIDDEN = 4403
|
||||
|
||||
|
||||
class DbType(StrEnum):
|
||||
"""Enum for database types."""
|
||||
|
||||
SQLITE = "sqlite"
|
||||
POSTGRES = "postgres"
|
||||
MYSQL = "mysql"
|
||||
COCKROACHDB = "cockroachdb"
|
||||
|
||||
|
||||
def get_db_type() -> DbType:
|
||||
"""Return the database type from environment variables."""
|
||||
env_db_type = os.environ.get("DB_TYPE")
|
||||
if env_db_type is None:
|
||||
raise RuntimeError("DB_TYPE environment variable not set")
|
||||
try:
|
||||
db_type = DbType(env_db_type)
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"Unknown database type: {env_db_type}") from e
|
||||
return db_type
|
||||
|
||||
|
||||
def pytest_sessionstart(session): # noqa: ARG001, ANN001
|
||||
"""Wait for the server to start up."""
|
||||
get_db_type()
|
||||
start_time = time.time()
|
||||
while True:
|
||||
try:
|
||||
print("pytest: Waiting for spoolman to be available...") # noqa: T201
|
||||
response = httpx.get(URL, timeout=1)
|
||||
response.raise_for_status()
|
||||
print("pytest: Spoolman now seems to be up!") # noqa: T201
|
||||
except httpx.HTTPError:
|
||||
if time.time() - start_time > TIMEOUT:
|
||||
raise
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
def new_client() -> httpx.Client:
|
||||
"""Return a client with no credentials, rooted at the v1 API."""
|
||||
return httpx.Client(base_url=API_URL, timeout=TIMEOUT)
|
||||
|
||||
|
||||
def attach_csrf(client: httpx.Client) -> str:
|
||||
"""Copy the readable CSRF cookie into the header the server checks.
|
||||
|
||||
This is what a browser client does by hand; the session cookie rides along
|
||||
automatically, so without this step every non-GET would be rejected as CSRF.
|
||||
|
||||
Args:
|
||||
client: The signed-in client to modify.
|
||||
|
||||
Returns:
|
||||
str: The CSRF token that was attached.
|
||||
|
||||
"""
|
||||
csrf = client.cookies.get(CSRF_COOKIE)
|
||||
if not csrf:
|
||||
pytest.fail("No CSRF cookie was set; the client is not signed in.")
|
||||
client.headers[CSRF_HEADER] = csrf
|
||||
return csrf
|
||||
|
||||
|
||||
def login(
|
||||
*,
|
||||
username: str = OWNER_USERNAME,
|
||||
password: str = OWNER_PASSWORD,
|
||||
remember: bool = False,
|
||||
) -> tuple[httpx.Client, httpx.Response]:
|
||||
"""Sign in and return a client carrying the resulting session.
|
||||
|
||||
Args:
|
||||
username: The username to present.
|
||||
password: The password to present.
|
||||
remember: Whether to ask for a long-lived session.
|
||||
|
||||
Returns:
|
||||
tuple: The client, and the raw login response.
|
||||
|
||||
"""
|
||||
client = new_client()
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
json={"username": username, "password": password, "remember": remember},
|
||||
)
|
||||
if response.is_success:
|
||||
attach_csrf(client)
|
||||
return client, response
|
||||
|
||||
|
||||
@contextmanager
|
||||
def signed_in(
|
||||
*,
|
||||
username: str = OWNER_USERNAME,
|
||||
password: str = OWNER_PASSWORD,
|
||||
remember: bool = False,
|
||||
) -> Iterator[tuple[httpx.Client, httpx.Response]]:
|
||||
"""Sign in for the duration of a block, closing the client afterwards.
|
||||
|
||||
A plain ``with client:`` will not do here: httpx marks a client as opened on its
|
||||
first request, and ``login`` has already made one, so entering it again raises.
|
||||
|
||||
Args:
|
||||
username: The username to present.
|
||||
password: The password to present.
|
||||
remember: Whether to ask for a long-lived session.
|
||||
|
||||
Yields:
|
||||
tuple: The client, and the raw login response.
|
||||
|
||||
"""
|
||||
client, response = login(username=username, password=password, remember=remember)
|
||||
try:
|
||||
yield client, response
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def assert_code(response: httpx.Response, code: int) -> None:
|
||||
"""Assert that a response has the expected status code."""
|
||||
if response.status_code != code:
|
||||
pytest.fail(f"Expected {code}, got {response.status_code}: {response.text}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def setup_response() -> httpx.Response:
|
||||
"""Claim the instance, once per run.
|
||||
|
||||
Owning the only call to POST /auth/setup is what lets test_setup make assertions
|
||||
about the very first claim while every other file is free to assume an owner exists.
|
||||
"""
|
||||
return httpx.post(
|
||||
f"{API_URL}/auth/setup",
|
||||
json={
|
||||
"username": OWNER_USERNAME,
|
||||
"password": OWNER_PASSWORD,
|
||||
"display_name": OWNER_DISPLAY_NAME,
|
||||
},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def owner(setup_response: httpx.Response) -> httpx.Client:
|
||||
"""Return a client signed in as the owner, at the manage level.
|
||||
|
||||
Signs in afresh rather than reusing the setup response's cookies, so the session this
|
||||
fixture holds is independent of anything test_setup does. It also survives an account
|
||||
lockout, since a lockout only blocks new sign-ins and never invalidates a live
|
||||
session -- which is what lets test_login lock the account mid-run.
|
||||
"""
|
||||
assert_code(setup_response, 201)
|
||||
client, response = login()
|
||||
assert_code(response, 200)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anonymous() -> httpx.Client:
|
||||
"""Return a client with no credentials at all."""
|
||||
with new_client() as client:
|
||||
yield client
|
||||
|
||||
|
||||
def create_user(
|
||||
admin: httpx.Client,
|
||||
username: str,
|
||||
*,
|
||||
level: str = "read",
|
||||
is_admin: bool = False,
|
||||
password: str | None = None,
|
||||
must_change_password: bool = False,
|
||||
) -> tuple[int, str]:
|
||||
"""Create an account and return its ID and password.
|
||||
|
||||
Phase 2's answer to what tests_auth could not do before: make a second account
|
||||
without a command line. Every caller picks a username of its own, because the suite
|
||||
shares one server and one database for the whole run.
|
||||
|
||||
Args:
|
||||
admin: A client signed in as an administrator.
|
||||
username: The username to create.
|
||||
level: The permission level to grant.
|
||||
is_admin: Whether the account administers other users.
|
||||
password: The password to set, or None to have one generated.
|
||||
must_change_password: Whether to force a change at first sign-in.
|
||||
|
||||
Returns:
|
||||
tuple: The new user's ID, and the password it can sign in with.
|
||||
|
||||
"""
|
||||
response = admin.post(
|
||||
"/auth/user",
|
||||
json={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"level": level,
|
||||
"is_admin": is_admin,
|
||||
"must_change_password": must_change_password,
|
||||
},
|
||||
)
|
||||
assert_code(response, 201)
|
||||
body = response.json()
|
||||
# The server returns a generated password exactly once, here. If one was supplied it
|
||||
# is deliberately absent from the response, so fall back to what the caller sent.
|
||||
return body["user"]["id"], body.get("password") or (password or "")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reader(owner: httpx.Client) -> Iterator[httpx.Client]:
|
||||
"""Return a client signed in as a read-level, non-administrator user.
|
||||
|
||||
This is what test_levels.py's docstring said phase 1 could not build. It exists for
|
||||
exactly one purpose: proving that a low-level *user* is refused, which the anonymous
|
||||
reader could only approximate.
|
||||
"""
|
||||
username = f"reader-{int(time.time() * 1000)}"
|
||||
create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
client, response = login(username=username, password=READER_PASSWORD)
|
||||
assert_code(response, 200)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Integration tests: the auth_anonymous_read setting.
|
||||
|
||||
An operator who wants a read-only public dashboard turns this on. It grants an
|
||||
unauthenticated caller a principal at the read level -- nothing more -- so it must open
|
||||
exactly the read routes and no others.
|
||||
|
||||
The setting is a database row rather than an environment variable, which means it can be
|
||||
flipped at runtime by anyone at manage level. That makes 'turning it off again really
|
||||
does close the door' a property worth asserting, not assuming.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from .conftest import assert_code
|
||||
|
||||
SETTING = "/setting/auth_anonymous_read"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anonymous_read_on(owner: httpx.Client):
|
||||
"""Turn the setting on for one test, and off again afterwards.
|
||||
|
||||
The teardown is not optional: leaving it on would silently defeat every 401
|
||||
assertion in test_unauthenticated.py, which collects after this file.
|
||||
"""
|
||||
assert_code(owner.post(SETTING, json="true"), 200)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
assert_code(owner.post(SETTING, json="false"), 200)
|
||||
|
||||
|
||||
def test_setting_defaults_to_off(owner: httpx.Client, anonymous: httpx.Client) -> None:
|
||||
"""With the setting off an unauthenticated read is refused."""
|
||||
result = owner.get(SETTING)
|
||||
assert_code(result, 200)
|
||||
assert result.json()["value"] in {"false", "null"}
|
||||
|
||||
assert_code(anonymous.get("/spool"), 401)
|
||||
assert_code(anonymous.get("/filament"), 401)
|
||||
|
||||
|
||||
def test_anonymous_read_opens_read_routes(anonymous_read_on: None, anonymous: httpx.Client) -> None: # noqa: ARG001
|
||||
"""With the setting on, read routes answer without any credential."""
|
||||
assert_code(anonymous.get("/spool"), 200)
|
||||
assert_code(anonymous.get("/filament"), 200)
|
||||
assert_code(anonymous.get("/vendor"), 200)
|
||||
assert_code(anonymous.get("/setting/"), 200)
|
||||
assert_code(anonymous.get("/info"), 200)
|
||||
|
||||
|
||||
def test_anonymous_read_is_advertised_in_config(anonymous_read_on: None, anonymous: httpx.Client) -> None: # noqa: ARG001
|
||||
"""A client can discover the mode before deciding whether to prompt for a sign-in."""
|
||||
result = anonymous.get("/auth/config")
|
||||
assert_code(result, 200)
|
||||
assert result.json()["anonymous_read"] is True
|
||||
|
||||
|
||||
def test_anonymous_session_reports_anonymous(anonymous_read_on: None, anonymous: httpx.Client) -> None: # noqa: ARG001
|
||||
"""The caller is anonymous, not authenticated, and holds read level only."""
|
||||
result = anonymous.get("/auth/session")
|
||||
assert_code(result, 200)
|
||||
info = result.json()
|
||||
assert info["authenticated"] is False
|
||||
assert info["anonymous"] is True
|
||||
assert info["level"] == "read"
|
||||
assert info["is_admin"] is False
|
||||
assert info["is_owner"] is False
|
||||
|
||||
|
||||
def test_anonymous_read_does_not_open_writes(anonymous_read_on: None, anonymous: httpx.Client) -> None: # noqa: ARG001
|
||||
"""Nothing above read opens up.
|
||||
|
||||
The status is 403 rather than 401: a principal was established, it simply does not
|
||||
cover the level. See the note on the export test below -- the same reasoning applies
|
||||
here, and the same caveat about what a client can do with the answer.
|
||||
"""
|
||||
assert_code(anonymous.post("/spool", json={"filament_id": 1}), 403)
|
||||
assert_code(anonymous.post("/vendor", json={"name": "nope"}), 403)
|
||||
assert_code(anonymous.post("/filament", json={"density": 1.25, "diameter": 1.75}), 403)
|
||||
assert_code(anonymous.patch("/spool/1", json={"comment": "nope"}), 403)
|
||||
assert_code(anonymous.put("/spool/1/use", json={"use_weight": 1}), 403)
|
||||
assert_code(anonymous.delete("/spool/1"), 403)
|
||||
assert_code(anonymous.post("/backup"), 403)
|
||||
assert_code(anonymous.post("/setting/currency", json='"SEK"'), 403)
|
||||
|
||||
|
||||
def test_anonymous_read_does_not_open_the_exports(
|
||||
anonymous_read_on: None, # noqa: ARG001
|
||||
anonymous: httpx.Client,
|
||||
) -> None:
|
||||
"""The bulk exports stay shut, which is the whole reason they sit at manage level.
|
||||
|
||||
'Read-only public dashboard' must not mean 'download the entire database', so this is
|
||||
the assertion that gives the manage level on /export/* its purpose.
|
||||
|
||||
The answer is 403. AUTHENTICATION_PLAN.md describes these as endpoints that expose
|
||||
the database in bulk and therefore refuse the anonymous reader outright, which
|
||||
``require_level(allow_anonymous=False)`` exists to express -- but no route passes
|
||||
that argument today, so the refusal comes from the level check instead. The door is
|
||||
shut either way; only the status code differs. If the intent is later wired up, this
|
||||
expectation becomes 401.
|
||||
"""
|
||||
assert_code(anonymous.get("/export/spools", params={"fmt": "json"}), 403)
|
||||
assert_code(anonymous.get("/export/filaments", params={"fmt": "json"}), 403)
|
||||
assert_code(anonymous.get("/export/vendors", params={"fmt": "json"}), 403)
|
||||
|
||||
|
||||
def test_turning_the_setting_off_closes_the_door(owner: httpx.Client, anonymous: httpx.Client) -> None:
|
||||
"""The grant is revoked immediately, without a restart or a cache to wait out."""
|
||||
assert_code(owner.post(SETTING, json="true"), 200)
|
||||
assert_code(anonymous.get("/spool"), 200)
|
||||
|
||||
assert_code(owner.post(SETTING, json="false"), 200)
|
||||
assert_code(anonymous.get("/spool"), 401)
|
||||
|
||||
|
||||
def test_owner_still_works_while_anonymous_read_is_on(
|
||||
anonymous_read_on: None, # noqa: ARG001
|
||||
owner: httpx.Client,
|
||||
) -> None:
|
||||
"""A real session is not downgraded to the anonymous principal."""
|
||||
result = owner.get("/auth/session")
|
||||
assert_code(result, 200)
|
||||
info = result.json()
|
||||
assert info["anonymous"] is False
|
||||
assert info["level"] == "manage"
|
||||
assert_code(owner.get("/export/spools", params={"fmt": "json"}), 200)
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Integration tests: API keys.
|
||||
|
||||
The properties worth defending, in order of how badly they fail if broken:
|
||||
|
||||
* A key never outranks the account it belongs to, checked on every request rather than
|
||||
frozen at creation. :func:`test_key_is_capped_by_owner_level` demotes a user and shows
|
||||
the key it already issued weakening in the same breath.
|
||||
* A key cannot manage keys, so a leak cannot be made permanent by minting a replacement.
|
||||
* A key carries no administrative rights, whoever owns it.
|
||||
* A key skips CSRF, because it is not an ambient credential -- and a session does not,
|
||||
which is the half that proves the skip is targeted rather than a hole.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from .conftest import (
|
||||
CSRF_HEADER,
|
||||
READER_PASSWORD,
|
||||
assert_code,
|
||||
create_user,
|
||||
login,
|
||||
new_client,
|
||||
)
|
||||
|
||||
KEY_HEADER = "X-API-Key"
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
"""Build a name no other test in this run will collide with."""
|
||||
return f"{prefix}-{int(time.time() * 1000)}"
|
||||
|
||||
|
||||
def _keyed_client(key: str) -> httpx.Client:
|
||||
"""Return a client that authenticates with an API key and nothing else."""
|
||||
client = new_client()
|
||||
client.headers[KEY_HEADER] = key
|
||||
return client
|
||||
|
||||
|
||||
def test_create_returns_the_secret_once(owner: httpx.Client) -> None:
|
||||
"""The plaintext key is in the creation response and in no later one."""
|
||||
response = owner.post("/auth/apikey", json={"name": _unique("once"), "level": "read"})
|
||||
assert_code(response, 201)
|
||||
body = response.json()
|
||||
|
||||
key = body["key"]
|
||||
assert key.startswith("spoolman_")
|
||||
assert body["info"]["prefix"] in key
|
||||
assert body["info"]["revoked"] is False
|
||||
assert body["info"]["expired"] is False
|
||||
|
||||
listed = owner.get("/auth/apikey")
|
||||
assert_code(listed, 200)
|
||||
entry = next(item for item in listed.json() if item["id"] == body["info"]["id"])
|
||||
# The secret must not survive anywhere in the listing, under any field name.
|
||||
assert key not in str(entry)
|
||||
|
||||
|
||||
def test_key_authenticates_by_header_and_bearer(owner: httpx.Client) -> None:
|
||||
"""Both presentation forms are accepted, because clients differ."""
|
||||
created = owner.post("/auth/apikey", json={"name": _unique("forms"), "level": "read"})
|
||||
assert_code(created, 201)
|
||||
key = created.json()["key"]
|
||||
|
||||
with _keyed_client(key) as client:
|
||||
assert_code(client.get("/spool"), 200)
|
||||
|
||||
with new_client() as client:
|
||||
client.headers["Authorization"] = f"Bearer {key}"
|
||||
assert_code(client.get("/spool"), 200)
|
||||
|
||||
|
||||
def test_unknown_key_is_rejected() -> None:
|
||||
"""A well-formed but unknown key authenticates nobody."""
|
||||
with _keyed_client("spoolman_aaaaaaaaaaaa.not-a-real-secret") as client:
|
||||
assert_code(client.get("/spool"), 401)
|
||||
|
||||
# Malformed enough that it never reaches a database lookup at all.
|
||||
with _keyed_client("not-a-key") as client:
|
||||
assert_code(client.get("/spool"), 401)
|
||||
|
||||
|
||||
def test_key_skips_csrf_but_a_session_does_not(owner: httpx.Client) -> None:
|
||||
"""The CSRF requirement applies to ambient credentials only.
|
||||
|
||||
A key travels in a header that a cross-site page cannot set, so there is nothing for
|
||||
a second factor to defend against. A session cookie is attached by the browser
|
||||
without being asked, so it needs one. Both halves are asserted here: dropping the
|
||||
header must break the session and must not break the key.
|
||||
"""
|
||||
created = owner.post("/auth/apikey", json={"name": _unique("csrf"), "level": "edit"})
|
||||
assert_code(created, 201)
|
||||
key = created.json()["key"]
|
||||
|
||||
vendor = owner.post("/vendor", json={"name": _unique("csrf-vendor")})
|
||||
assert_code(vendor, 200)
|
||||
vendor_id = vendor.json()["id"]
|
||||
|
||||
try:
|
||||
with _keyed_client(key) as client:
|
||||
# No CSRF header anywhere on this client, and the write still lands.
|
||||
assert_code(client.patch(f"/vendor/{vendor_id}", json={"comment": "by key"}), 200)
|
||||
|
||||
stripped = owner.headers.pop(CSRF_HEADER)
|
||||
try:
|
||||
assert_code(owner.patch(f"/vendor/{vendor_id}", json={"comment": "by session"}), 403)
|
||||
finally:
|
||||
owner.headers[CSRF_HEADER] = stripped
|
||||
finally:
|
||||
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
|
||||
|
||||
|
||||
def test_key_cannot_exceed_its_level(owner: httpx.Client) -> None:
|
||||
"""A read key is refused an edit route and a manage route."""
|
||||
created = owner.post("/auth/apikey", json={"name": _unique("readonly"), "level": "read"})
|
||||
assert_code(created, 201)
|
||||
key = created.json()["key"]
|
||||
|
||||
with _keyed_client(key) as client:
|
||||
assert_code(client.get("/spool"), 200)
|
||||
assert_code(client.post("/vendor", json={"name": "should not exist"}), 403)
|
||||
|
||||
|
||||
def test_key_cannot_manage_keys(owner: httpx.Client) -> None:
|
||||
"""A key cannot mint, list or revoke keys, however senior its owner.
|
||||
|
||||
Without this a leaked key is unrevocable in practice: whoever holds it issues
|
||||
themselves a fresh one before the original is withdrawn.
|
||||
"""
|
||||
created = owner.post("/auth/apikey", json={"name": _unique("nomint"), "level": "manage"})
|
||||
assert_code(created, 201)
|
||||
key = created.json()["key"]
|
||||
key_id = created.json()["info"]["id"]
|
||||
|
||||
with _keyed_client(key) as client:
|
||||
assert_code(client.get("/auth/apikey"), 403)
|
||||
assert_code(client.post("/auth/apikey", json={"name": "child", "level": "read"}), 403)
|
||||
assert_code(client.post(f"/auth/apikey/{key_id}/revoke"), 403)
|
||||
assert_code(client.delete(f"/auth/apikey/{key_id}"), 403)
|
||||
|
||||
|
||||
def test_key_carries_no_admin_rights(owner: httpx.Client) -> None:
|
||||
"""A manage-level key held by the owner still cannot administer users."""
|
||||
created = owner.post("/auth/apikey", json={"name": _unique("noadmin"), "level": "manage"})
|
||||
assert_code(created, 201)
|
||||
key = created.json()["key"]
|
||||
|
||||
with _keyed_client(key) as client:
|
||||
assert_code(client.get("/auth/user"), 403)
|
||||
assert_code(client.get("/auth/audit"), 403)
|
||||
# Nor can it change the password of the account it belongs to.
|
||||
assert_code(
|
||||
client.post("/auth/password", json={"current_password": "x" * 10, "new_password": "y" * 12}),
|
||||
403,
|
||||
)
|
||||
|
||||
|
||||
def test_cannot_issue_above_own_level(owner: httpx.Client) -> None:
|
||||
"""A read-level user is refused when asking for an edit key.
|
||||
|
||||
Refused rather than silently capped: a key quietly issued weaker than asked for is a
|
||||
support ticket six months later, when a script starts failing for no visible reason.
|
||||
"""
|
||||
username = _unique("issuer")
|
||||
create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
client, response = login(username=username, password=READER_PASSWORD)
|
||||
assert_code(response, 200)
|
||||
try:
|
||||
refused = client.post("/auth/apikey", json={"name": _unique("too-strong"), "level": "manage"})
|
||||
assert_code(refused, 403)
|
||||
assert "above your own" in refused.json()["message"]
|
||||
|
||||
allowed = client.post("/auth/apikey", json={"name": _unique("just-right"), "level": "read"})
|
||||
assert_code(allowed, 201)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_key_is_capped_by_owner_level(owner: httpx.Client) -> None:
|
||||
"""Demoting a user weakens the keys they already hold, immediately.
|
||||
|
||||
This is the property that makes a key safe to hand out: it is not a frozen grant,
|
||||
it is a pointer at an account whose rights are re-read on every request.
|
||||
"""
|
||||
username = _unique("demoted")
|
||||
user_id, password = create_user(owner, username, level="manage", password=READER_PASSWORD)
|
||||
|
||||
client, response = login(username=username, password=password)
|
||||
assert_code(response, 200)
|
||||
try:
|
||||
created = client.post("/auth/apikey", json={"name": _unique("capped"), "level": "manage"})
|
||||
assert_code(created, 201)
|
||||
key = created.json()["key"]
|
||||
key_id = created.json()["info"]["id"]
|
||||
assert created.json()["info"]["effective_level"] == "manage"
|
||||
|
||||
with _keyed_client(key) as keyed:
|
||||
vendor = keyed.post("/vendor", json={"name": _unique("capped-vendor")})
|
||||
assert_code(vendor, 200)
|
||||
vendor_id = vendor.json()["id"]
|
||||
|
||||
assert_code(owner.patch(f"/auth/user/{user_id}", json={"level": "read"}), 200)
|
||||
|
||||
with _keyed_client(key) as keyed:
|
||||
# Same key, same request, different answer -- the cap is re-applied per call.
|
||||
assert_code(keyed.get("/spool"), 200)
|
||||
assert_code(keyed.post("/vendor", json={"name": "should not exist"}), 403)
|
||||
|
||||
listed = client.get("/auth/apikey")
|
||||
assert_code(listed, 200)
|
||||
entry = next(item for item in listed.json() if item["id"] == key_id)
|
||||
assert entry["level"] == "manage", "the issued level is a historical fact and does not change"
|
||||
assert entry["effective_level"] == "read", "what it can actually do follows the owner"
|
||||
|
||||
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_revoke_stops_the_key_but_keeps_the_record(owner: httpx.Client) -> None:
|
||||
"""A revoked key stops authenticating and stays visible as revoked."""
|
||||
created = owner.post("/auth/apikey", json={"name": _unique("revoked"), "level": "read"})
|
||||
assert_code(created, 201)
|
||||
key = created.json()["key"]
|
||||
key_id = created.json()["info"]["id"]
|
||||
|
||||
with _keyed_client(key) as client:
|
||||
assert_code(client.get("/spool"), 200)
|
||||
|
||||
revoked = owner.post(f"/auth/apikey/{key_id}/revoke")
|
||||
assert_code(revoked, 200)
|
||||
assert revoked.json()["revoked"] is True
|
||||
|
||||
with _keyed_client(key) as client:
|
||||
assert_code(client.get("/spool"), 401)
|
||||
|
||||
listed = owner.get("/auth/apikey")
|
||||
assert_code(listed, 200)
|
||||
assert any(item["id"] == key_id and item["revoked"] for item in listed.json())
|
||||
|
||||
|
||||
def test_delete_removes_the_key(owner: httpx.Client) -> None:
|
||||
"""A deleted key stops working and leaves the listing."""
|
||||
created = owner.post("/auth/apikey", json={"name": _unique("deleted"), "level": "read"})
|
||||
assert_code(created, 201)
|
||||
key = created.json()["key"]
|
||||
key_id = created.json()["info"]["id"]
|
||||
|
||||
assert_code(owner.delete(f"/auth/apikey/{key_id}"), 200)
|
||||
|
||||
with _keyed_client(key) as client:
|
||||
assert_code(client.get("/spool"), 401)
|
||||
|
||||
listed = owner.get("/auth/apikey")
|
||||
assert_code(listed, 200)
|
||||
assert all(item["id"] != key_id for item in listed.json())
|
||||
|
||||
|
||||
def test_keys_are_private_to_their_owner(owner: httpx.Client, reader: httpx.Client) -> None:
|
||||
"""One user cannot see or touch another's keys.
|
||||
|
||||
The refusal is 404 rather than 403 on purpose: answering "forbidden" would confirm
|
||||
that a key with this ID exists and belongs to somebody else.
|
||||
"""
|
||||
created = owner.post("/auth/apikey", json={"name": _unique("private"), "level": "read"})
|
||||
assert_code(created, 201)
|
||||
key_id = created.json()["info"]["id"]
|
||||
|
||||
listed = reader.get("/auth/apikey")
|
||||
assert_code(listed, 200)
|
||||
assert all(item["id"] != key_id for item in listed.json())
|
||||
|
||||
assert_code(reader.post(f"/auth/apikey/{key_id}/revoke"), 404)
|
||||
assert_code(reader.delete(f"/auth/apikey/{key_id}"), 404)
|
||||
|
||||
|
||||
def test_key_belonging_to_a_disabled_user_stops_working(owner: httpx.Client) -> None:
|
||||
"""Disabling an account disables its machine credentials too."""
|
||||
username = _unique("disabled")
|
||||
user_id, password = create_user(owner, username, level="edit", password=READER_PASSWORD)
|
||||
|
||||
client, response = login(username=username, password=password)
|
||||
assert_code(response, 200)
|
||||
try:
|
||||
created = client.post("/auth/apikey", json={"name": _unique("orphan"), "level": "read"})
|
||||
assert_code(created, 201)
|
||||
key = created.json()["key"]
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
with _keyed_client(key) as keyed:
|
||||
assert_code(keyed.get("/spool"), 200)
|
||||
|
||||
assert_code(owner.patch(f"/auth/user/{user_id}", json={"is_active": False}), 200)
|
||||
|
||||
with _keyed_client(key) as keyed:
|
||||
assert_code(keyed.get("/spool"), 401)
|
||||
|
||||
|
||||
def test_anonymous_cannot_reach_key_endpoints(anonymous: httpx.Client) -> None:
|
||||
"""Without a session there is no account to own a key."""
|
||||
assert_code(anonymous.get("/auth/apikey"), 401)
|
||||
assert_code(anonymous.post("/auth/apikey", json={"name": "nope", "level": "read"}), 401)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Integration tests: the audit log.
|
||||
|
||||
The log's value rests on three things, and each is asserted below.
|
||||
|
||||
It records what happened even when the request failed -- a sign-in attempt against a
|
||||
real account is exactly the event an administrator wants to see, and it is the one the
|
||||
caller was told nothing about.
|
||||
|
||||
It cannot be edited through the API. There is no write endpoint and no delete endpoint,
|
||||
so a log its subjects could tamper with never exists in the first place.
|
||||
|
||||
It outlives its subjects. Deleting an account detaches its entries rather than removing
|
||||
them, so the record of what that account did survives the account.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from .conftest import (
|
||||
READER_PASSWORD,
|
||||
assert_code,
|
||||
create_user,
|
||||
login,
|
||||
)
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
"""Build a name no other test in this run will collide with."""
|
||||
return f"{prefix}-{int(time.time() * 1000)}"
|
||||
|
||||
|
||||
def _entries(owner: httpx.Client, **params: object) -> list[dict]:
|
||||
"""Read the audit log."""
|
||||
response = owner.get("/auth/audit", params=params)
|
||||
assert_code(response, 200)
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_successful_sign_in_is_recorded(owner: httpx.Client) -> None:
|
||||
"""Signing in leaves an attributable entry."""
|
||||
username = _unique("audited")
|
||||
create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
client, response = login(username=username, password=READER_PASSWORD)
|
||||
assert_code(response, 200)
|
||||
client.close()
|
||||
|
||||
entries = _entries(owner, event="login.success", limit=100)
|
||||
assert any(entry["target"] == username.lower() for entry in entries)
|
||||
|
||||
|
||||
def test_failed_sign_in_is_recorded_with_a_reason(owner: httpx.Client) -> None:
|
||||
"""The log distinguishes what the response deliberately does not.
|
||||
|
||||
The caller is told only "incorrect username or password", because saying more would
|
||||
let anyone enumerate accounts. The administrator reading the log already knows which
|
||||
accounts exist, so the entry says which case it was.
|
||||
"""
|
||||
username = _unique("badpass")
|
||||
create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
|
||||
_, response = login(username=username, password="definitely-not-the-password")
|
||||
assert_code(response, 401)
|
||||
|
||||
entries = _entries(owner, event="login.failure", limit=100)
|
||||
match = next((entry for entry in entries if entry["target"] == username.lower()), None)
|
||||
assert match is not None, "the failed attempt should have been recorded"
|
||||
assert match["detail"]["reason"] == "bad_password"
|
||||
assert match["actor_username"] == username.lower()
|
||||
|
||||
|
||||
def test_user_and_key_changes_are_recorded(owner: httpx.Client) -> None:
|
||||
"""Creating an account and issuing a key both leave entries."""
|
||||
username = _unique("recorded")
|
||||
user_id, _ = create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
|
||||
key_name = _unique("recorded-key")
|
||||
created = owner.post("/auth/apikey", json={"name": key_name, "level": "read"})
|
||||
assert_code(created, 201)
|
||||
|
||||
assert_code(owner.patch(f"/auth/user/{user_id}", json={"level": "edit"}), 200)
|
||||
|
||||
entries = _entries(owner, limit=200)
|
||||
events = {(entry["event"], entry["target"]) for entry in entries}
|
||||
assert ("user.created", username.lower()) in events
|
||||
assert ("user.updated", username.lower()) in events
|
||||
assert ("apikey.created", key_name) in events
|
||||
|
||||
# The key's prefix is recorded, so a key seen in a log can be traced to its creation.
|
||||
key_entry = next(entry for entry in entries if entry["event"] == "apikey.created" and entry["target"] == key_name)
|
||||
assert key_entry["detail"]["prefix"] == created.json()["info"]["prefix"]
|
||||
assert key_entry["actor_kind"] == "user"
|
||||
|
||||
|
||||
def test_entries_survive_the_account_that_made_them(owner: httpx.Client) -> None:
|
||||
"""Deleting a user detaches its entries instead of erasing them.
|
||||
|
||||
A log that loses its record of what an account did the moment the account is removed
|
||||
is not evidence of anything. The username survives in ``target``.
|
||||
"""
|
||||
username = _unique("ghost")
|
||||
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
|
||||
client, response = login(username=username, password=password)
|
||||
assert_code(response, 200)
|
||||
client.close()
|
||||
|
||||
assert_code(owner.delete(f"/auth/user/{user_id}"), 200)
|
||||
|
||||
entries = _entries(owner, event="login.success", limit=200)
|
||||
match = next((entry for entry in entries if entry["target"] == username.lower()), None)
|
||||
assert match is not None, "the sign-in should still be recorded"
|
||||
assert match.get("actor_user_id") is None, "the foreign key should have been cleared"
|
||||
assert match.get("actor_username") is None
|
||||
|
||||
|
||||
def test_filtering_and_pagination(owner: httpx.Client) -> None:
|
||||
"""The event filter narrows the list, and the total counts what matched."""
|
||||
unfiltered = owner.get("/auth/audit", params={"limit": 1})
|
||||
assert_code(unfiltered, 200)
|
||||
assert len(unfiltered.json()) <= 1
|
||||
total = int(unfiltered.headers["x-total-count"])
|
||||
assert total > 0
|
||||
|
||||
filtered = owner.get("/auth/audit", params={"event": "login.success", "limit": 100})
|
||||
assert_code(filtered, 200)
|
||||
assert all(entry["event"] == "login.success" for entry in filtered.json())
|
||||
assert int(filtered.headers["x-total-count"]) <= total
|
||||
|
||||
# An event nobody has produced yields an empty page rather than an error.
|
||||
empty = owner.get("/auth/audit", params={"event": "no.such.event"})
|
||||
assert_code(empty, 200)
|
||||
assert empty.json() == []
|
||||
|
||||
|
||||
def test_newest_first(owner: httpx.Client) -> None:
|
||||
"""Entries come back with the most recent first."""
|
||||
entries = _entries(owner, limit=50)
|
||||
dates = [entry["date"] for entry in entries]
|
||||
assert dates == sorted(dates, reverse=True)
|
||||
|
||||
|
||||
def test_event_vocabulary_is_published(owner: httpx.Client) -> None:
|
||||
"""A client can learn the event names rather than hardcoding them."""
|
||||
response = owner.get("/auth/audit/events")
|
||||
assert_code(response, 200)
|
||||
events = response.json()
|
||||
for expected in ("login.success", "login.failure", "user.created", "apikey.created"):
|
||||
assert expected in events
|
||||
|
||||
|
||||
def test_the_log_is_read_only(owner: httpx.Client) -> None:
|
||||
"""There is no way to add or remove an entry through the API."""
|
||||
assert owner.post("/auth/audit", json={"event": "login.success"}).status_code == 405
|
||||
assert owner.delete("/auth/audit").status_code == 405
|
||||
|
||||
|
||||
def test_non_admin_cannot_read_the_log(reader: httpx.Client) -> None:
|
||||
"""The log names accounts and addresses, so reading it needs admin."""
|
||||
assert_code(reader.get("/auth/audit"), 403)
|
||||
assert_code(reader.get("/auth/audit/events"), 403)
|
||||
|
||||
|
||||
def test_anonymous_cannot_read_the_log(anonymous: httpx.Client) -> None:
|
||||
"""Without a credential the log answers 401."""
|
||||
assert_code(anonymous.get("/auth/audit"), 401)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Integration tests: cross-site request forgery protection.
|
||||
|
||||
The session cookie is ambient -- the browser attaches it to any same-origin request,
|
||||
including ones a hostile page caused. The defence is a double submit: a second, readable
|
||||
cookie whose value must be echoed in a header. A cross-site page can make the browser
|
||||
send the cookies but cannot read them, so it cannot construct the header.
|
||||
|
||||
The rejection is 403 and not 401 on purpose. The credential is valid; answering 401
|
||||
would make a client treat a stale tab as a sign-out.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from .conftest import (
|
||||
CSRF_COOKIE,
|
||||
CSRF_HEADER,
|
||||
assert_code,
|
||||
signed_in,
|
||||
)
|
||||
|
||||
|
||||
def test_post_without_csrf_header_is_forbidden(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""A cookie-authenticated write with no CSRF header is refused."""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
# signed_in attaches the header; drop it to model a request the client did not make.
|
||||
del client.headers[CSRF_HEADER]
|
||||
|
||||
result = client.post("/vendor", json={"name": "CSRF test vendor"})
|
||||
assert_code(result, 403)
|
||||
assert "csrf" in result.json()["message"].lower()
|
||||
|
||||
|
||||
def test_post_with_mismatched_csrf_header_is_forbidden(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""The header has to match the cookie, not merely be present."""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
client.headers[CSRF_HEADER] = "not-the-token-from-the-cookie"
|
||||
|
||||
result = client.post("/vendor", json={"name": "CSRF test vendor"})
|
||||
assert_code(result, 403)
|
||||
|
||||
|
||||
def test_post_with_csrf_header_succeeds(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""With the cookie echoed into the header the write goes through."""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
assert client.headers[CSRF_HEADER] == client.cookies.get(CSRF_COOKIE)
|
||||
|
||||
result = client.post("/vendor", json={"name": "CSRF test vendor"})
|
||||
assert_code(result, 200)
|
||||
vendor = result.json()
|
||||
try:
|
||||
assert vendor["name"] == "CSRF test vendor"
|
||||
finally:
|
||||
assert_code(client.delete(f"/vendor/{vendor['id']}"), 200)
|
||||
|
||||
|
||||
def test_reads_do_not_need_a_csrf_token(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""Safe methods change nothing, so they are exempt.
|
||||
|
||||
Requiring a header on GET would break every plain link and bookmark for no gain.
|
||||
"""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
del client.headers[CSRF_HEADER]
|
||||
|
||||
assert_code(client.get("/spool"), 200)
|
||||
assert_code(client.get("/vendor"), 200)
|
||||
assert_code(client.get("/setting/"), 200)
|
||||
|
||||
|
||||
def test_every_unsafe_method_is_checked(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""The check covers every state-changing method, not just POST."""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
created = client.post("/vendor", json={"name": "CSRF delete test vendor"})
|
||||
assert_code(created, 200)
|
||||
vendor_id = created.json()["id"]
|
||||
|
||||
try:
|
||||
del client.headers[CSRF_HEADER]
|
||||
assert_code(client.delete(f"/vendor/{vendor_id}"), 403)
|
||||
assert_code(client.patch(f"/vendor/{vendor_id}", json={"comment": "nope"}), 403)
|
||||
assert_code(client.put("/spool/1/use", json={"use_weight": 1}), 403)
|
||||
finally:
|
||||
client.headers[CSRF_HEADER] = client.cookies.get(CSRF_COOKIE)
|
||||
assert_code(client.delete(f"/vendor/{vendor_id}"), 200)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Integration tests: permission levels.
|
||||
|
||||
Phase 1 could only assert the ordering from above -- the owner, at manage, reaching
|
||||
routes at all three levels -- because there was no way to create a second account
|
||||
through the API, and the anonymous reader was the only low-level principal available.
|
||||
|
||||
Phase 2's POST /auth/user closes that. The `reader` fixture is a genuine read-level,
|
||||
signed-in, non-anonymous user, which is what makes the refusing half of the ordering a
|
||||
real test rather than an approximation: it distinguishes "refused for lack of level"
|
||||
from "refused for being anonymous", which the two previous tests could not tell apart.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from .conftest import (
|
||||
OWNER_USERNAME_STORED,
|
||||
READER_PASSWORD,
|
||||
assert_code,
|
||||
create_user,
|
||||
login,
|
||||
)
|
||||
|
||||
|
||||
def test_owner_is_manage_level(owner: httpx.Client) -> None:
|
||||
"""The account that claimed the instance holds every right."""
|
||||
result = owner.get("/auth/session")
|
||||
assert_code(result, 200)
|
||||
info = result.json()
|
||||
assert info["authenticated"] is True
|
||||
assert info["anonymous"] is False
|
||||
assert info["level"] == "manage"
|
||||
assert info["is_admin"] is True
|
||||
assert info["is_owner"] is True
|
||||
assert info["user"]["username"] == OWNER_USERNAME_STORED
|
||||
|
||||
|
||||
def test_owner_can_read(owner: httpx.Client) -> None:
|
||||
"""Read-level routes admit a manage-level caller."""
|
||||
for path in ("/info", "/spool", "/filament", "/vendor", "/setting/", "/material", "/location"):
|
||||
assert_code(owner.get(path), 200)
|
||||
|
||||
|
||||
def test_owner_can_edit(owner: httpx.Client) -> None:
|
||||
"""Edit-level routes: PATCH on a resource, and the use endpoint."""
|
||||
vendor = owner.post("/vendor", json={"name": "Level test vendor"})
|
||||
assert_code(vendor, 200)
|
||||
vendor_id = vendor.json()["id"]
|
||||
|
||||
filament = owner.post(
|
||||
"/filament",
|
||||
json={
|
||||
"name": "Level test filament",
|
||||
"vendor_id": vendor_id,
|
||||
"density": 1.25,
|
||||
"diameter": 1.75,
|
||||
"weight": 1000,
|
||||
},
|
||||
)
|
||||
assert_code(filament, 200)
|
||||
filament_id = filament.json()["id"]
|
||||
|
||||
spool = owner.post("/spool", json={"filament_id": filament_id, "remaining_weight": 500})
|
||||
assert_code(spool, 200)
|
||||
spool_id = spool.json()["id"]
|
||||
|
||||
try:
|
||||
assert_code(owner.patch(f"/vendor/{vendor_id}", json={"comment": "edited"}), 200)
|
||||
assert_code(owner.patch(f"/filament/{filament_id}", json={"comment": "edited"}), 200)
|
||||
assert_code(owner.patch(f"/spool/{spool_id}", json={"comment": "edited"}), 200)
|
||||
|
||||
# used_weight is the spool's running total, not the amount of this one call.
|
||||
# Creating the spool with remaining_weight=500 against a 1000 g filament already
|
||||
# counts 500 g as used, so assert the delta rather than the absolute value.
|
||||
before = owner.get(f"/spool/{spool_id}")
|
||||
assert_code(before, 200)
|
||||
used_before = before.json()["used_weight"]
|
||||
|
||||
used = owner.put(f"/spool/{spool_id}/use", json={"use_weight": 10})
|
||||
assert_code(used, 200)
|
||||
assert used.json()["used_weight"] == pytest.approx(used_before + 10)
|
||||
finally:
|
||||
assert_code(owner.delete(f"/spool/{spool_id}"), 200)
|
||||
assert_code(owner.delete(f"/filament/{filament_id}"), 200)
|
||||
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
|
||||
|
||||
|
||||
def test_owner_can_manage(owner: httpx.Client) -> None:
|
||||
"""Manage-level routes: create, delete, settings, and the bulk exports."""
|
||||
vendor = owner.post("/vendor", json={"name": "Manage test vendor"})
|
||||
assert_code(vendor, 200)
|
||||
vendor_id = vendor.json()["id"]
|
||||
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
|
||||
|
||||
# Settings are read at read level but written at manage level.
|
||||
assert_code(owner.post("/setting/currency", json='"SEK"'), 200)
|
||||
assert_code(owner.post("/setting/currency", json=""), 200)
|
||||
|
||||
# The exports hand over the whole database in one response, which is why they sit at
|
||||
# manage rather than read.
|
||||
for path in ("/export/spools", "/export/filaments", "/export/vendors"):
|
||||
assert_code(owner.get(path, params={"fmt": "json"}), 200)
|
||||
|
||||
|
||||
def test_owner_can_change_own_password_endpoint_level(owner: httpx.Client) -> None:
|
||||
"""POST /auth/password needs a real user, and the owner is one.
|
||||
|
||||
Sending a wrong current password proves the gate was passed: the 400 comes from the
|
||||
endpoint's own check, not from a permission error.
|
||||
"""
|
||||
result = owner.post(
|
||||
"/auth/password",
|
||||
json={"current_password": "not-the-current-password", "new_password": "a-brand-new-password"},
|
||||
)
|
||||
assert_code(result, 400)
|
||||
|
||||
|
||||
def test_read_user_is_refused_edit_and_manage(owner: httpx.Client, reader: httpx.Client) -> None:
|
||||
"""A signed-in read-level user reaches read routes and nothing above them.
|
||||
|
||||
Distinct from the anonymous-reader tests: this principal is authenticated, so a
|
||||
refusal here can only be about its level. An anonymous caller failing the same
|
||||
request proves less, because ``allow_anonymous`` could have been what stopped it.
|
||||
"""
|
||||
session = reader.get("/auth/session")
|
||||
assert_code(session, 200)
|
||||
assert session.json()["authenticated"] is True
|
||||
assert session.json()["anonymous"] is False
|
||||
assert session.json()["level"] == "read"
|
||||
|
||||
for path in ("/info", "/spool", "/filament", "/vendor", "/setting/"):
|
||||
assert_code(reader.get(path), 200)
|
||||
|
||||
vendor = owner.post("/vendor", json={"name": "Read-level test vendor"})
|
||||
assert_code(vendor, 200)
|
||||
vendor_id = vendor.json()["id"]
|
||||
try:
|
||||
# edit
|
||||
assert_code(reader.patch(f"/vendor/{vendor_id}", json={"comment": "nope"}), 403)
|
||||
# manage
|
||||
assert_code(reader.post("/vendor", json={"name": "should not exist"}), 403)
|
||||
assert_code(reader.delete(f"/vendor/{vendor_id}"), 403)
|
||||
assert_code(reader.post("/setting/currency", json='"USD"'), 403)
|
||||
# The bulk exports sit at manage rather than read on purpose.
|
||||
assert_code(reader.get("/export/spools", params={"fmt": "json"}), 403)
|
||||
finally:
|
||||
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
|
||||
|
||||
|
||||
def test_edit_user_can_edit_but_not_create_or_delete(owner: httpx.Client) -> None:
|
||||
"""The middle of the ordering, which nothing could exercise before.
|
||||
|
||||
PUT /spool/{id}/use sits at edit rather than manage deliberately: it is Moonraker's
|
||||
and OctoPrint's hot path, so a printer can be given a credential that tracks filament
|
||||
usage without being able to delete anything. That distinction is only testable with a
|
||||
real edit-level account.
|
||||
"""
|
||||
username = f"editor-{int(time.time() * 1000)}"
|
||||
create_user(owner, username, level="edit", password=READER_PASSWORD)
|
||||
client, response = login(username=username, password=READER_PASSWORD)
|
||||
assert_code(response, 200)
|
||||
|
||||
vendor = owner.post("/vendor", json={"name": "Edit-level test vendor"})
|
||||
assert_code(vendor, 200)
|
||||
vendor_id = vendor.json()["id"]
|
||||
filament = owner.post(
|
||||
"/filament",
|
||||
json={"name": "Edit-level filament", "vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "weight": 1000},
|
||||
)
|
||||
assert_code(filament, 200)
|
||||
filament_id = filament.json()["id"]
|
||||
spool = owner.post("/spool", json={"filament_id": filament_id, "remaining_weight": 500})
|
||||
assert_code(spool, 200)
|
||||
spool_id = spool.json()["id"]
|
||||
|
||||
try:
|
||||
assert_code(client.patch(f"/vendor/{vendor_id}", json={"comment": "edited by editor"}), 200)
|
||||
assert_code(client.put(f"/spool/{spool_id}/use", json={"use_weight": 5}), 200)
|
||||
|
||||
assert_code(client.post("/vendor", json={"name": "should not exist"}), 403)
|
||||
assert_code(client.delete(f"/spool/{spool_id}"), 403)
|
||||
finally:
|
||||
client.close()
|
||||
assert_code(owner.delete(f"/spool/{spool_id}"), 200)
|
||||
assert_code(owner.delete(f"/filament/{filament_id}"), 200)
|
||||
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Integration tests: signing in.
|
||||
|
||||
Two properties matter more than the happy path here. The first is that every failure
|
||||
looks identical, so nobody can use the endpoint to find out which usernames exist. The
|
||||
second is that repeated guessing stops working, and says so with a Retry-After the
|
||||
client can obey.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from .conftest import (
|
||||
API_URL,
|
||||
CSRF_COOKIE,
|
||||
INVALID_CREDENTIALS,
|
||||
OWNER_PASSWORD,
|
||||
OWNER_USERNAME,
|
||||
OWNER_USERNAME_STORED,
|
||||
SESSION_COOKIE,
|
||||
TIMEOUT,
|
||||
assert_code,
|
||||
signed_in,
|
||||
)
|
||||
|
||||
# spoolman.database.auth_user.LOCKOUT_THRESHOLD.
|
||||
LOCKOUT_THRESHOLD = 5
|
||||
|
||||
# The first lockout, in seconds, doubling per further failure up to 900.
|
||||
LOCKOUT_BASE_SECONDS = 60
|
||||
|
||||
# spoolman.auth.cookies.REMEMBER_LIFETIME, in seconds.
|
||||
REMEMBER_LIFETIME_SECONDS = 90 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _cookie_header(response: httpx.Response, name: str) -> str:
|
||||
"""Return the Set-Cookie header for one cookie."""
|
||||
for header in response.headers.get_list("set-cookie"):
|
||||
if header.startswith(f"{name}="):
|
||||
return header
|
||||
return pytest.fail(f"No {name} cookie was set. Headers: {response.headers.get_list('set-cookie')}")
|
||||
|
||||
|
||||
def test_login_succeeds(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""Correct credentials return the caller's standing and set both cookies."""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
info = response.json()
|
||||
assert info["authenticated"] is True
|
||||
assert info["level"] == "manage"
|
||||
assert info["user"]["username"] == OWNER_USERNAME_STORED
|
||||
|
||||
assert client.cookies.get(SESSION_COOKIE)
|
||||
assert client.cookies.get(CSRF_COOKIE)
|
||||
|
||||
# The session it handed out works.
|
||||
assert_code(client.get("/spool"), 200)
|
||||
|
||||
|
||||
def test_login_is_case_insensitive(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""Usernames are matched in their normalised, lowercased form."""
|
||||
with signed_in(username=OWNER_USERNAME.upper()) as (_, response):
|
||||
assert_code(response, 200)
|
||||
assert response.json()["user"]["username"] == OWNER_USERNAME_STORED
|
||||
|
||||
|
||||
def test_login_with_wrong_password_is_refused(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""A wrong password is a 401 and sets no cookies."""
|
||||
result = httpx.post(
|
||||
f"{API_URL}/auth/login",
|
||||
json={"username": OWNER_USERNAME, "password": "definitely-not-the-password"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert_code(result, 401)
|
||||
assert result.json()["message"] == INVALID_CREDENTIALS
|
||||
assert not result.headers.get_list("set-cookie")
|
||||
|
||||
|
||||
def test_unknown_user_gives_the_same_answer(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""An account that does not exist is indistinguishable from a wrong password.
|
||||
|
||||
Identical status and body: any difference here would turn the endpoint into a
|
||||
username oracle.
|
||||
"""
|
||||
wrong_password = httpx.post(
|
||||
f"{API_URL}/auth/login",
|
||||
json={"username": OWNER_USERNAME, "password": "definitely-not-the-password"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
unknown_user = httpx.post(
|
||||
f"{API_URL}/auth/login",
|
||||
json={"username": "no-such-person", "password": "definitely-not-the-password"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
|
||||
assert unknown_user.status_code == wrong_password.status_code == 401
|
||||
assert unknown_user.json() == wrong_password.json() == {"message": INVALID_CREDENTIALS}
|
||||
|
||||
|
||||
def test_remember_controls_cookie_lifetime(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""'Remember me' is the difference between a persistent and a session cookie.
|
||||
|
||||
Without it the cookies must carry no Max-Age, so the browser drops them when it
|
||||
closes. With it they get the 90-day lifetime the session itself is given.
|
||||
"""
|
||||
with signed_in(remember=False) as (_, plain):
|
||||
assert_code(plain, 200)
|
||||
assert "max-age" not in _cookie_header(plain, SESSION_COOKIE).lower()
|
||||
assert "max-age" not in _cookie_header(plain, CSRF_COOKIE).lower()
|
||||
|
||||
with signed_in(remember=True) as (_, remembered):
|
||||
assert_code(remembered, 200)
|
||||
assert f"max-age={REMEMBER_LIFETIME_SECONDS}" in _cookie_header(remembered, SESSION_COOKIE).lower()
|
||||
assert f"max-age={REMEMBER_LIFETIME_SECONDS}" in _cookie_header(remembered, CSRF_COOKIE).lower()
|
||||
|
||||
|
||||
def test_logout_revokes_the_session(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""After signing out the cookie is dead server-side, not merely cleared client-side."""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
token = client.cookies.get(SESSION_COOKIE)
|
||||
csrf = client.cookies.get(CSRF_COOKIE)
|
||||
assert_code(client.post("/auth/logout"), 200)
|
||||
|
||||
# Present the old token by hand: the row is gone, so it authenticates nothing.
|
||||
with httpx.Client(base_url=API_URL, timeout=TIMEOUT) as fresh:
|
||||
result = fresh.get(
|
||||
"/spool",
|
||||
headers={"Cookie": f"{SESSION_COOKIE}={token}; {CSRF_COOKIE}={csrf}"},
|
||||
)
|
||||
assert_code(result, 401)
|
||||
|
||||
|
||||
def test_repeated_failures_lock_the_account(owner: httpx.Client) -> None:
|
||||
"""Guessing stops working after five failures, with a Retry-After the client can obey.
|
||||
|
||||
The lockout is persisted on the user row, so it survives a restart, and it applies to
|
||||
new sign-ins only -- the ``owner`` fixture's existing session keeps working
|
||||
throughout, which is what makes the recovery below possible.
|
||||
|
||||
This is the only test that locks the account. It restores it before finishing by
|
||||
re-setting the same password through the still-live owner session: ``set_password``
|
||||
clears failed_logins and locked_until, which is the only way back in short of waiting
|
||||
out the minute.
|
||||
"""
|
||||
# Start from a known state: a successful sign-in zeroes the persisted failure count,
|
||||
# so the loop below is exactly five failures whatever ran before it.
|
||||
with signed_in() as (_, reset):
|
||||
assert_code(reset, 200)
|
||||
|
||||
try:
|
||||
for attempt in range(LOCKOUT_THRESHOLD):
|
||||
result = httpx.post(
|
||||
f"{API_URL}/auth/login",
|
||||
json={"username": OWNER_USERNAME, "password": f"wrong-guess-{attempt}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
# Every one of the first five is an ordinary credential failure; the lockout
|
||||
# only takes effect from the next attempt onwards.
|
||||
assert_code(result, 401)
|
||||
assert result.json()["message"] == INVALID_CREDENTIALS
|
||||
|
||||
# Even the correct password is now refused, and with a different status: a client
|
||||
# must be able to tell "wrong password, try again" from "stop trying".
|
||||
locked = httpx.post(
|
||||
f"{API_URL}/auth/login",
|
||||
json={"username": OWNER_USERNAME, "password": OWNER_PASSWORD},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert_code(locked, 429)
|
||||
retry_after = locked.headers.get("Retry-After")
|
||||
assert retry_after is not None, "A 429 without Retry-After leaves the client guessing."
|
||||
assert 0 < int(retry_after) <= LOCKOUT_BASE_SECONDS
|
||||
finally:
|
||||
# Recover, so the rest of the run can still sign in.
|
||||
recovered = owner.post(
|
||||
"/auth/password",
|
||||
json={"current_password": OWNER_PASSWORD, "new_password": OWNER_PASSWORD},
|
||||
)
|
||||
assert_code(recovered, 200)
|
||||
|
||||
with signed_in() as (_, unlocked):
|
||||
assert_code(unlocked, 200)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Integration tests: changing one's own password.
|
||||
|
||||
The endpoint takes the current password as well as the new one, so that a borrowed
|
||||
session -- an unlocked laptop, a stolen cookie -- cannot be turned into permanent
|
||||
ownership of the account.
|
||||
|
||||
Every test here restores the original password before finishing. The owner account is
|
||||
shared with the rest of the suite, and a stale password would strand every later sign-in.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from .conftest import (
|
||||
OWNER_PASSWORD,
|
||||
OWNER_USERNAME,
|
||||
assert_code,
|
||||
signed_in,
|
||||
)
|
||||
|
||||
NEW_PASSWORD = "an-entirely-different-password"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore_password(owner: httpx.Client):
|
||||
"""Put the owner's password back however the test ends."""
|
||||
yield
|
||||
# Whichever password is current, one of these two puts it back.
|
||||
for current in (NEW_PASSWORD, OWNER_PASSWORD):
|
||||
result = owner.post(
|
||||
"/auth/password",
|
||||
json={"current_password": current, "new_password": OWNER_PASSWORD},
|
||||
)
|
||||
if result.status_code == 200:
|
||||
break
|
||||
else:
|
||||
pytest.fail("Could not restore the owner password; later tests will not be able to sign in.")
|
||||
|
||||
|
||||
def test_wrong_current_password_is_refused(owner: httpx.Client) -> None:
|
||||
"""A caller who cannot produce the current password cannot replace it."""
|
||||
result = owner.post(
|
||||
"/auth/password",
|
||||
json={"current_password": "not-the-current-password", "new_password": NEW_PASSWORD},
|
||||
)
|
||||
assert_code(result, 400)
|
||||
assert "current password" in result.json()["message"].lower()
|
||||
|
||||
# The old password still works, so nothing was changed on the way to the refusal.
|
||||
with signed_in() as (_, response):
|
||||
assert_code(response, 200)
|
||||
|
||||
|
||||
def test_password_change_requires_a_session(anonymous: httpx.Client) -> None:
|
||||
"""Unlike the other auth endpoints, this one is gated."""
|
||||
result = anonymous.post(
|
||||
"/auth/password",
|
||||
json={"current_password": OWNER_PASSWORD, "new_password": NEW_PASSWORD},
|
||||
)
|
||||
assert_code(result, 401)
|
||||
|
||||
|
||||
def test_password_change_succeeds_and_takes_effect(
|
||||
owner: httpx.Client,
|
||||
restore_password: None, # noqa: ARG001
|
||||
) -> None:
|
||||
"""The new password works on the next sign-in, and the old one stops working."""
|
||||
result = owner.post(
|
||||
"/auth/password",
|
||||
json={"current_password": OWNER_PASSWORD, "new_password": NEW_PASSWORD},
|
||||
)
|
||||
assert_code(result, 200)
|
||||
|
||||
with signed_in(password=NEW_PASSWORD) as (_, with_new):
|
||||
assert_code(with_new, 200)
|
||||
assert with_new.json()["is_owner"] is True
|
||||
|
||||
with signed_in(password=OWNER_PASSWORD) as (_, with_old):
|
||||
assert_code(with_old, 401)
|
||||
|
||||
|
||||
def test_changing_the_password_keeps_the_current_session_alive(
|
||||
owner: httpx.Client,
|
||||
restore_password: None, # noqa: ARG001
|
||||
) -> None:
|
||||
"""The session that made the change is not signed out by it.
|
||||
|
||||
Phase 1 does not revoke any session on a password change. Asserting the current
|
||||
behaviour here means that if revocation is added later, this test is the thing that
|
||||
says so out loud rather than users being silently signed out.
|
||||
"""
|
||||
assert_code(
|
||||
owner.post("/auth/password", json={"current_password": OWNER_PASSWORD, "new_password": NEW_PASSWORD}),
|
||||
200,
|
||||
)
|
||||
assert_code(owner.get("/spool"), 200)
|
||||
assert_code(owner.get("/auth/session"), 200)
|
||||
|
||||
|
||||
def test_new_password_works_for_any_username_casing(
|
||||
owner: httpx.Client,
|
||||
restore_password: None, # noqa: ARG001
|
||||
) -> None:
|
||||
"""The changed password belongs to the account, whichever casing signs in with it."""
|
||||
assert_code(
|
||||
owner.post("/auth/password", json={"current_password": OWNER_PASSWORD, "new_password": NEW_PASSWORD}),
|
||||
200,
|
||||
)
|
||||
with signed_in(username=OWNER_USERNAME.upper(), password=NEW_PASSWORD) as (_, response):
|
||||
assert_code(response, 200)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Integration tests: claiming an unclaimed instance.
|
||||
|
||||
The first caller to reach POST /auth/setup becomes the owner. That is a one-shot,
|
||||
irreversible transition, so the interesting assertions are that it happens exactly once
|
||||
and that the claim really does hand out a working session.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from .conftest import (
|
||||
API_URL,
|
||||
CSRF_COOKIE,
|
||||
OWNER_DISPLAY_NAME,
|
||||
OWNER_USERNAME_STORED,
|
||||
SESSION_COOKIE,
|
||||
TIMEOUT,
|
||||
assert_code,
|
||||
)
|
||||
|
||||
|
||||
def test_setup_claims_the_instance(setup_response: httpx.Response) -> None:
|
||||
"""The first claim answers 201 with an owner-level session."""
|
||||
assert_code(setup_response, 201)
|
||||
|
||||
info = setup_response.json()
|
||||
assert info["authenticated"] is True
|
||||
assert info["anonymous"] is False
|
||||
assert info["level"] == "manage"
|
||||
assert info["is_admin"] is True
|
||||
assert info["is_owner"] is True
|
||||
|
||||
|
||||
def test_setup_lowercases_the_username(setup_response: httpx.Response) -> None:
|
||||
"""The username is stored lowercased regardless of how it was typed."""
|
||||
assert_code(setup_response, 201)
|
||||
user = setup_response.json()["user"]
|
||||
assert user["username"] == OWNER_USERNAME_STORED
|
||||
assert user["display_name"] == OWNER_DISPLAY_NAME
|
||||
assert user["level"] == "manage"
|
||||
assert user["is_owner"] is True
|
||||
|
||||
|
||||
def test_setup_sets_session_and_csrf_cookies(setup_response: httpx.Response) -> None:
|
||||
"""The claim is also a sign-in: both cookies come back, session one HttpOnly."""
|
||||
assert_code(setup_response, 201)
|
||||
|
||||
set_cookie = "; ".join(setup_response.headers.get_list("set-cookie")).lower()
|
||||
assert SESSION_COOKIE in set_cookie
|
||||
assert CSRF_COOKIE in set_cookie
|
||||
|
||||
session_header = next(
|
||||
header for header in setup_response.headers.get_list("set-cookie") if header.startswith(SESSION_COOKIE)
|
||||
)
|
||||
csrf_header = next(
|
||||
header for header in setup_response.headers.get_list("set-cookie") if header.startswith(CSRF_COOKIE)
|
||||
)
|
||||
# The session token must not be reachable from script; the CSRF token must be, since
|
||||
# the client has to copy it into a request header.
|
||||
assert "httponly" in session_header.lower()
|
||||
assert "httponly" not in csrf_header.lower()
|
||||
|
||||
|
||||
def test_setup_is_refused_once_claimed(setup_response: httpx.Response) -> None:
|
||||
"""A second claim is a conflict, not a second owner."""
|
||||
assert_code(setup_response, 201)
|
||||
|
||||
result = httpx.post(
|
||||
f"{API_URL}/auth/setup",
|
||||
json={"username": "impostor", "password": "another-long-password"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert_code(result, 409)
|
||||
assert "claimed" in result.json()["message"].lower()
|
||||
|
||||
|
||||
def test_setup_session_can_be_used(setup_response: httpx.Response) -> None:
|
||||
"""The cookies the claim handed out actually authenticate a request."""
|
||||
assert_code(setup_response, 201)
|
||||
|
||||
with httpx.Client(base_url=API_URL, timeout=TIMEOUT, cookies=setup_response.cookies) as client:
|
||||
result = client.get("/auth/session")
|
||||
assert_code(result, 200)
|
||||
info = result.json()
|
||||
assert info["authenticated"] is True
|
||||
assert info["is_owner"] is True
|
||||
assert info["user"]["username"] == OWNER_USERNAME_STORED
|
||||
|
||||
|
||||
def test_config_reports_the_instance_claimed(setup_response: httpx.Response) -> None:
|
||||
"""GET /auth/config stops advertising setup once an account exists."""
|
||||
assert_code(setup_response, 201)
|
||||
|
||||
result = httpx.get(f"{API_URL}/auth/config", timeout=TIMEOUT)
|
||||
assert_code(result, 200)
|
||||
config = result.json()
|
||||
assert config["enabled"] is True
|
||||
assert config["setup_required"] is False
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Integration tests: every gated route refuses a caller with no credentials.
|
||||
|
||||
This is the load-bearing test of the suite. ``spoolman.auth.coverage`` already proves at
|
||||
import time that no route was left without a gate, but that is a static check on the
|
||||
route table -- it says a dependency is attached, not that the dependency actually
|
||||
rejects anybody. This file exercises the real thing over HTTP: every gated HTTP route,
|
||||
each of which must answer 401 to a request carrying nothing.
|
||||
|
||||
The table below is written out rather than derived, so that adding a route forces a
|
||||
deliberate decision about whether it is public. ``test_route_table_matches_openapi``
|
||||
is the guard that keeps the table honest: it fails the moment the served schema and this
|
||||
list disagree, in either direction.
|
||||
|
||||
Websocket routes are gated by a different mechanism and are covered in test_websocket.py.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from .conftest import (
|
||||
API_URL,
|
||||
INVALID_CREDENTIALS,
|
||||
SESSION_COOKIE,
|
||||
TIMEOUT,
|
||||
assert_code,
|
||||
)
|
||||
|
||||
# Every gated HTTP route, as (method, request path, documented level).
|
||||
#
|
||||
# Path parameters are filled with values that need not exist: a permission gate is a
|
||||
# route dependency and is solved before the handler's own parameters, so an unauthorised
|
||||
# caller never gets far enough to produce a 404 or a 422.
|
||||
GATED_ROUTES: list[tuple[str, str, str]] = [
|
||||
# -- read -------------------------------------------------------------------
|
||||
("GET", "/info", "read"),
|
||||
("GET", "/filament", "read"),
|
||||
("GET", "/filament/1", "read"),
|
||||
("GET", "/spool", "read"),
|
||||
("GET", "/spool/1", "read"),
|
||||
("GET", "/spool/group", "read"),
|
||||
("GET", "/vendor", "read"),
|
||||
("GET", "/vendor/1", "read"),
|
||||
("GET", "/search", "read"),
|
||||
("GET", "/material", "read"),
|
||||
("GET", "/article-number", "read"),
|
||||
("GET", "/lot-number", "read"),
|
||||
("GET", "/location", "read"),
|
||||
("GET", "/external/filament", "read"),
|
||||
("GET", "/external/filament/search", "read"),
|
||||
("GET", "/external/material", "read"),
|
||||
("GET", "/field/spool", "read"),
|
||||
("GET", "/field/spool/some_field/values", "read"),
|
||||
# A read-level route even though the payload is settings: reading them is how the
|
||||
# client learns the currency and the extra-field definitions.
|
||||
("GET", "/setting/", "read"),
|
||||
("GET", "/setting/currency", "read"),
|
||||
# Changing one's own password needs a real signed-in user, not merely read rights,
|
||||
# but the level recorded on the gate is read.
|
||||
("POST", "/auth/password", "read"),
|
||||
# The API key routes sit at read for the same reason: their gate is require_user,
|
||||
# which is require_level(read) plus a refusal of anonymous callers and of API keys.
|
||||
# A key that could mint a key would be unrevocable in practice.
|
||||
("GET", "/auth/apikey", "read"),
|
||||
("POST", "/auth/apikey", "read"),
|
||||
("POST", "/auth/apikey/1/revoke", "read"),
|
||||
("DELETE", "/auth/apikey/1", "read"),
|
||||
# -- edit -------------------------------------------------------------------
|
||||
("PATCH", "/filament/1", "edit"),
|
||||
("PATCH", "/spool/1", "edit"),
|
||||
("PATCH", "/vendor/1", "edit"),
|
||||
("PATCH", "/location/somewhere", "edit"),
|
||||
("PUT", "/spool/1/use", "edit"),
|
||||
("PUT", "/spool/1/measure", "edit"),
|
||||
# -- manage -----------------------------------------------------------------
|
||||
("POST", "/filament", "manage"),
|
||||
("POST", "/spool", "manage"),
|
||||
("POST", "/vendor", "manage"),
|
||||
("DELETE", "/filament/1", "manage"),
|
||||
("DELETE", "/spool/1", "manage"),
|
||||
("DELETE", "/vendor/1", "manage"),
|
||||
("POST", "/setting/currency", "manage"),
|
||||
("POST", "/field/spool/some_field", "manage"),
|
||||
("DELETE", "/field/spool/some_field", "manage"),
|
||||
("POST", "/backup", "manage"),
|
||||
# The export routes are manage rather than read on purpose: they hand over the whole
|
||||
# database in one response, which is a different thing from browsing it.
|
||||
("GET", "/export/spools?fmt=json", "manage"),
|
||||
("GET", "/export/filaments?fmt=json", "manage"),
|
||||
("GET", "/export/vendors?fmt=json", "manage"),
|
||||
# User administration and the audit log are recorded as manage, which is what their
|
||||
# gate enforces; require_admin adds the administrator check on top of it. An
|
||||
# unauthenticated caller is refused by the level gate first, which is what this file
|
||||
# asserts -- the admin half is covered in test_users.py and test_audit.py.
|
||||
("GET", "/auth/user", "manage"),
|
||||
("POST", "/auth/user", "manage"),
|
||||
("GET", "/auth/user/1", "manage"),
|
||||
("PATCH", "/auth/user/1", "manage"),
|
||||
("DELETE", "/auth/user/1", "manage"),
|
||||
("POST", "/auth/user/1/password", "manage"),
|
||||
("POST", "/auth/user/1/revoke-sessions", "manage"),
|
||||
# The audit log holds addresses, user agents and the list of accounts, so serving it
|
||||
# at read would hand an anonymous browser a map of the instance's users.
|
||||
("GET", "/auth/audit", "manage"),
|
||||
("GET", "/auth/audit/events", "manage"),
|
||||
]
|
||||
|
||||
# The routes that must stay reachable with no credentials, mirroring PUBLIC_ROUTES in
|
||||
# spoolman/api/v1/router.py. /health has to answer before anyone can possibly be signed
|
||||
# in; the auth endpoints are how a credential is obtained, so gating them is circular.
|
||||
PUBLIC_ROUTES: list[tuple[str, str]] = [
|
||||
("GET", "/health"),
|
||||
("GET", "/auth/config"),
|
||||
("GET", "/auth/session"),
|
||||
("POST", "/auth/setup"),
|
||||
("POST", "/auth/login"),
|
||||
("POST", "/auth/logout"),
|
||||
]
|
||||
|
||||
|
||||
def _strip_query(path: str) -> str:
|
||||
return path.split("?", 1)[0]
|
||||
|
||||
|
||||
def _template(path: str) -> str:
|
||||
"""Turn a concrete request path back into its OpenAPI template form."""
|
||||
replacements = {
|
||||
"/filament/1": "/filament/{filament_id}",
|
||||
"/spool/1/use": "/spool/{spool_id}/use",
|
||||
"/spool/1/measure": "/spool/{spool_id}/measure",
|
||||
"/spool/group": "/spool/group",
|
||||
"/spool/1": "/spool/{spool_id}",
|
||||
"/vendor/1": "/vendor/{vendor_id}",
|
||||
"/setting/currency": "/setting/{key}",
|
||||
"/location/somewhere": "/location/{location}",
|
||||
"/field/spool/some_field/values": "/field/{entity_type}/{key}/values",
|
||||
"/field/spool/some_field": "/field/{entity_type}/{key}",
|
||||
"/field/spool": "/field/{entity_type}",
|
||||
"/auth/apikey/1/revoke": "/auth/apikey/{key_id}/revoke",
|
||||
"/auth/apikey/1": "/auth/apikey/{key_id}",
|
||||
"/auth/user/1/revoke-sessions": "/auth/user/{user_id}/revoke-sessions",
|
||||
"/auth/user/1/password": "/auth/user/{user_id}/password",
|
||||
"/auth/user/1": "/auth/user/{user_id}",
|
||||
}
|
||||
return replacements.get(path, path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[(method, path) for method, path, _ in GATED_ROUTES],
|
||||
ids=[f"{method} {path}" for method, path, _ in GATED_ROUTES],
|
||||
)
|
||||
def test_gated_route_requires_credentials(anonymous: httpx.Client, method: str, path: str) -> None:
|
||||
"""A request with no credentials is refused with 401, before any handler runs."""
|
||||
result = anonymous.request(method, path, json={} if method in {"POST", "PATCH", "PUT"} else None)
|
||||
assert_code(result, 401)
|
||||
# The body shape is the same models.Message every other error in this API uses, not
|
||||
# FastAPI's default {"detail": ...}. Clients branch on it.
|
||||
assert result.json() == {"message": "Authentication required."}
|
||||
|
||||
|
||||
def test_every_gated_route_is_listed() -> None:
|
||||
"""Guard the count, so a route added without a test entry is noticed.
|
||||
|
||||
Phase 1 gated 40 HTTP routes. Phase 2 added thirteen: four for API keys, seven for
|
||||
user administration, two for the audit log.
|
||||
"""
|
||||
assert len(GATED_ROUTES) == 53
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
PUBLIC_ROUTES,
|
||||
ids=[f"{method} {path}" for method, path in PUBLIC_ROUTES],
|
||||
)
|
||||
def test_public_route_is_reachable(
|
||||
setup_response: httpx.Response, # noqa: ARG001
|
||||
anonymous: httpx.Client,
|
||||
method: str,
|
||||
path: str,
|
||||
) -> None:
|
||||
"""A public route answers on its own terms rather than with a permission error.
|
||||
|
||||
Each of these does its own checking, so 'reachable' means 'anything but 401 or 403'.
|
||||
The instance is claimed first, which pins the answers: /auth/setup is a conflict
|
||||
rather than a fresh claim, and a bogus /auth/login is a credential failure.
|
||||
"""
|
||||
if path == "/auth/setup":
|
||||
body = {"username": "impostor", "password": "another-long-password"}
|
||||
elif path == "/auth/login":
|
||||
body = {"username": "nobody-at-all", "password": "not-the-password"}
|
||||
else:
|
||||
body = {}
|
||||
|
||||
result = anonymous.request(method, path, json=body if method == "POST" else None)
|
||||
assert result.status_code not in {401, 403} or (
|
||||
# A wrong password is a 401 from the credential check itself, not from a gate --
|
||||
# which is the point: the route was reached.
|
||||
path == "/auth/login" and result.json()["message"] == INVALID_CREDENTIALS
|
||||
), f"{method} {path} answered {result.status_code}: {result.text}"
|
||||
|
||||
|
||||
def test_health_is_the_only_public_non_auth_route() -> None:
|
||||
"""Nothing outside /health and /auth/* may be public."""
|
||||
for _, path in PUBLIC_ROUTES:
|
||||
assert path == "/health" or path.startswith("/auth/")
|
||||
|
||||
|
||||
def test_route_table_matches_openapi() -> None:
|
||||
"""The tables above must describe exactly the routes the server serves.
|
||||
|
||||
Fetched from the live server rather than imported, since the tester runs in its own
|
||||
container with no access to the Spoolman source. A route added or removed on either
|
||||
side fails here, which is what stops this file from silently going stale.
|
||||
"""
|
||||
schema = httpx.get(f"{API_URL}/openapi.json", timeout=TIMEOUT)
|
||||
assert_code(schema, 200)
|
||||
|
||||
served = {
|
||||
(method.upper(), path)
|
||||
for path, operations in schema.json()["paths"].items()
|
||||
for method in operations
|
||||
if method.upper() != "HEAD"
|
||||
}
|
||||
expected = {(method, _template(_strip_query(path))) for method, path, _ in GATED_ROUTES}
|
||||
expected |= set(PUBLIC_ROUTES)
|
||||
|
||||
assert served == expected, (
|
||||
f"Routes served but not in the table: {sorted(served - expected)}. "
|
||||
f"Routes in the table but not served: {sorted(expected - served)}."
|
||||
)
|
||||
|
||||
|
||||
def test_session_endpoint_reports_signed_out(anonymous: httpx.Client) -> None:
|
||||
"""GET /auth/session answers for a signed-out caller instead of rejecting them.
|
||||
|
||||
A client needs to tell 'no session' apart from 'server unreachable', so this one
|
||||
endpoint must never 401.
|
||||
"""
|
||||
result = anonymous.get("/auth/session")
|
||||
assert_code(result, 200)
|
||||
info = result.json()
|
||||
assert info["authenticated"] is False
|
||||
assert info["anonymous"] is False
|
||||
assert info["user"] is None
|
||||
|
||||
|
||||
def test_logout_without_a_session_succeeds(anonymous: httpx.Client) -> None:
|
||||
"""Signing out is idempotent, so a client can always clear its cookies."""
|
||||
result = anonymous.post("/auth/logout")
|
||||
assert_code(result, 200)
|
||||
|
||||
|
||||
def test_garbage_session_cookie_is_refused(anonymous: httpx.Client) -> None:
|
||||
"""A made-up session token is not a credential."""
|
||||
result = anonymous.get("/spool", headers={"Cookie": f"{SESSION_COOKIE}=not-a-real-token"})
|
||||
assert_code(result, 401)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Integration tests: user administration.
|
||||
|
||||
Two things are being defended here.
|
||||
|
||||
The first is that an instance cannot be administered into a state nobody can recover
|
||||
from through the interface: the owner and the caller's own account are shielded from
|
||||
demotion, disabling and deletion. Neither is a security boundary -- both are recoverable
|
||||
with ``python -m spoolman.cli`` -- so the tests assert the refusal, not that it is
|
||||
unbypassable.
|
||||
|
||||
The second is that the protections only fire on changes that actually take something
|
||||
away. A form that re-sends an account unchanged must not be refused just because it
|
||||
included a field the caller may not lower.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from .conftest import (
|
||||
OWNER_USERNAME_STORED,
|
||||
READER_PASSWORD,
|
||||
assert_code,
|
||||
create_user,
|
||||
login,
|
||||
)
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
"""Build a username no other test in this run will collide with."""
|
||||
return f"{prefix}-{int(time.time() * 1000)}"
|
||||
|
||||
|
||||
def test_create_generates_a_password_when_none_is_given(owner: httpx.Client) -> None:
|
||||
"""A generated password comes back once, and works."""
|
||||
username = _unique("generated")
|
||||
response = owner.post("/auth/user", json={"username": username, "level": "read"})
|
||||
assert_code(response, 201)
|
||||
body = response.json()
|
||||
|
||||
password = body["password"]
|
||||
assert password, "a password should have been generated"
|
||||
assert body["user"]["must_change_password"] is True, "generated passwords default to one-time"
|
||||
|
||||
client, login_response = login(username=username, password=password)
|
||||
assert_code(login_response, 200)
|
||||
client.close()
|
||||
|
||||
# And it is never retrievable again.
|
||||
fetched = owner.get(f"/auth/user/{body['user']['id']}")
|
||||
assert_code(fetched, 200)
|
||||
assert password not in str(fetched.json())
|
||||
|
||||
|
||||
def test_create_accepts_a_supplied_password(owner: httpx.Client) -> None:
|
||||
"""When the caller supplies a password, none is generated or echoed."""
|
||||
username = _unique("supplied")
|
||||
response = owner.post(
|
||||
"/auth/user",
|
||||
json={"username": username, "password": READER_PASSWORD, "level": "edit"},
|
||||
)
|
||||
assert_code(response, 201)
|
||||
assert response.json().get("password") is None
|
||||
|
||||
client, login_response = login(username=username, password=READER_PASSWORD)
|
||||
assert_code(login_response, 200)
|
||||
client.close()
|
||||
|
||||
|
||||
def test_usernames_are_unique_and_normalised(owner: httpx.Client) -> None:
|
||||
"""A second account with the same name, in any case, is refused."""
|
||||
username = _unique("Unique")
|
||||
create_user(owner, username, password=READER_PASSWORD)
|
||||
|
||||
clash = owner.post("/auth/user", json={"username": username.upper(), "level": "read"})
|
||||
assert_code(clash, 409)
|
||||
assert "already exists" in clash.json()["message"]
|
||||
|
||||
|
||||
def test_list_includes_every_account(owner: httpx.Client) -> None:
|
||||
"""The listing shows the owner alongside whatever the suite has created."""
|
||||
username = _unique("listed")
|
||||
create_user(owner, username, password=READER_PASSWORD)
|
||||
|
||||
response = owner.get("/auth/user")
|
||||
assert_code(response, 200)
|
||||
usernames = {user["username"] for user in response.json()}
|
||||
assert OWNER_USERNAME_STORED in usernames
|
||||
assert username.lower() in usernames
|
||||
assert response.headers["x-total-count"] == str(len(response.json()))
|
||||
|
||||
|
||||
def test_update_changes_level_and_flags(owner: httpx.Client) -> None:
|
||||
"""Level, administrator and display name all take effect."""
|
||||
user_id, _ = create_user(owner, _unique("updated"), level="read", password=READER_PASSWORD)
|
||||
|
||||
response = owner.patch(
|
||||
f"/auth/user/{user_id}",
|
||||
json={"level": "manage", "is_admin": True, "display_name": "Renamed"},
|
||||
)
|
||||
assert_code(response, 200)
|
||||
body = response.json()
|
||||
assert body["level"] == "manage"
|
||||
assert body["is_admin"] is True
|
||||
assert body["display_name"] == "Renamed"
|
||||
|
||||
|
||||
def test_owner_cannot_be_demoted_disabled_or_deleted(owner: httpx.Client) -> None:
|
||||
"""The account that can always fix the others cannot be taken apart."""
|
||||
session = owner.get("/auth/session")
|
||||
assert_code(session, 200)
|
||||
owner_id = session.json()["user"]["id"]
|
||||
|
||||
for change in ({"level": "read"}, {"is_admin": False}, {"is_active": False}):
|
||||
refused = owner.patch(f"/auth/user/{owner_id}", json=change)
|
||||
assert_code(refused, 403)
|
||||
assert "owner" in refused.json()["message"].lower()
|
||||
|
||||
assert_code(owner.delete(f"/auth/user/{owner_id}"), 403)
|
||||
|
||||
|
||||
def test_unchanged_fields_are_not_refused(owner: httpx.Client) -> None:
|
||||
"""Re-sending an account's current values is a no-op, not a violation.
|
||||
|
||||
The protections compare against what is stored, so a form that submits every field
|
||||
-- including the owner's own level -- succeeds as long as nothing actually changes.
|
||||
"""
|
||||
session = owner.get("/auth/session")
|
||||
assert_code(session, 200)
|
||||
owner_id = session.json()["user"]["id"]
|
||||
|
||||
response = owner.patch(
|
||||
f"/auth/user/{owner_id}",
|
||||
json={"level": "manage", "is_admin": True, "is_active": True},
|
||||
)
|
||||
assert_code(response, 200)
|
||||
assert response.json()["level"] == "manage"
|
||||
|
||||
|
||||
def test_admin_cannot_demote_or_delete_themselves(owner: httpx.Client) -> None:
|
||||
"""Removing your own rights needs a second pair of hands."""
|
||||
username = _unique("selfadmin")
|
||||
user_id, password = create_user(owner, username, level="manage", is_admin=True, password=READER_PASSWORD)
|
||||
|
||||
client, response = login(username=username, password=password)
|
||||
assert_code(response, 200)
|
||||
try:
|
||||
for change in ({"level": "read"}, {"is_admin": False}, {"is_active": False}):
|
||||
refused = client.patch(f"/auth/user/{user_id}", json=change)
|
||||
assert_code(refused, 403)
|
||||
assert_code(client.delete(f"/auth/user/{user_id}"), 403)
|
||||
|
||||
# Somebody else with the rights can, which is the point of the restriction.
|
||||
assert_code(owner.patch(f"/auth/user/{user_id}", json={"is_admin": False}), 200)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_disabling_an_account_ends_its_sessions(owner: httpx.Client) -> None:
|
||||
"""A disabled account whose cookie still worked would not be disabled."""
|
||||
username = _unique("cutoff")
|
||||
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
|
||||
client, response = login(username=username, password=password)
|
||||
assert_code(response, 200)
|
||||
try:
|
||||
assert_code(client.get("/spool"), 200)
|
||||
assert_code(owner.patch(f"/auth/user/{user_id}", json={"is_active": False}), 200)
|
||||
assert_code(client.get("/spool"), 401)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_password_reset_ends_sessions_and_sets_the_new_password(owner: httpx.Client) -> None:
|
||||
"""A reset invalidates what the old password bought."""
|
||||
username = _unique("reset")
|
||||
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
|
||||
client, response = login(username=username, password=password)
|
||||
assert_code(response, 200)
|
||||
try:
|
||||
assert_code(client.get("/spool"), 200)
|
||||
|
||||
reset = owner.post(f"/auth/user/{user_id}/password", json={"must_change_password": True})
|
||||
assert_code(reset, 200)
|
||||
new_password = reset.json()["password"]
|
||||
assert new_password
|
||||
assert new_password != password
|
||||
|
||||
# Whoever knew the old password may be exactly who the reset defends against.
|
||||
assert_code(client.get("/spool"), 401)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
fresh, response = login(username=username, password=new_password)
|
||||
assert_code(response, 200)
|
||||
try:
|
||||
assert response.json()["user"]["must_change_password"] is True
|
||||
finally:
|
||||
fresh.close()
|
||||
|
||||
|
||||
def test_revoke_sessions_without_changing_the_password(owner: httpx.Client) -> None:
|
||||
"""Signing somebody out everywhere leaves their credential alone."""
|
||||
username = _unique("revoked")
|
||||
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
|
||||
client, response = login(username=username, password=password)
|
||||
assert_code(response, 200)
|
||||
try:
|
||||
assert_code(owner.post(f"/auth/user/{user_id}/revoke-sessions"), 200)
|
||||
assert_code(client.get("/spool"), 401)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
again, response = login(username=username, password=password)
|
||||
assert_code(response, 200)
|
||||
again.close()
|
||||
|
||||
|
||||
def test_delete_removes_the_account_and_its_sessions(owner: httpx.Client) -> None:
|
||||
"""A deleted account cannot sign in and is gone from the listing."""
|
||||
username = _unique("gone")
|
||||
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
|
||||
|
||||
assert_code(owner.delete(f"/auth/user/{user_id}"), 200)
|
||||
assert_code(owner.get(f"/auth/user/{user_id}"), 404)
|
||||
|
||||
listed = owner.get("/auth/user")
|
||||
assert_code(listed, 200)
|
||||
assert all(user["id"] != user_id for user in listed.json())
|
||||
|
||||
_, response = login(username=username, password=password)
|
||||
assert_code(response, 401)
|
||||
|
||||
|
||||
def test_non_admin_is_refused_every_user_endpoint(reader: httpx.Client) -> None:
|
||||
"""Managing accounts needs administrator rights, not merely a session."""
|
||||
assert_code(reader.get("/auth/user"), 403)
|
||||
assert_code(reader.post("/auth/user", json={"username": "nope", "level": "read"}), 403)
|
||||
assert_code(reader.patch("/auth/user/1", json={"level": "read"}), 403)
|
||||
assert_code(reader.delete("/auth/user/1"), 403)
|
||||
|
||||
|
||||
def test_anonymous_is_refused_every_user_endpoint(anonymous: httpx.Client) -> None:
|
||||
"""Without any credential the endpoints answer 401, not 403."""
|
||||
assert_code(anonymous.get("/auth/user"), 401)
|
||||
assert_code(anonymous.post("/auth/user", json={"username": "nope", "level": "read"}), 401)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Integration tests: websocket authentication.
|
||||
|
||||
This file exists for one subtle behaviour. A websocket handler that closes the socket
|
||||
*before* accepting the handshake gets translated by uvicorn into an HTTP 403 rejection
|
||||
with the close code thrown away, and Starlette routes a WebSocketException raised from a
|
||||
dependency down that same path. Neither can deliver 4401 or 4403 to the browser, so the
|
||||
client cannot tell "you are signed out, stop retrying" from "the network blipped, back
|
||||
off and retry" -- and a client that cannot tell reconnects forever.
|
||||
|
||||
``ws_authenticated`` therefore accepts first and closes afterwards, which looks wrong at
|
||||
a glance and is the reason these assertions check the close code explicitly rather than
|
||||
merely checking that the connection failed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from websockets.asyncio.client import connect
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from .conftest import (
|
||||
SESSION_COOKIE,
|
||||
WS_API_URL,
|
||||
WS_UNAUTHENTICATED,
|
||||
assert_code,
|
||||
signed_in,
|
||||
)
|
||||
|
||||
# Every websocket route the v1 API serves. All of them subscribe at read level.
|
||||
WS_ROUTES = [
|
||||
"/",
|
||||
"/spool",
|
||||
"/spool/1",
|
||||
"/filament",
|
||||
"/filament/1",
|
||||
"/vendor",
|
||||
"/vendor/1",
|
||||
"/setting",
|
||||
"/setting/currency",
|
||||
]
|
||||
|
||||
|
||||
async def _close_code(path: str, cookie: str | None = None) -> int | None:
|
||||
"""Connect, and report the code the server closed with.
|
||||
|
||||
Args:
|
||||
path: The websocket path, relative to the v1 API.
|
||||
cookie: A session cookie header value, or None to connect with no credentials.
|
||||
|
||||
Returns:
|
||||
Optional[int]: The close code, or None if the socket stayed open.
|
||||
|
||||
"""
|
||||
headers = {"Cookie": f"{SESSION_COOKIE}={cookie}"} if cookie else None
|
||||
try:
|
||||
async with connect(WS_API_URL + path, additional_headers=headers) as websocket:
|
||||
try:
|
||||
await asyncio.wait_for(websocket.recv(), timeout=2)
|
||||
except TimeoutError:
|
||||
# Still open and idle, which is what a healthy subscription looks like.
|
||||
return None
|
||||
except ConnectionClosed as exc:
|
||||
return exc.code
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", WS_ROUTES)
|
||||
async def test_unauthenticated_websocket_is_closed_with_4401(
|
||||
setup_response: httpx.Response, # noqa: ARG001
|
||||
path: str,
|
||||
) -> None:
|
||||
"""The handshake is accepted, then closed with 4401 -- not rejected with HTTP 403.
|
||||
|
||||
Asserting the exact code is the regression test. If someone "simplifies"
|
||||
ws_authenticated into raising a WebSocketException or closing before accept, the
|
||||
handshake starts failing with an HTTP 403 instead and this assertion catches it.
|
||||
"""
|
||||
assert await _close_code(path) == WS_UNAUTHENTICATED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_websocket_stays_open(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""A session cookie on the handshake gets a working subscription."""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
token = client.cookies.get(SESSION_COOKIE)
|
||||
|
||||
assert await _close_code("/spool", cookie=token) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_websocket_receives_events(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""The subscription is a real one, not merely a socket that was left open."""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
token = client.cookies.get(SESSION_COOKIE)
|
||||
|
||||
async with connect(
|
||||
f"{WS_API_URL}/vendor",
|
||||
additional_headers={"Cookie": f"{SESSION_COOKIE}={token}"},
|
||||
) as websocket:
|
||||
# The subscription registers right after the handshake; give the server a
|
||||
# beat before triggering the event.
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
created = client.post("/vendor", json={"name": "Websocket test vendor"})
|
||||
assert_code(created, 200)
|
||||
vendor_id = created.json()["id"]
|
||||
try:
|
||||
event = json.loads(await asyncio.wait_for(websocket.recv(), timeout=10))
|
||||
assert event["type"] == "added"
|
||||
assert event["resource"] == "vendor"
|
||||
assert event["payload"]["id"] == vendor_id
|
||||
finally:
|
||||
assert_code(client.delete(f"/vendor/{vendor_id}"), 200)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoked_session_cannot_open_a_websocket(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""A signed-out cookie is refused at the handshake, same as no cookie at all."""
|
||||
with signed_in() as (client, response):
|
||||
assert_code(response, 200)
|
||||
token = client.cookies.get(SESSION_COOKIE)
|
||||
assert_code(client.post("/auth/logout"), 200)
|
||||
|
||||
assert await _close_code("/spool", cookie=token) == WS_UNAUTHENTICATED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garbage_cookie_cannot_open_a_websocket(setup_response: httpx.Response) -> None: # noqa: ARG001
|
||||
"""A made-up token is not a credential over websockets either."""
|
||||
assert await _close_code("/spool", cookie="not-a-real-token") == WS_UNAUTHENTICATED
|
||||
@@ -1,6 +1,6 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "aiomysql"
|
||||
@@ -35,7 +35,6 @@ source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" }
|
||||
@@ -66,9 +65,7 @@ name = "anyio"
|
||||
version = "4.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
||||
wheels = [
|
||||
@@ -87,56 +84,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907, upload-time = "2023-10-02T13:49:26.943Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "5.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncpg"
|
||||
version = "0.31.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-timeout", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
|
||||
@@ -155,15 +108,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.6.17"
|
||||
@@ -182,55 +126,6 @@ dependencies = [
|
||||
]
|
||||
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/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||
{ 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" },
|
||||
@@ -282,7 +177,6 @@ version = "49.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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 = [
|
||||
@@ -325,24 +219,6 @@ wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -367,45 +243,6 @@ version = "3.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/8c/231ca675b0df779816950ca66b40b1fa14dbff4a0ed9814a9a29ec399140/greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8", size = 622473, upload-time = "2026-06-26T19:24:12.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/fe/dd97c483a3ff82849196ccd07851600edd3ac9de74669ca8a6022ada9ea1/greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71", size = 418421, upload-time = "2026-06-26T19:25:34.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
|
||||
@@ -491,34 +328,6 @@ version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
|
||||
@@ -586,61 +395,6 @@ version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
@@ -671,49 +425,6 @@ version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" },
|
||||
@@ -781,7 +492,6 @@ source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pastel" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/92/93a4af9511b8c7c647874521d9e6c904266be98067c2ee1eb2e74520d208/poethepoet-0.48.0.tar.gz", hash = "sha256:a06f49d244fadfc2e2e7faa78b54e64a9694727e4ce1d50e08f23cea3ded74f1", size = 148679, upload-time = "2026-07-05T21:48:30.106Z" }
|
||||
wheels = [
|
||||
@@ -803,50 +513,6 @@ version = "2.9.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/80/49bacf9e51617d8309f6f0123e29edc793f6f5f6700c7d1f1b20782fbb37/psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4", size = 3712314, upload-time = "2026-04-20T23:33:31.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/98eeac7d60c43df9338287834edf9b3e69be68a2db78a57b1b81d705e735/psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56", size = 3822389, upload-time = "2026-04-20T23:33:34.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/7c/30575e75f14d5351a56a1971bb43fe7f8bf7edf1b654fb1bec65c42a8812/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433", size = 4578448, upload-time = "2026-04-20T23:33:37.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/9b/4df366d89f28c527dc39d0b6c98a5ca74e30d37ac097b73f3352147568ae/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e", size = 4273705, upload-time = "2026-04-20T23:33:39.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8a/c566803818eb03161ba869b6ba612bf7ad56816d98b9e5121e0a22ad6b0b/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3", size = 5893784, upload-time = "2026-04-20T23:33:41.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/fe/0dfa5797e0b229e0567bc378695224caf14d547f73b05be0c80549089772/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4", size = 4109306, upload-time = "2026-04-20T23:33:43.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/89/28063adf17a4ba501eedd9890feab0c649ee4d8bd0a97df0ff1e9584feab/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256", size = 3654400, upload-time = "2026-04-20T23:33:46.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/94/5a01de0aa4ead0b8d8d1aa4ec18cec0bd36d03fa714eaa5bb8a0b1b50020/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c", size = 3299215, upload-time = "2026-04-20T23:33:48.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/85/723bb085a61c6ac2dc0a0043f375f2fe7365363e27b073bad56ca5bda979/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463", size = 3047724, upload-time = "2026-04-20T23:33:50.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/67/4d8b1e0d2fc4166677380eac0edf9cdff91013aca2546e8ef7bc04b56158/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1", size = 3349183, upload-time = "2026-04-20T23:33:59.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/99/21af7a5498637ea4dc91a17c281a53bc1d632fbafe00f6689fbfb32a9fed/psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03", size = 2757036, upload-time = "2026-04-20T23:34:01.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/19/d4ce60954f3bb9d8e3bc5e5c4d1f2487de2d3851bf2391d54954c9df12a6/psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", size = 3712338, upload-time = "2026-04-20T23:34:03.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/71/c85409ee0d78890f0660eff262e815e7dd2bb741a17611d82e9e8cd9dc5e/psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", size = 3822407, upload-time = "2026-04-20T23:34:05.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/ed/60486c2c7f0d4d1ede2bfb1ed27e2498477ce646bc7f6b2759906303117e/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", size = 4578425, upload-time = "2026-04-20T23:34:08.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b9/656cb03fad9f4f49f2145c334b1126ee75189929ca4e6187d485a2d59951/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", size = 4273709, upload-time = "2026-04-20T23:34:10.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/66/08cf0da0e25cc6fb142c89be45fc8418792858f0c4cbff5e24530ff02cd6/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", size = 5893779, upload-time = "2026-04-20T23:34:13.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d7/eecd9ce8e146d3721115d82d3836efdbb712187e4590325df549989d18f4/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", size = 4109308, upload-time = "2026-04-20T23:34:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2e/b1dc289b362cc8d45697b57eefbd673186f49a4ea0906928988e3affcc98/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", size = 3654405, upload-time = "2026-04-20T23:34:19.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e4/4c4aea6473214dbdbd0fbba11aa4691e76dc01722c55724c5951719865ff/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", size = 3299187, upload-time = "2026-04-20T23:34:21.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5d/b03b99986446a4f57b170ed9a2579fb7ff9783ca0fa5226b19db99737fee/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", size = 3047716, upload-time = "2026-04-20T23:34:23.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/86/382ee4afbd1d97500c9d2862b20c2fdeddf4b7335e984df3fb4309f64108/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", size = 3349237, upload-time = "2026-04-20T23:34:25.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/16/9a57c75ba1eda7165c017342f526810d5f5a12647dde749c99ae9a7141d7/psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", size = 2757036, upload-time = "2026-04-20T23:34:27.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" },
|
||||
@@ -893,65 +559,6 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
@@ -982,22 +589,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1029,12 +620,10 @@ version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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 = [
|
||||
@@ -1046,9 +635,7 @@ name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
@@ -1061,44 +648,6 @@ version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
@@ -1241,34 +790,6 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/76/b3ea1d8842e7b62c718a88d302809003d65ed82011460ca48907dde658c4/sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0", size = 2162087, upload-time = "2026-06-15T16:05:15.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/22/f19552eb7876774d50cfd025337ef5d67acc10cd8f29adab7716cf47c352/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652", size = 3244579, upload-time = "2026-06-15T16:10:36.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/97/e4a2eb5a8ec5cd3c2a0615a2f15f0afca89ac039229599b9ed0c0ed28e5e/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d", size = 3243515, upload-time = "2026-06-15T16:12:22.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/c6/5900ec624fab3360aa2ec59b99bb2046dd79799e310bb78a0514eaa4038e/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84", size = 3195492, upload-time = "2026-06-15T16:10:38.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/41/2ee3c4e1ac4fd22309349823fe13f33febeab1a71db1d7e9d60293a07dcb/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080", size = 3215782, upload-time = "2026-06-15T16:12:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/1c/3bd72c341f1cb5faed5a7457ea840228a46be51cfbaf31a9db72fc963f11/sqlalchemy-2.0.51-cp310-cp310-win32.whl", hash = "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1", size = 2122119, upload-time = "2026-06-15T16:13:26.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/63/b6dfdd646abf91c3bedb13727226a5e765e5f8365e898d43818e6672fa46/sqlalchemy-2.0.51-cp310-cp310-win_amd64.whl", hash = "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a", size = 2145158, upload-time = "2026-06-15T16:13:28.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
|
||||
@@ -1322,67 +843,12 @@ version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeguard"
|
||||
version = "4.5.2"
|
||||
@@ -1423,7 +889,6 @@ source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/bb/88735238d7ead151c28d5432551170f17746c70c257aa66e8d7e64eca7a3/uvicorn-0.50.1.tar.gz", hash = "sha256:ccb3061887829fd8471cfa6fc65b2594689342ee00792e5d257d34871755b09f", size = 93722, upload-time = "2026-07-06T07:52:25.238Z" }
|
||||
wheels = [
|
||||
@@ -1436,30 +901,6 @@ version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
@@ -1480,55 +921,5 @@ version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
Reference in new issue
Block a user