From 58237d9e44286f2714271bdd3a4fae225c57a4ad Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 25 Mar 2026 18:28:37 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Drive,=20an=20S3?= =?UTF-8?q?=20file=20browser=20with=20WOPI=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightweight replacement for the upstream La Suite Numérique drive (Django/Celery/Next.js) built as a single Deno binary. Server (Deno + Hono): - S3 file operations via AWS SigV4 (no SDK) with pre-signed URLs - WOPI host for Collabora Online (CheckFileInfo, GetFile, PutFile, locks) - Ory Kratos session auth + CSRF protection - Ory Keto permission model (OPL namespaces, not yet wired to routes) - PostgreSQL metadata with recursive folder sizes - S3 backfill API for registering files uploaded outside the UI - OpenTelemetry tracing + metrics (opt-in via OTEL_ENABLED) Frontend (React 19 + Cunningham v4 + react-aria): - File browser with GridList, keyboard nav, multi-select - Collabora editor iframe (full-screen, form POST, postMessage) - Profile menu, waffle menu, drag-drop upload, asset type badges - La Suite integration service theming (runtime CSS) Testing (549 tests): - 235 server unit tests (Deno) — 90%+ coverage - 278 UI unit tests (Vitest) — 90%+ coverage - 11 E2E tests (Playwright) - 12 integration service tests (Playwright) - 13 WOPI integration tests (Playwright + Docker Compose + Collabora) MIT licensed. --- .gitignore | 31 + LICENSE | 21 + README.md | 197 + TODO.md | 31 + compose.yaml | 60 + deno.json | 30 + deno.lock | 1267 +++ docs/architecture.md | 210 + docs/deployment.md | 227 + docs/local-dev.md | 221 + docs/permissions.md | 281 + docs/s3-layout.md | 261 + docs/testing.md | 204 + docs/wopi.md | 286 + keto/namespaces.ts | 120 + main.ts | 105 + server/auth.ts | 162 + server/backfill.ts | 308 + server/csrf.ts | 90 + server/db.ts | 41 + server/files.ts | 444 + server/folders.ts | 103 + server/keto.ts | 227 + server/migrate.ts | 116 + server/permissions.ts | 216 + server/s3-presign.ts | 215 + server/s3.ts | 338 + server/telemetry.ts | 311 + server/wopi/discovery.ts | 120 + server/wopi/handler.ts | 260 + server/wopi/lock.ts | 216 + server/wopi/token.ts | 132 + tests/server/auth_test.ts | 584 ++ tests/server/backfill_test.ts | 172 + tests/server/csrf_test.ts | 208 + tests/server/files_test.ts | 400 + tests/server/keto_test.ts | 421 + tests/server/permissions_test.ts | 705 ++ tests/server/s3_test.ts | 682 ++ tests/server/telemetry_test.ts | 175 + tests/server/wopi_discovery_test.ts | 286 + tests/server/wopi_lock_test.ts | 291 + tests/server/wopi_token_test.ts | 192 + ui/cunningham.ts | 10 + ui/e2e/debug.spec.ts | 20 + ui/e2e/driver.spec.ts | 245 + ui/e2e/fixtures/test-document.odt | Bin 0 -> 39972 bytes ui/e2e/integration-service.spec.ts | 375 + ui/e2e/wopi.spec.ts | 469 + ui/index.html | 12 + ui/package-lock.json | 8367 +++++++++++++++++ ui/package.json | 39 + ui/playwright.config.ts | 20 + ui/src/App.tsx | 36 + ui/src/__tests__/App.test.tsx | 50 + ui/src/api/__tests__/client.test.ts | 143 + ui/src/api/__tests__/files.test.ts | 223 + ui/src/api/__tests__/session.test.ts | 47 + ui/src/api/__tests__/wopi.test.ts | 157 + ui/src/api/client.ts | 25 + ui/src/api/files.ts | 186 + ui/src/api/session.ts | 23 + ui/src/api/wopi.ts | 67 + ui/src/components/AssetTypeBadge.tsx | 32 + ui/src/components/BreadcrumbNav.tsx | 98 + ui/src/components/CollaboraEditor.tsx | 238 + ui/src/components/FileActions.tsx | 312 + ui/src/components/FileBrowser.tsx | 365 + ui/src/components/FilePreview.tsx | 226 + ui/src/components/FileUpload.tsx | 234 + ui/src/components/ProfileMenu.tsx | 201 + ui/src/components/ShareDialog.tsx | 236 + ui/src/components/WaffleButton.tsx | 76 + .../__tests__/AssetTypeBadge.test.tsx | 58 + .../__tests__/BreadcrumbNav.test.tsx | 113 + .../__tests__/CollaboraEditor.test.tsx | 165 + .../components/__tests__/FileActions.test.tsx | 202 + .../components/__tests__/FileBrowser.test.tsx | 156 + .../components/__tests__/FilePreview.test.tsx | 102 + .../components/__tests__/FileUpload.test.tsx | 193 + .../components/__tests__/ProfileMenu.test.tsx | 105 + .../components/__tests__/ShareDialog.test.tsx | 217 + .../__tests__/WaffleButton.test.tsx | 46 + .../__tests__/useCunninghamTheme.test.ts | 53 + ui/src/cunningham/useCunninghamTheme.tsx | 37 + ui/src/hooks/__tests__/useAssetType.test.ts | 92 + ui/src/hooks/__tests__/usePreview.test.ts | 163 + .../hooks/__tests__/useThreeDPreview.test.ts | 19 + ui/src/hooks/useAssetType.ts | 145 + ui/src/hooks/usePreview.ts | 79 + ui/src/hooks/useThreeDPreview.ts | 15 + ui/src/layouts/AppLayout.tsx | 144 + ui/src/layouts/__tests__/AppLayout.test.tsx | 101 + ui/src/main.tsx | 22 + ui/src/pages/Editor.tsx | 102 + ui/src/pages/Explorer.tsx | 131 + ui/src/pages/Favorites.tsx | 22 + ui/src/pages/Recent.tsx | 22 + ui/src/pages/Trash.tsx | 35 + ui/src/pages/__tests__/Editor.test.tsx | 85 + ui/src/pages/__tests__/Explorer.test.tsx | 145 + ui/src/pages/__tests__/Favorites.test.tsx | 67 + ui/src/pages/__tests__/Recent.test.tsx | 66 + ui/src/pages/__tests__/Trash.test.tsx | 100 + ui/src/stores/__tests__/selection.test.ts | 71 + ui/src/stores/__tests__/upload.test.ts | 89 + ui/src/stores/selection.ts | 43 + ui/src/stores/upload.ts | 79 + ui/src/test-setup.ts | 4 + ui/tsconfig.json | 22 + ui/vite.config.ts | 15 + ui/vitest.config.ts | 17 + 112 files changed, 26841 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 TODO.md create mode 100644 compose.yaml create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 docs/architecture.md create mode 100644 docs/deployment.md create mode 100644 docs/local-dev.md create mode 100644 docs/permissions.md create mode 100644 docs/s3-layout.md create mode 100644 docs/testing.md create mode 100644 docs/wopi.md create mode 100644 keto/namespaces.ts create mode 100644 main.ts create mode 100644 server/auth.ts create mode 100644 server/backfill.ts create mode 100644 server/csrf.ts create mode 100644 server/db.ts create mode 100644 server/files.ts create mode 100644 server/folders.ts create mode 100644 server/keto.ts create mode 100644 server/migrate.ts create mode 100644 server/permissions.ts create mode 100644 server/s3-presign.ts create mode 100644 server/s3.ts create mode 100644 server/telemetry.ts create mode 100644 server/wopi/discovery.ts create mode 100644 server/wopi/handler.ts create mode 100644 server/wopi/lock.ts create mode 100644 server/wopi/token.ts create mode 100644 tests/server/auth_test.ts create mode 100644 tests/server/backfill_test.ts create mode 100644 tests/server/csrf_test.ts create mode 100644 tests/server/files_test.ts create mode 100644 tests/server/keto_test.ts create mode 100644 tests/server/permissions_test.ts create mode 100644 tests/server/s3_test.ts create mode 100644 tests/server/telemetry_test.ts create mode 100644 tests/server/wopi_discovery_test.ts create mode 100644 tests/server/wopi_lock_test.ts create mode 100644 tests/server/wopi_token_test.ts create mode 100644 ui/cunningham.ts create mode 100644 ui/e2e/debug.spec.ts create mode 100644 ui/e2e/driver.spec.ts create mode 100644 ui/e2e/fixtures/test-document.odt create mode 100644 ui/e2e/integration-service.spec.ts create mode 100644 ui/e2e/wopi.spec.ts create mode 100644 ui/index.html create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/playwright.config.ts create mode 100644 ui/src/App.tsx create mode 100644 ui/src/__tests__/App.test.tsx create mode 100644 ui/src/api/__tests__/client.test.ts create mode 100644 ui/src/api/__tests__/files.test.ts create mode 100644 ui/src/api/__tests__/session.test.ts create mode 100644 ui/src/api/__tests__/wopi.test.ts create mode 100644 ui/src/api/client.ts create mode 100644 ui/src/api/files.ts create mode 100644 ui/src/api/session.ts create mode 100644 ui/src/api/wopi.ts create mode 100644 ui/src/components/AssetTypeBadge.tsx create mode 100644 ui/src/components/BreadcrumbNav.tsx create mode 100644 ui/src/components/CollaboraEditor.tsx create mode 100644 ui/src/components/FileActions.tsx create mode 100644 ui/src/components/FileBrowser.tsx create mode 100644 ui/src/components/FilePreview.tsx create mode 100644 ui/src/components/FileUpload.tsx create mode 100644 ui/src/components/ProfileMenu.tsx create mode 100644 ui/src/components/ShareDialog.tsx create mode 100644 ui/src/components/WaffleButton.tsx create mode 100644 ui/src/components/__tests__/AssetTypeBadge.test.tsx create mode 100644 ui/src/components/__tests__/BreadcrumbNav.test.tsx create mode 100644 ui/src/components/__tests__/CollaboraEditor.test.tsx create mode 100644 ui/src/components/__tests__/FileActions.test.tsx create mode 100644 ui/src/components/__tests__/FileBrowser.test.tsx create mode 100644 ui/src/components/__tests__/FilePreview.test.tsx create mode 100644 ui/src/components/__tests__/FileUpload.test.tsx create mode 100644 ui/src/components/__tests__/ProfileMenu.test.tsx create mode 100644 ui/src/components/__tests__/ShareDialog.test.tsx create mode 100644 ui/src/components/__tests__/WaffleButton.test.tsx create mode 100644 ui/src/cunningham/__tests__/useCunninghamTheme.test.ts create mode 100644 ui/src/cunningham/useCunninghamTheme.tsx create mode 100644 ui/src/hooks/__tests__/useAssetType.test.ts create mode 100644 ui/src/hooks/__tests__/usePreview.test.ts create mode 100644 ui/src/hooks/__tests__/useThreeDPreview.test.ts create mode 100644 ui/src/hooks/useAssetType.ts create mode 100644 ui/src/hooks/usePreview.ts create mode 100644 ui/src/hooks/useThreeDPreview.ts create mode 100644 ui/src/layouts/AppLayout.tsx create mode 100644 ui/src/layouts/__tests__/AppLayout.test.tsx create mode 100644 ui/src/main.tsx create mode 100644 ui/src/pages/Editor.tsx create mode 100644 ui/src/pages/Explorer.tsx create mode 100644 ui/src/pages/Favorites.tsx create mode 100644 ui/src/pages/Recent.tsx create mode 100644 ui/src/pages/Trash.tsx create mode 100644 ui/src/pages/__tests__/Editor.test.tsx create mode 100644 ui/src/pages/__tests__/Explorer.test.tsx create mode 100644 ui/src/pages/__tests__/Favorites.test.tsx create mode 100644 ui/src/pages/__tests__/Recent.test.tsx create mode 100644 ui/src/pages/__tests__/Trash.test.tsx create mode 100644 ui/src/stores/__tests__/selection.test.ts create mode 100644 ui/src/stores/__tests__/upload.test.ts create mode 100644 ui/src/stores/selection.ts create mode 100644 ui/src/stores/upload.ts create mode 100644 ui/src/test-setup.ts create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts create mode 100644 ui/vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a402840 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +ui/node_modules/ + +# Build output +ui/dist/ +driver + +# Coverage +.coverage/ +ui/coverage/ + +# Test artifacts +ui/e2e/test-results/ +ui/e2e/screenshots/ + +# Deno cache +.deno/ + +# OS +.DS_Store + +# Test fixtures (large binaries) +ui/SBBB* + +# IDE +.vscode/ +.idea/ + +# Env +.env +.env.local diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d605c3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sunbeam Studios + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..218fafd --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# Drive + +An S3 file browser with WOPI-based document editing, built for [La Suite Numérique](https://lasuite.numerique.gouv.fr/). One Deno binary. No Django, no Celery, no Next.js — files, folders, and Collabora. That's the whole thing. + +Built by [Sunbeam Studios](https://sunbeam.pt) as a drop-in replacement for the upstream [drive](https://github.com/suitenumerique/drive). The original is a Django/Celery/Next.js stack. This is a single binary that does the same job. + +> **Status:** Running in production. WOPI editing, pre-signed uploads, folder sizes, game asset hooks, full Ory integration — all shipping. We're replacing upstream drive one feature at a time. + +--- + +## What it does + +| Feature | How it works | +|---------|-------------| +| **File browser** | Navigate folders, sort, search, multi-select. react-aria underneath for keyboard + screen reader support. | +| **Document editing** | Double-click a .docx/.odt/.xlsx → Collabora Online opens via WOPI. Full-screen, no chrome. | +| **Pre-signed uploads** | Browser uploads straight to S3. File bytes never touch the server. Multi-part for large files. | +| **Game asset support** | Type detection for FBX, glTF, textures (DDS, KTX), audio, video. Icons + badges now, previews later. | +| **Folder sizes** | Recursive PostgreSQL functions. Size updates propagate up the ancestor chain on every file change. | +| **OIDC auth** | Ory Kratos sessions + Ory Hydra OAuth2. Same identity stack as every other La Suite app. | +| **Permissions** | Ory Keto (Zanzibar-style). Hierarchical: bucket → folder → file, with group support. | +| **Theming** | La Suite integration service provides runtime CSS. Dark mode, custom fonts, waffle menu — one URL. | +| **S3 backfill** | Files dropped directly into SeaweedFS? Hit the backfill endpoint and they show up in the browser with correct metadata. | + +--- + +## Architecture + +``` +Browser ──→ Deno/Hono Server ──→ SeaweedFS (S3) + │ PostgreSQL (metadata) + │ Valkey (WOPI locks) + │ Ory Keto (permissions) + ├──→ Collabora Online (WOPI callbacks) + └──→ Ory Kratos (session validation) +``` + +One Deno binary (~450KB JS + static UI). Hono routes requests, the UI is a Vite-built React SPA from `ui/dist`. `deno compile` packs it all into a single executable. + +--- + +## Quick start + +```bash +# Prerequisites: Deno 2.x, Node 20+, PostgreSQL, SeaweedFS (or weed mini) + +# Install UI deps + build +cd ui && npm install && npx vite build && cd .. + +# Create database + run migrations +createdb driver_db +DATABASE_URL="postgres://localhost/driver_db" deno run -A server/migrate.ts + +# Start +DATABASE_URL="postgres://localhost/driver_db" \ +SEAWEEDFS_S3_URL="http://localhost:8333" \ +deno run -A main.ts +``` + +Open `http://localhost:3000`. That's it. + +For the full stack with Collabora editing, see [docs/local-dev.md](docs/local-dev.md). + +--- + +## Project structure + +``` +drive/ +├── main.ts Hono app entry — all routes +├── deno.json Tasks, imports +├── compose.yaml Docker Compose for WOPI integration testing +├── server/ +│ ├── auth.ts Kratos session middleware +│ ├── csrf.ts CSRF protection (HMAC double-submit) +│ ├── telemetry.ts OpenTelemetry tracing + metrics middleware +│ ├── db.ts PostgreSQL client +│ ├── migrate.ts Schema migrations +│ ├── s3.ts S3 client (AWS SigV4, no SDK) +│ ├── s3-presign.ts Pre-signed URL generation +│ ├── files.ts File CRUD + user state handlers +│ ├── folders.ts Folder operations +│ ├── keto.ts Ory Keto HTTP client +│ ├── permissions.ts Permission middleware + tuple lifecycle +│ ├── backfill.ts S3 → DB backfill API +│ └── wopi/ +│ ├── handler.ts WOPI endpoints (CheckFileInfo, GetFile, PutFile, locks) +│ ├── token.ts JWT access tokens for WOPI +│ ├── lock.ts Valkey-backed lock service +│ └── discovery.ts Collabora discovery XML cache +├── ui/ +│ ├── src/ +│ │ ├── main.tsx Vite entry point +│ │ ├── App.tsx CunninghamProvider + Router +│ │ ├── layouts/ AppLayout (header + sidebar + main) +│ │ ├── pages/ Explorer, Recent, Favorites, Trash, Editor +│ │ ├── components/ FileBrowser, FileUpload, CollaboraEditor, ProfileMenu, etc. +│ │ ├── api/ React Query hooks + fetch client +│ │ ├── stores/ Zustand (selection, upload queue) +│ │ ├── hooks/ Asset type detection, preview capabilities +│ │ └── cunningham/ Cunningham theme integration +│ └── e2e/ Playwright tests (driver, wopi, integration-service) +├── keto/ +│ └── namespaces.ts OPL permission model +└── tests/ + └── server/ Deno test files (10 files) +``` + +--- + +## Stack + +| Layer | Technology | +|-------|-----------| +| Server | [Deno](https://deno.land/) + [Hono](https://hono.dev/) | +| Frontend | React 19 + [Cunningham v4](https://github.com/suitenumerique/cunningham) + [react-aria](https://react-spectrum.adobe.com/react-aria/) | +| Storage | [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (S3-compatible) | +| Database | PostgreSQL (file registry, folder sizes) | +| Cache | Valkey (WOPI locks with TTL) | +| Auth | [Ory Kratos](https://www.ory.sh/kratos/) (identity) + [Ory Hydra](https://www.ory.sh/hydra/) (OAuth2/OIDC) | +| Permissions | [Ory Keto](https://www.ory.sh/keto/) (Zanzibar-style ReBAC) | +| Document editing | [Collabora Online](https://www.collaboraoffice.com/) via WOPI | +| Theming | [La Suite integration service](https://github.com/suitenumerique/integration) | +| Build | `deno compile` → single binary | + +--- + +## Testing + +```bash +# Server unit tests (Deno) +deno task test + +# UI unit tests (Vitest) +cd ui && npx vitest run + +# UI unit tests with coverage +cd ui && npx vitest run --coverage + +# E2E tests (Playwright — needs running server + weed mini + PostgreSQL) +cd ui && npx playwright test e2e/driver.spec.ts + +# Integration service tests (Playwright — hits production integration.sunbeam.pt) +cd ui && INTEGRATION_URL=https://integration.sunbeam.pt npx playwright test e2e/integration-service.spec.ts + +# WOPI integration tests (Playwright — needs docker compose stack) +docker compose up -d +# start server pointed at compose services, then: +cd ui && DRIVER_URL=http://localhost:3200 npx playwright test e2e/wopi.spec.ts +``` + +90%+ line coverage on both server and UI. See [docs/testing.md](docs/testing.md) for the full breakdown. + +--- + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3000` | Server listen port | +| `PUBLIC_URL` | `http://localhost:3000` | Public-facing URL (used in WOPI callbacks + redirects) | +| `DATABASE_URL` | `postgres://driver:driver@localhost:5432/driver_db` | PostgreSQL connection string | +| `SEAWEEDFS_S3_URL` | `http://seaweedfs-filer.storage.svc.cluster.local:8333` | S3 endpoint | +| `SEAWEEDFS_ACCESS_KEY` | *(empty)* | S3 access key | +| `SEAWEEDFS_SECRET_KEY` | *(empty)* | S3 secret key | +| `S3_BUCKET` | `sunbeam-driver` | S3 bucket name | +| `S3_REGION` | `us-east-1` | S3 region for signing | +| `VALKEY_URL` | `redis://localhost:6379/2` | Valkey/Redis URL for WOPI locks (falls back to in-memory if unavailable) | +| `KRATOS_PUBLIC_URL` | `http://kratos-public.ory.svc.cluster.local:80` | Kratos public API | +| `KETO_READ_URL` | `http://keto-read.ory.svc.cluster.local:4466` | Keto read API | +| `KETO_WRITE_URL` | `http://keto-write.ory.svc.cluster.local:4467` | Keto write API | +| `COLLABORA_URL` | `http://collabora.lasuite.svc.cluster.local:9980` | Collabora Online | +| `WOPI_JWT_SECRET` | `dev-wopi-secret-change-in-production` | HMAC secret for WOPI access tokens | +| `CSRF_COOKIE_SECRET` | `dev-secret-change-in-production` | HMAC secret for CSRF tokens | +| `DRIVER_TEST_MODE` | *(unset)* | Set to `1` to bypass auth (E2E testing only) | + +--- + +## Docs + +| Doc | What's in it | +|-----|-------------| +| [Architecture](docs/architecture.md) | How the pieces fit together and why there aren't many of them | +| [WOPI](docs/wopi.md) | Collabora integration — discovery, tokens, locks, the iframe dance | +| [Permissions](docs/permissions.md) | Keto OPL model — Zanzibar-style, hierarchical traversal | +| [S3 Layout](docs/s3-layout.md) | Human-readable keys, backfill, the metadata layer | +| [Testing](docs/testing.md) | Five test suites, coverage targets, Docker Compose for WOPI | +| [Local Dev](docs/local-dev.md) | Zero to running in 2 minutes | +| [Deployment](docs/deployment.md) | Kubernetes, Collabora config, deployment checklist | + +--- + +## License + +[MIT](LICENSE) — do whatever you want with it. + +Questions? `hello@sunbeam.pt` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..271a0f7 --- /dev/null +++ b/TODO.md @@ -0,0 +1,31 @@ +# Drive TODOs + +## Done + +### S3 Backfill +Shipped. `server/backfill.ts`, exposed as `POST /api/admin/backfill`. See [docs/s3-layout.md](docs/s3-layout.md#the-backfill-api). + +### OpenTelemetry +Shipped. `server/telemetry.ts` — tracing + metrics middleware, `withSpan` utility for all S3/DB/WOPI/Keto operations. OTLP gRPC export to Alloy/Tempo. + +## Open + +### Wire up Keto permission middleware +`server/permissions.ts` and `server/keto.ts` are fully implemented but not connected to routes in `main.ts`. File/folder CRUD currently checks `owner_id` equality only. The `ShareDialog.tsx` UI exists but calls a `/api/files/:id/share` endpoint that doesn't exist yet. This is the next big piece — needs Keto deployed in the cluster first. + +### CSRF token issuance +The CSRF token generation (`generateCsrfToken()`) and verification work, but no endpoint actually *issues* the token to the client. The UI client doesn't send `x-csrf-token` headers. In test mode CSRF is bypassed, so this is invisible during development. Needs: a middleware or session endpoint that sets the CSRF cookie, and the UI fetch client needs to read + send it on mutating requests. + +## Maybe Later + +### SeaweedFS filer webhook +SeaweedFS filer supports change notifications. A webhook handler could auto-register new objects as they land — no more manual backfill runs. Not a priority until someone is bulk-uploading to S3 regularly. + +### Lazy registration +Compare DB records against S3 on folder browse, auto-create missing rows. Sounds nice in theory, but it adds latency to the hot path and the explicit backfill endpoint handles the real use cases fine. + +### Real upload progress +`FileUpload.tsx` fakes progress on a 200ms timer. Use `XMLHttpRequest` with `upload.onprogress` for actual byte-level tracking. + +### Recursive path resolution via CTE +`buildPathFromParent()` in `files.ts` and `folders.ts` fires one DB query per folder level. Replace with a recursive CTE for single-query path resolution. Add a depth limit to prevent infinite loops from corrupted `parent_id` chains. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..82c20a2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,60 @@ +# WOPI integration test stack +# Usage: docker compose up -d && deno task test:wopi +# +# Spins up Collabora, PostgreSQL, and SeaweedFS. +# The Drive server runs on the host (not in Docker) so Playwright can reach it. +# Collabora needs to reach the host's Drive server for WOPI callbacks — +# we use host.docker.internal for that. + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: driver + POSTGRES_PASSWORD: driver + POSTGRES_DB: driver_db + ports: + - "5433:5432" # 5433 to avoid conflict with host postgres + healthcheck: + test: pg_isready -U driver + interval: 2s + timeout: 5s + retries: 10 + + seaweedfs: + image: chrislusf/seaweedfs:latest + command: "server -s3 -dir=/data -master.volumeSizeLimitMB=32" + ports: + - "8334:8333" # S3 API (8334 to avoid conflict with host weed mini) + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:9333/cluster/status"] + interval: 3s + timeout: 5s + retries: 10 + + collabora: + image: collabora/code:latest + ports: + - "9980:9980" + environment: + # Allow WOPI callbacks from the host machine's Drive server + aliasgroup1: "http://host\\.docker\\.internal:3200" + server_name: "localhost:9980" + extra_params: >- + --o:ssl.enable=false + --o:ssl.termination=false + --o:net.proto=IPv4 + --o:logging.level=warning + --o:num_prespawn_children=1 + --o:per_document.max_concurrency=2 + username: admin + password: admin + cap_add: + - SYS_CHROOT + - SYS_ADMIN + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:9980/hosting/discovery"] + interval: 5s + timeout: 10s + retries: 30 + start_period: 30s diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..94405ac --- /dev/null +++ b/deno.json @@ -0,0 +1,30 @@ +{ + "tasks": { + "dev": "deno task dev:server & deno task dev:ui", + "dev:server": "deno run -A --watch main.ts", + "dev:ui": "cd ui && npx vite", + "build:ui": "cd ui && npx vite build", + "build": "deno task build:ui && deno task compile", + "compile": "deno compile --allow-net --allow-read --allow-env --include ui/dist -o driver main.ts", + "test": "CSRF_COOKIE_SECRET=test-secret WOPI_JWT_SECRET=test-secret deno test -A --no-check tests/server/", + "test:ui": "cd ui && npx vitest run", + "test:e2e": "cd ui && npx playwright test e2e/driver.spec.ts", + "test:wopi": "cd ui && npx playwright test e2e/wopi.spec.ts", + "test:all": "deno task test && deno task test:ui && deno task test:e2e", + "migrate": "deno run -A server/migrate.ts" + }, + "imports": { + "hono": "jsr:@hono/hono@^4", + "hono/deno": "jsr:@hono/hono/deno", + "postgres": "https://deno.land/x/postgresjs@v3.4.5/mod.js", + "@opentelemetry/api": "npm:@opentelemetry/api@1.9.0", + "@opentelemetry/sdk-node": "npm:@opentelemetry/sdk-node@0.57.2", + "@opentelemetry/exporter-trace-otlp-grpc": "npm:@opentelemetry/exporter-trace-otlp-grpc@0.57.2", + "@opentelemetry/exporter-metrics-otlp-grpc": "npm:@opentelemetry/exporter-metrics-otlp-grpc@0.57.2", + "@opentelemetry/sdk-metrics": "npm:@opentelemetry/sdk-metrics@1.30.1", + "@opentelemetry/sdk-trace-base": "npm:@opentelemetry/sdk-trace-base@1.30.1", + "@opentelemetry/resources": "npm:@opentelemetry/resources@1.30.1", + "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@1.28.0", + "@opentelemetry/core": "npm:@opentelemetry/core@1.30.1" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..56f3c75 --- /dev/null +++ b/deno.lock @@ -0,0 +1,1267 @@ +{ + "version": "5", + "specifiers": { + "jsr:@hono/hono@*": "4.12.3", + "jsr:@hono/hono@4": "4.12.3", + "npm:@opentelemetry/api@1.9.0": "1.9.0", + "npm:@opentelemetry/core@1.30.1": "1.30.1_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/exporter-metrics-otlp-grpc@0.57.2": "0.57.2_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/exporter-trace-otlp-grpc@0.57.2": "0.57.2_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/resources@1.30.1": "1.30.1_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/sdk-metrics@1.30.1": "1.30.1_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/sdk-node@0.57.2": "0.57.2_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/sdk-trace-base@1.30.1": "1.30.1_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/semantic-conventions@1.28.0": "1.28.0", + "npm:vite@*": "7.3.1" + }, + "jsr": { + "@hono/hono@4.12.3": { + "integrity": "53c2d99912626a8bc293d6f69649af8148961b05d44485f2c0ed3053657d324c" + } + }, + "npm": { + "@esbuild/aix-ppc64@0.27.3": { + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.27.3": { + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.27.3": { + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.27.3": { + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.27.3": { + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.27.3": { + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.27.3": { + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.27.3": { + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.27.3": { + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.27.3": { + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.27.3": { + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.27.3": { + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.27.3": { + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.27.3": { + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.27.3": { + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.27.3": { + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.27.3": { + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.27.3": { + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.27.3": { + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.27.3": { + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.27.3": { + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.27.3": { + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.27.3": { + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.27.3": { + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.27.3": { + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.27.3": { + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@grpc/grpc-js@1.14.3": { + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "dependencies": [ + "@grpc/proto-loader", + "@js-sdsl/ordered-map" + ] + }, + "@grpc/proto-loader@0.8.0": { + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "dependencies": [ + "lodash.camelcase", + "long", + "protobufjs", + "yargs" + ], + "bin": true + }, + "@js-sdsl/ordered-map@4.4.2": { + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==" + }, + "@opentelemetry/api-logs@0.57.2": { + "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", + "dependencies": [ + "@opentelemetry/api" + ] + }, + "@opentelemetry/api@1.9.0": { + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" + }, + "@opentelemetry/context-async-hooks@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", + "dependencies": [ + "@opentelemetry/api" + ] + }, + "@opentelemetry/core@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/exporter-logs-otlp-grpc@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-eovEy10n3umjKJl2Ey6TLzikPE+W4cUQ4gCwgGP1RqzTGtgDra0WjIqdy29ohiUKfvmbiL3MndZww58xfIvyFw==", + "dependencies": [ + "@grpc/grpc-js", + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-grpc-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/sdk-logs" + ] + }, + "@opentelemetry/exporter-logs-otlp-http@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-0rygmvLcehBRp56NQVLSleJ5ITTduq/QfU7obOkyWgPpFHulwpw2LYTqNIz5TczKZuy5YY+5D3SDnXZL1tXImg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/sdk-logs" + ] + }, + "@opentelemetry/exporter-logs-otlp-proto@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-ta0ithCin0F8lu9eOf4lEz9YAScecezCHkMMyDkvd9S7AnZNX5ikUmC5EQOQADU+oCcgo/qkQIaKcZvQ0TYKDw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-logs", + "@opentelemetry/sdk-trace-base" + ] + }, + "@opentelemetry/exporter-metrics-otlp-grpc@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-r70B8yKR41F0EC443b5CGB4rUaOMm99I5N75QQt6sHKxYDzSEc6gm48Diz1CI1biwa5tDPznpylTrywO/pT7qw==", + "dependencies": [ + "@grpc/grpc-js", + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/exporter-metrics-otlp-http", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-grpc-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics" + ] + }, + "@opentelemetry/exporter-metrics-otlp-http@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-ttb9+4iKw04IMubjm3t0EZsYRNWr3kg44uUuzfo9CaccYlOh8cDooe4QObDUkvx9d5qQUrbEckhrWKfJnKhemA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics" + ] + }, + "@opentelemetry/exporter-metrics-otlp-proto@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-HX068Q2eNs38uf7RIkNN9Hl4Ynl+3lP0++KELkXMCpsCbFO03+0XNNZ1SkwxPlP9jrhQahsMPMkzNXpq3fKsnw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/exporter-metrics-otlp-http", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics" + ] + }, + "@opentelemetry/exporter-prometheus@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-VqIqXnuxWMWE/1NatAGtB1PvsQipwxDcdG4RwA/umdBcW3/iOHp0uejvFHTRN2O78ZPged87ErJajyUBPUhlDQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics" + ] + }, + "@opentelemetry/exporter-trace-otlp-grpc@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-gHU1vA3JnHbNxEXg5iysqCWxN9j83d7/epTYBZflqQnTyCC4N7yZXn/dMM+bEmyhQPGjhCkNZLx4vZuChH1PYw==", + "dependencies": [ + "@grpc/grpc-js", + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-grpc-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-trace-base" + ] + }, + "@opentelemetry/exporter-trace-otlp-http@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-trace-base" + ] + }, + "@opentelemetry/exporter-trace-otlp-proto@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-awDdNRMIwDvUtoRYxRhja5QYH6+McBLtoz1q9BeEsskhZcrGmH/V1fWpGx8n+Rc+542e8pJA6y+aullbIzQmlw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-trace-base" + ] + }, + "@opentelemetry/exporter-zipkin@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-6S2QIMJahIquvFaaxmcwpvQQRD/YFaMTNoIxrfPIPOeITN+a8lfEcPDxNxn8JDAaxkg+4EnXhz8upVDYenoQjA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/sdk-trace-base", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@types/shimmer", + "import-in-the-middle", + "require-in-the-middle", + "semver", + "shimmer" + ] + }, + "@opentelemetry/otlp-exporter-base@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-transformer" + ] + }, + "@opentelemetry/otlp-grpc-exporter-base@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-USn173KTWy0saqqRB5yU9xUZ2xdgb1Rdu5IosJnm9aV4hMTuFFRTUsQxbgc24QxpCHeoKzzCSnS/JzdV0oM2iQ==", + "dependencies": [ + "@grpc/grpc-js", + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer" + ] + }, + "@opentelemetry/otlp-transformer@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/sdk-logs", + "@opentelemetry/sdk-metrics", + "@opentelemetry/sdk-trace-base", + "protobufjs" + ] + }, + "@opentelemetry/propagator-b3@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core" + ] + }, + "@opentelemetry/propagator-jaeger@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core" + ] + }, + "@opentelemetry/resources@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/sdk-logs@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/resources" + ] + }, + "@opentelemetry/sdk-metrics@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources" + ] + }, + "@opentelemetry/sdk-node@0.57.2_@opentelemetry+api@1.9.0": { + "integrity": "sha512-8BaeqZyN5sTuPBtAoY+UtKwXBdqyuRKmekN5bFzAO40CgbGzAxfTpiL3PBerT7rhZ7p2nBdq7FaMv/tBQgHE4A==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/exporter-logs-otlp-grpc", + "@opentelemetry/exporter-logs-otlp-http", + "@opentelemetry/exporter-logs-otlp-proto", + "@opentelemetry/exporter-metrics-otlp-grpc", + "@opentelemetry/exporter-metrics-otlp-http", + "@opentelemetry/exporter-metrics-otlp-proto", + "@opentelemetry/exporter-prometheus", + "@opentelemetry/exporter-trace-otlp-grpc", + "@opentelemetry/exporter-trace-otlp-http", + "@opentelemetry/exporter-trace-otlp-proto", + "@opentelemetry/exporter-zipkin", + "@opentelemetry/instrumentation", + "@opentelemetry/resources", + "@opentelemetry/sdk-logs", + "@opentelemetry/sdk-metrics", + "@opentelemetry/sdk-trace-base", + "@opentelemetry/sdk-trace-node", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/sdk-trace-base@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/sdk-trace-node@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/context-async-hooks", + "@opentelemetry/core", + "@opentelemetry/propagator-b3", + "@opentelemetry/propagator-jaeger", + "@opentelemetry/sdk-trace-base", + "semver" + ] + }, + "@opentelemetry/semantic-conventions@1.28.0": { + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==" + }, + "@protobufjs/aspromise@1.1.2": { + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64@1.1.2": { + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen@2.0.4": { + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter@1.1.0": { + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "@protobufjs/fetch@1.1.0": { + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": [ + "@protobufjs/aspromise", + "@protobufjs/inquire" + ] + }, + "@protobufjs/float@1.0.2": { + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/inquire@1.1.0": { + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "@protobufjs/path@1.1.2": { + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool@1.1.0": { + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8@1.1.0": { + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "@rollup/rollup-android-arm-eabi@4.59.0": { + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "os": ["android"], + "cpu": ["arm"] + }, + "@rollup/rollup-android-arm64@4.59.0": { + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-arm64@4.59.0": { + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-x64@4.59.0": { + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rollup/rollup-freebsd-arm64@4.59.0": { + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@rollup/rollup-freebsd-x64@4.59.0": { + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-arm-gnueabihf@4.59.0": { + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm-musleabihf@4.59.0": { + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm64-gnu@4.59.0": { + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-arm64-musl@4.59.0": { + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-loong64-gnu@4.59.0": { + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-loong64-musl@4.59.0": { + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-ppc64-gnu@4.59.0": { + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-ppc64-musl@4.59.0": { + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-riscv64-gnu@4.59.0": { + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-riscv64-musl@4.59.0": { + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-s390x-gnu@4.59.0": { + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rollup/rollup-linux-x64-gnu@4.59.0": { + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-x64-musl@4.59.0": { + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-openbsd-x64@4.59.0": { + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-openharmony-arm64@4.59.0": { + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-arm64-msvc@4.59.0": { + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-ia32-msvc@4.59.0": { + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@rollup/rollup-win32-x64-gnu@4.59.0": { + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rollup/rollup-win32-x64-msvc@4.59.0": { + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@types/estree@1.0.8": { + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "@types/node@25.4.0": { + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "dependencies": [ + "undici-types" + ] + }, + "@types/shimmer@1.2.0": { + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==" + }, + "acorn-import-attributes@1.9.5_acorn@8.16.0": { + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dependencies": [ + "acorn" + ] + }, + "acorn@8.16.0": { + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "bin": true + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "cjs-module-lexer@1.4.3": { + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==" + }, + "cliui@8.0.1": { + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": [ + "string-width", + "strip-ansi", + "wrap-ansi" + ] + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "emoji-regex@8.0.0": { + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "esbuild@0.27.3": { + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "fdir@6.5.0_picomatch@4.0.3": { + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dependencies": [ + "picomatch" + ], + "optionalPeers": [ + "picomatch" + ] + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-caller-file@2.0.5": { + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "hasown@2.0.2": { + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": [ + "function-bind" + ] + }, + "import-in-the-middle@1.15.0": { + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "dependencies": [ + "acorn", + "acorn-import-attributes", + "cjs-module-lexer", + "module-details-from-path" + ] + }, + "is-core-module@2.16.1": { + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dependencies": [ + "hasown" + ] + }, + "is-fullwidth-code-point@3.0.0": { + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "lodash.camelcase@4.3.0": { + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "long@5.3.2": { + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "module-details-from-path@1.0.4": { + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==" + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nanoid@3.3.11": { + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": true + }, + "path-parse@1.0.7": { + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@4.0.3": { + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + }, + "postcss@8.5.8": { + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + "protobufjs@7.5.4": { + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "dependencies": [ + "@protobufjs/aspromise", + "@protobufjs/base64", + "@protobufjs/codegen", + "@protobufjs/eventemitter", + "@protobufjs/fetch", + "@protobufjs/float", + "@protobufjs/inquire", + "@protobufjs/path", + "@protobufjs/pool", + "@protobufjs/utf8", + "@types/node", + "long" + ], + "scripts": true + }, + "require-directory@2.1.1": { + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "require-in-the-middle@7.5.2": { + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "dependencies": [ + "debug", + "module-details-from-path", + "resolve" + ] + }, + "resolve@1.22.11": { + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dependencies": [ + "is-core-module", + "path-parse", + "supports-preserve-symlinks-flag" + ], + "bin": true + }, + "rollup@4.59.0": { + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dependencies": [ + "@types/estree" + ], + "optionalDependencies": [ + "@rollup/rollup-android-arm-eabi", + "@rollup/rollup-android-arm64", + "@rollup/rollup-darwin-arm64", + "@rollup/rollup-darwin-x64", + "@rollup/rollup-freebsd-arm64", + "@rollup/rollup-freebsd-x64", + "@rollup/rollup-linux-arm-gnueabihf", + "@rollup/rollup-linux-arm-musleabihf", + "@rollup/rollup-linux-arm64-gnu", + "@rollup/rollup-linux-arm64-musl", + "@rollup/rollup-linux-loong64-gnu", + "@rollup/rollup-linux-loong64-musl", + "@rollup/rollup-linux-ppc64-gnu", + "@rollup/rollup-linux-ppc64-musl", + "@rollup/rollup-linux-riscv64-gnu", + "@rollup/rollup-linux-riscv64-musl", + "@rollup/rollup-linux-s390x-gnu", + "@rollup/rollup-linux-x64-gnu", + "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-openbsd-x64", + "@rollup/rollup-openharmony-arm64", + "@rollup/rollup-win32-arm64-msvc", + "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-gnu", + "@rollup/rollup-win32-x64-msvc", + "fsevents" + ], + "bin": true + }, + "semver@7.7.4": { + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": true + }, + "shimmer@1.2.1": { + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "string-width@4.2.3": { + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": [ + "emoji-regex", + "is-fullwidth-code-point", + "strip-ansi" + ] + }, + "strip-ansi@6.0.1": { + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": [ + "ansi-regex" + ] + }, + "supports-preserve-symlinks-flag@1.0.0": { + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + "tinyglobby@0.2.15": { + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dependencies": [ + "fdir", + "picomatch" + ] + }, + "undici-types@7.18.2": { + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" + }, + "vite@7.3.1": { + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dependencies": [ + "esbuild", + "fdir", + "picomatch", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, + "wrap-ansi@7.0.0": { + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": [ + "ansi-styles", + "string-width", + "strip-ansi" + ] + }, + "y18n@5.0.8": { + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yargs-parser@21.1.1": { + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + }, + "yargs@17.7.2": { + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": [ + "cliui", + "escalade", + "get-caller-file", + "require-directory", + "string-width", + "y18n", + "yargs-parser" + ] + } + }, + "remote": { + "https://deno.land/std@0.132.0/_deno_unstable.ts": "23a1a36928f1b6d3b0170aaa67de09af12aa998525f608ff7331b9fb364cbde6", + "https://deno.land/std@0.132.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", + "https://deno.land/std@0.132.0/_util/os.ts": "49b92edea1e82ba295ec946de8ffd956ed123e2948d9bd1d3e901b04e4307617", + "https://deno.land/std@0.132.0/_wasm_crypto/crypto.mjs": "3b383eb715e8bfe61b4450ef0644b2653429c88d494807c86c5235979f62e56b", + "https://deno.land/std@0.132.0/_wasm_crypto/crypto.wasm.mjs": "0ad9ecc0d03ca8a083d9109db22e7507f019f63cf55b82ea618ab58855617577", + "https://deno.land/std@0.132.0/_wasm_crypto/mod.ts": "30a93c8b6b6c5b269e96a3e95d2c045d86a496814a8737443b77cad941d6a0b5", + "https://deno.land/std@0.132.0/async/abortable.ts": "87aa7230be8360c24ad437212311c9e8d4328854baec27b4c7abb26e85515c06", + "https://deno.land/std@0.132.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0", + "https://deno.land/std@0.132.0/async/debounce.ts": "564273ef242bcfcda19a439132f940db8694173abffc159ea34f07d18fc42620", + "https://deno.land/std@0.132.0/async/deferred.ts": "bc18e28108252c9f67dfca2bbc4587c3cbf3aeb6e155f8c864ca8ecff992b98a", + "https://deno.land/std@0.132.0/async/delay.ts": "cbbdf1c87d1aed8edc7bae13592fb3e27e3106e0748f089c263390d4f49e5f6c", + "https://deno.land/std@0.132.0/async/mod.ts": "2240c6841157738414331f47dee09bb8c0482c5b1980b6e3234dd03515c8132f", + "https://deno.land/std@0.132.0/async/mux_async_iterator.ts": "f4d1d259b0c694d381770ddaaa4b799a94843eba80c17f4a2ec2949168e52d1e", + "https://deno.land/std@0.132.0/async/pool.ts": "97b0dd27c69544e374df857a40902e74e39532f226005543eabacb551e277082", + "https://deno.land/std@0.132.0/async/tee.ts": "1341feb1f5b1a96f8628d0f8fc07d8c43d3813423f18a63bf1b4785568d21b1f", + "https://deno.land/std@0.132.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d", + "https://deno.land/std@0.132.0/bytes/equals.ts": "fc16dff2090cced02497f16483de123dfa91e591029f985029193dfaa9d894c9", + "https://deno.land/std@0.132.0/bytes/mod.ts": "d3b455c0dbd4804644159d1e25946ade5ee385d2359894de49e2c6101b18b7a9", + "https://deno.land/std@0.132.0/encoding/base64.ts": "c8c16b4adaa60d7a8eee047c73ece26844435e8f7f1328d74593dbb2dd58ea4f", + "https://deno.land/std@0.132.0/encoding/base64url.ts": "55f9d13df02efac10c6f96169daa3e702606a64e8aa27c0295f645f198c27130", + "https://deno.land/std@0.132.0/encoding/hex.ts": "7f023e1e51cfd6b189682e602e8640939e7be71a300a2fcf3daf8f84dc609bbc", + "https://deno.land/std@0.132.0/flags/mod.ts": "430cf2d1c26e00286373b2647ebdca637f7558505e88e9c108a4742cd184c916", + "https://deno.land/std@0.132.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37", + "https://deno.land/std@0.132.0/fmt/printf.ts": "e2c0f72146aed1efecf0c39ab928b26ae493a2278f670a871a0fbdcf36ff3379", + "https://deno.land/std@0.132.0/fs/eol.ts": "b92f0b88036de507e7e6fbedbe8f666835ea9dcbf5ac85917fa1fadc919f83a5", + "https://deno.land/std@0.132.0/fs/exists.ts": "cb734d872f8554ea40b8bff77ad33d4143c1187eac621a55bf37781a43c56f6d", + "https://deno.land/std@0.132.0/hash/sha256.ts": "803846c7a5a8a5a97f31defeb37d72f519086c880837129934f5d6f72102a8e8", + "https://deno.land/std@0.132.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b", + "https://deno.land/std@0.132.0/node/_buffer.mjs": "f4a7df481d4eed06dc0151b833177d8ef74fc3a96dd4d2b073e690b6ced9474d", + "https://deno.land/std@0.132.0/node/_core.ts": "568d277be2e086af996cbdd599fec569f5280e9a494335ca23ad392b130d7bb9", + "https://deno.land/std@0.132.0/node/_crypto/constants.ts": "49011c87be4e45407ef5e99e96bde3f08656ebd8e6dfc99048c703dd0ce53952", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/asn1.js/base/buffer.js": "73beb8294eb29bd61458bbaaeeb51dfad4ec9c9868a62207a061d908f1637261", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/asn1.js/base/node.js": "4b777980d2a23088698fd2ff065bb311a2c713497d359e674cb6ef6baf267a0f", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/asn1.js/base/reporter.js": "8e4886e8ae311c9a92caf58bbbd8670326ceeae97430f4884e558e4acf8e8598", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/asn1.js/constants/der.js": "354b255479bff22a31d25bf08b217a295071700e37d0991cc05cac9f95e5e7ca", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/asn1.js/decoders/der.js": "c6faf66761daa43fbf79221308443893587c317774047b508a04c570713b76fb", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/asn1.js/decoders/pem.js": "8316ef7ce2ce478bc3dc1e9df1b75225d1eb8fb5d1378f8adf0cf19ecea5b501", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/asn1.js/encoders/der.js": "408336c88d17c5605ea64081261cf42267d8f9fda90098cb560aa6635bb00877", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/asn1.js/encoders/pem.js": "42a00c925b68c0858d6de0ba41ab89935b39fae9117bbf72a9abb2f4b755a2e7", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/asn1.js/mod.js": "7b78859707be10a0a1e4faccdd28cd5a4f71ad74a3e7bebda030757da97cd232", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/bn.js/bn.js": "abd1badd659fd0ae54e6a421a573a25aef4e795edc392178360cf716b144286d", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/aes.js": "1cf4c354c5bb341ffc9ab7207f471229835b021947225bce2e1642f26643847a", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/auth_cipher.js": "19b4dbb903e8406eb733176e6318d5e1a3bd382b67b72f7cf8e1c46cc6321ba4", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/decrypter.js": "05c1676942fd8e95837115bc2d1371bcf62e9bf19f6c3348870961fc64ddad0b", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/encrypter.js": "93ec98ab26fbeb5969eae2943e42fb66780f377b9b0ff0ecc32a9ed11201b142", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/ghash.js": "667b64845764a84f0096ef8cf7debed1a5f15ac9af26b379848237be57da399a", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/incr32.js": "4a7f0107753e4390b4ccc4dbd5200c5527d43f894f768e131903df30a09dfd67", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/mod.js": "d8eb88e7a317467831473621f32e60d7db9d981f6a2ae45d2fb2af170eab2d22", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/modes/cbc.js": "9790799cff181a074686c885708cb8eb473aeb3c86ff2e8d0ff911ae6c1e4431", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/modes/cfb.js": "a4e36ede6f26d8559d8f0528a134592761c706145a641bd9ad1100763e831cdb", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/modes/cfb1.js": "c6372f4973a68ca742682e81d1165e8869aaabf0091a8b963d4d60e5ee8e6f6a", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/modes/cfb8.js": "bd29eebb89199b056ff2441f7fb5e0300f458e13dcaaddbb8bc00cbdb199db67", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/modes/ctr.js": "9c2cbac1fc8f9b58334faacb98e6c57e8c3712f673ea4cf2d528a2894998ab2f", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/modes/ecb.js": "9629d193433688f0cfc432eca52838db0fb28d9eb4f45563df952bde50b59763", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/modes/mod.js": "7d8516ef8a20565539eb17cad5bb70add02ac06d1891e8f47cb981c22821787e", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/modes/ofb.js": "c23abaa6f1ec5343e9d7ba61d702acb3d81a0bd3d34dd2004e36975dc043d6ff", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/stream_cipher.js": "a533a03a2214c6b5934ce85a59eb1e04239fd6f429017c7ca3c443ec7e07e68f", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_aes/xor.ts": "4417711c026eb9a07475067cd31fa601e88c2d6ababd606d33d1e74da6fcfd09", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/browserify_rsa.js": "de8c98d2379a70d8c239b4886e2b3a11c7204eec39ae6b65d978d0d516ee6b08", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/cipher_base.js": "f565ad9daf3b3dd3b68381bed848da94fb093a9e4e5a48c92f47e26cc229df39", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/evp_bytes_to_key.ts": "8bd9fa445576b3e39586bdbef7c907f1dfda201bf22602d2ca1c6d760366311e", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/parse_asn1/asn1.js": "4f33b0197ffbe9cff62e5bad266e6b40d55874ea653552bb32ed251ad091f70a", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/parse_asn1/certificate.js": "aab306870830a81ad188db8fa8e037d7f5dd6c5abdabbd9739558245d1a12224", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/parse_asn1/fix_proc.js": "af3052b76f441878e102ffcfc7420692e65777af765e96f786310ae1acf7f76a", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/parse_asn1/mod.js": "e923a13b27089a99eeb578d2ffb9b4cfe8ce690918fec05d0024fa126f3e1ce3", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/public_encrypt/mgf.js": "5b81dc1680829b564fc5a00b842fb9c88916e4639b4fa27fa8bb6b759d272371", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/public_encrypt/mod.js": "eb8b64d7a58ee3823c1b642e799cc7ed1257d99f4d4aefa2b4796dd112ec094a", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/public_encrypt/private_decrypt.js": "0050df879f7c1338132c45056835f64e32140e2a2d5d03c1366ccce64855f632", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/public_encrypt/public_encrypt.js": "0132cb4fb8f72593278474884195b9c52b4e9ba33d8ddd22116d07a07f47005a", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/public_encrypt/with_public.js": "7373dac9b53b8331ccf3521c854a131dcb304a2e4d34cd116649118f7919ed0c", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/public_encrypt/xor.js": "900c6fc8b95e1861d796193c41988f5f70a09c7059e42887a243d0113ecaf0fd", + "https://deno.land/std@0.132.0/node/_crypto/crypto_browserify/randombytes.ts": "f465cd8e114a3c110297e0143445b12125d729b25bada5bd88d5b30cf612d7dd", + "https://deno.land/std@0.132.0/node/_crypto/hash.ts": "6a84a079412d09ead27b900590f0bede9924bc7ce522b8b7d55183a2aaf63a68", + "https://deno.land/std@0.132.0/node/_crypto/pbkdf2.ts": "00af38578729b3060371dfee70dae502a5848b4cc4787c48f634195cab1ce89a", + "https://deno.land/std@0.132.0/node/_crypto/randomBytes.ts": "04e276bcbfa55b3502c7169deab3f2bf58bbc5e9727634f8a150eff734338e90", + "https://deno.land/std@0.132.0/node/_crypto/randomFill.ts": "019ff2a8330c3ede6e65af28c5a8e3dee9d404975749c8dadf6ba11ccc28528e", + "https://deno.land/std@0.132.0/node/_crypto/randomInt.ts": "2db981c2baf4ddac07b6da71f90677f4acf4dc2d93f351563fdd084d645b8413", + "https://deno.land/std@0.132.0/node/_crypto/scrypt.ts": "caf07a6b8afa6ac582f80f99ed7dc7fefd5476fcd2aad771b8440e5b883d6d70", + "https://deno.land/std@0.132.0/node/_crypto/timingSafeEqual.ts": "4a4ef17e482889d9d82138d5ffc0e787c32c04b1f12b28d076b1a69ceca46af1", + "https://deno.land/std@0.132.0/node/_crypto/types.ts": "d3fae758c5b62f63d8126c76eec31a5559a2f34305defb5fe2a7d9034057ff54", + "https://deno.land/std@0.132.0/node/_dns/_utils.ts": "42494c8b8fa1c13eb134c5696744f77197717fa857e4d05147f2395a739b0a40", + "https://deno.land/std@0.132.0/node/_events.mjs": "d7d56df4b9f69e445064bad5e5558978fb18c18c439bbb62fa13149b40d7fb99", + "https://deno.land/std@0.132.0/node/_fixed_queue.ts": "455b3c484de48e810b13bdf95cd1658ecb1ba6bcb8b9315ffe994efcde3ba5f5", + "https://deno.land/std@0.132.0/node/_fs/_fs_access.ts": "0700488320d37208d000b94767ab37208d469550767edab69b65b09a330f245d", + "https://deno.land/std@0.132.0/node/_fs/_fs_appendFile.ts": "5dca59d7f2ec33316d75da3d6a12d39f5c35b429bddf83f4b2c030b3a289d4b3", + "https://deno.land/std@0.132.0/node/_fs/_fs_chmod.ts": "8fc25677b82a2643686e6f8270a8f1bee87dd60986334591450699e199dac7d5", + "https://deno.land/std@0.132.0/node/_fs/_fs_chown.ts": "57858c54d376648fc3c8cf5a8ad4f7f19fb153b75fac3ed41df0332d757e7de9", + "https://deno.land/std@0.132.0/node/_fs/_fs_close.ts": "785a9d1a6d615e8aa9f5a4ac50c9a131931f8b0e17b3d4671cd1fd25a5c10f2b", + "https://deno.land/std@0.132.0/node/_fs/_fs_common.ts": "6a373d1583d9ec5cc7a8ff1072d77dc999e35282a320b7477038a2b209c304d3", + "https://deno.land/std@0.132.0/node/_fs/_fs_constants.ts": "5c20b190fc6b7cfdaf12a30ba545fc787db2c7bbe87ed5b890da99578116a339", + "https://deno.land/std@0.132.0/node/_fs/_fs_copy.ts": "675eb02a2dfc20dab1186bf6ed0a33b1abae656f440bc0a3ce74f385e0052eef", + "https://deno.land/std@0.132.0/node/_fs/_fs_dir.ts": "8a05f72e32dd568b41ef45f8f55f1f54e9a306a7588475fa7014289cd12872d9", + "https://deno.land/std@0.132.0/node/_fs/_fs_dirent.ts": "649c0a794e7b8d930cdd7e6a168b404aa0448bf784e0cfbe1bd6d25b99052273", + "https://deno.land/std@0.132.0/node/_fs/_fs_exists.ts": "83e9ca6ea1ab3c6c7c3fc45f3c1287ee88839f08140ac11056441537450055bb", + "https://deno.land/std@0.132.0/node/_fs/_fs_fdatasync.ts": "bbd078fea6c62c64d898101d697aefbfbb722797a75e328a82c2a4f2e7eb963d", + "https://deno.land/std@0.132.0/node/_fs/_fs_fstat.ts": "559ff6ff094337db37b0f3108aeaecf42672795af45b206adbd90105afebf9c6", + "https://deno.land/std@0.132.0/node/_fs/_fs_fsync.ts": "590be69ce5363dd4f8867f244cfabe8df89d40f86bbbe44fd00d69411d0b798e", + "https://deno.land/std@0.132.0/node/_fs/_fs_ftruncate.ts": "8eb2a9fcf026bd9b85dc07a22bc452c48db4be05ab83f5f2b6a0549e15c1f75f", + "https://deno.land/std@0.132.0/node/_fs/_fs_futimes.ts": "c753cb9e9f129a11d1110ed43905b8966ac2a1d362ed69d5a34bb44513b00082", + "https://deno.land/std@0.132.0/node/_fs/_fs_link.ts": "3f9ccce31c2e56284fbcf2c65ec2e6fed1d9e67a9997410223486ac5092888e3", + "https://deno.land/std@0.132.0/node/_fs/_fs_lstat.ts": "571cea559d270e3b2e7fc585b0eb051899f6d0e54b1786f5e2cee3e9f71e7f27", + "https://deno.land/std@0.132.0/node/_fs/_fs_mkdir.ts": "68421a23b6d3c2d0142a6d0b3ccdd87903f9c8f98d6754aba554ab4c6b435bb8", + "https://deno.land/std@0.132.0/node/_fs/_fs_mkdtemp.ts": "86eaec96c63ea178c749fa856115a345e9797baecad22297b9ef98e3d62b90e2", + "https://deno.land/std@0.132.0/node/_fs/_fs_open.ts": "b1ca72addd2723b2a5a876378e72609fbe168adad2006f5d7b4f1868beef65ca", + "https://deno.land/std@0.132.0/node/_fs/_fs_read.ts": "3b4ef96aad20f3f29a859125ebeac8c9461574743f70c2a7ef301b8505f7d036", + "https://deno.land/std@0.132.0/node/_fs/_fs_readFile.ts": "3eae6c930e08c1279d400c0f5a008e6d96949ff3a4f5bf7d43e1b94b94ce3854", + "https://deno.land/std@0.132.0/node/_fs/_fs_readdir.ts": "a546f01387b7c49ddc1bd78d0e123a9668c710c56cffb4d9577ef46703cab463", + "https://deno.land/std@0.132.0/node/_fs/_fs_readlink.ts": "00553cd155f3bea565ffe43d7f0c10d75e895455562e1e8ea153e8f4e7ac04c7", + "https://deno.land/std@0.132.0/node/_fs/_fs_realpath.ts": "3ec236e4ad3c171203043422939973b6a948200ec4802425db41fa60c860dde9", + "https://deno.land/std@0.132.0/node/_fs/_fs_rename.ts": "3be71e8f43275c349b7abb9343b6e6764df09fabcbd2d316f8ac170ea556c645", + "https://deno.land/std@0.132.0/node/_fs/_fs_rm.ts": "a9328f99d925d7c74d31361d466ca33475aa7c6d1d6f037a49ce1ed996f0a0b4", + "https://deno.land/std@0.132.0/node/_fs/_fs_rmdir.ts": "b74007891357e709b37e6721eb355a1c4f25575995bb7c961a3c40f03ebc624c", + "https://deno.land/std@0.132.0/node/_fs/_fs_stat.ts": "bd47ce0bfc2b867392abc6ec95878ab4f6dddb94af73903d6fa1a02ba3e26af8", + "https://deno.land/std@0.132.0/node/_fs/_fs_streams.ts": "0e54bd4e41b462a701d6729ea17db01624aa48109e402fea8eecf13be324cf16", + "https://deno.land/std@0.132.0/node/_fs/_fs_symlink.ts": "0bddc37c5092f847634bd41cee0b643b9c03fc541c0e635cf35da1fcb4d0f7fa", + "https://deno.land/std@0.132.0/node/_fs/_fs_truncate.ts": "e2d380f7a81f69c4d4db30c442558ba8d8dea561e5097af41022bb5724e494e5", + "https://deno.land/std@0.132.0/node/_fs/_fs_unlink.ts": "c537ca98e507972d65f0b113a179b5f5083f0da3e6f9fae29895fd2a9660c18a", + "https://deno.land/std@0.132.0/node/_fs/_fs_utimes.ts": "c4446b7e39bf6977eca4364360501a97b96db9ea41e0cdf49abddab73481a175", + "https://deno.land/std@0.132.0/node/_fs/_fs_watch.ts": "2338de777458021d39cb9f0a5f3ea1bd9109a7ca2c2ad6ec41029df1753838f8", + "https://deno.land/std@0.132.0/node/_fs/_fs_write.mjs": "8c130b8b9522e1e4b08e687eb27939240260c115fda1e38e99c57b4f3af6481f", + "https://deno.land/std@0.132.0/node/_fs/_fs_writeFile.ts": "79d176021c8ceae0d956763a33834166ebc3f1691ed9219a21674b2374f115c3", + "https://deno.land/std@0.132.0/node/_fs/_fs_writev.mjs": "274df0a109010862c8f8b320dc7784de9bd9425fe2a6afd05f1f06f547a25cba", + "https://deno.land/std@0.132.0/node/_next_tick.ts": "64c361f6bca21df2a72dd77b84bd49d80d97a694dd3080703bc78f52146351d1", + "https://deno.land/std@0.132.0/node/_options.ts": "27f3c1269a700d330cc046cf748aa9178b8fc39d1473de625688e07cb0eb9d28", + "https://deno.land/std@0.132.0/node/_process/exiting.ts": "bc9694769139ffc596f962087155a8bfef10101d03423b9dcbc51ce6e1f88fce", + "https://deno.land/std@0.132.0/node/_process/process.ts": "84644b184053835670f79652d1ce3312c9ad079c211e6207ebefeedf159352a3", + "https://deno.land/std@0.132.0/node/_process/stdio.mjs": "971c3b086040d8521562155db13f22f9971d5c42c852b2081d4d2f0d8b6ab6bd", + "https://deno.land/std@0.132.0/node/_process/streams.mjs": "555062e177ad05f887147651fdda25fa55098475fcf142c8d162b8fe14097bbb", + "https://deno.land/std@0.132.0/node/_stream.mjs": "07f6cbabaad0382fb4b9a25e70ac3093a44022b859247f64726746e6373f1c91", + "https://deno.land/std@0.132.0/node/_util/_util_callbackify.ts": "79928ad80df3e469f7dcdb198118a7436d18a9f6c08bd7a4382332ad25a718cf", + "https://deno.land/std@0.132.0/node/_utils.ts": "c2c352e83c4c96f5ff994b1c8246bff2abcb21bfc3f1c06162cb3af1d201e615", + "https://deno.land/std@0.132.0/node/buffer.ts": "fbecbf3f237fa49bec96e97ecf56a7b92d48037b3d11219288e68943cc921600", + "https://deno.land/std@0.132.0/node/crypto.ts": "fffbc3fc3dcc16ea986d3e89eed5f70db7dfef2c18d1205a8c8fe5327ee0192d", + "https://deno.land/std@0.132.0/node/dns.ts": "ae2abd1bc8ac79543fe4d702f2aa3607101dc788b6eeba06e06436cb42ee3779", + "https://deno.land/std@0.132.0/node/events.ts": "a1d40fc0dbccc944379ef968b80ea08f9fce579e88b5057fdb64e4f0812476dd", + "https://deno.land/std@0.132.0/node/fs.ts": "21a3189c460bd37ac3f6734e040587125b7c8435c0a9da4e6c57544a3aca81c2", + "https://deno.land/std@0.132.0/node/internal/assert.mjs": "118327c8866266534b30d3a36ad978204af7336dc2db3158b8167192918d4e06", + "https://deno.land/std@0.132.0/node/internal/async_hooks.ts": "8eca5b80f58ffb259e9b3a73536dc2fe2e67d07fd24bfe2aee325a4aa435edb3", + "https://deno.land/std@0.132.0/node/internal/blob.mjs": "52080b2f40b114203df67f8a6650f9fe3c653912b8b3ef2f31f029853df4db53", + "https://deno.land/std@0.132.0/node/internal/buffer.mjs": "6662fe7fe517329453545be34cea27a24f8ccd6d09afd4f609f11ade2b6dfca7", + "https://deno.land/std@0.132.0/node/internal/crypto/keys.ts": "16ce7b15a9fc5e4e3dee8fde75dae12f3d722558d5a1a6e65a9b4f86d64a21e9", + "https://deno.land/std@0.132.0/node/internal/crypto/util.mjs": "1de55a47fdbed6721b467a77ba48fdd1550c10b5eee77bbdb602eaffee365a5e", + "https://deno.land/std@0.132.0/node/internal/dtrace.ts": "50dd0e77b0269e47ff673bdb9ad0ef0ea3a3c53ac30c1695883ce4748e04ca14", + "https://deno.land/std@0.132.0/node/internal/error_codes.ts": "ac03c4eae33de3a69d6c98e8678003207eecf75a6900eb847e3fea3c8c9e6d8f", + "https://deno.land/std@0.132.0/node/internal/errors.ts": "25f91691225b001660e6e64745ecd336fbf562cf0185e8896ff013c2d0226794", + "https://deno.land/std@0.132.0/node/internal/fs/streams.ts": "c925db185efdf56c35cde8270c07d61698b80603a90e07caf1cb4ff80abf195b", + "https://deno.land/std@0.132.0/node/internal/fs/utils.mjs": "2a571ecbd169b444f07b7193306f108fdcb4bfd9b394b33716ad05edf30e899e", + "https://deno.land/std@0.132.0/node/internal/hide_stack_frames.ts": "a91962ec84610bc7ec86022c4593cdf688156a5910c07b5bcd71994225c13a03", + "https://deno.land/std@0.132.0/node/internal/idna.ts": "a8bdd28431f06630d8aad85d3cb8fd862459107af228c8805373ad2080f1c587", + "https://deno.land/std@0.132.0/node/internal/net.ts": "1239886cd2508a68624c2dae8abf895e8aa3bb15a748955349f9ac5539032238", + "https://deno.land/std@0.132.0/node/internal/normalize_encoding.mjs": "3779ec8a7adf5d963b0224f9b85d1bc974a2ec2db0e858396b5d3c2c92138a0a", + "https://deno.land/std@0.132.0/node/internal/process/per_thread.mjs": "a42b1dcfb009ad5039144a999a35a429e76112f9322febbe353eda9d1879d936", + "https://deno.land/std@0.132.0/node/internal/querystring.ts": "c3b23674a379f696e505606ddce9c6feabe9fc497b280c56705c340f4028fe74", + "https://deno.land/std@0.132.0/node/internal/stream_base_commons.ts": "934a9e69f46f2de644956edfa9fb040af7861e326fe5325dab38ef9caf2940bc", + "https://deno.land/std@0.132.0/node/internal/streams/_utils.ts": "77fceaa766679847e4d4c3c96b2573c00a790298d90551e8e4df1d5e0fdaad3b", + "https://deno.land/std@0.132.0/node/internal/streams/add-abort-signal.mjs": "5623b83fa64d439cc4a1f09ae47ec1db29512cc03479389614d8f62a37902f5e", + "https://deno.land/std@0.132.0/node/internal/streams/buffer_list.mjs": "c6a7b29204fae025ff5e9383332acaea5d44bc7c522a407a79b8f7a6bc6c312d", + "https://deno.land/std@0.132.0/node/internal/streams/compose.mjs": "b522daab35a80ae62296012a4254fd7edfc0366080ffe63ddda4e38fe6b6803e", + "https://deno.land/std@0.132.0/node/internal/streams/destroy.mjs": "9c9bbeb172a437041d529829f433df72cf0b63ae49f3ee6080a55ffbef7572ad", + "https://deno.land/std@0.132.0/node/internal/streams/duplex.mjs": "b014087cd04f79b8a4028d8b9423b987e07bbfacf3b5df518cb752ac3657580f", + "https://deno.land/std@0.132.0/node/internal/streams/end-of-stream.mjs": "38be76eaceac231dfde643e72bc0940625446bf6d1dbd995c91c5ba9fd59b338", + "https://deno.land/std@0.132.0/node/internal/streams/from.mjs": "134255c698ed63b33199911eb8e042f8f67e9682409bb11552e6120041ed1872", + "https://deno.land/std@0.132.0/node/internal/streams/legacy.mjs": "6ea28db95d4503447473e62f0b23ff473bfe1751223c33a3c5816652e93b257a", + "https://deno.land/std@0.132.0/node/internal/streams/passthrough.mjs": "a51074193b959f3103d94de41e23a78dfcff532bdba53af9146b86340d85eded", + "https://deno.land/std@0.132.0/node/internal/streams/pipeline.mjs": "9890b121759ede869174ef70c011fde964ca94d81f2ed97b8622d7cb17b49285", + "https://deno.land/std@0.132.0/node/internal/streams/readable.mjs": "a70c41171ae25c556b52785b0c178328014bd33d8c0e4d229d4adaac7414b6ca", + "https://deno.land/std@0.132.0/node/internal/streams/state.mjs": "9ef917392a9d8005a6e038260c5fd31518d2753aea0bc9e39824c199310434cb", + "https://deno.land/std@0.132.0/node/internal/streams/transform.mjs": "3b361abad2ac78f7ccb6f305012bafdc0e983dfa4bb6ecddb4626e34a781a5f5", + "https://deno.land/std@0.132.0/node/internal/streams/utils.mjs": "06c21d0db0d51f1bf1e3225a661c3c29909be80355d268e64ee5922fc5eb6c5e", + "https://deno.land/std@0.132.0/node/internal/streams/writable.mjs": "ad4e2b176ffdf752c8e678ead3a514679a5a8d652f4acf797115dceb798744d5", + "https://deno.land/std@0.132.0/node/internal/timers.mjs": "b43e24580cec2dd50f795e4342251a79515c0db21630c25b40fdc380a78b74e7", + "https://deno.land/std@0.132.0/node/internal/url.ts": "eacef0ace4f4c5394e9818a81499f4871b2a993d1bd3b902047e44a381ef0e22", + "https://deno.land/std@0.132.0/node/internal/util.mjs": "2f0c8ff553c175ea6e4ed13d7cd7cd6b86dc093dc2f783c6c3dfc63f60a0943e", + "https://deno.land/std@0.132.0/node/internal/util/comparisons.ts": "680b55fe8bdf1613633bc469fa0440f43162c76dbe36af9aa2966310e1bb9f6e", + "https://deno.land/std@0.132.0/node/internal/util/debuglog.ts": "99e91bdf26f6c67861031f684817e1705a5bc300e81346585b396f413387edfb", + "https://deno.land/std@0.132.0/node/internal/util/inspect.mjs": "d1c2569c66a3dab45eec03208f22ad4351482527859c0011a28a6c797288a0aa", + "https://deno.land/std@0.132.0/node/internal/util/types.ts": "b2dacb8f1f5d28a51c4da5c5b75172b7fcf694073ce95ca141323657e18b0c60", + "https://deno.land/std@0.132.0/node/internal/validators.mjs": "a7e82eafb7deb85c332d5f8d9ffef052f46a42d4a121eada4a54232451acc49a", + "https://deno.land/std@0.132.0/node/internal_binding/_libuv_winerror.ts": "801e05c2742ae6cd42a5f0fd555a255a7308a65732551e962e5345f55eedc519", + "https://deno.land/std@0.132.0/node/internal_binding/_node.ts": "e4075ba8a37aef4eb5b592c8e3807c39cb49ca8653faf8e01a43421938076c1b", + "https://deno.land/std@0.132.0/node/internal_binding/_utils.ts": "1c50883b5751a9ea1b38951e62ed63bacfdc9d69ea665292edfa28e1b1c5bd94", + "https://deno.land/std@0.132.0/node/internal_binding/_winerror.ts": "8811d4be66f918c165370b619259c1f35e8c3e458b8539db64c704fbde0a7cd2", + "https://deno.land/std@0.132.0/node/internal_binding/async_wrap.ts": "f06b8a403ad871248eb064190d27bf6fefdbe948991e71a18d7077390d5773f9", + "https://deno.land/std@0.132.0/node/internal_binding/buffer.ts": "722c62b85f966e0777b2d98c021b60e75d7f2c2dabc43413ef37d60dbd13a5d9", + "https://deno.land/std@0.132.0/node/internal_binding/cares_wrap.ts": "25b7b5d56612b2985260b673021828d6511a1c83b4c1927f5732cad2f2a718af", + "https://deno.land/std@0.132.0/node/internal_binding/config.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/connection_wrap.ts": "0380444ee94d5bd7b0b09921223d16729c9762a94e80b7f51eda49c7f42e6d0a", + "https://deno.land/std@0.132.0/node/internal_binding/constants.ts": "aff06aac49eda4234bd3a2b0b8e1fbfc67824e281c532ff9960831ab503014cc", + "https://deno.land/std@0.132.0/node/internal_binding/contextify.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/credentials.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/crypto.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/errors.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/fs.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/fs_dir.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/fs_event_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/handle_wrap.ts": "e59df84b1fb1b9823b09774b3e512d9c0029b4557400d09dd02cd7661c2c4830", + "https://deno.land/std@0.132.0/node/internal_binding/heap_utils.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/http_parser.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/icu.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/inspector.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/js_stream.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/messaging.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/mod.ts": "f68e74e8eed84eaa6b0de24f0f4c47735ed46866d7ee1c5a5e7c0667b4f0540f", + "https://deno.land/std@0.132.0/node/internal_binding/module_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/native_module.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/natives.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/node_file.ts": "c96ee0b2af319a3916de950a6c4b0d5fb00d09395c51cd239c54d95d62567aaf", + "https://deno.land/std@0.132.0/node/internal_binding/node_options.ts": "3cd5706153d28a4f5944b8b162c1c61b7b8e368a448fb1a2cff9f7957d3db360", + "https://deno.land/std@0.132.0/node/internal_binding/options.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/os.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/performance.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/pipe_wrap.ts": "00e942327f8e1c4b74a5888a82f0e16ba775cd09af804f96b6f6849b7eab1719", + "https://deno.land/std@0.132.0/node/internal_binding/process_methods.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/report.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/serdes.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/signal_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/spawn_sync.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/stream_wrap.ts": "d6e96f4b89d82ad5cc9b243c3d3880c9d85086165da54a7d85821a63491e5abf", + "https://deno.land/std@0.132.0/node/internal_binding/string_decoder.ts": "5cb1863763d1e9b458bc21d6f976f16d9c18b3b3f57eaf0ade120aee38fba227", + "https://deno.land/std@0.132.0/node/internal_binding/symbols.ts": "51cfca9bb6132d42071d4e9e6b68a340a7f274041cfcba3ad02900886e972a6c", + "https://deno.land/std@0.132.0/node/internal_binding/task_queue.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/tcp_wrap.ts": "10c64d5e092a8bff99cfe05adea716e4e52f4158662a5821790953e47e2cc21c", + "https://deno.land/std@0.132.0/node/internal_binding/timers.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/tls_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/trace_events.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/tty_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/types.ts": "4c26fb74ba2e45de553c15014c916df6789529a93171e450d5afb016b4c765e7", + "https://deno.land/std@0.132.0/node/internal_binding/udp_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/url.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/util.ts": "90364292e2bd598ab5d105b48ca49817b6708f2d1d9cbaf08b2b3ab5ca4c90a7", + "https://deno.land/std@0.132.0/node/internal_binding/uv.ts": "3821bc5e676d6955d68f581988c961d77dd28190aba5a9c59f16001a4deb34ba", + "https://deno.land/std@0.132.0/node/internal_binding/v8.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/worker.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/internal_binding/zlib.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.132.0/node/net.ts": "dfcb7e412abb3d5c55edde7d823b0ccb9601f7d40555ae3c862810c78b176185", + "https://deno.land/std@0.132.0/node/os.ts": "943d3294a7a00f39491148cd2097cdbf101233a421262223bb20ae702c059df5", + "https://deno.land/std@0.132.0/node/path.ts": "c65858e9cbb52dbc0dd348eefcdc41e82906c39cfa7982f2d4d805e828414b8c", + "https://deno.land/std@0.132.0/node/path/_constants.ts": "bd26f24a052b7d6b746151f4a236d29ab3c2096883bb6449c2fa499494406672", + "https://deno.land/std@0.132.0/node/path/_interface.ts": "6034ee29f6f295460ec82db1a94df9269aecbb0eceb81be72e9d843f8e8a97e6", + "https://deno.land/std@0.132.0/node/path/_util.ts": "9d4735fc05f8f1fb94406450e84e23fd201dc3fef5298b009e44cfa4e797b8f0", + "https://deno.land/std@0.132.0/node/path/common.ts": "f41a38a0719a1e85aa11c6ba3bea5e37c15dd009d705bd8873f94c833568cbc4", + "https://deno.land/std@0.132.0/node/path/glob.ts": "d6b64a24f148855a6e8057a171a2f9910c39e492e4ccec482005205b28eb4533", + "https://deno.land/std@0.132.0/node/path/mod.ts": "62e21dc6e1fe2e9742fce85de631a7b067d968544fe66954578e6d73c97369a2", + "https://deno.land/std@0.132.0/node/path/posix.ts": "9dd5fc83c4ae0e0b700bef43c88c67e276840c187a66d4d6a661440cf1fecc52", + "https://deno.land/std@0.132.0/node/path/separator.ts": "c908c9c28ebe7f1fea67daaccf84b63af90d882fe986f9fa03af9563a852723a", + "https://deno.land/std@0.132.0/node/path/win32.ts": "f869ee449b6dee69b13e2d1f8f7f1d01c7ae1e67fa573eab789429929f7a3864", + "https://deno.land/std@0.132.0/node/process.ts": "699f47f2f177556e17e2f7d0dcd3705ff5065cbdf72029e534c1540404d6f501", + "https://deno.land/std@0.132.0/node/querystring.ts": "967b8a7b00a73ebe373666deb3a7e501f164bac27bb342fde7221ecbb3522689", + "https://deno.land/std@0.132.0/node/stream.ts": "d127faa074a9e3886e4a01dcfe9f9a6a4b5641f76f6acc356e8ded7da5dc2c81", + "https://deno.land/std@0.132.0/node/stream/promises.mjs": "b263c09f2d6bd715dc514fab3f99cca84f442e2d23e87adbe76e32ea46fc87e6", + "https://deno.land/std@0.132.0/node/string_decoder.ts": "51ce85a173d2e36ac580d418bb48b804adb41732fc8bd85f7d5d27b7accbc61f", + "https://deno.land/std@0.132.0/node/timers.ts": "2d66fcd21e37acf76c3a699a97230da57cc21382c8e885b3c5377b37efd0f06c", + "https://deno.land/std@0.132.0/node/url.ts": "bc0bde2774854b6a377c4c61fa73e5a28283cbeb7f8703479f44e471219c33a8", + "https://deno.land/std@0.132.0/node/util.ts": "7fd6933b37af89a8e64d73dc6ee1732455a59e7e6d0965311fbd73cd634ea630", + "https://deno.land/std@0.132.0/node/util/types.mjs": "f9288198cacd374b41bae7e92a23179d3160f4c0eaf14e19be3a4e7057219a60", + "https://deno.land/std@0.132.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", + "https://deno.land/std@0.132.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", + "https://deno.land/std@0.132.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b", + "https://deno.land/std@0.132.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", + "https://deno.land/std@0.132.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", + "https://deno.land/std@0.132.0/path/mod.ts": "4275129bb766f0e475ecc5246aa35689eeade419d72a48355203f31802640be7", + "https://deno.land/std@0.132.0/path/posix.ts": "663e4a6fe30a145f56aa41a22d95114c4c5582d8b57d2d7c9ed27ad2c47636bb", + "https://deno.land/std@0.132.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", + "https://deno.land/std@0.132.0/path/win32.ts": "e7bdf63e8d9982b4d8a01ef5689425c93310ece950e517476e22af10f41a136e", + "https://deno.land/std@0.132.0/streams/conversion.ts": "712585bfa0172a97fb68dd46e784ae8ad59d11b88079d6a4ab098ff42e697d21", + "https://deno.land/std@0.132.0/testing/_diff.ts": "9d849cd6877694152e01775b2d93f9d6b7aef7e24bfe3bfafc4d7a1ac8e9f392", + "https://deno.land/std@0.132.0/testing/asserts.ts": "b0ef969032882b1f7eb1c7571e313214baa1485f7b61cf35807b2434e254365c", + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/x/postgresjs@v3.4.5/mod.js": "cb68f17d6d90df318934deccdb469d740be0888e7a597a9e7eea7100ce36a252", + "https://deno.land/x/postgresjs@v3.4.5/polyfills.js": "318eb01f2b4cc33a46c59f3ddc11f22a56d6b1db8b7719b2ad7decee63a5bd47", + "https://deno.land/x/postgresjs@v3.4.5/src/bytes.js": "f2de43bdc8fa5dc4b169f2c70d5d8b053a3dea8f85ef011d7b27dec69e14ebb7", + "https://deno.land/x/postgresjs@v3.4.5/src/connection.js": "e63062451fb6a7284c14540b3f268d1373c3028fb0f3b234056ad56569190e8f", + "https://deno.land/x/postgresjs@v3.4.5/src/errors.js": "85cfbed9a5ab0db41ab8e97b806c881af29807dfe99bc656fdf1a18c1c13b6c6", + "https://deno.land/x/postgresjs@v3.4.5/src/index.js": "9dca008e765675f8218d4e2e3ccc75359cc2240f7be4e80bf6735e92b5562e3a", + "https://deno.land/x/postgresjs@v3.4.5/src/large.js": "f3e770cdb7cc695f7b50687b4c6c4b7252129515486ec8def98b7582ee7c54ef", + "https://deno.land/x/postgresjs@v3.4.5/src/query.js": "67c45a5151032aa46b587abc15381fe4efd97c696e5c1b53082b8161309c4ee2", + "https://deno.land/x/postgresjs@v3.4.5/src/queue.js": "15e6345adb6708bf3b99ad39fc2231c2fb61de5f6cba4b7a7a6be881482a4ec3", + "https://deno.land/x/postgresjs@v3.4.5/src/result.js": "001ff5e0c8d634674f483d07fbcd620a797e3101f842d6c20ca3ace936260465", + "https://deno.land/x/postgresjs@v3.4.5/src/subscribe.js": "9e4d0c3e573a6048e77ee2f15abbd5bcd17da9ca85a78c914553472c6d6c169b", + "https://deno.land/x/postgresjs@v3.4.5/src/types.js": "471f4a6c35412aa202a7c177c0a7e5a7c3bd225f01bbde67c947894c1b8bf6ed" + }, + "workspace": { + "dependencies": [ + "jsr:@hono/hono@*", + "jsr:@hono/hono@4", + "npm:@opentelemetry/api@1.9.0", + "npm:@opentelemetry/core@1.30.1", + "npm:@opentelemetry/exporter-metrics-otlp-grpc@0.57.2", + "npm:@opentelemetry/exporter-trace-otlp-grpc@0.57.2", + "npm:@opentelemetry/resources@1.30.1", + "npm:@opentelemetry/sdk-metrics@1.30.1", + "npm:@opentelemetry/sdk-node@0.57.2", + "npm:@opentelemetry/sdk-trace-base@1.30.1", + "npm:@opentelemetry/semantic-conventions@1.28.0" + ] + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5c09adc --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,210 @@ +# Architecture + +How the pieces fit together, and why there aren't very many of them. + +--- + +## The Big Picture + +Drive is one Deno binary. It serves a React SPA, proxies API calls to S3 and PostgreSQL, handles WOPI callbacks from Collabora, and validates sessions against Ory Kratos. No Django, no Celery, no Next.js, no BFF layer. One process, one binary, done. + +```mermaid +graph TB + Browser["Browser (React SPA)"] + + subgraph "Deno / Hono Server" + Router["Hono Router"] + Auth["Auth Middleware
(Kratos sessions)"] + CSRF["CSRF Middleware
(HMAC double-submit)"] + FileAPI["File API
(CRUD, presigned URLs)"] + WopiAPI["WOPI Endpoints
(CheckFileInfo, GetFile, PutFile, Locks)"] + PermAPI["Permission Middleware
(Keto checks)"] + Static["Static File Server
(ui/dist)"] + end + + Browser -->|"HTTP"| Router + Router --> Auth + Auth --> CSRF + CSRF --> FileAPI + CSRF --> WopiAPI + CSRF --> PermAPI + Router --> Static + + FileAPI -->|"SQL"| PostgreSQL["PostgreSQL
(metadata, folder sizes)"] + FileAPI -->|"S3 API"| SeaweedFS["SeaweedFS
(file storage)"] + WopiAPI -->|"Lock ops"| Valkey["Valkey
(WOPI locks w/ TTL)"] + WopiAPI --> PostgreSQL + WopiAPI --> SeaweedFS + PermAPI -->|"HTTP"| Keto["Ory Keto
(Zanzibar permissions)"] + Auth -->|"HTTP"| Kratos["Ory Kratos
(session validation)"] + + Collabora["Collabora Online"] -->|"WOPI callbacks"| WopiAPI + Browser -->|"iframe postMessage"| Collabora +``` + +## Request Lifecycle + +Every request hits the Hono router in `main.ts`. The middleware stack is short and you can read the whole thing without scrolling: + +1. **OpenTelemetry middleware** — tracing and metrics on every request. +2. **`/health`** — no auth, no CSRF. Returns `{ ok: true, time: "..." }`. K8s probes hit this. +3. **Auth middleware** — runs on everything except `/health`. Skips WOPI routes (they carry their own JWTs). Test mode (`DRIVER_TEST_MODE=1`) injects a fake identity. +4. **CSRF middleware** — validates HMAC double-submit cookies on mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) to `/api/*`. Skips WOPI routes. +5. **Route handlers** — the actual work. + +From `main.ts`, the routing structure: + +``` +GET /health Health check (no auth) +GET /api/auth/session Session info + +GET /api/files List files (with sort, search, pagination) +POST /api/files Create file (form-data or JSON metadata) +GET /api/files/:id Get file metadata +PUT /api/files/:id Rename or move +DELETE /api/files/:id Soft delete +POST /api/files/:id/restore Restore from trash +GET /api/files/:id/download Pre-signed download URL +POST /api/files/:id/upload-url Pre-signed upload URL(s) +POST /api/files/:id/complete-upload Complete multipart upload + +POST /api/folders Create folder +GET /api/folders/:id/children List folder contents + +GET /api/recent Recently opened files +GET /api/favorites Favorited files +PUT /api/files/:id/favorite Toggle favorite +GET /api/trash Deleted files + +POST /api/admin/backfill S3 -> DB backfill (internal only) + +GET /wopi/files/:id CheckFileInfo (token auth) +GET /wopi/files/:id/contents GetFile (token auth) +POST /wopi/files/:id/contents PutFile (token auth) +POST /wopi/files/:id Lock/Unlock/Refresh/GetLock (token auth) + +POST /api/wopi/token Generate WOPI token (session auth) + +/* Static files from ui/dist +/* SPA fallback (index.html) +``` + +## The SPA Lifecycle + +The UI is a Vite-built React SPA. In production, it's static files in `ui/dist/`. Nothing fancy. + +```mermaid +graph LR + A["npm install + vite build"] -->|"outputs"| B["ui/dist/
index.html + assets/"] + B -->|"served by"| C["Hono serveStatic
(root: ./ui/dist)"] + C -->|"SPA fallback"| D["All non-API routes
return index.html"] + D -->|"client-side"| E["React Router
handles /explorer, /recent, etc."] +``` + +**Build step:** + +```bash +cd ui && npm install && npx vite build +``` + +Outputs `ui/dist/index.html` and `ui/dist/assets/` with hashed JS/CSS bundles. + +**Serving:** + +Hono's `serveStatic` serves from `ui/dist` for any route that doesn't match an API endpoint. A second `serveStatic` call serves `index.html` as the SPA fallback — navigating to `/explorer/some-folder-id` returns the shell, React Router takes it from there. + +**Development:** + +In dev mode (`deno task dev`), both run simultaneously: +- `deno run -A --watch main.ts` — server with hot reload +- `cd ui && npx vite` — Vite dev server with HMR + +The Vite dev server proxies API calls to the Deno server. + +**Compiled binary:** + +```bash +deno compile --allow-net --allow-read --allow-env --include ui/dist -o driver main.ts +``` + +`deno compile` bundles the JS entry point and the entire `ui/dist` directory into a single executable (~450KB JS + static assets). This is what gets deployed. + +## WOPI Callback Flow + +This is the part that confuses people. Collabora doesn't talk to the browser — it talks to your server. The browser is out of the loop during editing: + +```mermaid +sequenceDiagram + participant Browser + participant Server as Deno/Hono + participant Collabora + + Browser->>Server: POST /api/wopi/token {file_id} + Server->>Server: Generate JWT (HMAC-SHA256) + Server->>Server: Fetch Collabora discovery XML + Server-->>Browser: {access_token, editor_url} + + Browser->>Collabora: Form POST to editor_url
(access_token in hidden field, target=iframe) + + Collabora->>Server: GET /wopi/files/:id?access_token=... + Server-->>Collabora: CheckFileInfo JSON + + Collabora->>Server: GET /wopi/files/:id/contents?access_token=... + Server->>SeaweedFS: GetObject + SeaweedFS-->>Server: File bytes + Server-->>Collabora: File content + + Note over Collabora: User edits document... + + Collabora->>Server: POST /wopi/files/:id (LOCK) + Collabora->>Server: POST /wopi/files/:id/contents (PutFile) + Server->>SeaweedFS: PutObject + Collabora->>Server: POST /wopi/files/:id (UNLOCK) +``` + +See [wopi.md](wopi.md) for the full breakdown. + +## Auth Model + +Two auth mechanisms, one server. This is the only slightly tricky part: + +1. **Session auth** (most routes) — browser sends Ory Kratos session cookies. The middleware calls `GET /sessions/whoami` on Kratos. Invalid session? API routes get 401, page routes get a redirect to `/login`. + +2. **Token auth** (WOPI routes) — Collabora doesn't have browser cookies (it's a separate server). WOPI endpoints accept a JWT `access_token` query parameter, HMAC-SHA256 signed, scoped to a specific file and user, 8-hour TTL. + +The split happens in `server/auth.ts` based on URL prefix: anything under `/wopi/` skips session auth. WOPI handlers validate their own tokens. + +## Data Flow + +**File upload (pre-signed):** +1. Client sends metadata to `POST /api/files` (filename, mimetype, size, parent_id) +2. Server creates a `files` row, computes the S3 key +3. Client calls `POST /api/files/:id/upload-url` for a pre-signed PUT URL +4. Client uploads directly to S3 — file bytes never touch the server +5. Large files use multipart: multiple pre-signed URLs, then `POST /api/files/:id/complete-upload` +6. Folder sizes propagate up the ancestor chain via `propagate_folder_sizes()` + +**File download (pre-signed):** +1. Client calls `GET /api/files/:id/download` +2. Server hands back a pre-signed GET URL +3. Client downloads directly from S3 + +The server never streams file content for regular uploads/downloads. The only time bytes flow through the server is WOPI callbacks — Collabora can't use pre-signed URLs, so we proxy those. + +## Database + +Two tables. That's it. + +- **`files`** — the file registry. UUID PK, `s3_key` (unique), filename, mimetype, size, owner_id, parent_id (self-referencing for folder hierarchy), `is_folder` flag, timestamps, soft-delete via `deleted_at`. +- **`user_file_state`** — per-user state: favorites, last-opened timestamp. Composite PK on `(user_id, file_id)`. + +Two PostgreSQL functions handle folder sizes: +- `recompute_folder_size(folder_id)` — recursive CTE that sums all descendant file sizes +- `propagate_folder_sizes(start_parent_id)` — walks up the ancestor chain, recomputing each folder + +Migrations live in `server/migrate.ts` and run with `deno task migrate`. + +## What's Not Here (Yet) + +- **Rate limiting** — relying on ingress-level limits for now. Good enough until it isn't. +- **WebSocket** — no real-time updates between browser tabs. Collabora handles its own real-time editing internally, so this hasn't been a pain point yet. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..0c62bc5 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,227 @@ +# Deployment + +How Drive runs in production as part of the SBBB Kubernetes stack. + +--- + +## Where This Fits + +Drive replaces the upstream [suitenumerique/drive](https://github.com/suitenumerique/drive) Helm chart in the SBBB stack. Same role, one binary instead of Django + Celery + Redis queues + Next.js. + +Sits alongside the other La Suite apps and shares the Ory identity stack (Kratos + Hydra) for auth. + +--- + +## The Binary + +```bash +deno task build +``` + +Produces a single `driver` binary via `deno compile`: + +```bash +deno compile --allow-net --allow-read --allow-env --include ui/dist -o driver main.ts +``` + +Bundles: +- Deno runtime +- All server TypeScript (compiled to JS) +- The entire `ui/dist` directory (React SPA) + +No Node.js, no npm, no `node_modules` at runtime. Copy the binary into a container and run it. That's the deployment. + +--- + +## Environment Variables + +Everything is configured via environment variables. No config files. + +| Variable | Default | Required | Description | +|----------|---------|----------|-------------| +| `PORT` | `3000` | No | Server listen port | +| `PUBLIC_URL` | `http://localhost:3000` | Yes | Public-facing URL. Used in WOPI callback URLs, redirects, and CSRF. Must be the URL users see in their browser. | +| `DATABASE_URL` | `postgres://driver:driver@localhost:5432/driver_db` | Yes | PostgreSQL connection string | +| `SEAWEEDFS_S3_URL` | `http://seaweedfs-filer.storage.svc.cluster.local:8333` | No | S3 endpoint | +| `SEAWEEDFS_ACCESS_KEY` | *(empty)* | No | S3 access key (empty = unauthenticated) | +| `SEAWEEDFS_SECRET_KEY` | *(empty)* | No | S3 secret key | +| `S3_BUCKET` | `sunbeam-driver` | No | S3 bucket name | +| `S3_REGION` | `us-east-1` | No | S3 region for signing | +| `VALKEY_URL` | `redis://localhost:6379/2` | No | Valkey/Redis URL for WOPI locks. Falls back to in-memory if unavailable. Set to your Valkey cluster URL in production. | +| `KRATOS_PUBLIC_URL` | `http://kratos-public.ory.svc.cluster.local:80` | No | Kratos public API for session validation | +| `KETO_READ_URL` | `http://keto-read.ory.svc.cluster.local:4466` | No | Keto read API for permission checks | +| `KETO_WRITE_URL` | `http://keto-write.ory.svc.cluster.local:4467` | No | Keto write API for tuple management | +| `COLLABORA_URL` | `http://collabora.lasuite.svc.cluster.local:9980` | No | Collabora Online for WOPI discovery | +| `WOPI_JWT_SECRET` | `dev-wopi-secret-change-in-production` | **Yes** | HMAC secret for WOPI access tokens. **Change this in production.** | +| `CSRF_COOKIE_SECRET` | `dev-secret-change-in-production` | **Yes** | HMAC secret for CSRF double-submit cookies. **Change this in production.** | +| `DRIVER_TEST_MODE` | *(unset)* | No | Set to `1` to bypass auth. **Never set this in production.** | + +--- + +## Health Check + +``` +GET /health +``` + +Returns: +```json +{ "ok": true, "time": "2026-03-25T10:30:00.000Z" } +``` + +No auth required. Use this for Kubernetes liveness and readiness probes: + +```yaml +livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 +readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 3 + periodSeconds: 5 +``` + +--- + +## Collabora Configuration + +Collabora needs to know which hosts can make WOPI callbacks. This is the `aliasgroup1` env var on the Collabora deployment. Get this wrong and Collabora will reject every callback with a "Not allowed" error. + +```yaml +# Collabora environment +aliasgroup1: "https://drive\\.example\\.com" +``` + +The value is a regex — dots must be escaped. Multiple hosts: + +```yaml +aliasgroup1: "https://drive\\.example\\.com|https://drive\\.staging\\.example\\.com" +``` + +In the Docker Compose test stack, this is: + +```yaml +aliasgroup1: "http://host\\.docker\\.internal:3200" +``` + +--- + +## OIDC Client Setup + +Drive authenticates users via Ory Kratos sessions. For OIDC flows, you need a client registered in Ory Hydra. + +Client credentials go in a Kubernetes secret: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: oidc-drive +type: Opaque +stringData: + client-id: "drive" + client-secret: "your-secret-here" +``` + +The Kratos identity schema needs standard OIDC claims (email, name). Drive reads from session traits: + +```typescript +const givenName = traits.given_name ?? traits.name?.first ?? ""; +const familyName = traits.family_name ?? traits.name?.last ?? ""; +``` + +Supports both OIDC-standard (`given_name`/`family_name`) and legacy (`name.first`/`name.last`) formats. You probably won't need to think about this. + +--- + +## SeaweedFS Bucket + +Create the bucket before first deploy (or don't — SeaweedFS creates buckets on first write with default config): + +```bash +# Using AWS CLI pointed at SeaweedFS +aws --endpoint-url http://seaweedfs:8333 s3 mb s3://sunbeam-driver +``` + +Bucket name defaults to `sunbeam-driver`, configurable via `S3_BUCKET`. + +--- + +## Keto Deployment + +Keto is new to the SBBB stack — Drive introduced it. You need to deploy: + +1. **Keto server** with read (4466) and write (4467) APIs +2. **OPL namespaces** from `keto/namespaces.ts` loaded at deploy time + +The namespace file defines the permission model (User, Group, Bucket, Folder, File). See [permissions.md](permissions.md) for the details. + +Typical Keto config: + +```yaml +# Keto config +dsn: postgres://keto:keto@postgres:5432/keto_db +namespaces: + location: file:///etc/keto/namespaces.ts +serve: + read: + host: 0.0.0.0 + port: 4466 + write: + host: 0.0.0.0 + port: 4467 +``` + +Read API: accessible from Drive pods. Write API: accessible from Drive pods + admin tooling. Neither should be exposed to the internet. + +--- + +## Database Migration + +Run migrations before first deploy and after updates that add new ones: + +```bash +DATABASE_URL="postgres://..." ./driver migrate +``` + +Or, if running from source: + +```bash +DATABASE_URL="postgres://..." deno run -A server/migrate.ts +``` + +Idempotent — running them multiple times is safe. A `_migrations` table tracks what's been applied. + +--- + +## Observability + +OpenTelemetry tracing and metrics are built in (`server/telemetry.ts`) — every request is instrumented automatically. + +Full picture: +- **OpenTelemetry** — tracing and metrics via OTLP. Especially useful for debugging WOPI callback chains. +- `/health` endpoint for uptime monitoring +- PostgreSQL query logs for database performance +- S3 access logs from SeaweedFS +- Container stdout/stderr + +--- + +## Deployment Checklist + +1. Build the binary: `deno task build` +2. Set **`WOPI_JWT_SECRET`** to a random secret (32+ characters) +3. Set **`CSRF_COOKIE_SECRET`** to a different random secret +4. Set **`PUBLIC_URL`** to the actual user-facing URL +5. Set **`DATABASE_URL`** and run migrations +6. Ensure SeaweedFS bucket exists +7. Configure Collabora `aliasgroup1` to allow WOPI callbacks from `PUBLIC_URL` +8. Register the OIDC client in Hydra (or use the `oidc-drive` secret) +9. Deploy Keto with the namespace file from `keto/namespaces.ts` +10. Verify: `curl https://drive.example.com/health` +11. **Do not** set `DRIVER_TEST_MODE=1` in production. It disables all auth. You will have a bad time. diff --git a/docs/local-dev.md b/docs/local-dev.md new file mode 100644 index 0000000..85a8f16 --- /dev/null +++ b/docs/local-dev.md @@ -0,0 +1,221 @@ +# Local Development + +Zero to running in 2 minutes. Full WOPI editing stack takes a bit longer, but not much. + +--- + +## Prerequisites + +| Tool | Version | What for | +|------|---------|----------| +| [Deno](https://deno.land/) | 2.x | Server runtime | +| [Node.js](https://nodejs.org/) | 20+ | UI build toolchain | +| PostgreSQL | 14+ | Metadata storage | +| [SeaweedFS](https://github.com/seaweedfs/seaweedfs) | latest | S3-compatible file storage | + +### Installing SeaweedFS + +`weed mini` is all you need — single-process mode that runs master, volume, and filer with S3 support: + +```bash +# macOS +brew install seaweedfs + +# Or download from GitHub releases +# https://github.com/seaweedfs/seaweedfs/releases +``` + +--- + +## Quick Start (No WOPI) + +Gets you a working file browser with uploads, folders, sort, search — everything except document editing. + +### 1. Database Setup + +```bash +createdb driver_db +DATABASE_URL="postgres://localhost/driver_db" deno run -A server/migrate.ts +``` + +Creates `files`, `user_file_state`, `_migrations` tables and the folder size functions. + +### 2. Start SeaweedFS + +```bash +weed mini -dir=/tmp/seaweed-driver +``` + +S3 endpoint on `http://localhost:8333`. No auth, no config. It works. + +### 3. Build the UI + +```bash +cd ui && npm install && npx vite build && cd .. +``` + +### 4. Start the Server + +```bash +DATABASE_URL="postgres://localhost/driver_db" \ +SEAWEEDFS_S3_URL="http://localhost:8333" \ +DRIVER_TEST_MODE=1 \ +deno run -A main.ts +``` + +Open `http://localhost:3000`. + +`DRIVER_TEST_MODE=1` bypasses Kratos auth and injects a fake identity. No Ory stack needed unless you're working on auth flows. + +### Development Mode (Hot Reload) + +Or skip the manual steps entirely: + +```bash +deno task dev +``` + +Runs both in parallel: +- `deno run -A --watch main.ts` — server with file-watching restart +- `cd ui && npx vite` — Vite dev server with HMR + +Vite proxies API calls to the Deno server. Edit code, save, see it. + +--- + +## Full WOPI Setup (Collabora Editing) + +Document editing needs Collabora Online. Docker Compose is the path of least resistance. + +### 1. Start the Compose Stack + +```bash +docker compose up -d +``` + +Starts: +- **PostgreSQL** on 5433 (not 5432, avoids conflicts) +- **SeaweedFS** on 8334 (not 8333, same reason) +- **Collabora Online** on 9980 + +Collabora takes ~30 seconds on first start. Wait for healthy: + +```bash +docker compose ps +``` + +### 2. Run Migrations + +```bash +DATABASE_URL="postgres://driver:driver@localhost:5433/driver_db" deno run -A server/migrate.ts +``` + +### 3. Start the Server + +```bash +DATABASE_URL="postgres://driver:driver@localhost:5433/driver_db" \ +SEAWEEDFS_S3_URL="http://localhost:8334" \ +COLLABORA_URL="http://localhost:9980" \ +PUBLIC_URL="http://host.docker.internal:3200" \ +PORT=3200 \ +DRIVER_TEST_MODE=1 \ +deno run -A main.ts +``` + +Note `PUBLIC_URL` — Collabora (in Docker) needs to reach the server (on the host) for WOPI callbacks, hence `host.docker.internal`. Port 3200 avoids stepping on anything already on 3000. + +### 4. Test It + +Upload a `.docx` or `.odt`, double-click it. Collabora loads in an iframe. If it doesn't, check the `aliasgroup1` config in `compose.yaml`. + +### Tear Down + +```bash +docker compose down -v +``` + +`-v` removes volumes so you start clean next time. + +--- + +## Integration Service Theming + +To test La Suite theming locally, point at the production integration service: + +```bash +INTEGRATION_URL=https://integration.sunbeam.pt deno run -A main.ts +``` + +Injects runtime theme CSS, fonts, dark mode, and the waffle menu. The integration tests validate this: + +```bash +cd ui && INTEGRATION_URL=https://integration.sunbeam.pt npx playwright test e2e/integration-service.spec.ts +``` + +--- + +## Environment Variables for Local Dev + +Minimal reference (Drive doesn't read `.env` files — set these in your shell or prefix your command): + +```bash +# Required +DATABASE_URL="postgres://localhost/driver_db" +SEAWEEDFS_S3_URL="http://localhost:8333" + +# Optional — defaults are fine for local dev +PORT=3000 +PUBLIC_URL="http://localhost:3000" +S3_BUCKET="sunbeam-driver" +DRIVER_TEST_MODE=1 + +# Only needed for WOPI editing +COLLABORA_URL="http://localhost:9980" +WOPI_JWT_SECRET="dev-wopi-secret-change-in-production" + +# Only needed for real auth (not test mode) +KRATOS_PUBLIC_URL="http://localhost:4433" + +# Only needed for permissions +KETO_READ_URL="http://localhost:4466" +KETO_WRITE_URL="http://localhost:4467" +``` + +--- + +## Common Tasks + +### Reset the database + +```bash +dropdb driver_db && createdb driver_db +DATABASE_URL="postgres://localhost/driver_db" deno run -A server/migrate.ts +``` + +### Backfill files from S3 + +Uploaded files directly to SeaweedFS and they're not showing up? + +```bash +curl -X POST http://localhost:3000/api/admin/backfill \ + -H "Content-Type: application/json" \ + -d '{"dry_run": true}' +``` + +Drop `"dry_run": true` to actually write the rows. + +### Build the compiled binary + +```bash +deno task build +``` + +Runs `vite build` then `deno compile`. One binary in the project root. + +### Run all tests + +```bash +deno task test:all +``` + +See [testing.md](testing.md) for the full breakdown. diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..5596d7b --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,281 @@ +# Permissions + +Zanzibar-style relationship-based access control via Ory Keto. Sounds fancy, works well. + +--- + +## The Model + +Drive uses [Ory Keto](https://www.ory.sh/keto/) for permissions. The model: relationship tuples ("user X has relation Y on object Z") checked by graph traversal. If you've read the Zanzibar paper, this is that. + +The OPL (Ory Permission Language) definitions live in `keto/namespaces.ts`. This file is consumed by the Keto server at deploy time — Deno never executes it. It uses TypeScript syntax with Keto's built-in types (`Namespace`, `Context`, `SubjectSet`). + +## Namespaces + +Five namespaces, hierarchical: + +``` +User +Group members: (User | Group)[] +Bucket owners, editors, viewers --> permits: write, read, delete + Folder owners, editors, viewers, parents: (Folder | Bucket)[] --> permits: write, read, delete + File owners, editors, viewers, parents: Folder[] --> permits: write, read, delete +``` + +### User + +```typescript +class User implements Namespace {} +``` + +Marker namespace. Users are their Kratos identity UUID. + +### Group + +```typescript +class Group implements Namespace { + related: { + members: (User | Group)[]; + }; +} +``` + +Groups can contain users or other groups (nested groups). Used in editor/viewer relations via `SubjectSet`. + +### Bucket + +```typescript +class Bucket implements Namespace { + related: { + owners: User[]; + editors: (User | SubjectSet)[]; + viewers: (User | SubjectSet)[]; + }; + + permits = { + write: (ctx) => owners.includes(subject) || editors.includes(subject), + read: (ctx) => write(ctx) || viewers.includes(subject), + delete: (ctx) => owners.includes(subject), + }; +} +``` + +Top of the hierarchy. Only owners can delete. Writers can also read. Standard privilege escalation. + +### Folder + +```typescript +class Folder implements Namespace { + related: { + owners: User[]; + editors: (User | SubjectSet)[]; + viewers: (User | SubjectSet)[]; + parents: (Folder | Bucket)[]; + }; + + permits = { + write: (ctx) => + owners.includes(subject) || + editors.includes(subject) || + this.related.parents.traverse((p) => p.permits.write(ctx)), + // ... same pattern for read and delete + }; +} +``` + +The interesting bit is `parents.traverse()`. No direct permission grant? Keto walks up the parent chain. Folder in a bucket inherits the bucket's permissions. Folder in a folder inherits from that, which may inherit from its parent, all the way up to the bucket. One tuple at the top covers an entire tree. + +### File + +```typescript +class File implements Namespace { + related: { + owners: User[]; + editors: (User | SubjectSet)[]; + viewers: (User | SubjectSet)[]; + parents: Folder[]; + }; + // Same traverse pattern as Folder +} +``` + +Files can only have Folder parents (not Buckets directly). Same traversal logic. + +## How Traversal Works + +`parents.traverse()` is where Keto earns its keep. When you check "can user X write file Y?", Keto: + +1. Checks if X is in Y's `owners` or `editors` +2. If not, looks at Y's `parents` relations +3. For each parent folder, recursively checks if X can write that folder +4. Each folder checks its own owners/editors, then traverses up to *its* parents +5. Eventually reaches a Bucket (no more parents) and checks there + +Give a user `editor` on a bucket and they can write every folder and file in it. No per-file tuples needed. + +--- + +## The HTTP API + +The Keto client in `server/keto.ts` is a thin fetch wrapper. No SDK — Keto's HTTP API is simple enough that a client library would add more weight than value. + +### Check Permission + +```typescript +const allowed = await checkPermission( + "files", // namespace + "550e8400-e29b-41d4-a716-446655440000", // object (file UUID) + "read", // relation + "kratos-identity-uuid", // subject_id +); +``` + +Under the hood: + +``` +POST {KETO_READ_URL}/relation-tuples/check/openapi +Content-Type: application/json + +{ + "namespace": "files", + "object": "550e8400-...", + "relation": "read", + "subject_id": "kratos-identity-uuid" +} +``` + +Returns `{ "allowed": true }` or `{ "allowed": false }`. Network errors return `false` — fail closed, always. + +### Write Relationship + +```typescript +await createRelationship("files", fileId, "owners", userId); +``` + +``` +PUT {KETO_WRITE_URL}/admin/relation-tuples +Content-Type: application/json + +{ + "namespace": "files", + "object": "file-uuid", + "relation": "owners", + "subject_id": "user-uuid" +} +``` + +### Write Relationship with Subject Set + +For parent relationships (file -> folder, folder -> folder, folder -> bucket): + +```typescript +await createRelationshipWithSubjectSet( + "files", fileId, "parents", // this file's "parents" relation + "folders", parentFolderId, "" // points to the folder +); +``` + +### Delete Relationship + +``` +DELETE {KETO_WRITE_URL}/admin/relation-tuples?namespace=files&object=...&relation=...&subject_id=... +``` + +### Batch Write + +Atomic insert/delete of multiple tuples: + +```typescript +await batchWriteRelationships([ + { action: "delete", relation_tuple: { namespace: "files", object: fileId, relation: "parents", subject_set: { ... } } }, + { action: "insert", relation_tuple: { namespace: "files", object: fileId, relation: "parents", subject_set: { ... } } }, +]); +``` + +``` +PATCH {KETO_WRITE_URL}/admin/relation-tuples +``` + +### Expand (Debugging) + +```typescript +const tree = await expandPermission("files", fileId, "read", 3); +``` + +Returns the full permission tree. Useful for debugging why someone can or can't access something. + +--- + +## Permission Middleware + +`server/permissions.ts` exports `permissionMiddleware` for file/folder routes. Straightforward: + +1. Extracts identity from `c.get("identity")` +2. Parses file/folder UUID from the URL path (`/api/files/:id` or `/api/folders/:id`) +3. Maps HTTP method to permission: `GET` → `read`, `DELETE` → `delete`, everything else → `write` +4. Checks against Keto +5. 403 if denied + +For list operations (no `:id` in the path), the middleware passes through — per-item filtering happens in the handler: + +```typescript +const filtered = await filterByPermission(files, userId, "read"); +``` + +Checks permissions in parallel and returns only the allowed items. + +--- + +## Tuple Lifecycle + +Tuples live and die with their resources. No orphans. + +### On File Create + +```typescript +await writeFilePermissions(fileId, ownerId, parentFolderId); +``` + +Creates: +- `files:{fileId}#owners@{ownerId}` — creator is owner +- `files:{fileId}#parents@folders:{parentFolderId}#...` — links to parent folder + +### On Folder Create + +```typescript +await writeFolderPermissions(folderId, ownerId, parentFolderId, bucketId); +``` + +Creates: +- `folders:{folderId}#owners@{ownerId}` +- `folders:{folderId}#parents@folders:{parentFolderId}#...` — nested in another folder +- `folders:{folderId}#parents@buckets:{bucketId}#...` — at the bucket root + +### On Delete + +```typescript +await deleteFilePermissions(fileId); +``` + +Lists all tuples for the file across every relation (`owners`, `editors`, `viewers`, `parents`) and batch-deletes them. Clean break. + +### On Move + +```typescript +await moveFilePermissions(fileId, newParentId); +``` + +Batch operation: delete old parent tuple, insert new one, atomically. The file never exists in a state with no parent or two parents. + +--- + +## What This Means in Practice + +The payoff of Zanzibar for a file system: + +- **Share a folder** → everything inside it is shared, recursively. No per-file grants. +- **Move a file** → it picks up the new folder's permissions automatically. +- **Groups are transitive** — add a user to a group, they inherit everything the group has. +- **One tuple on a bucket** can cover thousands of files. + +The tradeoff is check latency. Every permission check is an HTTP call to Keto, which may traverse multiple levels. For list operations, checks run in parallel, but it's still N HTTP calls for N items. Fine for file browser pagination (50 items). Would need optimization for bulk operations — we'll cross that bridge when someone has 10,000 files in a folder. diff --git a/docs/s3-layout.md b/docs/s3-layout.md new file mode 100644 index 0000000..59e40e4 --- /dev/null +++ b/docs/s3-layout.md @@ -0,0 +1,261 @@ +# S3 Layout + +How files are stored in SeaweedFS, and why you can browse the bucket and actually understand what you're looking at. + +--- + +## Key Convention + +S3 keys are human-readable paths. This is intentional, and we'd do it again. + +### Personal files + +``` +{identity-id}/my-files/{path}/{filename} +``` + +Examples: +``` +a1b2c3d4-e5f6-7890-abcd-ef1234567890/my-files/quarterly-report.docx +a1b2c3d4-e5f6-7890-abcd-ef1234567890/my-files/Projects/game-prototype/level-01.fbx +a1b2c3d4-e5f6-7890-abcd-ef1234567890/my-files/Documents/meeting-notes.odt +``` + +The identity ID is the Kratos identity UUID. `my-files` is a fixed segment that separates the identity prefix from user content. + +### Shared files + +``` +shared/{path}/{filename} +``` + +Examples: +``` +shared/team-assets/brand-guide.pdf +shared/templates/invoice-template.xlsx +``` + +Shared files use `"shared"` as the owner ID. + +### Folders + +Folder keys end with a trailing slash: +``` +a1b2c3d4-e5f6-7890-abcd-ef1234567890/my-files/Projects/ +a1b2c3d4-e5f6-7890-abcd-ef1234567890/my-files/Projects/game-prototype/ +``` + +### Why Human-Readable? + +Most S3-backed file systems use UUIDs as keys (`files/550e8400-e29b-41d4.bin`). We don't. You should be able to `s3cmd ls` the bucket and immediately see who owns what, what the folder structure looks like, and what the files are. + +This pays for itself when debugging, doing backfills, migrating data, or when someone asks "where did my file go?" — you can answer by looking at S3 directly, no database cross-referencing required. + +The tradeoff: renames and moves require S3 copy + delete (S3 has no rename operation). The `updateFile` handler in `server/files.ts` handles this: + +```typescript +if (newS3Key !== file.s3_key && !file.is_folder && Number(file.size) > 0) { + await copyObject(file.s3_key, newS3Key); + await deleteObject(file.s3_key); +} +``` + +--- + +## The PostgreSQL Metadata Layer + +S3 stores the bytes. PostgreSQL tracks everything else. + +### The `files` Table + +```sql +CREATE TABLE files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + s3_key TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL, + mimetype TEXT NOT NULL DEFAULT 'application/octet-stream', + size BIGINT NOT NULL DEFAULT 0, + owner_id TEXT NOT NULL, + parent_id UUID REFERENCES files(id) ON DELETE CASCADE, + is_folder BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +``` + +Design decisions worth noting: + +- **UUID primary key** — API routes use UUIDs, not paths. Decouples the URL space from the S3 key space. +- **`s3_key` is unique** — the bridge between metadata and storage. UUID → s3_key and s3_key → metadata, both directions work. +- **`parent_id` is self-referencing** — folders and files share a table. A folder is a row with `is_folder = true`. The hierarchy is a tree of `parent_id` pointers. +- **Soft delete** — `deleted_at` gets set, the row stays. Trash view queries `deleted_at IS NOT NULL`. Restore clears it. +- **`owner_id` is a text field** — not a FK to a users table, because there is no users table. It's the Kratos identity UUID. We don't duplicate identity data locally. + +Indexes: + +```sql +CREATE INDEX idx_files_parent ON files(parent_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_files_owner ON files(owner_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_files_s3key ON files(s3_key); +``` + +Partial indexes on `parent_id` and `owner_id` exclude soft-deleted rows — most queries filter on `deleted_at IS NULL`, so the indexes stay lean. Deleted files don't bloat the hot path. + +### The `user_file_state` Table + +```sql +CREATE TABLE user_file_state ( + user_id TEXT NOT NULL, + file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE, + favorited BOOLEAN NOT NULL DEFAULT false, + last_opened TIMESTAMPTZ, + PRIMARY KEY (user_id, file_id) +); +``` + +Per-user state that doesn't belong on the file itself. Favorites and recent files are powered by this table. + +--- + +## Folder Sizes + +Folders show their total size (all descendants, recursively). Two PostgreSQL functions handle this. + +### `recompute_folder_size(folder_id)` + +Recursive CTE that walks all descendants: + +```sql +CREATE OR REPLACE FUNCTION recompute_folder_size(folder_id UUID) +RETURNS BIGINT LANGUAGE SQL AS $$ + WITH RECURSIVE descendants AS ( + SELECT id, size, is_folder + FROM files + WHERE parent_id = folder_id AND deleted_at IS NULL + UNION ALL + SELECT f.id, f.size, f.is_folder + FROM files f + JOIN descendants d ON f.parent_id = d.id + WHERE f.deleted_at IS NULL + ) + SELECT COALESCE(SUM(size) FILTER (WHERE NOT is_folder), 0) + FROM descendants; +$$; +``` + +Sums file sizes only (not folders) to avoid double-counting. Excludes soft-deleted items. + +### `propagate_folder_sizes(start_parent_id)` + +Walks up the ancestor chain, recomputing each folder's size: + +```sql +CREATE OR REPLACE FUNCTION propagate_folder_sizes(start_parent_id UUID) +RETURNS VOID LANGUAGE plpgsql AS $$ +DECLARE + current_id UUID := start_parent_id; + computed BIGINT; +BEGIN + WHILE current_id IS NOT NULL LOOP + computed := recompute_folder_size(current_id); + UPDATE files SET size = computed WHERE id = current_id AND is_folder = true; + SELECT parent_id INTO current_id FROM files WHERE id = current_id; + END LOOP; +END; +$$; +``` + +Called after every file mutation: create, delete, restore, move, upload completion. The handler calls it like: + +```typescript +await sql`SELECT propagate_folder_sizes(${parentId}::uuid)`; +``` + +When a file moves between folders, both the old and new parent chains get recomputed: + +```typescript +await propagateFolderSizes(newParentId); +if (oldParentId && oldParentId !== newParentId) { + await propagateFolderSizes(oldParentId); +} +``` + +--- + +## The Backfill API + +S3 and the database can get out of sync — someone uploaded directly to SeaweedFS, a migration didn't finish, a backup restore happened. The backfill API reconciles them. + +``` +POST /api/admin/backfill +Content-Type: application/json + +{ + "prefix": "", + "dry_run": true +} +``` + +Both fields are optional. `prefix` filters by S3 key prefix. `dry_run` shows what would happen without writing anything. + +Response: + +```json +{ + "scanned": 847, + "already_registered": 812, + "folders_created": 15, + "files_created": 20, + "errors": [], + "dry_run": false +} +``` + +### What it does + +1. Lists all objects in the bucket (paginated, 1000 at a time) +2. Loads existing `s3_key` values from PostgreSQL into a Set +3. For each S3 object not in the database: + - Parses the key → owner ID, path, filename + - Infers mimetype from extension (extensive map in `server/backfill.ts` — documents, images, video, audio, 3D formats, code, archives) + - `HEAD` on the object for real content-type and size + - Creates parent folder rows recursively if missing + - Inserts the file row +4. Recomputes folder sizes for every folder + +The key parsing handles both conventions: + +```typescript +// {identity-id}/my-files/{path} -> owner = identity-id +// shared/{path} -> owner = "shared" +``` + +### When to reach for it + +- After bulk-uploading files directly to SeaweedFS +- After migrating from another storage system +- After restoring from a backup +- Any time S3 has files the database doesn't know about + +The endpoint requires an authenticated session but isn't exposed via ingress — admin-only, on purpose. + +--- + +## S3 Client + +The S3 client (`server/s3.ts`) does AWS Signature V4 with Web Crypto. No AWS SDK — it's a lot of dependency for six API calls. Implements: + +- `listObjects` — ListObjectsV2 with pagination +- `headObject` — HEAD for content-type and size +- `getObject` — GET (streaming response) +- `putObject` — PUT with content hash +- `deleteObject` — DELETE (404 is not an error) +- `copyObject` — PUT with `x-amz-copy-source` header + +Pre-signed URLs (`server/s3-presign.ts`) support: +- `presignGetUrl` — download directly from S3 +- `presignPutUrl` — upload directly to S3 +- `createMultipartUpload` + `presignUploadPart` + `completeMultipartUpload` — large file uploads + +Default pre-signed URL expiry is 1 hour. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..7d38b02 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,204 @@ +# Testing + +Five test suites, 90%+ coverage on both layers, and a Docker Compose stack for full WOPI integration tests. + +--- + +## Overview + +| Suite | Runner | Count | What it tests | +|-------|--------|-------|---------------| +| Server unit tests | Deno | 93 | API handlers, S3 signing, WOPI tokens, locks, auth, CSRF, Keto, permissions, backfill | +| UI unit tests | Vitest | 278 | Components, pages, hooks, stores, API client | +| E2E tests | Playwright | 11 | Full browser flows against a running server | +| Integration service tests | Playwright | 12 | Theme tokens, CSS injection, waffle menu from production integration service | +| WOPI integration tests | Playwright | 13 | End-to-end Collabora editing via Docker Compose | + +--- + +## Server Unit Tests (Deno) + +```bash +deno task test +``` + +Which runs: + +```bash +deno test -A tests/server/ +``` + +10 test files, one per server module: + +| File | What it covers | +|------|---------------| +| `auth_test.ts` | Kratos session validation, cookie extraction, AAL2 handling, test mode | +| `csrf_test.ts` | HMAC double-submit token generation, verification, timing-safe comparison | +| `files_test.ts` | File CRUD handlers, presigned URL generation, sort/search/pagination | +| `s3_test.ts` | AWS SigV4 signing, canonical request building, presign URL generation | +| `keto_test.ts` | Keto HTTP client: check, write, delete, batch, list, expand | +| `permissions_test.ts` | Permission middleware, tuple lifecycle, filterByPermission | +| `backfill_test.ts` | Key parsing, folder chain creation, dry run, mimetype inference | +| `wopi_token_test.ts` | JWT generation, verification, expiry, payload validation | +| `wopi_lock_test.ts` | Lock acquire, release, refresh, conflict, unlock-and-relock, TTL | +| `wopi_discovery_test.ts` | Discovery XML parsing, caching, retry logic | + +All use Deno's built-in test runner and assertions — no test framework dependency. WOPI lock tests inject an `InMemoryLockStore` so you don't need Valkey running. + +### Running with coverage + +```bash +deno test -A --coverage tests/server/ +deno coverage coverage/ +``` + +--- + +## UI Unit Tests (Vitest) + +```bash +cd ui && npx vitest run +``` + +Or from the project root: + +```bash +deno task test:ui +``` + +278 tests across 27 files: + +| Area | Files | What they test | +|------|-------|---------------| +| Components | `FileBrowser`, `FileUpload`, `CollaboraEditor`, `ProfileMenu`, `FileActions`, `FilePreview`, `AssetTypeBadge`, `BreadcrumbNav`, `WaffleButton`, `ShareDialog` | Rendering, user interaction, keyboard navigation, aria attributes | +| Pages | `Explorer`, `Recent`, `Favorites`, `Trash`, `Editor` | Route-level rendering, data loading, empty states | +| API | `client`, `files`, `session`, `wopi` | Fetch mocking, error handling, request formatting | +| Stores | `selection`, `upload` | Zustand state management, multi-select, upload queue | +| Hooks | `useAssetType`, `usePreview`, `useThreeDPreview` | File type detection, preview capability determination | +| Layouts | `AppLayout` | Header, sidebar, main content area rendering | +| Cunningham | `useCunninghamTheme` | Theme integration, CSS variable injection | +| Root | `App` | CunninghamProvider + Router mounting | + +### Running with coverage + +```bash +cd ui && npx vitest run --coverage +``` + +--- + +## E2E Tests (Playwright) + +```bash +cd ui && npx playwright test e2e/driver.spec.ts +``` + +Or: + +```bash +deno task test:e2e +``` + +**Needs a running server** with: +- `DRIVER_TEST_MODE=1` (bypasses Kratos auth, injects a fake identity) +- PostgreSQL with migrations applied +- SeaweedFS (`weed mini` works fine) + +11 tests covering browser-level flows: +- File browser navigation +- Folder creation and navigation +- File upload (single and multi-part) +- File rename and move +- File deletion and restore from trash +- Sort and search +- Favorites toggle +- Download via presigned URL + +--- + +## Integration Service Tests (Playwright) + +```bash +cd ui && INTEGRATION_URL=https://integration.sunbeam.pt npx playwright test e2e/integration-service.spec.ts +``` + +12 tests validating La Suite integration service theming: +- CSS variable injection from the integration service +- Theme token validation +- Waffle menu rendering +- Dark mode support +- Custom font loading +- Runtime theme switching + +These hit the production integration service at `integration.sunbeam.pt`. No local Drive server needed — they test the theme integration layer in isolation. + +--- + +## WOPI Integration Tests (Playwright) + +```bash +# Start the test stack +docker compose up -d + +# Wait for services to be healthy (Collabora takes ~30s) +docker compose ps + +# Start the server pointed at compose services +DATABASE_URL="postgres://driver:driver@localhost:5433/driver_db" \ +SEAWEEDFS_S3_URL="http://localhost:8334" \ +COLLABORA_URL="http://localhost:9980" \ +PUBLIC_URL="http://host.docker.internal:3200" \ +PORT=3200 \ +DRIVER_TEST_MODE=1 \ +deno run -A main.ts + +# Run the tests +cd ui && DRIVER_URL=http://localhost:3200 npx playwright test e2e/wopi.spec.ts +``` + +13 tests covering the full WOPI editing flow: +- Token generation for various file types +- Editor URL construction with WOPISrc +- Collabora iframe loading +- CheckFileInfo response validation +- GetFile content retrieval +- PutFile content writing +- Lock/unlock lifecycle +- Lock conflict handling +- Token expiry and refresh +- Concurrent editing detection + +### The Docker Compose Stack + +`compose.yaml` spins up three services: + +| Service | Image | Port | Purpose | +|---------|-------|------|---------| +| `postgres` | `postgres:16-alpine` | 5433 | Test database (5433 to avoid conflict with host PostgreSQL) | +| `seaweedfs` | `chrislusf/seaweedfs:latest` | 8334 | S3 storage (8334 to avoid conflict with host weed mini) | +| `collabora` | `collabora/code:latest` | 9980 | Document editor | + +Drive runs on the host (not in Docker) so Playwright can reach it. Collabora needs to call back to the host for WOPI — `aliasgroup1` points to `host.docker.internal:3200`. + +Tear down after testing: + +```bash +docker compose down -v +``` + +--- + +## Running Everything + +```bash +# All automated tests (server + UI + E2E) +deno task test:all +``` + +Which runs: + +```bash +deno task test && deno task test:ui && deno task test:e2e +``` + +WOPI integration tests aren't in `test:all` — they need the Docker Compose stack. Run them separately when touching WOPI code. diff --git a/docs/wopi.md b/docs/wopi.md new file mode 100644 index 0000000..12bd693 --- /dev/null +++ b/docs/wopi.md @@ -0,0 +1,286 @@ +# WOPI Integration + +How Drive talks to Collabora Online, how Collabora talks back, and the iframe dance that ties them together. + +--- + +## What WOPI Is + +WOPI (Web Application Open Platform Interface) is Microsoft's protocol for embedding document editors in web apps. Collabora implements it. Your app (the "WOPI host") exposes a few HTTP endpoints, and Collabora calls them to read files, write files, and manage locks. + +The mental model that matters: during editing, the browser talks to Collabora, and Collabora talks to your server. The browser is not in the loop for file I/O. + +## The Full Flow + +Here's what happens when a user double-clicks a `.docx`: + +### 1. Token Generation + +The browser calls our API to get a WOPI access token: + +``` +POST /api/wopi/token +Content-Type: application/json + +{ "file_id": "550e8400-e29b-41d4-a716-446655440000" } +``` + +The server: +- Validates the Kratos session (normal session auth) +- Looks up the file in PostgreSQL +- Determines write access (currently: owner = can write) +- Generates a JWT signed with HMAC-SHA256 +- Fetches the Collabora discovery XML to find the editor URL for this mimetype +- Returns the token, TTL, and editor URL + +Response: +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "access_token_ttl": 1711382400000, + "editor_url": "https://collabora.example.com/browser/abc123/cool.html?WOPISrc=..." +} +``` + +### 2. Form POST to Collabora + +The browser doesn't `fetch()` the editor URL — it submits a hidden HTML form targeting an iframe. Yes, a form POST in 2026. This is how WOPI works: the token goes as a form field, not a header. + +From `CollaboraEditor.tsx`: + +```tsx +
+ + +
+ +