diff --git a/.env.example b/.env.example index 73a3308..257331c 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Required environment variables for production deployment # Copy this file to .env.local for local development -# Convex deployment URL +# Convex Cloud deployment URL # Get this from your Convex dashboard: https://dashboard.convex.dev VITE_CONVEX_URL=https://your-deployment.convex.cloud diff --git a/.github/workflows/deploy-convex.yaml b/.github/workflows/deploy-convex.yaml new file mode 100644 index 0000000..c921eaf --- /dev/null +++ b/.github/workflows/deploy-convex.yaml @@ -0,0 +1,27 @@ +name: Deploy Convex + +on: + push: + branches: [main] + paths: + - 'convex/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Deploy to Convex Cloud + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + run: npx convex deploy diff --git a/.github/workflows/mission-control-quality-gates.yml b/.github/workflows/mission-control-quality-gates.yml new file mode 100644 index 0000000..929aceb --- /dev/null +++ b/.github/workflows/mission-control-quality-gates.yml @@ -0,0 +1,54 @@ +name: mission-control-quality-gates + +on: + pull_request: + paths: + - "e2e/**" + - "playwright.config.ts" + - "package.json" + - ".github/workflows/mission-control-quality-gates.yml" + workflow_dispatch: + +jobs: + phase1-quality-gates: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Perf fixture parser gate + run: npm run test:e2e -- e2e/mission-control-perf-fixture.spec.ts --reporter=line + + - name: Mission Control Phase 1 acceptance + perf gates + env: + MISSION_CONTROL_FIXTURE_PATH: e2e/fixtures/mission-control.production.json + run: npm run test:e2e -- e2e/mission-control-phase1.spec.ts --reporter=line + + - name: Upload Playwright artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: mission-control-playwright-artifacts + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml new file mode 100644 index 0000000..8cd0be2 --- /dev/null +++ b/.github/workflows/mobile-build.yml @@ -0,0 +1,125 @@ +name: Mobile Build CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + web-build: + name: Build Web App + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Convex types + run: npx convex codegen + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - name: Build web app + run: npm run build + env: + VITE_CONVEX_URL: ${{ secrets.VITE_CONVEX_URL }} + + - uses: actions/upload-artifact@v4 + with: + name: web-dist + path: dist/ + retention-days: 1 + + ios-build: + name: Build & Deploy iOS to TestFlight + runs-on: macos-latest + needs: web-build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - uses: actions/download-artifact@v4 + with: + name: web-dist + path: dist/ + + - name: Install dependencies + run: npm ci + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: ios + + - name: Capacitor sync + run: npx cap sync ios + + - name: Fastlane Beta + working-directory: ios + env: + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} + APP_STORE_CONNECT_API_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: bundle exec fastlane beta + + android-build: + name: Build Android + runs-on: ubuntu-latest + needs: web-build + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - uses: actions/download-artifact@v4 + with: + name: web-dist + path: dist/ + + - name: Install dependencies + run: npm ci + + - name: Capacitor sync + run: npx cap sync android + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }} + restore-keys: gradle- + + - name: Build Android Debug + working-directory: android + run: ./gradlew assembleDebug diff --git a/.gitignore b/.gitignore index d21d049..0719cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,18 @@ dist/ # OS .DS_Store + +# Capacitor - Copied web assets (regenerated by `npx cap sync`) +ios/App/App/public/ +ios/App/App/capacitor.config.json +android/app/src/main/assets/public/ +android/app/src/main/assets/capacitor.config.json +android/app/src/main/assets/capacitor.plugins.json + +# Capacitor - Native build artifacts +ios/App/Pods/ +ios/App/App.xcworkspace/xcuserdata/ +android/.gradle/ +android/app/build/ +android/build/ +.secrets/ diff --git a/.superdesign/init/components.md b/.superdesign/init/components.md new file mode 100644 index 0000000..35213d8 --- /dev/null +++ b/.superdesign/init/components.md @@ -0,0 +1,58 @@ +# Shared UI Primitives Inventory + +## Core UI Components (`src/components/ui/`) +- **EmptyState.tsx** — Empty state illustrations (NoListsEmptyState, NoSearchResultsEmptyState, NoItemsEmptyState) +- **Panel.tsx** — Reusable panel/card container +- **SearchInput.tsx** — Search input with icon +- **Skeleton.tsx** — Loading skeleton placeholders (HomePageSkeleton, ListViewSkeleton) +- **SortDropdown.tsx** — Sort options dropdown + +## List Components (`src/components/lists/`) +- **CategoryHeader.tsx** — Collapsible category section header +- **CategoryManager.tsx** — Category CRUD modal +- **CategorySelector.tsx** — Category picker + +## Auth Components (`src/components/auth/`) +- **AuthGuard.tsx** — Route protection wrapper +- **OtpInput.tsx** — OTP code input for login + +## Sharing Components (`src/components/sharing/`) +- **CollaboratorList.tsx** — List of collaborators with roles + +## Notification Components (`src/components/notifications/`) +- **Toast.tsx** — Toast notification container + +## Feature Components (`src/components/`) +- **AddItemInput.tsx** — Text input for adding new items +- **ListCard.tsx** — Card preview of a list (used on Home page) +- **ListItem.tsx** — Individual todo item row with check, drag, edit +- **HeaderActionsMenu.tsx** — Overflow menu (⋯) for list actions +- **ProfileBadge.tsx** — User avatar/badge in header +- **VerificationBadge.tsx** — VC verification status indicator +- **ItemDetailsModal.tsx** — Full item detail/edit modal +- **ShareModal.tsx** — Sharing/invite modal +- **CreateListModal.tsx** — New list creation modal +- **TemplatePickerModal.tsx** — Template selection for new lists +- **SaveAsTemplateModal.tsx** — Save list as template +- **DeleteListDialog.tsx** — Delete confirmation dialog +- **RenameListDialog.tsx** — Rename list dialog +- **ChangeCategoryDialog.tsx** — Change list category +- **ConfirmDialog.tsx** — Generic confirmation dialog +- **CalendarView.tsx** — Calendar view for items with due dates +- **BatchOperations.tsx** — Bulk actions bar for multi-select +- **SubItems.tsx** — Nested sub-items +- **TagSelector.tsx** — Tag picker +- **Comments.tsx** — Item comments +- **Attachments.tsx** — File attachments +- **Settings.tsx** — App settings panel +- **ErrorBoundary.tsx** — Error boundary wrapper +- **AppLockGuard.tsx** — App lock/biometric guard + +## Design System Notes +- Uses Tailwind CSS v4 with `@import "tailwindcss"` +- Dark mode via `.dark` class variant +- Color palette: amber/orange primary, gray neutrals +- Rounded corners: `rounded-xl` to `rounded-3xl` +- Shadows: `shadow-lg`, `shadow-xl` with colored shadows (e.g., `shadow-amber-500/20`) +- Animations: slide-up, float, bounce-slow, shimmer (custom keyframes) +- Mobile: safe-area-inset support, touch targets, haptic feedback hooks diff --git a/API.md b/API.md new file mode 100644 index 0000000..a0ca7aa --- /dev/null +++ b/API.md @@ -0,0 +1,344 @@ +# Poo App Agent API + +REST API for programmatic access to Poo App lists and items. Designed for agents, scripts, and integrations to interact without using a browser. + +## Base URL + +``` +https://.convex.site +``` + +## Authentication + +All endpoints require JWT authentication via the `Authorization` header: + +``` +Authorization: Bearer +``` + +To obtain a JWT token, use the standard auth flow: +1. `POST /auth/initiate` with `{ "email": "your@email.com" }` +2. `POST /auth/verify` with `{ "sessionId": "...", "code": "..." }` (OTP from email) +3. Use the returned `token` in subsequent requests + +## Endpoints + +### Lists + +#### Get All Lists +``` +GET /api/agent/lists +``` + +Returns all lists the authenticated user has access to. + +**Response:** +```json +{ + "lists": [ + { + "_id": "abc123...", + "name": "Shopping List", + "ownerDid": "did:webvh:...", + "createdAt": 1704067200000, + "role": "owner" + } + ] +} +``` + +#### Get List with Items +``` +GET /api/agent/lists/:listId +GET /api/agent/lists/:listId/items +``` + +Returns a list and all its items. + +**Response:** +```json +{ + "list": { + "_id": "abc123...", + "name": "Shopping List", + "ownerDid": "did:webvh:...", + "createdAt": 1704067200000, + "assetDid": "did:peer:..." + }, + "items": [ + { + "_id": "item123...", + "name": "Milk", + "checked": false, + "createdByDid": "did:webvh:...", + "createdAt": 1704067200000, + "order": 0, + "description": "2% organic", + "priority": "high" + } + ], + "role": "owner" +} +``` + +#### Add Item to List +``` +POST /api/agent/lists/:listId/items +Content-Type: application/json + +{ + "name": "Buy groceries", + "description": "From Whole Foods", + "priority": "high", + "dueDate": 1704153600000, + "url": "https://example.com" +} +``` + +**Response (201 Created):** +```json +{ + "itemId": "item456...", + "item": { + "_id": "item456...", + "name": "Buy groceries", + "checked": false, + "createdByDid": "did:webvh:...", + "description": "From Whole Foods", + "priority": "high", + "dueDate": 1704153600000, + "url": "https://example.com" + } +} +``` + +### Items + +#### Update Item +``` +PATCH /api/agent/items/:itemId +Content-Type: application/json + +{ + "checked": true, + "name": "Updated name", + "description": "Updated description", + "priority": "medium", + "dueDate": 1704240000000, + "url": "https://new-url.com" +} +``` + +All fields are optional. To clear a field, set it to `null`: +```json +{ + "priority": null, + "dueDate": null +} +``` + +**Response:** +```json +{ + "success": true, + "item": { + "_id": "item123...", + "name": "Updated name", + "checked": true, + ... + } +} +``` + +#### Delete Item +``` +DELETE /api/agent/items/:itemId +``` + +**Response:** +```json +{ + "success": true +} +``` + +## Field Reference + +### Item Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Item title (required for creation) | +| `description` | string | Optional notes/details | +| `checked` | boolean | Whether the item is complete | +| `priority` | "high" \| "medium" \| "low" | Priority level | +| `dueDate` | number | Unix timestamp in milliseconds | +| `url` | string | Link to PR, URL, or reference | +| `order` | number | Position in list (lower = higher) | +| `createdByDid` | string | DID of user who created the item | +| `checkedByDid` | string | DID of user who checked the item | +| `createdAt` | number | Creation timestamp | +| `checkedAt` | number | When item was checked | + +### Roles + +| Role | Permissions | +|------|-------------| +| `owner` | Full access (read, write, delete, share) | +| `editor` | Read and write access | +| `viewer` | Read-only access | + +## Mission Control REST v1 (P1) + +New endpoints for Agent Mission Control with scoped API keys. + +### Auth Modes +- JWT bearer token (`Authorization: Bearer ...`) +- API key (`X-API-Key: pa_xxx...`) for `/api/v1/*` endpoints + +### API Keys +- `GET /api/v1/auth/keys` — list keys + recent rotation events (JWT only) +- `POST /api/v1/auth/keys` — create key (JWT only) + - body: `{ "label": "CI Agent", "scopes": ["tasks:read","memory:write"] }` +- `POST /api/v1/auth/keys/:keyId/rotate` — zero-downtime rotation (JWT only) + - creates a new key, keeps old key active for grace period + - body: `{ "gracePeriodHours": 24, "label": "CI Agent v2" }` +- `POST /api/v1/auth/keys/:keyId/finalize-rotation` — revoke old key after cutover (JWT only) +- `DELETE /api/v1/auth/keys/:keyId` — revoke key immediately (JWT only) + +### Agent Registration / Profiles +- `GET /api/v1/agents` — list agent profiles (`agents:read`) +- `POST /api/v1/agents` — create/update profile (`agents:write`) + +### Tasks +- `GET /api/v1/tasks?listId=&limit=100` (`tasks:read`) +- `GET /api/v1/tasks/:taskId` (`tasks:read`) + +### Activity +- `GET /api/v1/activity?listId=&limit=100` (`activity:read`) + +### Memory +- `GET /api/v1/memory?agentSlug=[&key=]` (`memory:read`) +- `POST /api/v1/memory` (`memory:write`) +- `GET /api/v1/memory/sync?since=&limit=` (`memory:read`) — pull Convex memory changes for OpenClaw (results are ordered oldest→newest after `since`; response `cursor` equals newest returned `updatedAt` for lossless paging) +- `POST /api/v1/memory/sync` (`memory:write`) — push OpenClaw memory entries into Convex with conflict policy (`lww` or `preserve_both`) + - body: `{ "agentSlug": "platform", "key": "runbook", "value": "...", "listId": "...optional..." }` + +### Mission Runs (P0-6 hardening) +- `GET /api/v1/runs?[listId=&itemId=&status=&limit=100]` (`runs:read`) +- `POST /api/v1/runs` (`runs:write`) + - body: `{ "listId": "...", "itemId": "...optional...", "agentSlug": "planner", "provider": "openclaw", "computerId": "orgo-1", "parentRunId": "...optional..." }` +- `POST /api/v1/runs/:runId/heartbeat` (`runs:write`) +- `POST /api/v1/runs/:runId/transition` (`runs:control`) + - body: `{ "nextStatus": "running|degraded|blocked|failed|finished", "terminalReason": "completed|killed|timeout|error|escalated" }` +- `POST /api/v1/runs/:runId/retry` (`runs:control`) +- `POST /api/v1/runs/:runId/artifacts` (`runs:write`) + - body: `{ "type": "screenshot|log|diff|file|url", "ref": "...", "label": "...optional..." }` +- `POST /api/v1/runs/monitor` (`runs:control`) — applies heartbeat timeout state updates for all owner runs +- `GET /api/v1/runs/retention` (JWT only) — retention config + recent deletion logs +- `PUT /api/v1/runs/retention` (JWT only) — set artifact retention days (default 30) +- `POST /api/v1/runs/retention` (JWT only) — run retention job (`dryRun` defaults to `true`) + - retention clamp: `1..365` days, stale rule: `artifact.createdAt < cutoff` + - audit logs are idempotent on `(runId, retentionCutoffAt, dryRun, deletedArtifacts fingerprint)` + +### Launch-gate drill auth split +For `npm run mission-control:readiness-drill`: +- `MISSION_CONTROL_BASE_URL` — Convex site base URL +- `MISSION_CONTROL_API_KEY` — used for API-key routes (dashboard/run controls) +- `MISSION_CONTROL_JWT` — used for JWT-only routes (API key rotation inventory + retention/audit endpoints) + +### Run Dashboard +- `GET /api/v1/dashboard/runs?[windowMs=86400000]` (`dashboard:read`) + - returns success/intervention/timeout rates plus active/degraded run visibility + +### Scopes +- `tasks:read`, `tasks:write` +- `activity:read` +- `memory:read`, `memory:write` +- `agents:read`, `agents:write` +- `runs:read`, `runs:write`, `runs:control` +- `dashboard:read` + +## Error Responses + +All errors return JSON with an `error` field: + +```json +{ + "error": "Error message here" +} +``` + +| Status | Description | +|--------|-------------| +| 400 | Bad request (missing/invalid parameters) | +| 401 | Unauthorized (missing/invalid token) | +| 403 | Forbidden (no access to resource) | +| 404 | Not found | +| 405 | Method not allowed | +| 500 | Server error | + +## Examples + +### cURL + +```bash +# Get all lists +curl -H "Authorization: Bearer $TOKEN" \ + https://your-deployment.convex.site/api/agent/lists + +# Get a specific list with items +curl -H "Authorization: Bearer $TOKEN" \ + https://your-deployment.convex.site/api/agent/lists/abc123xyz + +# Add an item +curl -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "New task", "priority": "high"}' \ + https://your-deployment.convex.site/api/agent/lists/abc123xyz/items + +# Check off an item +curl -X PATCH \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"checked": true}' \ + https://your-deployment.convex.site/api/agent/items/item123xyz + +# Delete an item +curl -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + https://your-deployment.convex.site/api/agent/items/item123xyz +``` + +### JavaScript/TypeScript + +```typescript +const BASE_URL = "https://your-deployment.convex.site"; +const TOKEN = "your-jwt-token"; + +// Get all lists +const lists = await fetch(`${BASE_URL}/api/agent/lists`, { + headers: { Authorization: `Bearer ${TOKEN}` } +}).then(r => r.json()); + +// Add an item +const newItem = await fetch(`${BASE_URL}/api/agent/lists/${listId}/items`, { + method: "POST", + headers: { + Authorization: `Bearer ${TOKEN}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ name: "New task", priority: "high" }) +}).then(r => r.json()); + +// Check off an item +await fetch(`${BASE_URL}/api/agent/items/${itemId}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${TOKEN}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ checked: true }) +}); +``` diff --git a/MISSION-CONTROL-SPRINT-2026-02-23.md b/MISSION-CONTROL-SPRINT-2026-02-23.md new file mode 100644 index 0000000..cc224da --- /dev/null +++ b/MISSION-CONTROL-SPRINT-2026-02-23.md @@ -0,0 +1,21 @@ +# Mission Control Sprint Plan — Overnight Push + +Target: complete Phase 1 acceptance-tests + observability implementation by morning. + +## Timed blocks (PST) +- [x] 02:30 AM — Block 1 kickoff: implementation plan + task tracking setup +- [x] 03:15 AM — Block 2: acceptance tests implementation pass +- [x] 03:50 AM — Block 3: observability dashboard wiring pass +- [x] 04:00 AM — Block 4: hardening + PR prep +- [x] 06:00 AM — Block 5: completion push + final status + +## Must-complete scope +- [x] Phase 1 acceptance tests scaffold + runnable baseline +- [x] Observability metrics + dashboard config/docs +- [x] Alert thresholds implemented/mapped +- [x] PR(s) opened/updated with summary (PR #139) + +## Notes +- Cron jobs are scheduled for overnight execution and status announcements after each block. +- If direct Poo App API tracking auth is unavailable, use this file as temporary tracker and sync to Poo App once auth is available. +- Block 4 hardening branch: `chore/mission-control-block4-hardening` (includes observability validator + metrics/dashboard sync tightening). diff --git a/MISSION-CONTROL-TEMP-TRACKER.json b/MISSION-CONTROL-TEMP-TRACKER.json new file mode 100644 index 0000000..c2cdf40 --- /dev/null +++ b/MISSION-CONTROL-TEMP-TRACKER.json @@ -0,0 +1,88 @@ +{ + "sprintDate": "2026-02-23", + "block": "5/5", + "updatedAt": "2026-03-03T08:05:00Z", + "pooAppAgentApiTracking": { + "attempted": true, + "status": "blocked", + "reason": "No local agent API credentials/session discovered (.env.local missing; no bearer token configured for task write)." + }, + "tasks": [ + { + "id": "MC-P1-AT-SCAFFOLD", + "title": "Phase 1 acceptance tests scaffold", + "status": "done", + "artifacts": [ + "e2e/mission-control-phase1.spec.ts" + ] + }, + { + "id": "MC-P1-AT-BLOCK2", + "title": "Block 2 acceptance test implementation pass (AC1-AC5 harness + gating)", + "status": "done", + "artifacts": [ + "e2e/mission-control-phase1.spec.ts", + "e2e/fixtures/auth.ts", + "e2e/fixtures/mission-control.production.json" + ] + }, + { + "id": "MC-P1-OBS-PLAN", + "title": "Phase 1 observability dashboard instrumentation plan", + "status": "done", + "artifacts": [ + "docs/mission-control/phase1-observability-dashboard-plan.md", + "docs/mission-control/phase1-observability-metrics.json", + "docs/mission-control/phase1-observability-alert-routing.json", + "scripts/validate-mission-control-observability.mjs" + ] + }, + { + "id": "MC-P1-TRACKING-AUTH", + "title": "Track tasks in Poo App via agent API", + "status": "blocked", + "blocker": "Need API credentials or authenticated agent session" + }, + { + "id": "MC-P1-PR-OPEN", + "title": "Open/update PR with overnight mission control scope summary", + "status": "done", + "artifacts": [ + "https://github.com/aviarytech/todo/pull/153" + ] + }, + { + "id": "MC-P1-AC3-PRESENCE-WIRE", + "title": "Wire list-level presence indicator + heartbeat and unskip AC3 feature gate", + "status": "done", + "artifacts": [ + "src/pages/ListView.tsx", + "e2e/mission-control-phase1.spec.ts" + ] + } + ], + "validation": { + "playwrightSpecRun": "partial", + "command": "npm run test:e2e -- e2e/mission-control-phase1.spec.ts -g \"AC3 presence freshness\"", + "result": { + "passed": 0, + "skipped": 1, + "failed": 0 + }, + "observabilityValidation": { + "command": "npm run mission-control:validate-observability", + "passed": true + }, + "notes": [ + "Added quick Assign action in list item UI wired to items.updateItem(assigneeDid=userDid).", + "Removed AC1 feature-availability dynamic skip; AC1 now asserts Assign control visibility.", + "Remaining AC1 skip is environment readiness gate (authenticated app shell availability).", + "AC3 feature dynamic skip removed; scenario still environment-gated on authenticated app-shell readiness." + ] + }, + "next": [ + "Acquire stable authenticated e2e backend session so AC3 can execute instead of setup-skip", + "Run full mission-control-phase1 spec on production-sized fixture to capture AC5 metrics without skips", + "Close MC-P1-TRACKING-AUTH blocker once agent API credentials/session are provisioned" + ] +} \ No newline at end of file diff --git a/PRD-AGENT-MISSION-CONTROL.md b/PRD-AGENT-MISSION-CONTROL.md new file mode 100644 index 0000000..49bf330 --- /dev/null +++ b/PRD-AGENT-MISSION-CONTROL.md @@ -0,0 +1,609 @@ +# PRD: Poo App — Agent Mission Control + +**Version:** 1.1 (Orgo-first runtime revision) +**Date:** 2026-02-22 +**Author:** Krusty 🦞🤡 + Brian +**Status:** Draft (Revised) + +--- + +## 1. Vision + +Poo App is a beautifully simple todo list that *happens to be* the best way to manage AI agents. + +For a regular person, it's a fast, clean task manager with lists, due dates, streaks, and offline sync. For someone running AI agents (via OpenClaw, ClawBootBot, or any future platform), it's a Mission Control — a real-time dashboard where humans and agents collaborate on work, share context, and stay in sync. In V1, agents execute tasks on Orgo computers by default, with activity and artifacts surfaced directly in Poo App. + +**The key principle: every "agent" feature must also make sense as a "human collaboration" feature.** Assigning a task to an agent is the same UX as assigning it to a teammate. An agent's activity feed is the same pattern as a shared list's activity log. The agent layer is invisible until you turn it on. + +--- + +## 2. Problem Statement + +Today's AI agent setups have a visibility problem: +- Agents log work to markdown files buried on a machine +- Cron jobs and scheduled tasks are invisible (config files, CLI only) +- Sub-agents spin up and die with no trace in the UI +- The human has no real-time view of what their agent is doing +- There's no bidirectional task assignment — you can't just drop a task for your agent to pick up +- Compute context is fragmented — no standard runtime session record (computer/workspace/artifacts) shared back to the task layer + +Meanwhile, Poo App already has: +- Task management with lists, subtasks, due dates, priorities +- Real-time sync via Convex +- DID-based identity (Originals) +- Mobile app (iOS via Capacitor) +- Offline mode with full sync +- Streaks and gamification + +The gap is small. Poo App needs **agent awareness** — the ability to recognize that some collaborators are AI agents, and to surface their work in a way that's useful. + +--- + +## 3. Users & Personas + +### Persona 1: Regular Human ("Sam") +- Wants a simple, fast todo app +- Doesn't know or care about AI agents +- Uses lists for groceries, work tasks, personal goals +- Expects: clean UI, fast add, satisfying check-off, maybe streaks + +### Persona 2: Agent Operator ("Brian") +- Runs one or more AI agents via OpenClaw/ClawBootBot +- Wants to see what agents are doing in real-time +- Wants to assign tasks to agents and have them pick them up +- Wants a memory browser, activity feed, and schedule view +- Expects: Mission Control dashboard, API access, multi-agent support + +### Persona 3: AI Agent ("Krusty") +- Needs to read/write tasks, log activity, store memories +- Needs an API (not a UI) — agents don't click buttons +- Needs to report status, claim tasks, mark complete +- Expects: REST/Convex API, webhooks, structured data + +**Design rule:** Build for Sam first. Everything Brian and Krusty need should be additive — feature flags, optional views, API endpoints that don't affect the core UX. + +--- + +## 4. Architecture Principles + +### 4.1 Progressive Disclosure +The app has three "layers" of complexity: + +| Layer | Who sees it | What it adds | +|-------|-------------|--------------| +| **Core** | Everyone | Lists, items, due dates, tags, streaks | +| **Collaboration** | Users who share lists | Assignees, comments, activity log, real-time cursors | +| **Agent** | Users who connect an agent | Agent identity, activity feed, memory browser, schedule view, API keys | + +Each layer builds on the previous. Agent features are a *superset* of collaboration features. + +### 4.2 Agents Are Just Collaborators +In the data model, an agent is a user with a DID — same as a human. The `users` table already has `did`, `displayName`, `email`. An agent gets the same fields, plus: +- `isAgent: true` flag +- `agentPlatform: "openclaw" | "clawboot" | ...` (optional metadata) +- `connectedBy: userId` (which human linked this agent) + +This means: +- Assigning a task to an agent = assigning to a collaborator +- An agent completing a task = a collaborator checking it off +- Agent activity = collaboration activity +- No separate "agent" data model + +### 4.3 API-First for Agent Features +Agents interact via API, not UI. Every agent feature needs: +- A Convex mutation/query (for direct Convex clients) +- An HTTP API endpoint (for agents that use REST) +- Auth via API key or DID-signed token + +### 4.4 The Convex Advantage +Convex gives us real-time subscriptions for free. When an agent updates a task, the human sees it *instantly* in the UI. No polling, no websocket plumbing. This is the core magic of Mission Control — you watch your agent work in real-time. + +### 4.5 Compute Substrate Abstraction (Orgo-first) +Mission Control should not assume where agents run. For V1, we standardize on **Orgo computers** as the default execution substrate, with OpenClaw/ClawBoot as orchestration. + +Principles: +- `AgentControl API` in Poo App is substrate-agnostic (`claimTask`, `heartbeat`, `logActivity`, `attachArtifact`, `completeTask`). +- Runtime metadata is first-class: computer ID, workspace ID, provider (`orgo`), session status, and last screenshot artifact. +- Every critical agent action should emit artifacts (screenshot/logs) for auditability in the activity feed. +- Keep portability: if we swap Orgo later, Poo App data model and API remain stable. + +### 4.6 "Really Good Employee" Abstraction (product north star) +Mission Control should model an agent as a dependable digital employee with five core capabilities: + +1. **Schedule Tasks** — can receive planned work and execute on schedule. +2. **Code/Execution Capability** — can perform technical work (e.g., coding/automation) when assigned. +3. **Runs 24/7** — can operate continuously with health/status visibility. +4. **Textable Interface** — human can assign/reprioritize work via messaging. +5. **Own Computer** — each agent session can bind to an isolated runtime computer. + +This abstraction should drive product decisions: if a new feature does not improve one of these five capabilities or observability around them, it is lower priority. + +--- + +## 5. Features — Phased Rollout + +### Phase 1: Collaborative Tasks (Foundation) +*Makes the app better for everyone, enables agent features later.* + +**1.1 Assignees** +- Any item can have an `assigneeDid` field +- UI: avatar/initials next to the item, filter by assignee +- For Sam: assign tasks to family members on shared lists +- For Brian: assign tasks to Krusty + +**1.2 Activity Log** +- Per-list log of actions: created, completed, commented, assigned, edited +- New table: `activity` with `listId`, `actorDid`, `action`, `itemId`, `timestamp`, `details` +- UI: slide-up panel on a list showing recent activity +- For Sam: see what your partner checked off the grocery list +- For Brian: see exactly what Krusty did and when + +**1.3 Real-Time Presence** +- Show who's currently viewing a list (avatar dots) +- Convex presence via heartbeat mutations +- For Sam: know your roommate is also editing the list +- For Brian: see that Krusty is actively working on tasks + +**Schema additions:** +```typescript +// In items table +assigneeDid: v.optional(v.string()), +assignedAt: v.optional(v.number()), + +// New table +activity: defineTable({ + listId: v.id("lists"), + actorDid: v.string(), + action: v.string(), // "created" | "completed" | "assigned" | "commented" | "edited" + itemId: v.optional(v.id("items")), + details: v.optional(v.string()), // JSON blob for action-specific data + timestamp: v.number(), +}).index("by_list", ["listId"]) + .index("by_list_time", ["listId", "timestamp"]) + .index("by_actor", ["actorDid"]), + +// New table +presence: defineTable({ + listId: v.id("lists"), + userDid: v.string(), + lastSeen: v.number(), +}).index("by_list", ["listId"]) + .index("by_user", ["userDid"]), +``` + +**Phase 1 Acceptance Tests (required before Phase 2 starts)** +1. **Assignee round-trip** + - Given an item in a shared list, when assignee is changed, then all active clients show updated assignee in <1s. +2. **Activity log completeness** + - For actions `created|completed|assigned|commented|edited`, each action writes exactly one activity row with correct `actorDid`, `itemId`, and timestamp ordering. +3. **Presence freshness** + - When a user closes a list, presence indicator disappears within 90 seconds max. +4. **No-regression core UX** + - Existing non-collab users can create/complete/edit tasks with no new required fields and no agent UI shown by default. +5. **Phase 1 perf floor** + - P95 list open < 500ms; P95 activity panel load < 700ms on production-sized test data. + +**Phase 1 Observability Dashboard Spec (must ship with Phase 1)** +Create a Mission Control internal dashboard with these panels: + +- **Realtime Health** + - subscription latency (P50/P95) + - mutation error rate (5m/1h) + - active presence sessions +- **Collaboration Throughput** + - activity events/minute by action type + - assignments/day + - completion events/day +- **Data Integrity** + - % items with invalid/missing assignee references + - duplicate activity event detector + - out-of-order timestamp detector +- **User Experience** + - activity panel open latency (P95) + - list render latency (P95) + - client-side error rate by route + +Alert thresholds (initial): +- mutation error rate > 2% for 10 min +- realtime subscription latency P95 > 1200ms for 10 min +- data integrity anomaly count > 0 for 15 min + +Alert routing: +- staging: send to internal dev channel only +- production: send to on-call channel + pager integration; require acknowledgement and incident note + +--- + +### Phase 2: Agent Identity, API & Runtime Sessions +*Connect agents to the app and track execution sessions. This is where it becomes Mission Control.* + +**2.1 Agent Registration** +- Settings → "Connect an Agent" +- Generate an API key (scoped to the user's lists) +- Agent authenticates via API key in HTTP header +- Agent gets a `users` record with `isAgent: true` + +**2.2 HTTP API** +REST endpoints backed by Convex HTTP actions: + +``` +POST /api/v1/tasks — Create a task +PATCH /api/v1/tasks/:id — Update a task (status, description, etc.) +GET /api/v1/tasks?list=X — List tasks (filterable) +POST /api/v1/tasks/:id/claim — Agent claims an unassigned task +POST /api/v1/tasks/:id/complete — Agent marks task complete +POST /api/v1/activity — Log an activity entry +GET /api/v1/activity?list=X — Get activity feed +POST /api/v1/memory — Store a memory entry +GET /api/v1/memory?q=search — Search memories +``` + +Auth: `Authorization: Bearer ` + +**2.3 Agent Profile** +- Agent appears as a collaborator with a distinct avatar (robot icon or custom) +- Shows agent platform, connection status, last active time +- Agent DID is a real Originals DID — cryptographic identity + +**2.4 Mission Runs (Orgo runtime session tracking)** +- Every claimed task can have one or more `missionRuns`. +- A run tracks provider + runtime context + evidence artifacts. +- UI: task detail shows run timeline (started, heartbeats, artifacts, completed/failed). + +**Schema additions:** +```typescript +// In users table +isAgent: v.optional(v.boolean()), +agentPlatform: v.optional(v.string()), +connectedByUserId: v.optional(v.id("users")), + +// New table +apiKeys: defineTable({ + userId: v.id("users"), + keyHash: v.string(), // SHA-256 of the API key (never store raw) + name: v.string(), // "Krusty's key", "ClawBot #3" + scopes: v.array(v.string()), // ["tasks:read", "tasks:write", "memory:write"] + lastUsedAt: v.optional(v.number()), + createdAt: v.number(), + revokedAt: v.optional(v.number()), +}).index("by_key_hash", ["keyHash"]) + .index("by_user", ["userId"]), + +// New table +missionRuns: defineTable({ + ownerDid: v.string(), + taskId: v.id("items"), + agentDid: v.string(), + provider: v.string(), // "orgo" + schemaVersion: v.number(), // event/schema compatibility control + workspaceId: v.optional(v.string()), + computerId: v.optional(v.string()), + state: v.string(), // starting | running | blocked | failed | finished + startedAt: v.number(), + endedAt: v.optional(v.number()), + error: v.optional(v.string()), + artifactRefs: v.optional(v.array(v.string())), +}).index("by_task", ["taskId"]) + .index("by_agent_time", ["agentDid", "startedAt"]) + .index("by_owner_time", ["ownerDid", "startedAt"]), +``` + +--- + +### Phase 3: Memory & Knowledge +*Give agents persistent, searchable memory inside the app.* + +**3.1 Memory Store** +- Key-value + full-text searchable memory entries +- Replaces (or syncs with) markdown `MEMORY.md` files +- Each entry: title, content (markdown), tags, source, timestamp + +**3.2 Memory Browser UI** +- New tab/view in the app: "Memory" (only visible if agent features enabled) +- Card-based layout showing recent memories +- Full-text search across all memories +- Filter by tag, date range, source (which agent wrote it) + +**3.3 Memory Sync** +- OpenClaw skill that syncs `MEMORY.md` ↔ Convex memory store +- Bidirectional: human can edit memories in UI, agent sees changes +- Conflict resolution: last-write-wins with merge UI for conflicts + +**Schema:** +```typescript +memories: defineTable({ + ownerDid: v.string(), // User who owns this memory space + authorDid: v.string(), // Who wrote it (human or agent) + title: v.string(), + content: v.string(), // Markdown + tags: v.optional(v.array(v.string())), + source: v.optional(v.string()), // "manual" | "openclaw" | "clawboot" | "import" + sourceRef: v.optional(v.string()), // e.g. "memory/2026-02-19.md#L15" + createdAt: v.number(), + updatedAt: v.number(), +}).index("by_owner", ["ownerDid"]) + .index("by_owner_time", ["ownerDid", "updatedAt"]) + .searchIndex("search_content", { + searchField: "content", + filterFields: ["ownerDid", "tags"], + }), +``` + +--- + +### Phase 4: Schedule & Calendar +*Visualize what agents (and humans) have planned.* + +**4.1 Schedule View** +- Calendar UI showing: + - Tasks with due dates (already exists partially) + - Recurring tasks (already exists) + - Agent cron jobs (new — synced from OpenClaw) + - One-time scheduled events + +**4.2 Cron Sync** +- OpenClaw pushes cron job metadata to Convex via API +- Shows job name, schedule, last run, next run, status +- Human can enable/disable crons from the UI (writes back to OpenClaw) + +**4.3 Schedule Entries** +```typescript +scheduleEntries: defineTable({ + ownerDid: v.string(), + agentDid: v.optional(v.string()), // Which agent owns this schedule + title: v.string(), + description: v.optional(v.string()), + scheduleType: v.union(v.literal("cron"), v.literal("once"), v.literal("recurring")), + cronExpr: v.optional(v.string()), // "0 9 * * 1" + scheduledAt: v.optional(v.number()), // For one-time + lastRunAt: v.optional(v.number()), + nextRunAt: v.optional(v.number()), + lastStatus: v.optional(v.string()), // "ok" | "error" | "skipped" + enabled: v.boolean(), + externalId: v.optional(v.string()), // OpenClaw cron job ID for sync + createdAt: v.number(), +}).index("by_owner", ["ownerDid"]) + .index("by_next_run", ["ownerDid", "nextRunAt"]), +``` + +--- + +### Phase 5: Team & Multi-Agent +*For users running multiple agents (power users, ClawBootBot).* + +**5.1 Team View** +- Dashboard showing all connected agents +- Each agent card: name, avatar, status, current task, last active +- Role/specialty tags ("developer", "researcher", "writer") + +**5.2 Agent Status** +- Agents periodically push status via API: idle, working, error +- Current task shown on agent card +- Activity sparkline (how active over last 24h) + +**5.3 Sub-Agent Awareness** +- When an agent spawns sub-agents, they register in the team view +- Tree structure: main agent → sub-agents +- Sub-agents auto-archive when their task completes + +--- + +### Phase 6: Dashboard / Home +*The actual "Mission Control" view.* + +**6.1 Dashboard View** +- Replaces or augments the home screen (toggle-able) +- Widgets: + - **Active Tasks** — what's being worked on right now + - **Recent Activity** — live feed across all lists + - **Agent Status** — who's online, what they're doing + - **Upcoming** — next 24h of due dates and scheduled jobs + - **Memory Highlights** — recent memories or search +- Customizable layout (drag to reorder widgets) + +**6.2 Quick Actions** +- "Assign to agent" button on any task +- "Ask agent" — send a message/instruction to an agent from the UI +- "Pause agent" — temporarily stop an agent's scheduled work + +--- + +## 6. What We're NOT Building (Yet) + +- **Chat with agent in-app** — Use Telegram/Signal for now; in-app chat is a future phase +- **Agent marketplace** — No third-party agent installation; connect your own +- **In-app compute scheduler** — Poo App will not run VMs itself; Orgo handles computer lifecycle +- **Office/avatar view** — Fun but low priority; focus on utility first +- **Billing/paid tiers** — Free for now; monetization strategy TBD + +--- + +## 7. OpenClaw + Orgo Integration (Technical) + +### 7.1 OpenClaw Skill: `poo-app` +A new OpenClaw skill that teaches agents how to use the Poo App API: + +``` +Skills: + poo-app: + description: "Manage tasks, log activity, and store memories in Poo App" + commands: + - task create "Buy milk" --list "Groceries" --priority high + - task complete + - task list --assigned-to me --status open + - activity log "Reviewed PR #142, left 3 comments" + - memory store "Brian prefers Railway for deploys" + - memory search "deployment preferences" + - schedule sync # Push local crons to Poo App +``` + +### 7.2 Orgo Computer Adapter (V1 default runtime) +Each active agent session can optionally bind to an Orgo computer. + +**Lifecycle** +1. Agent claims task in Poo App. +2. OpenClaw provisions/attaches Orgo computer (`workspaceId`, `computerId`). +3. Agent executes work (browser/desktop actions) on Orgo. +4. Agent posts periodic heartbeats + artifacts to Poo App. +5. On completion/failure, agent closes task and releases/stops computer. + +**Runtime metadata captured in activity/missions** +- `provider: "orgo"` +- `workspaceId` +- `computerId` +- `sessionState: starting | running | blocked | failed | finished` +- `artifactRefs` (screenshots, logs, exported files) + +### 7.3 Webhook Support (Future) +- Poo App fires webhooks when tasks are created/assigned/completed. +- OpenClaw listens and reacts (e.g., auto-claim unassigned tasks). +- Enables fully autonomous task pickup. + +### 7.4 ClawBootBot Integration +- ClawBootBot-managed agents register in Poo App as team members. +- Each bot gets its own agent profile and API key. +- Default execution target for bot work is Orgo unless explicitly overridden. +- Centralized dashboard for all bots across ClawBootBot + OpenClaw. + +--- + +## 8. Data Model Summary + +### Existing tables (unchanged) +`users`, `lists`, `items`, `tags`, `categories`, `comments`, `didLogs`, `publications`, `bookmarks`, `listTemplates`, `pushSubscriptions`, `pushTokens`, `authSessions`, `rateLimits`, `bitcoinAnchors` + +### Modified tables +- **`users`** — add `isAgent`, `agentPlatform`, `connectedByUserId` +- **`items`** — add `assigneeDid`, `assignedAt` + +### New tables +- **`activity`** — action log per list +- **`presence`** — who's currently viewing what +- **`apiKeys`** — agent API authentication +- **`memories`** — searchable agent/human memory store +- **`scheduleEntries`** — cron jobs and scheduled tasks +- **`missionRuns`** — per-task runtime session records (provider, computerId, status, artifacts) + +--- + +## 9. Success Metrics + +| Metric | Target | How | +|--------|--------|-----| +| Regular users don't notice agent features | 0 complaints about complexity | Agent layer hidden by default | +| Agent task completion visible in <1s | P95 < 1s latency | Convex real-time subscriptions | +| API response time | P95 < 200ms | Convex HTTP actions | +| Agent operators use dashboard daily | >60% DAU among agent users | Analytics | +| Mission run observability | >90% runs have at least 1 artifact + terminal state | `missionRuns` completeness checks | +| Orgo-backed task completion | >80% success without manual intervention | Run state + activity analytics | +| Memory search returns relevant results | >80% satisfaction | Full-text + semantic search | + +--- + +## 10. Rollout Plan + +| Phase | Timeline | Milestone | +|-------|----------|-----------| +| **Phase 1** — Collaborative Tasks | 2 weeks | Assignees + activity log + presence | +| **Phase 2** — Agent Identity, API & Runtime Sessions | 2 weeks | API keys, REST endpoints, agent profiles, `missionRuns`, Orgo runtime adapter | +| **Phase 3** — Memory & Knowledge | 2 weeks | Memory store, browser UI, search | +| **Phase 4** — Schedule & Calendar | 1 week | Calendar view with cron sync | +| **Phase 5** — Team & Multi-Agent | 2 weeks | Team dashboard, status, sub-agents | +| **Phase 6** — Dashboard | 2 weeks | Mission Control home view | + +**Phase 1 is the priority** — it makes the app better for *everyone* and lays the foundation for everything else. + +### Capability Checklist ("Really Good Employee" test) +Before shipping each phase, verify which core capability it improves: + +- **Schedule Tasks:** due dates, recurring tasks, cron sync, queue visibility +- **Code/Execution Capability:** task claim/execute/complete flow + artifact proof +- **Runs 24/7:** agent heartbeats, run-state transitions, failure alerts, restart policy +- **Textable Interface:** Telegram/Signal → task create/assign/reprioritize round-trip +- **Own Computer:** Orgo workspace/computer mapping, runtime isolation, session metadata in `missionRuns` + +Any phase item without a clear mapping should be deprioritized or reframed. + +--- + +## 11. Open Questions + +1. **Memory sync direction** — Should Poo App be the source of truth for memories, or just a mirror of the markdown files? (Recommendation: Poo App becomes source of truth, with export to markdown.) + +2. **Multi-agent auth** — One API key per agent, or one key per human that acts on behalf of multiple agents? (Recommendation: one key per agent for auditability.) + +3. **Webhook vs polling** — Should agents poll for new tasks, or should Poo App push via webhooks? (Recommendation: webhooks for real-time, with polling as fallback.) + +4. **ClawBootBot free tier** — Should free-tier ClawBootBot users get Mission Control features? (Recommendation: yes, with limits on number of agents.) + +5. **Privacy** — Agent memories may contain sensitive info. Per-memory visibility controls? (Recommendation: yes, private by default, share explicitly.) + +6. **Orgo tenancy model** — Shared workspace per user, or per-agent workspace isolation? (Recommendation: per-agent default with optional shared workspace for cost optimization.) + +7. **Artifact retention** — How long should screenshots/log artifacts from mission runs be retained? (Recommendation: 30 days default, configurable by workspace.) + +## 12. Required V1.1 Launch Gates (must-have) + +These are mandatory before shipping Phase 2 production. + +**Default owners (assumed):** +- PM: scope, acceptance criteria, rollout gating +- FE: Mission Control UI controls, dashboards, operator flows +- BE: API endpoints, schema/state machine, auth/retention logic +- SRE/Platform: observability, alerts, reliability policies, incident playbooks + +### 12.1 Operator Controls (required) +Minimum controls in Mission Control UI and API: +- **Pause run** (soft stop: no new actions, preserve runtime context) +- **Kill run** (hard stop: end runtime + mark terminal state) +- **Reassign task** (agent A -> agent B) +- **Escalate to human** (agent marks blocked with reason) + +Required endpoints/events: +- `POST /api/v1/runs/:id/pause` +- `POST /api/v1/runs/:id/kill` +- `POST /api/v1/tasks/:id/reassign` +- `POST /api/v1/runs/:id/escalate` + +### 12.2 Reliability SLOs (required) +- **Heartbeat interval target:** every 30–60s while running +- **Heartbeat timeout:** mark `degraded` after 2 missed heartbeats; `failed` after 5 missed heartbeats +- **Run state machine:** `starting -> running -> (blocked | failed | finished)` +- **Retry policy:** max 2 automatic retries for transient failures; then escalate +- **Alert policy:** notify operator on terminal `failed` and `blocked > 5 min` +- **Environment policy:** + - staging: enforce state machine + heartbeat timeout + alert simulation (no paging) + - production: enforce full SLOs with live alert routing and on-call acknowledgement + +### 12.3 missionRuns Hardening (required) +`missionRuns` must include: +- `attempt` (int) +- `parentRunId` (optional, for retries/branches) +- `durationMs` +- `terminalReason` (`completed | killed | timeout | error | escalated`) +- `costEstimate` (optional) +- `tokenUsage` (optional) +- `lastHeartbeatAt` +- `artifactRefs[]` with typed refs (`screenshot | log | diff | file | url`) + +### 12.4 Security Baseline (required) +- API keys are **scoped** (`tasks:read`, `tasks:write`, `runs:control`, `memory:write`, etc.) +- Key rotation supported (create new, revoke old, no downtime) +- Keys stored hashed only (no plaintext persistence) +- Artifact retention policy enforced (default 30 days) +- Retention enforcement via daily cleanup job + auditable deletion logs (+ optional legal-hold override) +- Tenant isolation checks on all run/task/memory reads and writes + +## 13. Phase 2 Execution Checklist (engineering) +- [x] Implement operator control endpoints + UI actions *(API shipped; UI surfaced in Team Dashboard operator controls)* +- [x] Implement run-state machine + heartbeat monitor worker +- [x] Extend `missionRuns` schema with required fields +- [x] Add failure taxonomy + retry/escalation handlers +- [x] Add scoped API key middleware + rotation flow +- [x] Add artifact retention job + admin policy setting +- [x] Ship dashboards for run health (success rate, intervention rate, timeout rate) +- [x] Run production readiness drill (pause/kill/escalation/test alerts) *(scripted drill path + dry-run/live modes in `scripts/mission-control-readiness-drill.mjs`)* + +--- + +*This PRD is a living document. Update as we build and learn.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..0235547 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Poo App 💩 + +A collaborative list-sharing app with decentralized identifiers (DID) and verifiable credentials (VCs) built using Originals. + +## Features Backlog + +**The source of truth for features is the app itself:** +👉 **[https://trypoo.app/list/js77strp35s0br8deqf30bvrxh80pm4t](https://trypoo.app/user-20ed9d43-2d31-44/resources/list-k172r0frhyxtm5dj6cqx1mh48h81k6wp)** + +Check that list for what needs to be built. Mark items done when you ship them. + +## Tech Stack + +- **Frontend**: React 19 + Vite + TypeScript + TailwindCSS v4 +- **Backend**: Convex (realtime DB + HTTP actions) +- **Auth**: Turnkey OTP + JWT (via @originals/auth) +- **Identity**: DIDs (did:webvh + did:key) +- **Credentials**: Verifiable Credentials (via @originals/sdk) +- **Deploy**: Railway (frontend) + Convex Cloud (backend) + +## Development + +```bash +# Install dependencies +bun install + +# Run locally (requires .env.local with Convex/Turnkey config) +bun dev + +# Deploy frontend to Railway +railway up + +# Deploy Convex functions +npx convex deploy +``` + +## Domains + +- **Production**: https://trypoo.app +- **Railway**: https://pooapp-frontend-production.up.railway.app +- **Convex HTTP**: https://pooapp-http.aviarytech.com + diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..9f99527 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,54 @@ +apply plugin: 'com.android.application' + +android { + namespace = "app.trypoo.app" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "app.trypoo.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/android/app/build/.npmkeep b/android/app/build/.npmkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..ff7bdaf --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,24 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':capacitor-app') + implementation project(':capacitor-haptics') + implementation project(':capacitor-keyboard') + implementation project(':capacitor-network') + implementation project(':capacitor-status-bar') + implementation project(':capacitor-native-biometric') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b06ddbf --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/capacitor.config.json b/android/app/src/main/assets/capacitor.config.json new file mode 100644 index 0000000..c9e084e --- /dev/null +++ b/android/app/src/main/assets/capacitor.config.json @@ -0,0 +1,15 @@ +{ + "appId": "app.trypoo.app", + "appName": "Poo App", + "webDir": "dist", + "plugins": { + "SplashScreen": { + "launchShowDuration": 2000, + "backgroundColor": "#FFFFFF", + "androidScaleType": "CENTER_CROP", + "showSpinner": false, + "splashFullScreen": true, + "splashImmersive": true + } + } +} diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json new file mode 100644 index 0000000..7b84c5b --- /dev/null +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -0,0 +1,26 @@ +[ + { + "pkg": "@capacitor/app", + "classpath": "com.capacitorjs.plugins.app.AppPlugin" + }, + { + "pkg": "@capacitor/haptics", + "classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin" + }, + { + "pkg": "@capacitor/keyboard", + "classpath": "com.capacitorjs.plugins.keyboard.KeyboardPlugin" + }, + { + "pkg": "@capacitor/network", + "classpath": "com.capacitorjs.plugins.network.NetworkPlugin" + }, + { + "pkg": "@capacitor/status-bar", + "classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin" + }, + { + "pkg": "capacitor-native-biometric", + "classpath": "com.epicshaggy.biometric.NativeBiometric" + } +] diff --git a/android/app/src/main/assets/public/assets/convex-vendor-l0sNRNKZ.js b/android/app/src/main/assets/public/assets/convex-vendor-l0sNRNKZ.js new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/android/app/src/main/assets/public/assets/convex-vendor-l0sNRNKZ.js @@ -0,0 +1 @@ + diff --git a/android/app/src/main/assets/public/assets/time-C9oM0N83.js b/android/app/src/main/assets/public/assets/time-C9oM0N83.js new file mode 100644 index 0000000..1751786 --- /dev/null +++ b/android/app/src/main/assets/public/assets/time-C9oM0N83.js @@ -0,0 +1 @@ +function i(n){const s=Date.now()-n,e=Math.floor(s/1e3),o=Math.floor(e/60),t=Math.floor(o/60),a=Math.floor(t/24);if(e<60)return"just now";if(o<60)return o===1?"1 minute ago":`${o} minutes ago`;if(t<24)return t===1?"1 hour ago":`${t} hours ago`;if(a<2)return"yesterday";const r=new Date(n),f=r.toLocaleString("en-US",{month:"short"}),u=r.getDate();return`${f} ${u}`}export{i as f}; diff --git a/android/app/src/main/assets/public/cordova.js b/android/app/src/main/assets/public/cordova.js new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/assets/public/cordova_plugins.js b/android/app/src/main/assets/public/cordova_plugins.js new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/assets/public/icons/icon-128.png b/android/app/src/main/assets/public/icons/icon-128.png new file mode 100644 index 0000000..c23daa5 Binary files /dev/null and b/android/app/src/main/assets/public/icons/icon-128.png differ diff --git a/android/app/src/main/assets/public/icons/icon-144.png b/android/app/src/main/assets/public/icons/icon-144.png new file mode 100644 index 0000000..c79899d Binary files /dev/null and b/android/app/src/main/assets/public/icons/icon-144.png differ diff --git a/android/app/src/main/assets/public/icons/icon-152.png b/android/app/src/main/assets/public/icons/icon-152.png new file mode 100644 index 0000000..028a23b Binary files /dev/null and b/android/app/src/main/assets/public/icons/icon-152.png differ diff --git a/android/app/src/main/assets/public/icons/icon-192.png b/android/app/src/main/assets/public/icons/icon-192.png new file mode 100644 index 0000000..db5e468 Binary files /dev/null and b/android/app/src/main/assets/public/icons/icon-192.png differ diff --git a/android/app/src/main/assets/public/icons/icon-384.png b/android/app/src/main/assets/public/icons/icon-384.png new file mode 100644 index 0000000..1e43f88 Binary files /dev/null and b/android/app/src/main/assets/public/icons/icon-384.png differ diff --git a/android/app/src/main/assets/public/icons/icon-512.png b/android/app/src/main/assets/public/icons/icon-512.png new file mode 100644 index 0000000..a6bf7d7 Binary files /dev/null and b/android/app/src/main/assets/public/icons/icon-512.png differ diff --git a/android/app/src/main/assets/public/icons/icon-72.png b/android/app/src/main/assets/public/icons/icon-72.png new file mode 100644 index 0000000..bbeb8c0 Binary files /dev/null and b/android/app/src/main/assets/public/icons/icon-72.png differ diff --git a/android/app/src/main/assets/public/icons/icon-96.png b/android/app/src/main/assets/public/icons/icon-96.png new file mode 100644 index 0000000..1f9ffa5 Binary files /dev/null and b/android/app/src/main/assets/public/icons/icon-96.png differ diff --git a/android/app/src/main/assets/public/icons/new-list.png b/android/app/src/main/assets/public/icons/new-list.png new file mode 100644 index 0000000..4fcee0e Binary files /dev/null and b/android/app/src/main/assets/public/icons/new-list.png differ diff --git a/android/app/src/main/assets/public/index.html b/android/app/src/main/assets/public/index.html new file mode 100644 index 0000000..992a636 --- /dev/null +++ b/android/app/src/main/assets/public/index.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 💩 Poo App - Organize Your Life + + + + + + + + + +
+ + diff --git a/android/app/src/main/assets/public/manifest.json b/android/app/src/main/assets/public/manifest.json new file mode 100644 index 0000000..e85104d --- /dev/null +++ b/android/app/src/main/assets/public/manifest.json @@ -0,0 +1,86 @@ +{ + "name": "PooApp", + "short_name": "PooApp", + "description": "Organize your life while you poop. The world's most productive todo app.", + "start_url": "/", + "display": "standalone", + "background_color": "#FFFBEB", + "theme_color": "#78350F", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon-72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "screenshots": [ + { + "src": "/screenshots/home.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide" + }, + { + "src": "/screenshots/mobile.png", + "sizes": "750x1334", + "type": "image/png", + "form_factor": "narrow" + } + ], + "categories": ["productivity", "utilities"], + "shortcuts": [ + { + "name": "New List", + "short_name": "New", + "description": "Create a new list", + "url": "/app?action=new", + "icons": [{"src": "/icons/new-list.png", "sizes": "96x96"}] + } + ], + "related_applications": [], + "prefer_related_applications": false +} diff --git a/android/app/src/main/assets/public/screenshots/home.png b/android/app/src/main/assets/public/screenshots/home.png new file mode 100644 index 0000000..da98053 Binary files /dev/null and b/android/app/src/main/assets/public/screenshots/home.png differ diff --git a/android/app/src/main/assets/public/screenshots/mobile.png b/android/app/src/main/assets/public/screenshots/mobile.png new file mode 100644 index 0000000..8bdfab3 Binary files /dev/null and b/android/app/src/main/assets/public/screenshots/mobile.png differ diff --git a/android/app/src/main/assets/public/sw.js b/android/app/src/main/assets/public/sw.js new file mode 100644 index 0000000..c2ddd3e --- /dev/null +++ b/android/app/src/main/assets/public/sw.js @@ -0,0 +1 @@ +(()=>{var s="lisa-v3";self.addEventListener("install",e=>{e.waitUntil(self.skipWaiting())});self.addEventListener("activate",e=>{e.waitUntil(Promise.all([caches.keys().then(t=>Promise.all(t.filter(n=>n!==s).map(n=>(console.log("[SW] Deleting old cache:",n),caches.delete(n))))),self.clients.claim()]))});self.addEventListener("fetch",e=>{let{request:t}=e,n=new URL(t.url);if(t.method==="GET"&&t.url.startsWith("http")&&!t.url.includes("convex.cloud")&&!(n.pathname.startsWith("/assets/")&&/[-\.][a-f0-9]{8,}\.(js|css|woff2?)$/.test(n.pathname))){if(t.mode==="navigate"){e.respondWith(fetch(t).then(i=>{if(i.ok){let a=i.clone();caches.open(s).then(o=>{o.put(t,a)})}return i}).catch(async()=>{let i=await caches.match(t);if(i)return i;let a=await caches.match("/");return a||new Response("Offline",{status:503,statusText:"Service Unavailable",headers:{"Content-Type":"text/plain"}})}));return}if(n.origin===self.location.origin){e.respondWith(caches.match(t).then(i=>i||fetch(t).then(a=>{if(a.ok){let o=a.clone();caches.open(s).then(c=>{c.put(t,o)})}return a})));return}}});self.addEventListener("message",e=>{e.data==="skipWaiting"&&self.skipWaiting(),e.data==="clearCaches"&&caches.keys().then(t=>{t.forEach(n=>caches.delete(n))})});self.addEventListener("push",e=>{if(e.data)try{let t=e.data.json(),n=t.title||"\u{1F4A9} Poo App",i={body:t.body||"You have a notification",icon:"/pwa-192x192.png",badge:"/pwa-192x192.png",tag:t.tag||"poo-notification",data:{url:t.url||"/",itemId:t.itemId,listId:t.listId},requireInteraction:t.requireInteraction||!1,vibrate:[100,50,100]};e.waitUntil(self.registration.showNotification(n,i))}catch{let t=e.data.text();e.waitUntil(self.registration.showNotification("\u{1F4A9} Poo App",{body:t,icon:"/pwa-192x192.png"}))}});self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url||"/app";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(n=>{for(let i of n)if("focus"in i){i.focus(),"navigate"in i&&i.navigate(t);return}return self.clients.openWindow(t)}))});})(); diff --git a/android/app/src/main/assets/public/vite.svg b/android/app/src/main/assets/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/android/app/src/main/assets/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/java/app/trypoo/app/MainActivity.java b/android/app/src/main/java/app/trypoo/app/MainActivity.java new file mode 100644 index 0000000..497b1ce --- /dev/null +++ b/android/app/src/main/java/app/trypoo/app/MainActivity.java @@ -0,0 +1,5 @@ +package app.trypoo.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/android/app/src/main/res/drawable-land-hdpi/splash.png b/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-mdpi/splash.png b/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-hdpi/splash.png b/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-mdpi/splash.png b/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c023e50 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2127973 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b441f37 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..72905b8 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ed0605 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9502e47 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4d1e077 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df0f158 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..853db04 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6cdf97c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2960cbb Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8e3093a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..46de6e2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d2ea9ab Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a40d73e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7cb9a83 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Poo App + Poo App + app.trypoo.app + app.trypoo.app + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/config.xml b/android/app/src/main/res/xml/config.xml new file mode 100644 index 0000000..1b1b0e0 --- /dev/null +++ b/android/app/src/main/res/xml/config.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..f8f0e43 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/capacitor-cordova-android-plugins/build.gradle b/android/capacitor-cordova-android-plugins/build.gradle new file mode 100644 index 0000000..b2e25dd --- /dev/null +++ b/android/capacitor-cordova-android-plugins/build.gradle @@ -0,0 +1,59 @@ +ext { + androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1' + cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '14.0.1' +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + } +} + +apply plugin: 'com.android.library' + +android { + namespace = "capacitor.cordova.android.plugins" + compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36 + defaultConfig { + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36 + versionCode 1 + versionName "1.0" + } + lintOptions { + abortOnError = false + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +repositories { + google() + mavenCentral() + flatDir{ + dirs 'src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(dir: 'src/main/libs', include: ['*.jar']) + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "org.apache.cordova:framework:$cordovaAndroidVersion" + // SUB-PROJECT DEPENDENCIES START + + // SUB-PROJECT DEPENDENCIES END +} + +// PLUGIN GRADLE EXTENSIONS START +apply from: "cordova.variables.gradle" +// PLUGIN GRADLE EXTENSIONS END + +for (def func : cdvPluginPostBuildExtras) { + func() +} \ No newline at end of file diff --git a/android/capacitor-cordova-android-plugins/cordova.variables.gradle b/android/capacitor-cordova-android-plugins/cordova.variables.gradle new file mode 100644 index 0000000..b806d8a --- /dev/null +++ b/android/capacitor-cordova-android-plugins/cordova.variables.gradle @@ -0,0 +1,7 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +ext { + cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24 + // Plugin gradle extensions can append to this to have code run at the end. + cdvPluginPostBuildExtras = [] + cordovaConfig = [:] +} \ No newline at end of file diff --git a/android/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml b/android/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cb9c8aa --- /dev/null +++ b/android/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/android/capacitor-cordova-android-plugins/src/main/java/.gitkeep b/android/capacitor-cordova-android-plugins/src/main/java/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/capacitor-cordova-android-plugins/src/main/res/.gitkeep b/android/capacitor-cordova-android-plugins/src/main/res/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/android/capacitor-cordova-android-plugins/src/main/res/.gitkeep @@ -0,0 +1 @@ + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle new file mode 100644 index 0000000..b572a1b --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,21 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-haptics' +project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') + +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') + +include ':capacitor-network' +project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') + +include ':capacitor-native-biometric' +project(':capacitor-native-biometric').projectDir = new File('../node_modules/capacitor-native-biometric/android') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/android/variables.gradle b/android/variables.gradle new file mode 100644 index 0000000..ee4ba41 --- /dev/null +++ b/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/bun.lock b/bun.lock index 9810091..0ff9cd3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,24 @@ "": { "name": "lisa-temp", "dependencies": { + "@capacitor/android": "^8.0.2", + "@capacitor/app": "^8.0.0", + "@capacitor/camera": "^8.0.0", + "@capacitor/cli": "^8.0.2", + "@capacitor/core": "^8.0.2", + "@capacitor/haptics": "^8.0.0", + "@capacitor/ios": "^8.0.2", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/network": "^8.0.0", + "@capacitor/network": "^8.0.0", + "@capacitor/preferences": "^8.0.0", + "@capacitor/push-notifications": "^8.0.0", + "@capacitor/share": "^8.0.0", + "@capacitor/status-bar": "^8.0.0", "@originals/auth": "^1.8.2", "@originals/sdk": "^1.8.2", "@turnkey/core": "^1.11.0", + "capacitor-native-biometric": "^4.2.2", "convex": "^1.31.6", "idb": "^8.0.3", "jose": "^6.1.3", @@ -15,14 +30,17 @@ "react-dom": "^19.2.0", "react-router-dom": "^7.12.0", "serve": "^14.2.5", + "web-push": "^3.6.7", }, "devDependencies": { "@eslint/js": "^9.39.1", + "@napi-rs/canvas": "^0.1.90", "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.1.18", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.23", "eslint": "^9.39.1", @@ -81,6 +99,32 @@ "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@capacitor/android": ["@capacitor/android@8.0.2", "", { "peerDependencies": { "@capacitor/core": "^8.0.0" } }, "sha512-0D7j0YvzjnfCMKLvFkAbx8b3Vwx+QfHFG5NzoXpI9sAl3zWiLsfa+NX4x92Fy+k4MGjLSMAfLThCqILYGDDsgw=="], + + "@capacitor/app": ["@capacitor/app@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA=="], + + "@capacitor/camera": ["@capacitor/camera@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-Iu8j2oxoIhY2mLuoEckbL7PFgw1XFm1nqmeWdIkILpcT3H9A+BrSDUDlzWqM/EeaDKo6JnhR59tYHwUhOdXaUg=="], + + "@capacitor/cli": ["@capacitor/cli@8.0.2", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.3", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^7.5.3", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-/8qLYxhytMyUKTHK8i6YU+DMD3AuFiQgSuJCyMltcg9MN3W9En7zqQZSo/WN4eC7qif/oyZACzm7OkAZKani7g=="], + + "@capacitor/core": ["@capacitor/core@8.0.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-EXZfxkL6GFJS2cb7TIBR7RiHA5iz6ufDcl1VmUpI2pga3lJ5Ck2+iqbx7N+osL3XYem9ad4XCidJEMm64DX6UQ=="], + + "@capacitor/haptics": ["@capacitor/haptics@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-DY1IUOjke1T4ITl7mFHQIKCaJJyHYAYRYHG9bVApU7PDOZiMVGMp48Yjzdqjya+wv/AHS5mDabSTUmhJ5uDvBA=="], + + "@capacitor/ios": ["@capacitor/ios@8.0.2", "", { "peerDependencies": { "@capacitor/core": "^8.0.0" } }, "sha512-7EM7vBxXI3Ku49aYCJcS9su5Y3i6UmXpx7e0y+oQV9PzCnZ6l5B0ACJ+gXAU0bM3q7/f+kGBsOtXMid84rU6MQ=="], + + "@capacitor/keyboard": ["@capacitor/keyboard@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g=="], + + "@capacitor/network": ["@capacitor/network@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-fgvB7pNKn8pKavuzys218j4YuA5euNfavp7nS3NuwWKWNupZAlbucfnl75lazxCyVF/ZRjzYVTb4vtTEfFrK1A=="], + + "@capacitor/preferences": ["@capacitor/preferences@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-NsE7Srk9Zr0SxiVelHGiAJR7M238eyCD6dI/sDhu3ckKwFrXn8/GRyGr+SZcnGLlQKy948li8Pfcfr0dqxNf1g=="], + + "@capacitor/push-notifications": ["@capacitor/push-notifications@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-xJWQLqAfC8b2ETqAPmwDnkKB4t/lVrbYc2D8VpA2fSu10JFSL/R722Vk0Lfl9Lo9WusmyIiQbVfILNQ3iFNGKw=="], + + "@capacitor/share": ["@capacitor/share@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-VU+xT4LFwr4keIC0UKDqGQVAiNlAHwoTMQg8wVVSxtn/k32VOvvtqFfu63qnXr40WKytZWrxJfVESvRjd761yg=="], + + "@capacitor/status-bar": ["@capacitor/status-bar@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-aIj3bc7z8lfPgOen8HlrBrkfnxpFnh21OCx6jCUx4Mvv+B6eEkUQ49b32DOddgVfr+igRHLX2SYi7duqIsNDXg=="], + "@digitalbazaar/http-client": ["@digitalbazaar/http-client@3.4.1", "", { "dependencies": { "ky": "^0.33.3", "ky-universal": "^0.11.0", "undici": "^5.21.2" } }, "sha512-Ahk1N+s7urkgj7WvvUND5f8GiWEPfUw0D41hdElaqLgu8wZScI8gdI0q+qWw5N1d35x7GCRH2uk9mi+Uzo9M3g=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], @@ -163,6 +207,28 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@ionic/cli-framework-output": ["@ionic/cli-framework-output@2.2.8", "", { "dependencies": { "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g=="], + + "@ionic/utils-array": ["@ionic/utils-array@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg=="], + + "@ionic/utils-fs": ["@ionic/utils-fs@3.1.7", "", { "dependencies": { "@types/fs-extra": "^8.0.0", "debug": "^4.0.0", "fs-extra": "^9.0.0", "tslib": "^2.0.1" } }, "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA=="], + + "@ionic/utils-object": ["@ionic/utils-object@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww=="], + + "@ionic/utils-process": ["@ionic/utils-process@2.1.12", "", { "dependencies": { "@ionic/utils-object": "2.1.6", "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "signal-exit": "^3.0.3", "tree-kill": "^1.2.2", "tslib": "^2.0.1" } }, "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg=="], + + "@ionic/utils-stream": ["@ionic/utils-stream@3.1.7", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w=="], + + "@ionic/utils-subprocess": ["@ionic/utils-subprocess@3.0.1", "", { "dependencies": { "@ionic/utils-array": "2.1.6", "@ionic/utils-fs": "3.1.7", "@ionic/utils-process": "2.1.12", "@ionic/utils-stream": "3.1.7", "@ionic/utils-terminal": "2.3.5", "cross-spawn": "^7.0.3", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A=="], + + "@ionic/utils-terminal": ["@ionic/utils-terminal@2.3.5", "", { "dependencies": { "@types/slice-ansi": "^4.0.0", "debug": "^4.0.0", "signal-exit": "^3.0.3", "slice-ansi": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "tslib": "^2.0.1", "untildify": "^4.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A=="], + + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], + + "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -175,6 +241,30 @@ "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="], + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.90", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.90", "@napi-rs/canvas-darwin-arm64": "0.1.90", "@napi-rs/canvas-darwin-x64": "0.1.90", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.90", "@napi-rs/canvas-linux-arm64-gnu": "0.1.90", "@napi-rs/canvas-linux-arm64-musl": "0.1.90", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.90", "@napi-rs/canvas-linux-x64-gnu": "0.1.90", "@napi-rs/canvas-linux-x64-musl": "0.1.90", "@napi-rs/canvas-win32-arm64-msvc": "0.1.90", "@napi-rs/canvas-win32-x64-msvc": "0.1.90" } }, "sha512-vO9j7TfwF9qYCoTOPO39yPLreTRslBVOaeIwhDZkizDvBb0MounnTl0yeWUMBxP4Pnkg9Sv+3eQwpxNUmTwt0w=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.90", "", { "os": "android", "cpu": "arm64" }, "sha512-3JBULVF+BIgr7yy7Rf8UjfbkfFx4CtXrkJFD1MDgKJ83b56o0U9ciT8ZGTCNmwWkzu8RbNKlyqPP3KYRG88y7Q=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-L8XVTXl+8vd8u7nPqcX77NyG5RuFdVsJapQrKV9WE3jBayq1aSMht/IH7Dwiz/RNJ86E5ZSg9pyUPFIlx52PZA=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-h0ukhlnGhacbn798VWYTQZpf6JPDzQYaow+vtQ2Fat7j7ImDdpg6tfeqvOTO1r8wS+s+VhBIFITC7aA1Aik0ZQ=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.90", "", { "os": "linux", "cpu": "arm" }, "sha512-JCvTl99b/RfdBtgftqrf+5UNF7GIbp7c5YBFZ+Bd6++4Y3phaXG/4vD9ZcF1bw1P4VpALagHmxvodHuQ9/TfTg=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-vbWFp8lrP8NIM5L4zNOwnsqKIkJo0+GIRUDcLFV9XEJCptCc1FY6/tM02PT7GN4PBgochUPB1nBHdji6q3ieyQ=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-8Bc0BgGEeOaux4EfIfNzcRRw0JE+lO9v6RWQFCJNM9dJFE4QJffTf88hnmbOaI6TEMpgWOKipbha3dpIdUqb/g=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.90", "", { "os": "linux", "cpu": "none" }, "sha512-0iiVDG5IH+gJb/YUrY/pRdbsjcgvwUmeckL/0gShWAA7004ygX2ST69M1wcfyxXrzFYjdF8S/Sn6aCAeBi89XQ=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-SkKmlHMvA5spXuKfh7p6TsScDf7lp5XlMbiUhjdCtWdOS6Qke/A4qGVOciy6piIUCJibL+YX+IgdGqzm2Mpx/w=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-o6QgS10gAS4vvELGDOOWYfmERXtkVRYFWBCjomILWfMgCvBVutn8M97fsMW5CrEuJI8YuxuJ7U+/DQ9oG93vDA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-2UHO/DC1oyuSjeCAhHA0bTD9qsg58kknRqjJqRfvIEFtdqdtNTcWXMCT9rQCuJ8Yx5ldhyh2SSp7+UDqD2tXZQ=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-48CxEbzua5BP4+OumSZdi3+9fNiRO8cGNBlO2bKwx1PoyD1R2AXzPtqd/no1f1uSl0W2+ihOO1v3pqT3USbmgQ=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], @@ -349,6 +439,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/fs-extra": ["@types/fs-extra@8.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], @@ -357,6 +449,10 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/slice-ansi": ["@types/slice-ansi@4.0.0", "", {}, "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ=="], + + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg=="], @@ -421,6 +517,8 @@ "@walletconnect/window-metadata": ["@walletconnect/window-metadata@1.0.1", "", { "dependencies": { "@walletconnect/window-getters": "^1.0.1", "tslib": "1.14.1" } }, "sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "@zeit/schemas": ["@zeit/schemas@2.36.0", "", {}, "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg=="], "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], @@ -433,6 +531,8 @@ "aes-js": ["aes-js@4.0.0-beta.5", "", {}, "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], @@ -451,12 +551,16 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "asn1.js": ["asn1.js@4.10.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], "assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="], + "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], + + "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="], @@ -475,18 +579,22 @@ "bech32": ["bech32@2.0.0", "", {}, "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "bip174": ["bip174@2.1.1", "", {}, "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ=="], "bitcoinjs-lib": ["bitcoinjs-lib@6.1.7", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bech32": "^2.0.0", "bip174": "^2.1.1", "bs58check": "^3.0.1", "typeforce": "^1.11.3", "varuint-bitcoin": "^1.1.2" } }, "sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg=="], "blakejs": ["blakejs@1.2.1", "", {}, "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ=="], - "bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="], + "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], "borsh": ["borsh@2.0.0", "", {}, "sha512-kc9+BgR3zz9+cjbwM8ODoUB4fs3X3I5A/HtX7LZKxCLaMrEeDFoBpnhZY//DTS1VZBSs6S5v46RZRbZjRFspEg=="], "boxen": ["boxen@7.0.0", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^7.0.0", "chalk": "^5.0.1", "cli-boxes": "^3.0.0", "string-width": "^5.1.2", "type-fest": "^2.13.0", "widest-line": "^4.0.1", "wrap-ansi": "^8.0.1" } }, "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg=="], + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "brorand": ["brorand@1.1.0", "", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="], @@ -513,6 +621,8 @@ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "buffer-xor": ["buffer-xor@1.0.3", "", {}, "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ=="], @@ -535,6 +645,8 @@ "canonicalize": ["canonicalize@1.0.8", "", {}, "sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A=="], + "capacitor-native-biometric": ["capacitor-native-biometric@4.2.2", "", { "dependencies": { "@capacitor/core": "^3.4.3" } }, "sha512-stg0h48UxgkNuNcCAgCXLp2DUspRQs79bCBPntpCBhsDxk2bhDRUu+J/QpFtDQHG4M4DioSUcYaAsVw2N6N7wA=="], + "cbor-js": ["cbor-js@0.1.0", "", {}, "sha512-7sQ/TvDZPl7csT1Sif9G0+MA0I0JOVah8+wWlJVQdVEgIbCzlN/ab3x+uvMNsc34TUvO6osQTAmB2ls80JX6tw=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -543,6 +655,8 @@ "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "cipher-base": ["cipher-base@1.0.7", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.2" } }, "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA=="], "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], @@ -553,6 +667,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], @@ -603,6 +719,8 @@ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], @@ -629,14 +747,18 @@ "electron-to-chromium": ["electron-to-chromium@1.5.277", "", {}, "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw=="], + "elementtree": ["elementtree@0.1.7", "", { "dependencies": { "sax": "1.1.4" } }, "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg=="], + "elliptic": ["elliptic@6.6.1", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g=="], - "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -691,6 +813,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], @@ -709,6 +833,8 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -723,6 +849,8 @@ "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "glob": ["glob@13.0.1", "", { "dependencies": { "minimatch": "^10.1.2", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], @@ -753,8 +881,12 @@ "hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + "https-browserify": ["https-browserify@1.0.0", "", {}, "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], @@ -771,7 +903,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], @@ -831,6 +963,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "jsonld": ["jsonld@8.3.3", "", { "dependencies": { "@digitalbazaar/http-client": "^3.4.1", "canonicalize": "^1.0.1", "lru-cache": "^6.0.0", "rdf-canonize": "^3.4.0" } }, "sha512-9YcilrF+dLfg9NTEof/mJLMtbdX1RJ8dbWtJgE00cMOIohb1lIyJl710vFiTaiHTl6ZYODJuBd32xFvUhmv3kg=="], "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], @@ -845,6 +979,8 @@ "keyvaluestorage-interface": ["keyvaluestorage-interface@1.0.0", "", {}, "sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "ky": ["ky@0.33.3", "", {}, "sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw=="], "ky-universal": ["ky-universal@0.11.0", "", { "dependencies": { "abort-controller": "^3.0.0", "node-fetch": "^3.2.10" }, "peerDependencies": { "ky": ">=0.31.4", "web-streams-polyfill": ">=3.2.1" }, "optionalPeers": ["web-streams-polyfill"] }, "sha512-65KyweaWvk+uKKkCrfAf+xqN2/epw1IJDtlyCPxYffFCMR8u1sp2U65NtWpnozYfZxQ6IUzIlvUcw+hQ82U2Xw=="], @@ -923,12 +1059,18 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "native-run": ["native-run@2.0.3", "", { "dependencies": { "@ionic/utils-fs": "^3.1.7", "@ionic/utils-terminal": "^2.3.4", "bplist-parser": "^0.3.2", "debug": "^4.3.4", "elementtree": "^0.1.7", "ini": "^4.1.1", "plist": "^3.1.0", "split2": "^4.2.0", "through2": "^4.0.2", "tslib": "^2.6.2", "yauzl": "^2.10.0" }, "bin": { "native-run": "bin/native-run" } }, "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], @@ -965,6 +1107,8 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "os-browserify": ["os-browserify@0.3.0", "", {}, "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A=="], @@ -975,6 +1119,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -991,10 +1137,14 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], + "path-to-regexp": ["path-to-regexp@3.3.0", "", {}, "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw=="], "pbkdf2": ["pbkdf2@3.1.5", "", { "dependencies": { "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", "sha.js": "^2.4.12", "to-buffer": "^1.2.1" } }, "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -1011,6 +1161,8 @@ "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -1027,6 +1179,8 @@ "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "public-encrypt": ["public-encrypt@4.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "browserify-rsa": "^4.0.0", "create-hash": "^1.1.0", "parse-asn1": "^5.0.0", "randombytes": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q=="], "punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="], @@ -1081,6 +1235,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "rimraf": ["rimraf@6.1.2", "", { "dependencies": { "glob": "^13.0.0", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g=="], + "ripemd160": ["ripemd160@2.0.3", "", { "dependencies": { "hash-base": "^3.1.2", "inherits": "^2.0.4" } }, "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA=="], "rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="], @@ -1091,9 +1247,13 @@ "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "serve": ["serve@14.2.5", "", { "dependencies": { "@zeit/schemas": "2.36.0", "ajv": "8.12.0", "arg": "5.0.2", "boxen": "7.0.0", "chalk": "5.0.1", "chalk-template": "0.4.0", "clipboardy": "3.0.0", "compression": "1.8.1", "is-port-reachable": "4.0.0", "serve-handler": "6.1.6", "update-check": "1.5.4" }, "bin": { "serve": "build/main.js" } }, "sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA=="], @@ -1123,6 +1283,10 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], + "slow-redact": ["slow-redact@0.3.2", "", {}, "sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw=="], "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], @@ -1135,7 +1299,7 @@ "stream-http": ["stream-http@3.2.0", "", { "dependencies": { "builtin-status-codes": "^3.0.0", "inherits": "^2.0.4", "readable-stream": "^3.6.0", "xtend": "^4.0.2" } }, "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A=="], - "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], @@ -1153,8 +1317,12 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], + "timers-browserify": ["timers-browserify@2.0.12", "", { "dependencies": { "setimmediate": "^1.0.4" } }, "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -1163,9 +1331,11 @@ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], - "tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], @@ -1193,8 +1363,12 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="], + "untildify": ["untildify@4.0.0", "", {}, "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-check": ["update-check@1.5.4", "", { "dependencies": { "registry-auth-token": "3.3.2", "registry-url": "3.1.0" } }, "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ=="], @@ -1221,6 +1395,8 @@ "vm-browserify": ["vm-browserify@1.1.2", "", {}, "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -1235,13 +1411,19 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], + + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -1251,35 +1433,19 @@ "@aviarytech/did-peer/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - - "@peculiar/asn1-cms/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@peculiar/asn1-csr/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@peculiar/asn1-ecc/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@peculiar/asn1-pfx/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@peculiar/asn1-pkcs8/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@peculiar/asn1-pkcs9/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@ionic/utils-fs/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], - "@peculiar/asn1-rsa/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@peculiar/asn1-schema/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@peculiar/asn1-x509/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@peculiar/asn1-x509-attr/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@peculiar/x509/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@scure/bip32/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], @@ -1315,8 +1481,6 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@walletconnect/environment/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "@walletconnect/events/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -1341,18 +1505,20 @@ "@walletconnect/window-metadata/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "asn1.js/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], - - "asn1js/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "bitcoinjs-lib/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "boxen/chalk": ["chalk@5.0.1", "", {}, "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w=="], + "boxen/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "boxen/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "browserify-rsa/bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="], + + "browserify-sign/bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="], + "browserify-sign/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "bs58/base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="], @@ -1361,17 +1527,15 @@ "bs58check/bs58": ["bs58@5.0.0", "", { "dependencies": { "base-x": "^4.0.0" } }, "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ=="], + "capacitor-native-biometric/@capacitor/core": ["@capacitor/core@3.9.0", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-j1lL0+/7stY8YhIq1Lm6xixvUqIn89vtyH5ZpJNNmcZ0kwz6K9eLkcG6fvq1UWMDgSVZg9JrRGSFhb4LLoYOsw=="], + "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], - "create-ecdh/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], - "didwebvh-ts/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "diffie-hellman/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], - - "elliptic/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + "elementtree/sax": ["sax@1.1.4", "", {}, "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg=="], "ethers/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], @@ -1379,13 +1543,15 @@ "ethers/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - "jsonwebtoken/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "ethers/tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="], + + "glob/minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="], "ky-universal/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "md5.js/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="], + "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "miller-rabin/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + "md5.js/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="], "mime-types/mime-db": ["mime-db@1.33.0", "", {}, "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="], @@ -1397,11 +1563,15 @@ "ox/@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + "parse-asn1/asn1.js": ["asn1.js@4.10.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw=="], + + "path-scurry/lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "public-encrypt/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - "pvtsutils/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], @@ -1413,8 +1583,6 @@ "serve-handler/bytes": ["bytes@3.0.0", "", {}, "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw=="], - "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - "to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -1433,9 +1601,9 @@ "viem/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "widest-line/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1449,7 +1617,13 @@ "@walletconnect/utils/ox/@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], - "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "boxen/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "boxen/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "boxen/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "boxen/wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "browserify-sign/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -1521,14 +1695,18 @@ "serve/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "viem/@scure/bip32/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "widest-line/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "widest-line/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "@walletconnect/utils/ox/@scure/bip32/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + "boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "boxen/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "md5.js/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "md5.js/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], @@ -1536,5 +1714,7 @@ "ripemd160/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "ripemd160/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], } } diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 0000000..78de5d2 --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,45 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'app.trypoo.app', + appName: 'Poo App', + webDir: 'dist', + // Allow service worker and offline caching in native WebView + server: { + // Use https scheme on both platforms for proper cookie/CORS handling + androidScheme: 'https', + iosScheme: 'https', + // Allow WebSocket and HTTP connections to backend + allowNavigation: [ + 'convex-backend-production-8e02.up.railway.app', + 'convex.x51.ca', + ], + }, + plugins: { + CapacitorHttp: { + // Route fetch() through native HTTP on iOS/Android to avoid + // CORS issues when the WebView origin is https://localhost + enabled: true, + }, + SplashScreen: { + launchShowDuration: 2000, + backgroundColor: '#FFFFFF', + androidScaleType: 'CENTER_CROP', + showSpinner: false, + splashFullScreen: true, + splashImmersive: true, + }, + Keyboard: { + // 'native' resize mode on iOS avoids pushing the whole webview up + resize: 'native', + resizeOnFullScreen: true, + }, + // No special config needed for Network plugin - it works out of the box + }, + // iOS: allow offline usage and background fetch + ios: { + allowsLinkPreview: false, + }, +}; + +export default config; diff --git a/convex.json b/convex.json index 5d921a3..0967ef4 100644 --- a/convex.json +++ b/convex.json @@ -1,5 +1 @@ -{ - "node": { - "externalPackages": ["*"] - } -} +{} diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 2f08c53..a547f9d 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,16 +8,17 @@ * @module */ +import type * as agentApi from "../agentApi.js"; +import type * as attachments from "../attachments.js"; import type * as auth from "../auth.js"; import type * as authInternal from "../authInternal.js"; import type * as authSessions from "../authSessions.js"; +import type * as bitcoinAnchors from "../bitcoinAnchors.js"; import type * as categories from "../categories.js"; import type * as categoriesHttp from "../categoriesHttp.js"; -import type * as collaborators from "../collaborators.js"; -import type * as collaboratorsHttp from "../collaboratorsHttp.js"; +import type * as comments from "../comments.js"; import type * as didCreation from "../didCreation.js"; import type * as http from "../http.js"; -import type * as invites from "../invites.js"; import type * as items from "../items.js"; import type * as itemsHttp from "../itemsHttp.js"; import type * as lib_auth from "../lib/auth.js"; @@ -27,9 +28,12 @@ import type * as lib_turnkeyClient from "../lib/turnkeyClient.js"; import type * as lib_turnkeySigner from "../lib/turnkeySigner.js"; import type * as lists from "../lists.js"; import type * as listsHttp from "../listsHttp.js"; -import type * as migrations_migrateCollaborators from "../migrations/migrateCollaborators.js"; +import type * as notificationActions from "../notificationActions.js"; +import type * as notifications from "../notifications.js"; import type * as publication from "../publication.js"; import type * as rateLimits from "../rateLimits.js"; +import type * as tags from "../tags.js"; +import type * as templates from "../templates.js"; import type * as turnkeyHelpers from "../turnkeyHelpers.js"; import type * as userHttp from "../userHttp.js"; import type * as users from "../users.js"; @@ -41,16 +45,17 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + agentApi: typeof agentApi; + attachments: typeof attachments; auth: typeof auth; authInternal: typeof authInternal; authSessions: typeof authSessions; + bitcoinAnchors: typeof bitcoinAnchors; categories: typeof categories; categoriesHttp: typeof categoriesHttp; - collaborators: typeof collaborators; - collaboratorsHttp: typeof collaboratorsHttp; + comments: typeof comments; didCreation: typeof didCreation; http: typeof http; - invites: typeof invites; items: typeof items; itemsHttp: typeof itemsHttp; "lib/auth": typeof lib_auth; @@ -60,9 +65,12 @@ declare const fullApi: ApiFromModules<{ "lib/turnkeySigner": typeof lib_turnkeySigner; lists: typeof lists; listsHttp: typeof listsHttp; - "migrations/migrateCollaborators": typeof migrations_migrateCollaborators; + notificationActions: typeof notificationActions; + notifications: typeof notifications; publication: typeof publication; rateLimits: typeof rateLimits; + tags: typeof tags; + templates: typeof templates; turnkeyHelpers: typeof turnkeyHelpers; userHttp: typeof userHttp; users: typeof users; diff --git a/convex/activity.ts b/convex/activity.ts new file mode 100644 index 0000000..4bea69d --- /dev/null +++ b/convex/activity.ts @@ -0,0 +1,54 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { canUserEditList } from "./lib/permissions"; + +export const recordActivity = mutation({ + args: { + listId: v.id("lists"), + itemId: v.optional(v.id("items")), + actorDid: v.string(), + legacyDid: v.optional(v.string()), + type: v.union( + v.literal("item_assigned"), + v.literal("item_unassigned"), + v.literal("presence_heartbeat"), + v.literal("presence_offline"), + v.literal("item_updated"), + v.literal("list_updated") + ), + metadata: v.optional(v.object({ + assigneeDid: v.optional(v.string()), + status: v.optional(v.union(v.literal("active"), v.literal("idle"), v.literal("offline"))), + note: v.optional(v.string()), + })), + }, + handler: async (ctx, args) => { + const canEdit = await canUserEditList(ctx, args.listId, args.actorDid, args.legacyDid); + if (!canEdit) throw new Error("Not authorized to write activity"); + + return await ctx.db.insert("activities", { + listId: args.listId, + itemId: args.itemId, + actorDid: args.actorDid, + type: args.type, + metadata: args.metadata, + createdAt: Date.now(), + }); + }, +}); + +export const getListActivity = query({ + args: { + listId: v.id("lists"), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const events = await ctx.db + .query("activities") + .withIndex("by_list_created", (q) => q.eq("listId", args.listId)) + .collect(); + + const sorted = events.sort((a, b) => b.createdAt - a.createdAt); + return sorted.slice(0, Math.max(1, Math.min(args.limit ?? 50, 200))); + }, +}); diff --git a/convex/activityHttp.ts b/convex/activityHttp.ts new file mode 100644 index 0000000..20a22b7 --- /dev/null +++ b/convex/activityHttp.ts @@ -0,0 +1,26 @@ +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; +import { requireAuthenticatedUser } from "./lib/authUser"; +import { jsonResponse, errorResponse } from "./lib/httpResponses"; + +export const getListActivity = httpAction(async (ctx, request) => { + try { + await requireAuthenticatedUser(ctx, request); + const body = await request.json(); + const { listId, limit } = body as { listId: string; limit?: number }; + if (!listId) return errorResponse(request, "listId is required"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const activities = await ctx.runQuery((api as any).activity.getListActivity, { + listId: listId as Id<"lists">, + limit, + }); + + return jsonResponse(request, { activities }); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed to get activity", 500); + } +}); diff --git a/convex/agentApi.ts b/convex/agentApi.ts new file mode 100644 index 0000000..9125694 --- /dev/null +++ b/convex/agentApi.ts @@ -0,0 +1,518 @@ +/** + * Agent API - HTTP endpoints for programmatic access to Poo App. + * + * These endpoints allow agents and scripts to interact with the app + * without using a browser. All endpoints require JWT authentication. + * + * Endpoints: + * - GET /api/agent/lists - Get all lists for the user + * - GET /api/agent/lists/:id - Get a list with its items + * - GET /api/agent/lists/:id/items - Get items for a list + * - POST /api/agent/lists/:id/items - Add an item to a list + * - PATCH /api/agent/items/:id - Update an item (check/uncheck/edit) + * - DELETE /api/agent/items/:id - Delete an item + * - GET /api/agent/team - Team dashboard data + * - POST /api/agent/team/status - Agent status update + */ + +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import type { ActionCtx } from "./_generated/server"; +import { + requireAuth, + AuthError, + unauthorizedResponseWithCors, +} from "./lib/auth"; +import { jsonResponse, errorResponse, getCorsHeaders } from "./lib/httpResponses"; + +/** + * Helper type for user info. + */ +type UserInfo = { did: string; legacyDid?: string } | null; + +/** + * Extract list ID from URL path. + * Handles paths like /api/agent/lists/:id or /api/agent/lists/:id/items + */ +function extractListIdFromPath(url: string): string | null { + const match = url.match(/\/api\/agent\/lists\/([a-z0-9]+)/); + return match ? match[1] : null; +} + +/** + * Extract item ID from URL path. + * Handles paths like /api/agent/items/:id + */ +function extractItemIdFromPath(url: string): string | null { + const match = url.match(/\/api\/agent\/items\/([a-z0-9]+)/); + return match ? match[1] : null; +} + +/** + * Get user from auth token. + */ +async function getAuthenticatedUser( + ctx: ActionCtx, + request: Request +): Promise<{ user: NonNullable; auth: { turnkeySubOrgId: string; email: string } }> { + const auth = await requireAuth(request); + + const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + turnkeySubOrgId: auth.turnkeySubOrgId, + }) as UserInfo; + + if (!user) { + throw new Error("User not found"); + } + + return { user, auth }; +} + +/** + * Handle GET /api/agent/lists/:id - Get a list with all its items. + */ +async function handleGetListWithItems( + ctx: ActionCtx, + request: Request, + listIdStr: string +): Promise { + const { user } = await getAuthenticatedUser(ctx, request); + const listId = listIdStr as Id<"lists">; + + // Get the list + const list = await ctx.runQuery(api.lists.getList, { listId }); + if (!list) { + return errorResponse(request, "List not found", 404); + } + + // Check if user has access (owner or published list) + const isOwner = list.ownerDid === user.did || list.ownerDid === user.legacyDid; + + if (!isOwner) { + // Check if list is published + const pubStatus = await ctx.runQuery(api.publication.getPublicationStatus, { listId }); + if (!pubStatus || pubStatus.status !== "active") { + return errorResponse(request, "Access denied", 403); + } + } + + const role = isOwner ? "owner" : "editor"; + + // Get items for the list + const items = await ctx.runQuery(api.items.getListItems, { listId }); + + return jsonResponse(request, { + list: { + _id: list._id, + name: list.name, + ownerDid: list.ownerDid, + createdAt: list.createdAt, + assetDid: list.assetDid, + }, + items: items.map((item: { + _id: Id<"items">; + name: string; + checked: boolean; + createdByDid: string; + checkedByDid?: string; + createdAt: number; + checkedAt?: number; + order?: number; + description?: string; + dueDate?: number; + url?: string; + priority?: "high" | "medium" | "low"; + }) => ({ + _id: item._id, + name: item.name, + checked: item.checked, + createdByDid: item.createdByDid, + checkedByDid: item.checkedByDid, + createdAt: item.createdAt, + checkedAt: item.checkedAt, + order: item.order, + description: item.description, + dueDate: item.dueDate, + url: item.url, + priority: item.priority, + })), + role, + }); +} + +/** + * Handle POST /api/agent/lists/:id/items - Add an item to a list. + */ +async function handleAddItemToList( + ctx: ActionCtx, + request: Request, + listIdStr: string +): Promise { + const { user } = await getAuthenticatedUser(ctx, request); + + // Parse request body + const body = await request.json(); + const { name, description, priority, dueDate, url: itemUrl } = body as { + name: string; + description?: string; + priority?: "high" | "medium" | "low"; + dueDate?: number; + url?: string; + }; + + if (!name) { + return errorResponse(request, "Item name is required", 400); + } + + const listId = listIdStr as Id<"lists">; + + // Call the mutation with server-verified DID + const itemId = await ctx.runMutation(api.items.addItem, { + listId, + name, + createdByDid: user.did, + legacyDid: user.legacyDid, + createdAt: Date.now(), + description, + priority, + dueDate, + url: itemUrl, + }); + + return jsonResponse(request, { + itemId, + item: { + _id: itemId, + name, + checked: false, + createdByDid: user.did, + description, + priority, + dueDate, + url: itemUrl, + }, + }, 201); +} + +/** + * Handle PATCH /api/agent/items/:id - Update an item. + */ +async function handleUpdateItem( + ctx: ActionCtx, + request: Request, + itemIdStr: string +): Promise { + const { user } = await getAuthenticatedUser(ctx, request); + const itemId = itemIdStr as Id<"items">; + + // Parse request body + const body = await request.json(); + const { checked, name, description, priority, dueDate, url: itemUrl } = body as { + checked?: boolean; + name?: string; + description?: string; + priority?: "high" | "medium" | "low" | null; + dueDate?: number | null; + url?: string | null; + }; + + // Handle checked state change + if (checked === true) { + await ctx.runMutation(api.items.checkItem, { + itemId, + checkedByDid: user.did, + legacyDid: user.legacyDid, + checkedAt: Date.now(), + }); + } else if (checked === false) { + await ctx.runMutation(api.items.uncheckItem, { + itemId, + userDid: user.did, + legacyDid: user.legacyDid, + }); + } + + // Handle other updates + if (name !== undefined || description !== undefined || priority !== undefined || dueDate !== undefined || itemUrl !== undefined) { + await ctx.runMutation(api.items.updateItem, { + itemId, + userDid: user.did, + legacyDid: user.legacyDid, + name, + description, + priority: priority === null ? undefined : priority, + dueDate: dueDate === null ? undefined : dueDate, + url: itemUrl === null ? undefined : itemUrl, + clearPriority: priority === null, + clearDueDate: dueDate === null, + clearUrl: itemUrl === null, + }); + } + + // Get updated item + const updatedItem = await ctx.runQuery(api.items.getItemForSync, { itemId }); + + return jsonResponse(request, { + success: true, + item: updatedItem, + }); +} + +/** + * Handle DELETE /api/agent/items/:id - Delete an item. + */ +async function handleDeleteItem( + ctx: ActionCtx, + request: Request, + itemIdStr: string +): Promise { + const { user } = await getAuthenticatedUser(ctx, request); + const itemId = itemIdStr as Id<"items">; + + // Call the mutation with server-verified DID + await ctx.runMutation(api.items.removeItem, { + itemId, + userDid: user.did, + legacyDid: user.legacyDid, + }); + + return jsonResponse(request, { success: true }); +} + +/** + * Handle GET /api/agent/lists - Get all lists for the user. + */ +async function handleGetUserLists( + ctx: ActionCtx, + request: Request +): Promise { + const { user } = await getAuthenticatedUser(ctx, request); + + // Get all lists for the user + const lists = await ctx.runQuery(api.lists.getUserLists, { + userDid: user.did, + legacyDid: user.legacyDid, + }); + + // Get roles for each list + const listsWithRoles = await Promise.all( + lists.map(async (list: { _id: Id<"lists">; name: string; ownerDid: string; createdAt: number; assetDid: string }) => { + const isOwner = list.ownerDid === user.did || list.ownerDid === user.legacyDid; + const role = isOwner ? "owner" : "editor"; + + return { + _id: list._id, + name: list.name, + ownerDid: list.ownerDid, + createdAt: list.createdAt, + role, + }; + }) + ); + + return jsonResponse(request, { lists: listsWithRoles }); +} + +async function handleGetTeamDashboard( + ctx: ActionCtx, + request: Request +): Promise { + const { user } = await getAuthenticatedUser(ctx, request); + const url = new URL(request.url); + const includeArchived = url.searchParams.get("includeArchived") === "true"; + + const teamApi = (api as any).agentTeam; + const [agents, tree, summary] = await Promise.all([ + ctx.runQuery(teamApi.listTeamAgents, { ownerDid: user.did, includeArchived }), + ctx.runQuery(teamApi.getTeamTree, { ownerDid: user.did, includeArchived }), + ctx.runQuery(teamApi.getTeamSummary, { ownerDid: user.did, includeArchived }), + ]); + + return jsonResponse(request, { ownerDid: user.did, agents, tree, summary }); +} + +async function handleUpsertTeamStatus( + ctx: ActionCtx, + request: Request +): Promise { + const { user } = await getAuthenticatedUser(ctx, request); + const body = await request.json(); + const { agentSlug, displayName, status, currentTask, parentAgentSlug, autoArchiveOnIdle, metadata } = body as { + agentSlug: string; + displayName?: string; + status: "idle" | "working" | "error"; + currentTask?: string; + parentAgentSlug?: string; + autoArchiveOnIdle?: boolean; + metadata?: string; + }; + + if (!agentSlug || !status) { + return errorResponse(request, "agentSlug and status are required", 400); + } + + const statusId = await ctx.runMutation((api as any).agentTeam.upsertAgentStatus, { + ownerDid: user.did, + agentSlug, + displayName: displayName ?? agentSlug, + status, + currentTask, + parentAgentSlug, + autoArchiveOnIdle, + metadata, + }); + + return jsonResponse(request, { success: true, statusId }); +} + +// ============================================================================ +// Exported HTTP Actions +// ============================================================================ + +/** + * GET /api/agent/lists + * + * Get all lists the authenticated user has access to. + */ +export const getUserLists = httpAction(async (ctx, request) => { + try { + return await handleGetUserLists(ctx, request); + } catch (error) { + if (error instanceof AuthError) { + return unauthorizedResponseWithCors(request, error.message); + } + console.error("[agentApi] getUserLists error:", error); + return errorResponse( + request, + error instanceof Error ? error.message : "Failed to get lists", + 500 + ); + } +}); + +/** + * Router for /api/agent/lists/:id and /api/agent/lists/:id/items + * + * Handles: + * - GET /api/agent/lists/:id - Get a specific list with items + * - GET /api/agent/lists/:id/items - Get items for a list + * - POST /api/agent/lists/:id/items - Add item to a list + */ +export const agentListHandler = httpAction(async (ctx, request) => { + try { + const url = new URL(request.url); + const path = url.pathname; + + // Extract list ID + const listIdStr = extractListIdFromPath(path); + if (!listIdStr) { + return errorResponse(request, "List ID is required", 400); + } + + // Check if this is the /items endpoint + const isItemsEndpoint = path.endsWith("/items"); + + if (request.method === "GET") { + // Both /lists/:id and /lists/:id/items return the same data + return await handleGetListWithItems(ctx, request, listIdStr); + } + + if (request.method === "POST" && isItemsEndpoint) { + return await handleAddItemToList(ctx, request, listIdStr); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) { + return unauthorizedResponseWithCors(request, error.message); + } + console.error("[agentApi] agentListHandler error:", error); + return errorResponse( + request, + error instanceof Error ? error.message : "Request failed", + 500 + ); + } +}); + +/** + * Router for /api/agent/items/:id + * + * Handles: + * - PATCH /api/agent/items/:id - Update an item + * - DELETE /api/agent/items/:id - Delete an item + */ +export const agentItemHandler = httpAction(async (ctx, request) => { + try { + const url = new URL(request.url); + const path = url.pathname; + + // Extract item ID + const itemIdStr = extractItemIdFromPath(path); + if (!itemIdStr) { + return errorResponse(request, "Item ID is required", 400); + } + + if (request.method === "PATCH") { + return await handleUpdateItem(ctx, request, itemIdStr); + } + + if (request.method === "DELETE") { + return await handleDeleteItem(ctx, request, itemIdStr); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) { + return unauthorizedResponseWithCors(request, error.message); + } + console.error("[agentApi] agentItemHandler error:", error); + return errorResponse( + request, + error instanceof Error ? error.message : "Request failed", + 500 + ); + } +}); + +export const teamHandler = httpAction(async (ctx, request) => { + try { + if (request.method === "GET") { + return await handleGetTeamDashboard(ctx, request); + } + + if (request.method === "POST") { + const url = new URL(request.url); + if (url.pathname.endsWith("/status")) { + return await handleUpsertTeamStatus(ctx, request); + } + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) { + return unauthorizedResponseWithCors(request, error.message); + } + console.error("[agentApi] teamHandler error:", error); + return errorResponse( + request, + error instanceof Error ? error.message : "Request failed", + 500 + ); + } +}); + +/** + * CORS preflight handler for agent API. + */ +export const corsHandler = httpAction(async (_ctx, request) => { + return new Response(null, { + status: 204, + headers: { + ...getCorsHeaders(request), + "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", + "Access-Control-Max-Age": "86400", + }, + }); +}); diff --git a/convex/agentTeam.ts b/convex/agentTeam.ts new file mode 100644 index 0000000..8d32801 --- /dev/null +++ b/convex/agentTeam.ts @@ -0,0 +1,229 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; + +type AgentStatus = "idle" | "working" | "error"; + +export const upsertAgentStatus = mutation({ + args: { + ownerDid: v.string(), + agentSlug: v.string(), + displayName: v.string(), + status: v.union(v.literal("idle"), v.literal("working"), v.literal("error")), + currentTask: v.optional(v.string()), + parentAgentSlug: v.optional(v.string()), + autoArchiveOnIdle: v.optional(v.boolean()), + metadata: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const shouldAutoArchive = args.autoArchiveOnIdle ?? true; + + const existing = await ctx.db + .query("agentProfiles") + .withIndex("by_owner_slug", (q) => + q.eq("ownerDid", args.ownerDid).eq("agentSlug", args.agentSlug) + ) + .first(); + + const archivedAt = + shouldAutoArchive && !!args.parentAgentSlug && args.status === "idle" && !args.currentTask + ? now + : undefined; + + const basePatch = { + displayName: args.displayName, + parentAgentSlug: args.parentAgentSlug, + status: args.status, + currentTask: args.currentTask, + lastHeartbeatAt: now, + lastStatusAt: now, + metadata: args.metadata, + updatedAt: now, + archivedAt, + }; + + if (existing) { + await ctx.db.patch(existing._id, basePatch); + return existing._id; + } + + return await ctx.db.insert("agentProfiles", { + ownerDid: args.ownerDid, + agentSlug: args.agentSlug, + displayName: args.displayName, + status: args.status, + currentTask: args.currentTask, + parentAgentSlug: args.parentAgentSlug, + lastHeartbeatAt: now, + lastStatusAt: now, + metadata: args.metadata, + archivedAt, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const listTeamAgents = query({ + args: { + ownerDid: v.string(), + includeArchived: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const includeArchived = args.includeArchived ?? false; + const rows = await ctx.db + .query("agentProfiles") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(); + + const filtered = includeArchived ? rows : rows.filter((r) => !r.archivedAt); + return filtered.sort((a, b) => b.updatedAt - a.updatedAt); + }, +}); + +export const getTeamTree = query({ + args: { + ownerDid: v.string(), + includeArchived: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const includeArchived = args.includeArchived ?? false; + const rows = await ctx.db + .query("agentProfiles") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(); + + const agents = includeArchived ? rows : rows.filter((r) => !r.archivedAt); + const bySlug = new Map(agents.map((a) => [a.agentSlug, a])); + + const childrenByParent = new Map(); + const roots: typeof agents = []; + + for (const agent of agents) { + const parent = agent.parentAgentSlug; + if (parent && bySlug.has(parent)) { + const children = childrenByParent.get(parent) ?? []; + children.push(agent); + childrenByParent.set(parent, children); + } else { + roots.push(agent); + } + } + + const toNode = (agent: (typeof agents)[number]) => ({ + ...agent, + children: (childrenByParent.get(agent.agentSlug) ?? []) + .sort((a, b) => b.updatedAt - a.updatedAt) + .map(toNode), + }); + + return roots.sort((a, b) => b.updatedAt - a.updatedAt).map(toNode); + }, +}); + +export const getTeamSummary = query({ + args: { ownerDid: v.string(), includeArchived: v.optional(v.boolean()) }, + handler: async (ctx, args) => { + const includeArchived = args.includeArchived ?? false; + const rows = await ctx.db + .query("agentProfiles") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(); + + const active = includeArchived ? rows : rows.filter((r) => !r.archivedAt); + const counts: Record = { idle: 0, working: 0, error: 0 }; + for (const row of active) { + const status = (row.status ?? "idle") as AgentStatus; + counts[status] += 1; + } + + return { total: active.length, statusCounts: counts, updatedAt: Date.now() }; + }, +}); + +export const getRunHealth = query({ + args: { ownerDid: v.string(), includeArchived: v.optional(v.boolean()) }, + handler: async (ctx, args) => { + const includeArchived = args.includeArchived ?? false; + const rows = await ctx.db + .query("agentProfiles") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(); + + const active = includeArchived ? rows : rows.filter((r) => !r.archivedAt); + const now = Date.now(); + const staleThresholdMs = 5 * 60 * 1000; + const criticalThresholdMs = 15 * 60 * 1000; + + const staleAgents = active + .map((agent) => { + const ageMs = agent.lastHeartbeatAt ? now - agent.lastHeartbeatAt : Number.POSITIVE_INFINITY; + return { + ...agent, + heartbeatAgeMs: ageMs, + isStale: ageMs >= staleThresholdMs, + isCritical: ageMs >= criticalThresholdMs, + }; + }) + .filter((agent) => agent.isStale || agent.status === "error") + .sort((a, b) => (b.heartbeatAgeMs ?? 0) - (a.heartbeatAgeMs ?? 0)); + + const stuckWorking = active.filter((agent) => { + if (agent.status !== "working" || !agent.lastStatusAt) return false; + return now - agent.lastStatusAt >= criticalThresholdMs; + }); + + return { + updatedAt: now, + totals: { + agents: active.length, + stale: staleAgents.filter((a) => a.isStale && !a.isCritical).length, + critical: staleAgents.filter((a) => a.isCritical).length, + errored: active.filter((a) => a.status === "error").length, + stuckWorking: stuckWorking.length, + }, + staleAgents: staleAgents.slice(0, 25), + }; + }, +}); + +export const quickAction = mutation({ + args: { + ownerDid: v.string(), + actorDid: v.string(), + agentId: v.id("agentProfiles"), + action: v.union(v.literal("assign"), v.literal("ask"), v.literal("pause")), + message: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const profile = await ctx.db.get(args.agentId); + if (!profile || profile.ownerDid !== args.ownerDid) throw new Error("Agent profile not found"); + + const now = Date.now(); + const nextMessage = args.message?.trim(); + + if (args.action === "pause") { + await ctx.db.patch(args.agentId, { + status: "idle", + launchState: "paused", + pausedAt: now, + lastStatusAt: now, + updatedAt: now, + }); + return { ok: true, action: args.action }; + } + + if (!nextMessage) throw new Error("message is required for assign/ask"); + + await ctx.db.patch(args.agentId, { + status: "working", + launchState: "running", + pausedAt: undefined, + currentTask: nextMessage, + lastStatusAt: now, + updatedAt: now, + }); + + return { ok: true, action: args.action }; + }, +}); diff --git a/convex/assignees.ts b/convex/assignees.ts new file mode 100644 index 0000000..2bf74e8 --- /dev/null +++ b/convex/assignees.ts @@ -0,0 +1,92 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { canUserEditList } from "./lib/permissions"; + +export const assignItem = mutation({ + args: { + itemId: v.id("items"), + assigneeDid: v.string(), + actorDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error("Item not found"); + + const canEdit = await canUserEditList(ctx, item.listId, args.actorDid, args.legacyDid); + if (!canEdit) throw new Error("Not authorized to assign item"); + + const existing = await ctx.db + .query("itemAssignees") + .withIndex("by_item_assignee", (q) => q.eq("itemId", args.itemId).eq("assigneeDid", args.assigneeDid)) + .first(); + + if (!existing) { + const now = Date.now(); + await ctx.db.insert("itemAssignees", { + itemId: args.itemId, + listId: item.listId, + assigneeDid: args.assigneeDid, + assignedByDid: args.actorDid, + assignedAt: now, + }); + + await ctx.db.insert("activities", { + listId: item.listId, + itemId: args.itemId, + actorDid: args.actorDid, + type: "item_assigned", + metadata: { assigneeDid: args.assigneeDid }, + createdAt: now, + }); + } + + return { success: true }; + }, +}); + +export const unassignItem = mutation({ + args: { + itemId: v.id("items"), + assigneeDid: v.string(), + actorDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error("Item not found"); + + const canEdit = await canUserEditList(ctx, item.listId, args.actorDid, args.legacyDid); + if (!canEdit) throw new Error("Not authorized to unassign item"); + + const existing = await ctx.db + .query("itemAssignees") + .withIndex("by_item_assignee", (q) => q.eq("itemId", args.itemId).eq("assigneeDid", args.assigneeDid)) + .first(); + + if (existing) { + const now = Date.now(); + await ctx.db.delete(existing._id); + await ctx.db.insert("activities", { + listId: item.listId, + itemId: args.itemId, + actorDid: args.actorDid, + type: "item_unassigned", + metadata: { assigneeDid: args.assigneeDid }, + createdAt: now, + }); + } + + return { success: true }; + }, +}); + +export const getItemAssignees = query({ + args: { itemId: v.id("items") }, + handler: async (ctx, args) => { + return await ctx.db + .query("itemAssignees") + .withIndex("by_item", (q) => q.eq("itemId", args.itemId)) + .collect(); + }, +}); diff --git a/convex/assigneesHttp.ts b/convex/assigneesHttp.ts new file mode 100644 index 0000000..30d5da1 --- /dev/null +++ b/convex/assigneesHttp.ts @@ -0,0 +1,71 @@ +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; +import { requireAuthenticatedUser } from "./lib/authUser"; +import { jsonResponse, errorResponse } from "./lib/httpResponses"; + +export const assignItem = httpAction(async (ctx, request) => { + try { + const user = await requireAuthenticatedUser(ctx, request); + const body = await request.json(); + const { itemId, assigneeDid } = body as { itemId: string; assigneeDid: string }; + + if (!itemId || !assigneeDid) return errorResponse(request, "itemId and assigneeDid are required"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await ctx.runMutation((api as any).assignees.assignItem, { + itemId: itemId as Id<"items">, + assigneeDid, + actorDid: user.did, + legacyDid: user.legacyDid, + }); + + return jsonResponse(request, { success: true }); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed to assign item", 500); + } +}); + +export const unassignItem = httpAction(async (ctx, request) => { + try { + const user = await requireAuthenticatedUser(ctx, request); + const body = await request.json(); + const { itemId, assigneeDid } = body as { itemId: string; assigneeDid: string }; + + if (!itemId || !assigneeDid) return errorResponse(request, "itemId and assigneeDid are required"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await ctx.runMutation((api as any).assignees.unassignItem, { + itemId: itemId as Id<"items">, + assigneeDid, + actorDid: user.did, + legacyDid: user.legacyDid, + }); + + return jsonResponse(request, { success: true }); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed to unassign item", 500); + } +}); + +export const getItemAssignees = httpAction(async (ctx, request) => { + try { + await requireAuthenticatedUser(ctx, request); + const body = await request.json(); + const { itemId } = body as { itemId: string }; + if (!itemId) return errorResponse(request, "itemId is required"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const assignees = await ctx.runQuery((api as any).assignees.getItemAssignees, { + itemId: itemId as Id<"items">, + }); + + return jsonResponse(request, { assignees }); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed to get assignees", 500); + } +}); diff --git a/convex/attachments.ts b/convex/attachments.ts new file mode 100644 index 0000000..9b2471f --- /dev/null +++ b/convex/attachments.ts @@ -0,0 +1,131 @@ +/** + * File attachments for list items. + */ + +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; + +/** + * Helper to check if a user can edit a list. + */ +async function canUserEditList( + ctx: MutationCtx | QueryCtx, + listId: Id<"lists">, + userDid: string, + legacyDid?: string +): Promise { + const list = await ctx.db.get(listId); + if (!list) return false; + + const dids = [userDid]; + if (legacyDid) dids.push(legacyDid); + + if (dids.includes(list.ownerDid)) return true; + + // Published lists are editable by anyone + const pub = await ctx.db + .query("publications") + .withIndex("by_list", (q) => q.eq("listId", listId)) + .first(); + + return pub?.status === "active"; +} + +/** + * Generate an upload URL for a file attachment. + */ +export const generateUploadUrl = mutation({ + args: { + itemId: v.id("items"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error("Item not found"); + + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to add attachments to this item"); + } + + return await ctx.storage.generateUploadUrl(); + }, +}); + +/** + * Add an attachment to an item after upload. + */ +export const addAttachment = mutation({ + args: { + itemId: v.id("items"), + storageId: v.id("_storage"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error("Item not found"); + + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to add attachments to this item"); + } + + const currentAttachments = item.attachments ?? []; + await ctx.db.patch(args.itemId, { + attachments: [...currentAttachments, args.storageId], + updatedAt: Date.now(), + }); + }, +}); + +/** + * Remove an attachment from an item. + */ +export const removeAttachment = mutation({ + args: { + itemId: v.id("items"), + storageId: v.id("_storage"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error("Item not found"); + + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to remove attachments from this item"); + } + + const currentAttachments = item.attachments ?? []; + await ctx.db.patch(args.itemId, { + attachments: currentAttachments.filter((id) => id !== args.storageId), + updatedAt: Date.now(), + }); + + // Delete the file from storage + await ctx.storage.delete(args.storageId); + }, +}); + +/** + * Get attachment URLs for an item. + */ +export const getAttachmentUrls = query({ + args: { itemId: v.id("items") }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item || !item.attachments) return []; + + const urls: { storageId: Id<"_storage">; url: string | null }[] = []; + for (const storageId of item.attachments) { + const url = await ctx.storage.getUrl(storageId); + urls.push({ storageId, url }); + } + return urls; + }, +}); diff --git a/convex/auth.ts b/convex/auth.ts index e8b0b0c..0c1c49c 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -22,7 +22,7 @@ export const upsertUser = mutation({ args: { turnkeySubOrgId: v.string(), email: v.string(), - did: v.string(), + did: v.optional(v.string()), displayName: v.optional(v.string()), // Migration: the user's old localStorage DID being migrated legacyDid: v.optional(v.string()), @@ -35,15 +35,12 @@ export const upsertUser = mutation({ .first(); if (existingByTurnkey) { - // Update last login, and upgrade DID if still temporary - await ctx.db.patch(existingByTurnkey._id, { - lastLoginAt: Date.now(), - ...(args.did && - !args.did.startsWith("did:temp:") && - existingByTurnkey.did.startsWith("did:temp:") - ? { did: args.did } - : {}), - }); + // Update last login, and upgrade DID if provided and not yet set + const patch: Record = { lastLoginAt: Date.now() }; + if (args.did && (!existingByTurnkey.did || !existingByTurnkey.did.startsWith("did:webvh:"))) { + patch.did = args.did; + } + await ctx.db.patch(existingByTurnkey._id, patch); return existingByTurnkey._id; } @@ -86,12 +83,12 @@ export const upsertUser = mutation({ return existingByDid._id; } - // Create new user + // Create new user (DID will be set client-side via /api/user/updateDID) const displayName = args.displayName ?? args.email.split("@")[0]; return await ctx.db.insert("users", { turnkeySubOrgId: args.turnkeySubOrgId, email: args.email, - did: args.did, + did: args.did, // undefined on first create — client upgrades to did:webvh displayName, createdAt: Date.now(), lastLoginAt: Date.now(), diff --git a/convex/bitcoinAnchors.ts b/convex/bitcoinAnchors.ts new file mode 100644 index 0000000..1b2760b --- /dev/null +++ b/convex/bitcoinAnchors.ts @@ -0,0 +1,375 @@ +/** + * Bitcoin Anchoring for List State + * + * Phase 5: Anchor list state to Bitcoin signet for immutable timestamping. + * Uses @originals/sdk for Bitcoin integration when available. + * + * The anchoring process: + * 1. Compute SHA-256 hash of list state (items, VCs, etc.) + * 2. Inscribe hash on Bitcoin signet via Ordinals + * 3. Store anchor proof with the list + * + * This provides cryptographic proof that a list existed in a specific state + * at a specific point in time, anchored to the Bitcoin blockchain. + */ + +import { v } from "convex/values"; +import { query, mutation, action } from "./_generated/server"; +import { api } from "./_generated/api"; +import type { Doc, Id } from "./_generated/dataModel"; + +/** + * Network configuration for Bitcoin anchoring + */ +const BITCOIN_NETWORK = "signet" as const; + +/** + * Compute SHA-256 hash of a string + * Uses Web Crypto API available in Convex runtime + */ +async function computeSha256(data: string): Promise { + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(data); + const hashBuffer = await crypto.subtle.digest("SHA-256", dataBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Build a canonical representation of list state for hashing. + * Ensures consistent ordering for deterministic hashes. + */ +function buildCanonicalState( + list: Doc<"lists">, + items: Doc<"items">[] +): string { + // Sort items by ID for deterministic ordering + const sortedItems = [...items].sort((a, b) => + a._id.toString().localeCompare(b._id.toString()) + ); + + const state = { + version: 2, // State schema version - v2 removes collaborators + list: { + id: list._id.toString(), + assetDid: list.assetDid, + name: list.name, + ownerDid: list.ownerDid, + createdAt: list.createdAt, + }, + items: sortedItems.map((item) => ({ + id: item._id.toString(), + name: item.name, + checked: item.checked, + createdByDid: item.createdByDid, + checkedByDid: item.checkedByDid, + createdAt: item.createdAt, + checkedAt: item.checkedAt, + order: item.order, + description: item.description, + dueDate: item.dueDate, + priority: item.priority, + })), + anchoredAt: Date.now(), + }; + + // Use JSON.stringify with sorted keys for deterministic output + return JSON.stringify(state, Object.keys(state).sort()); +} + +/** + * Internal mutation to create an anchor record. + * Called by the action after computing the hash. + */ +export const createAnchorRecord = mutation({ + args: { + listId: v.id("lists"), + stateHash: v.string(), + stateSnapshot: v.string(), + anchoredByDid: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db.insert("bitcoinAnchors", { + listId: args.listId, + contentHash: args.stateHash, + network: BITCOIN_NETWORK, + status: "pending", + requestedByDid: args.anchoredByDid, + createdAt: Date.now(), + stateSnapshot: args.stateSnapshot, + }); + }, +}); + +/** + * Update anchor status after Bitcoin inscription. + */ +export const updateAnchorStatus = mutation({ + args: { + anchorId: v.id("bitcoinAnchors"), + status: v.union( + v.literal("pending"), + v.literal("inscribed"), + v.literal("confirmed"), + v.literal("failed") + ), + txid: v.optional(v.string()), + inscriptionId: v.optional(v.string()), + blockHeight: v.optional(v.number()), + confirmations: v.optional(v.number()), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const { anchorId, status, ...updates } = args; + const now = Date.now(); + await ctx.db.patch(anchorId, { + status, + ...updates, + updatedAt: now, + ...(status === "inscribed" ? { inscribedAt: now } : {}), + ...(status === "confirmed" ? { confirmedAt: now } : {}), + }); + }, +}); + +/** + * Get list data for anchoring (internal helper query). + */ +export const getListDataForAnchor = query({ + args: { listId: v.id("lists") }, + handler: async (ctx, args) => { + const list = await ctx.db.get(args.listId); + if (!list) return null; + + const items = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .collect(); + + return { list, items }; + }, +}); + +/** + * Anchor list state to Bitcoin signet. + * + * This action: + * 1. Fetches current list state (items) + * 2. Computes SHA-256 hash of canonical state + * 3. Creates anchor record with "pending" status + * 4. Attempts Bitcoin inscription (when configured) + * 5. Updates anchor status based on result + * + * @param listId - The list to anchor + * @param userDid - DID of user requesting the anchor + * @returns The anchor record ID + */ +export const anchorListState = action({ + args: { + listId: v.id("lists"), + userDid: v.string(), + }, + handler: async (ctx, args): Promise<{ anchorId: Id<"bitcoinAnchors">; stateHash: string; status: string }> => { + // 1. Fetch list data + const data = await ctx.runQuery(api.bitcoinAnchors.getListDataForAnchor, { + listId: args.listId, + }); + + if (!data) { + throw new Error("List not found"); + } + + const { list, items } = data; + + // 2. Verify user has access (owner) + if (list.ownerDid !== args.userDid) { + throw new Error("Only the owner can anchor list state"); + } + + // 3. Build canonical state and compute hash + const canonicalState = buildCanonicalState(list, items); + const stateHash = await computeSha256(canonicalState); + + // 4. Create anchor record + const anchorId = await ctx.runMutation(api.bitcoinAnchors.createAnchorRecord, { + listId: args.listId, + stateHash, + stateSnapshot: canonicalState, + anchoredByDid: args.userDid, + }); + + // 5. Attempt Bitcoin inscription + // NOTE: Full Bitcoin integration requires: + // - Configured BitcoinManager with RPC endpoint + // - Funded wallet for transaction fees + // - Network access to Bitcoin signet + // + // For now, we create the anchor record in "pending" status. + // A background process or manual trigger can complete the inscription. + // + // When @originals/sdk Bitcoin integration is configured: + // ``` + // import { OriginalsSDK } from '@originals/sdk'; + // const sdk = new OriginalsSDK({ + // network: 'signet', + // bitcoinRpcUrl: process.env.BITCOIN_RPC_URL, + // }); + // const inscription = await sdk.bitcoin.inscribeData( + // { type: 'anchor', hash: stateHash, listDid: list.assetDid }, + // 'application/json', + // feeRate + // ); + // await ctx.runMutation(api.bitcoinAnchors.updateAnchorStatus, { + // anchorId, + // status: 'inscribed', + // txid: inscription.txid, + // inscriptionId: inscription.inscriptionId, + // }); + // ``` + + // For development/demo: simulate successful inscription + // In production, this would be replaced with actual Bitcoin RPC calls + if (process.env.SIMULATE_BITCOIN_ANCHOR === "true") { + const simulatedTxid = `signet:${stateHash.substring(0, 16)}:${Date.now()}`; + const simulatedInscriptionId = `${simulatedTxid}i0`; + + await ctx.runMutation(api.bitcoinAnchors.updateAnchorStatus, { + anchorId, + status: "inscribed", + txid: simulatedTxid, + inscriptionId: simulatedInscriptionId, + }); + + return { anchorId, stateHash, status: "inscribed" }; + } + + return { anchorId, stateHash, status: "pending" }; + }, +}); + +/** + * Get all anchors for a list. + */ +export const getListAnchors = query({ + args: { listId: v.id("lists") }, + handler: async (ctx, args) => { + return await ctx.db + .query("bitcoinAnchors") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .order("desc") + .collect(); + }, +}); + +/** + * Get all Bitcoin anchors for a specific item + */ +export const getItemAnchors = query({ + args: { itemId: v.id("items") }, + handler: async (ctx, { itemId }) => { + const anchors = await ctx.db + .query("bitcoinAnchors") + .withIndex("by_item", (q) => q.eq("itemId", itemId)) + .collect(); + + return anchors; + }, +}); + +/** + * Get anchor by transaction ID + */ +export const getAnchorByTxid = query({ + args: { txid: v.string() }, + handler: async (ctx, { txid }) => { + const anchor = await ctx.db + .query("bitcoinAnchors") + .withIndex("by_txid", (q) => q.eq("txid", txid)) + .first(); + + return anchor; + }, +}); + +/** + * Get the latest anchor for a list. + */ +export const getLatestAnchor = query({ + args: { listId: v.id("lists") }, + handler: async (ctx, args) => { + return await ctx.db + .query("bitcoinAnchors") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .order("desc") + .first(); + }, +}); + +/** + * Get all pending anchors (for background processing) + */ +export const getPendingAnchors = query({ + args: {}, + handler: async (ctx) => { + const anchors = await ctx.db + .query("bitcoinAnchors") + .withIndex("by_status", (q) => q.eq("status", "pending")) + .collect(); + + return anchors; + }, +}); + +/** + * Get anchor by ID. + */ +export const getAnchor = query({ + args: { anchorId: v.id("bitcoinAnchors") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.anchorId); + }, +}); + +/** + * Verify anchor against current list state. + */ +export const verifyAnchorState = action({ + args: { + anchorId: v.id("bitcoinAnchors"), + }, + handler: async (ctx, args): Promise<{ valid: boolean; currentHash: string; anchoredHash: string; stateChanged: boolean }> => { + // Get the anchor + const anchor = await ctx.runQuery(api.bitcoinAnchors.getAnchor, { + anchorId: args.anchorId, + }); + + if (!anchor) { + throw new Error("Anchor not found"); + } + + if (!anchor.listId) { + throw new Error("Anchor has no associated list"); + } + + // Get current list state + const data = await ctx.runQuery(api.bitcoinAnchors.getListDataForAnchor, { + listId: anchor.listId, + }); + + if (!data) { + throw new Error("List not found"); + } + + // Compute current state hash + const { list, items } = data; + const canonicalState = buildCanonicalState(list, items); + const currentHash = await computeSha256(canonicalState); + + return { + valid: anchor.status === "inscribed" || anchor.status === "confirmed", + currentHash, + anchoredHash: anchor.contentHash, + stateChanged: currentHash !== anchor.contentHash, + }; + }, +}); diff --git a/convex/categories.ts b/convex/categories.ts index 7d2e575..5ded0f0 100644 --- a/convex/categories.ts +++ b/convex/categories.ts @@ -207,25 +207,15 @@ export const setListCategory = mutation({ didsToCheck.push(args.legacyDid); } - // Check user has access via collaborators table - let hasAccess = false; - for (const did of didsToCheck) { - const collab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", did) - ) - .first(); + // Check user has access (owner or published list) + let hasAccess = didsToCheck.includes(list.ownerDid); - if (collab) { - hasAccess = true; - break; - } - } - - // Fallback: Check legacy ownerDid field if (!hasAccess) { - hasAccess = didsToCheck.includes(list.ownerDid); + const pub = await ctx.db + .query("publications") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .first(); + hasAccess = pub?.status === "active"; } if (!hasAccess) { diff --git a/convex/collaborators.ts b/convex/collaborators.ts deleted file mode 100644 index 0613054..0000000 --- a/convex/collaborators.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; - -/** - * Get all collaborators for a list with enriched user info. - */ -export const getListCollaborators = query({ - args: { listId: v.id("lists") }, - handler: async (ctx, args) => { - const collabs = await ctx.db - .query("collaborators") - .withIndex("by_list", (q) => q.eq("listId", args.listId)) - .collect(); - - // Enrich with user info - const enriched = await Promise.all( - collabs.map(async (c) => { - // Try current DID first - let user = await ctx.db - .query("users") - .withIndex("by_did", (q) => q.eq("did", c.userDid)) - .first(); - - // If not found, try legacy DID - if (!user) { - user = await ctx.db - .query("users") - .withIndex("by_legacy_did", (q) => q.eq("legacyDid", c.userDid)) - .first(); - } - - return { - _id: c._id, - listId: c.listId, - userDid: c.userDid, - role: c.role, - joinedAt: c.joinedAt, - invitedByDid: c.invitedByDid, - displayName: user?.displayName ?? "Unknown", - email: user?.email, - }; - }) - ); - - // Sort: owner first, then by joinedAt - return enriched.sort((a, b) => { - if (a.role === "owner" && b.role !== "owner") return -1; - if (b.role === "owner" && a.role !== "owner") return 1; - return a.joinedAt - b.joinedAt; - }); - }, -}); - -/** - * Check user's role on a list. - * Returns the role or null if user is not a collaborator. - * Supports legacy DIDs for migrated users. - */ -export const getUserRole = query({ - args: { - listId: v.id("lists"), - userDid: v.string(), - legacyDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { - // Check current DID - const collab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", args.userDid) - ) - .first(); - - if (collab) { - return collab.role; - } - - // Check legacy DID if provided - if (args.legacyDid) { - const legacyDid = args.legacyDid; // Type narrowing - const legacyCollab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", legacyDid) - ) - .first(); - - if (legacyCollab) { - return legacyCollab.role; - } - } - - return null; - }, -}); - -/** - * Get all lists where user is a collaborator. - * Supports legacy DIDs for migrated users. - */ -export const getUserCollaborations = query({ - args: { - userDid: v.string(), - legacyDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); - } - - const collabMap = new Map< - string, - { listId: string; role: "owner" | "editor" | "viewer" } - >(); - - for (const did of didsToCheck) { - const collabs = await ctx.db - .query("collaborators") - .withIndex("by_user", (q) => q.eq("userDid", did)) - .collect(); - - for (const c of collabs) { - // Use first found (current DID takes precedence if both match) - if (!collabMap.has(c.listId.toString())) { - collabMap.set(c.listId.toString(), { - listId: c.listId.toString(), - role: c.role, - }); - } - } - } - - return Array.from(collabMap.values()); - }, -}); - -/** - * Add a collaborator to a list. - * Called when accepting an invite or by owner directly adding someone. - */ -export const addCollaborator = mutation({ - args: { - listId: v.id("lists"), - userDid: v.string(), - role: v.union(v.literal("editor"), v.literal("viewer")), - invitedByDid: v.optional(v.string()), - joinedAt: v.number(), - }, - handler: async (ctx, args) => { - // Check if user is already a collaborator - const existing = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", args.userDid) - ) - .first(); - - if (existing) { - throw new Error("User is already a collaborator on this list"); - } - - // Verify the list exists - const list = await ctx.db.get(args.listId); - if (!list) { - throw new Error("List not found"); - } - - // Check if user is the owner (can't add owner as collaborator again) - if (list.ownerDid === args.userDid) { - throw new Error("Cannot add owner as a collaborator"); - } - - return await ctx.db.insert("collaborators", { - listId: args.listId, - userDid: args.userDid, - role: args.role, - joinedAt: args.joinedAt, - invitedByDid: args.invitedByDid, - }); - }, -}); - -/** - * Update a collaborator's role (owner only). - * Cannot change owner's role or demote owner. - */ -export const updateCollaboratorRole = mutation({ - args: { - listId: v.id("lists"), - collaboratorDid: v.string(), - newRole: v.union(v.literal("editor"), v.literal("viewer")), - requesterDid: v.string(), - legacyDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { - // Verify requester is owner - const didsToCheck = [args.requesterDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); - } - - let isOwner = false; - for (const did of didsToCheck) { - const requesterCollab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", did) - ) - .first(); - - if (requesterCollab?.role === "owner") { - isOwner = true; - break; - } - } - - if (!isOwner) { - throw new Error("Only owners can change collaborator roles"); - } - - // Find the collaborator to update - const collab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", args.collaboratorDid) - ) - .first(); - - if (!collab) { - throw new Error("Collaborator not found"); - } - - if (collab.role === "owner") { - throw new Error("Cannot change owner's role"); - } - - await ctx.db.patch(collab._id, { role: args.newRole }); - }, -}); - -/** - * Remove a collaborator from a list. - * Owner can remove anyone (except themselves). - * Users can remove themselves (leave list). - */ -export const removeCollaborator = mutation({ - args: { - listId: v.id("lists"), - collaboratorDid: v.string(), - requesterDid: v.string(), - legacyDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { - // Find the collaborator to remove - const collab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", args.collaboratorDid) - ) - .first(); - - if (!collab) { - throw new Error("Collaborator not found"); - } - - if (collab.role === "owner") { - throw new Error("Cannot remove the owner from the list"); - } - - // Check if requester is the collaborator themselves (leaving) or owner (removing) - const didsToCheck = [args.requesterDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); - } - - const isSelf = didsToCheck.includes(args.collaboratorDid); - - if (!isSelf) { - // Not removing self, must be owner - let isOwner = false; - for (const did of didsToCheck) { - const requesterCollab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", did) - ) - .first(); - - if (requesterCollab?.role === "owner") { - isOwner = true; - break; - } - } - - if (!isOwner) { - throw new Error("Only owners can remove other collaborators"); - } - } - - await ctx.db.delete(collab._id); - }, -}); - -/** - * Check if user has edit access (owner or editor). - * Used by item mutations for authorization. - */ -export const canUserEdit = query({ - args: { - listId: v.id("lists"), - userDid: v.string(), - legacyDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); - } - - for (const did of didsToCheck) { - const collab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", did) - ) - .first(); - - if (collab && (collab.role === "owner" || collab.role === "editor")) { - return true; - } - } - - return false; - }, -}); diff --git a/convex/collaboratorsHttp.ts b/convex/collaboratorsHttp.ts deleted file mode 100644 index 7af8afe..0000000 --- a/convex/collaboratorsHttp.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * HTTP action handlers for protected collaborator mutations. - * - * These endpoints require JWT authentication via requireAuth(). - * The user's DID is looked up server-side from their turnkeySubOrgId. - */ - -import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; -import type { Id } from "./_generated/dataModel"; -import { - requireAuth, - AuthError, - unauthorizedResponseWithCors, -} from "./lib/auth"; -import { jsonResponse, errorResponse } from "./lib/httpResponses"; - -/** - * Helper type for user info. - */ -type UserInfo = { did: string; legacyDid?: string } | null; - -/** - * POST /api/collaborators/add - * - * Add a collaborator to a list. Requires authentication. - * Typically called after accepting an invite. - * - * Request: { "listId": "...", "userDid": "...", "role": "editor"|"viewer", "invitedByDid": "..." (optional) } - * Response: { "collaboratorId": "..." } - */ -export const addCollaborator = httpAction(async (ctx, request) => { - try { - // Require authentication - const auth = await requireAuth(request); - - // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } - - // Parse request body - const body = await request.json(); - const { listId, userDid, role, invitedByDid } = body as { - listId: string; - userDid: string; - role: "editor" | "viewer"; - invitedByDid?: string; - }; - - if (!listId || !userDid || !role) { - return errorResponse(request, "listId, userDid, and role are required"); - } - - if (role !== "editor" && role !== "viewer") { - return errorResponse(request, "role must be 'editor' or 'viewer'"); - } - - // Call the mutation with server-verified DID - const collaboratorId = await ctx.runMutation(api.collaborators.addCollaborator, { - listId: listId as Id<"lists">, - userDid, - role, - invitedByDid, - joinedAt: Date.now(), - }); - - return jsonResponse(request, { collaboratorId }); - } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } - console.error("[collaboratorsHttp] addCollaborator error:", error); - return errorResponse( - request, - error instanceof Error ? error.message : "Failed to add collaborator", - 500 - ); - } -}); - -/** - * POST /api/collaborators/updateRole - * - * Update a collaborator's role. Requires authentication and ownership. - * - * Request: { "listId": "...", "collaboratorDid": "...", "newRole": "editor"|"viewer" } - * Response: { "success": true } - */ -export const updateCollaboratorRole = httpAction(async (ctx, request) => { - try { - // Require authentication - const auth = await requireAuth(request); - - // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } - - // Parse request body - const body = await request.json(); - const { listId, collaboratorDid, newRole } = body as { - listId: string; - collaboratorDid: string; - newRole: "editor" | "viewer"; - }; - - if (!listId || !collaboratorDid || !newRole) { - return errorResponse(request, "listId, collaboratorDid, and newRole are required"); - } - - if (newRole !== "editor" && newRole !== "viewer") { - return errorResponse(request, "newRole must be 'editor' or 'viewer'"); - } - - // Call the mutation with server-verified DID - await ctx.runMutation(api.collaborators.updateCollaboratorRole, { - listId: listId as Id<"lists">, - collaboratorDid, - newRole, - requesterDid: user.did, - legacyDid: user.legacyDid, - }); - - return jsonResponse(request, { success: true }); - } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } - console.error("[collaboratorsHttp] updateCollaboratorRole error:", error); - return errorResponse( - request, - error instanceof Error ? error.message : "Failed to update collaborator role", - 500 - ); - } -}); - -/** - * POST /api/collaborators/remove - * - * Remove a collaborator from a list. Requires authentication. - * Owner can remove anyone. Users can remove themselves (leave). - * - * Request: { "listId": "...", "collaboratorDid": "..." } - * Response: { "success": true } - */ -export const removeCollaborator = httpAction(async (ctx, request) => { - try { - // Require authentication - const auth = await requireAuth(request); - - // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } - - // Parse request body - const body = await request.json(); - const { listId, collaboratorDid } = body as { - listId: string; - collaboratorDid: string; - }; - - if (!listId || !collaboratorDid) { - return errorResponse(request, "listId and collaboratorDid are required"); - } - - // Call the mutation with server-verified DID - await ctx.runMutation(api.collaborators.removeCollaborator, { - listId: listId as Id<"lists">, - collaboratorDid, - requesterDid: user.did, - legacyDid: user.legacyDid, - }); - - return jsonResponse(request, { success: true }); - } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } - console.error("[collaboratorsHttp] removeCollaborator error:", error); - return errorResponse( - request, - error instanceof Error ? error.message : "Failed to remove collaborator", - 500 - ); - } -}); diff --git a/convex/comments.ts b/convex/comments.ts new file mode 100644 index 0000000..f8a1655 --- /dev/null +++ b/convex/comments.ts @@ -0,0 +1,185 @@ +/** + * Comments API - Threaded discussions on items for shared lists. + * Enables collaboration through item-level comments. + */ + +import { v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { mutation, query } from "./_generated/server"; + +/** + * Helper to check if a user can view a list. + * Owner can always view. Published lists are viewable by anyone. + */ +async function canUserViewList( + ctx: MutationCtx | QueryCtx, + listId: Id<"lists">, + userDid: string, + legacyDid?: string +): Promise { + const list = await ctx.db.get(listId); + if (!list) return false; + + const dids = [userDid]; + if (legacyDid) dids.push(legacyDid); + + if (dids.includes(list.ownerDid)) return true; + + // Published lists are viewable by anyone + const pub = await ctx.db + .query("publications") + .withIndex("by_list", (q) => q.eq("listId", listId)) + .first(); + + return pub?.status === "active"; +} + +/** + * Helper to check if a user can edit a list. + * Owner can always edit. Published lists are editable by anyone. + */ +async function canUserEditList( + ctx: MutationCtx | QueryCtx, + listId: Id<"lists">, + userDid: string, + legacyDid?: string +): Promise { + return canUserViewList(ctx, listId, userDid, legacyDid); +} + +/** + * Get all comments for an item, ordered by creation time. + */ +export const getItemComments = query({ + args: { + itemId: v.id("items"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) { + throw new Error("Item not found"); + } + + // Verify user can view this list + const canView = await canUserViewList( + ctx, + item.listId, + args.userDid, + args.legacyDid + ); + if (!canView) { + throw new Error("Not authorized to view comments on this item"); + } + + const comments = await ctx.db + .query("comments") + .withIndex("by_item", (q) => q.eq("itemId", args.itemId)) + .collect(); + + // Sort by createdAt ascending (oldest first for a thread) + return comments.sort((a, b) => a.createdAt - b.createdAt); + }, +}); + +/** + * Add a comment to an item. + * Any collaborator (owner, editor, or viewer) can comment. + */ +export const addComment = mutation({ + args: { + itemId: v.id("items"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + text: v.string(), + }, + handler: async (ctx, args) => { + if (!args.text.trim()) { + throw new Error("Comment text cannot be empty"); + } + + const item = await ctx.db.get(args.itemId); + if (!item) { + throw new Error("Item not found"); + } + + // Verify user can view this list (any collaborator can comment) + const canView = await canUserViewList( + ctx, + item.listId, + args.userDid, + args.legacyDid + ); + if (!canView) { + throw new Error("Not authorized to comment on this item"); + } + + return await ctx.db.insert("comments", { + itemId: args.itemId, + userDid: args.userDid, + text: args.text.trim(), + createdAt: Date.now(), + }); + }, +}); + +/** + * Delete a comment. + * Only the comment author or list owner/editor can delete. + */ +export const deleteComment = mutation({ + args: { + commentId: v.id("comments"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const comment = await ctx.db.get(args.commentId); + if (!comment) { + throw new Error("Comment not found"); + } + + const item = await ctx.db.get(comment.itemId); + if (!item) { + throw new Error("Item not found"); + } + + const didsToCheck = [args.userDid]; + if (args.legacyDid) { + didsToCheck.push(args.legacyDid); + } + + // Check if user is the comment author + const isAuthor = didsToCheck.includes(comment.userDid); + + // Check if user can edit the list (owner or editor) + const canEdit = await canUserEditList( + ctx, + item.listId, + args.userDid, + args.legacyDid + ); + + if (!isAuthor && !canEdit) { + throw new Error("Not authorized to delete this comment"); + } + + await ctx.db.delete(args.commentId); + }, +}); + +/** + * Get comment count for an item (useful for showing badge on item). + */ +export const getCommentCount = query({ + args: { itemId: v.id("items") }, + handler: async (ctx, args) => { + const comments = await ctx.db + .query("comments") + .withIndex("by_item", (q) => q.eq("itemId", args.itemId)) + .collect(); + return comments.length; + }, +}); diff --git a/convex/didCreation.ts b/convex/didCreation.ts index ef4cffc..fc48522 100644 --- a/convex/didCreation.ts +++ b/convex/didCreation.ts @@ -81,11 +81,8 @@ export const createDIDWebVH = internalAction({ `[didCreation] Creating user did:webvh for ${args.email} (subOrg: ${args.subOrgId})` ); - // Create URL-safe slug from email - const slug = `user-${args.email - .replace(/[@.]/g, "-") - .replace(/[^a-zA-Z0-9-]/g, "") - .toLowerCase()}`; + // Use subOrgId only — no PII in the DID + const slug = `user-${args.subOrgId.slice(0, 16)}`; const result = await createDIDRecord(args.subOrgId, domain, slug); diff --git a/convex/didLogs.ts b/convex/didLogs.ts new file mode 100644 index 0000000..f5ebeb1 --- /dev/null +++ b/convex/didLogs.ts @@ -0,0 +1,83 @@ +/** + * DID log storage and retrieval for did:webvh resolution. + * + * Stores DID logs in Convex so they can be served at + * https://trypoo.app/{path}/did.jsonl for DID resolution. + */ + +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; + +/** + * Store or update a user's DID log. + */ +export const upsertDidLog = mutation({ + args: { + userDid: v.string(), + path: v.string(), + log: v.string(), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("didLogs") + .withIndex("by_user_did", (q) => q.eq("userDid", args.userDid)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + log: args.log, + path: args.path, + updatedAt: Date.now(), + }); + return existing._id; + } + + return await ctx.db.insert("didLogs", { + userDid: args.userDid, + path: args.path, + log: args.log, + updatedAt: Date.now(), + }); + }, +}); + +/** + * Get a DID log by path (for resolution). + */ +export const getDidLogByPath = query({ + args: { path: v.string() }, + handler: async (ctx, args) => { + const record = await ctx.db + .query("didLogs") + .withIndex("by_path", (q) => q.eq("path", args.path)) + .first(); + return record?.log ?? null; + }, +}); + +/** + * Get the full DID log record by path (includes userDid). + */ +export const getDidLogRecordByPath = query({ + args: { path: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("didLogs") + .withIndex("by_path", (q) => q.eq("path", args.path)) + .first(); + }, +}); + +/** + * Get a DID log by user DID. + */ +export const getDidLogByUserDid = query({ + args: { userDid: v.string() }, + handler: async (ctx, args) => { + const record = await ctx.db + .query("didLogs") + .withIndex("by_user_did", (q) => q.eq("userDid", args.userDid)) + .first(); + return record ?? null; + }, +}); diff --git a/convex/didLogsHttp.ts b/convex/didLogsHttp.ts new file mode 100644 index 0000000..b6b827b --- /dev/null +++ b/convex/didLogsHttp.ts @@ -0,0 +1,103 @@ +/** + * HTTP actions for DID log storage and retrieval. + * + * POST /api/did/log - Store/update a DID log (requires auth) + * GET /api/did/log?path={path} - Get a DID log by path (public) + */ + +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; + +function getCorsHeaders(request: Request): Record { + const origin = request.headers.get("Origin") || "*"; + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Credentials": "true", + }; +} + +/** + * Store/update a DID log. Requires JWT auth. + */ +export const storeDidLog = httpAction(async (ctx, request) => { + const corsHeaders = getCorsHeaders(request); + + try { + // Verify auth via Authorization header + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + const body = await request.json(); + const { userDid, path, log } = body as { userDid: string; path: string; log: string }; + + if (!userDid || !path || !log) { + return new Response(JSON.stringify({ error: "Missing required fields: userDid, path, log" }), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + await ctx.runMutation(api.didLogs.upsertDidLog, { userDid, path, log }); + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } catch (error) { + console.error("[didLogsHttp] Store error:", error); + return new Response(JSON.stringify({ error: "Failed to store DID log" }), { + status: 500, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } +}); + +/** + * Get a DID log by path. Public endpoint (no auth required). + */ +export const getDidLog = httpAction(async (ctx, request) => { + const corsHeaders = getCorsHeaders(request); + + try { + const url = new URL(request.url); + const path = url.searchParams.get("path"); + + if (!path) { + return new Response(JSON.stringify({ error: "Missing 'path' query parameter" }), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + const log = await ctx.runQuery(api.didLogs.getDidLogByPath, { path }); + + if (!log) { + return new Response("Not found", { + status: 404, + headers: corsHeaders, + }); + } + + // Return as JSONL (text/plain with each line being a JSON object) + return new Response(log, { + status: 200, + headers: { + "Content-Type": "application/jsonl+json", + ...corsHeaders, + }, + }); + } catch (error) { + console.error("[didLogsHttp] Get error:", error); + return new Response("Internal server error", { + status: 500, + headers: corsHeaders, + }); + } +}); diff --git a/convex/didResources.ts b/convex/didResources.ts new file mode 100644 index 0000000..f26592a --- /dev/null +++ b/convex/didResources.ts @@ -0,0 +1,155 @@ +/** + * Queries for serving list resources publicly. + * + * These are used by the HTTP handlers to serve lists as Originals resources + * at /{userPath}/resources/list-{id}. + */ + +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; + +/** + * Get a list by its Convex ID, verifying ownership by DID. + * Returns null if not found or owner doesn't match. + */ +export const getPublicList = query({ + args: { + listId: v.string(), + ownerDid: v.string(), + }, + handler: async (ctx, args) => { + // Try to normalize the listId to a Convex ID + let list; + try { + list = await ctx.db.get(args.listId as Id<"lists">); + } catch { + // Invalid ID format + return null; + } + + if (!list || list.ownerDid !== args.ownerDid) { + return null; + } + + return list; + }, +}); + +/** + * Get all items for a list (public view — no auth required). + * Only returns non-sensitive fields. + */ +export const getPublicListItems = query({ + args: { + listId: v.id("lists"), + }, + handler: async (ctx, args) => { + const items = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .collect(); + + // Sort by order, then createdAt + items.sort((a, b) => { + if (a.order !== undefined && b.order !== undefined) return a.order - b.order; + if (a.order !== undefined) return -1; + if (b.order !== undefined) return 1; + return a.createdAt - b.createdAt; + }); + + // Only return top-level items (no sub-items) for the resource view + return items + .filter((item) => !item.parentId) + .map((item) => ({ + _id: item._id, + name: item.name, + checked: item.checked, + createdAt: item.createdAt, + checkedAt: item.checkedAt, + description: item.description, + priority: item.priority, + dueDate: item.dueDate, + order: item.order, + })); + }, +}); + +/** + * Get a list by ID without owner check (used as fallback for legacy users + * who don't have didLogs rows yet). + */ +export const getListById = query({ + args: { listId: v.string() }, + handler: async (ctx, args) => { + try { + return await ctx.db.get(args.listId as Id<"lists">); + } catch { + return null; + } + }, +}); + +/** + * Get active publication for a list. + */ +export const getActivePublicationByListId = query({ + args: { listId: v.id("lists") }, + handler: async (ctx, args) => { + const pub = await ctx.db + .query("publications") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .first(); + + if (!pub || pub.status !== "active") return null; + return pub; + }, +}); + +/** + * Mark a shared-list item as checked (public link access). + */ +export const checkSharedItem = mutation({ + args: { + listId: v.id("lists"), + itemId: v.id("items"), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item || item.listId !== args.listId) { + throw new Error("Item not found"); + } + + await ctx.db.patch(args.itemId, { + checked: true, + checkedAt: Date.now(), + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +/** + * Mark a shared-list item as unchecked (public link access). + */ +export const uncheckSharedItem = mutation({ + args: { + listId: v.id("lists"), + itemId: v.id("items"), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item || item.listId !== args.listId) { + throw new Error("Item not found"); + } + + await ctx.db.patch(args.itemId, { + checked: false, + checkedAt: undefined, + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); diff --git a/convex/didResourcesHttp.ts b/convex/didResourcesHttp.ts new file mode 100644 index 0000000..2a7ac0d --- /dev/null +++ b/convex/didResourcesHttp.ts @@ -0,0 +1,283 @@ +/** + * HTTP actions for serving DID logs and list resources at canonical paths. + * + * Resolution paths: + * GET /{userPath}/did.jsonl → user's DID log (JSONL) + * GET /{userPath}/resources/list-{id} → list resource (JSON) + */ + +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; + +function corsHeaders(request: Request): Record { + const origin = request.headers.get("Origin") || "*"; + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Credentials": "true", + }; +} + +/** + * Catch-all handler for /{userPath}/did.jsonl and /{userPath}/resources/list-{id}. + * Convex pathPrefix routes match everything under a prefix, so we parse the URL ourselves. + */ +export const didResourceHandler = httpAction(async (ctx, request) => { + const headers = corsHeaders(request); + + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers }); + } + + const url = new URL(request.url); + let pathname = url.pathname; + + // Strip /d/ prefix (Convex pathPrefix routing requires trailing /) + if (pathname.startsWith("/d/")) { + pathname = "/" + pathname.slice(3); + } + + // Strip leading slash and split + const parts = pathname.replace(/^\//, "").split("/"); + + if (parts.length < 2) { + return new Response("Not found", { status: 404, headers }); + } + + const userPath = parts[0]; // e.g. "user-abc123" + + // /{userPath}/did.jsonl + if (parts.length === 2 && parts[1] === "did.jsonl") { + return await serveDidLog(ctx, userPath, headers); + } + + // /{userPath}/resources/list-{listId} + if (parts.length === 3 && parts[1] === "resources" && parts[2].startsWith("list-")) { + const listId = parts[2].slice("list-".length); + return await serveListResource(ctx, userPath, listId, headers); + } + + // POST /{userPath}/resources/list-{listId}/items/{itemId}/check + if ( + request.method === "POST" && + parts.length === 6 && + parts[1] === "resources" && + parts[2].startsWith("list-") && + parts[3] === "items" && + parts[5] === "check" + ) { + const listId = parts[2].slice("list-".length); + const itemId = parts[4]; + return await toggleItem(ctx, userPath, listId, itemId, true, headers); + } + + // POST /{userPath}/resources/list-{listId}/items/{itemId}/uncheck + if ( + request.method === "POST" && + parts.length === 6 && + parts[1] === "resources" && + parts[2].startsWith("list-") && + parts[3] === "items" && + parts[5] === "uncheck" + ) { + const listId = parts[2].slice("list-".length); + const itemId = parts[4]; + return await toggleItem(ctx, userPath, listId, itemId, false, headers); + } + + return new Response("Not found", { status: 404, headers }); +}); + +async function serveDidLog( + ctx: { runQuery: Function }, + userPath: string, + headers: Record +): Promise { + try { + const log = await ctx.runQuery(api.didLogs.getDidLogByPath, { path: userPath }); + + if (!log) { + return new Response("DID not found", { + status: 404, + headers: { "Content-Type": "text/plain", ...headers }, + }); + } + + return new Response(log, { + status: 200, + headers: { + "Content-Type": "application/jsonl+json", + "Cache-Control": "public, max-age=60", + ...headers, + }, + }); + } catch (error) { + console.error("[didResources] Error serving DID log:", error); + return new Response("Internal server error", { status: 500, headers }); + } +} + +async function serveListResource( + ctx: { runQuery: Function }, + userPath: string, + listId: string, + headers: Record +): Promise { + try { + // Primary path: resolve owner DID via didLogs + const fullRecord = await ctx.runQuery(api.didLogs.getDidLogRecordByPath, { path: userPath }); + + let userDid: string | null = fullRecord?.userDid ?? null; + let list = null; + + if (userDid) { + list = await ctx.runQuery(api.didResources.getPublicList, { + listId, + ownerDid: userDid, + }); + } + + // Fallback path for legacy users without didLogs rows yet: + // verify the list is actively published and the publication DID matches this URL path. + if (!list) { + const candidate = await ctx.runQuery(api.didResources.getListById, { listId }); + if (!candidate) { + return new Response("List not found", { + status: 404, + headers: { "Content-Type": "text/plain", ...headers }, + }); + } + + const publication = await ctx.runQuery(api.didResources.getActivePublicationByListId, { + listId: candidate._id, + }); + if (!publication) { + return new Response("List not found", { + status: 404, + headers: { "Content-Type": "text/plain", ...headers }, + }); + } + + // Expected: did:webvh:{scid}:{domain}:{userPath}/resources/list-{listId} + const expectedSuffix = `:${userPath}/resources/list-${listId}`; + if (!publication.webvhDid.endsWith(expectedSuffix)) { + return new Response("List not found", { + status: 404, + headers: { "Content-Type": "text/plain", ...headers }, + }); + } + + // Derive controller DID from resource DID by stripping /resources/... suffix + userDid = publication.webvhDid.replace(/\/resources\/list-.+$/, ""); + list = candidate; + } + + // Get list items + const items = await ctx.runQuery(api.didResources.getPublicListItems, { + listId: list._id, + }); + + const checkedCount = items.filter((i: { checked: boolean }) => i.checked).length; + + // Build the resource response + const resource = { + "@context": ["https://www.w3.org/ns/did/v1"], + id: `${userDid}/resources/list-${listId}`, + type: "PooList", + controller: userDid, + name: list.name, + items: items.map((item: { + _id: string; + name: string; + checked: boolean; + createdAt: number; + checkedAt?: number; + description?: string; + priority?: string; + dueDate?: number; + order?: number; + }) => ({ + _id: item._id, + name: item.name, + checked: item.checked, + createdAt: item.createdAt, + ...(item.checkedAt && { checkedAt: item.checkedAt }), + ...(item.description && { description: item.description }), + ...(item.priority && { priority: item.priority }), + ...(item.dueDate && { dueDate: item.dueDate }), + })), + createdAt: list.createdAt, + itemCount: items.length, + checkedCount, + }; + + return new Response(JSON.stringify(resource, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=30", + ...headers, + }, + }); + } catch (error) { + console.error("[didResources] Error serving list resource:", error); + return new Response("Internal server error", { status: 500, headers }); + } +} + +async function toggleItem( + ctx: { runQuery: Function; runMutation: Function }, + userPath: string, + listId: string, + itemId: string, + checked: boolean, + headers: Record +): Promise { + try { + // Resolve list the same way as serveListResource (didLogs primary, publication fallback) + const fullRecord = await ctx.runQuery(api.didLogs.getDidLogRecordByPath, { path: userPath }); + let userDid: string | null = fullRecord?.userDid ?? null; + let list = null; + + if (userDid) { + list = await ctx.runQuery(api.didResources.getPublicList, { listId, ownerDid: userDid }); + } + + if (!list) { + const candidate = await ctx.runQuery(api.didResources.getListById, { listId }); + if (!candidate) return new Response("List not found", { status: 404, headers }); + + const publication = await ctx.runQuery(api.didResources.getActivePublicationByListId, { + listId: candidate._id, + }); + if (!publication) return new Response("List not found", { status: 404, headers }); + + const expectedSuffix = `:${userPath}/resources/list-${listId}`; + if (!publication.webvhDid.endsWith(expectedSuffix)) { + return new Response("List not found", { status: 404, headers }); + } + list = candidate; + } + + if (checked) { + await ctx.runMutation(api.didResources.checkSharedItem, { + listId: list._id, + itemId, + }); + } else { + await ctx.runMutation(api.didResources.uncheckSharedItem, { + listId: list._id, + itemId, + }); + } + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + } catch (error) { + console.error("[didResources] Error toggling item:", error); + return new Response("Internal server error", { status: 500, headers }); + } +} diff --git a/convex/http.ts b/convex/http.ts index 1c7f164..3ea6d55 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -21,12 +21,33 @@ import { removeItem, reorderItems, } from "./itemsHttp"; -import { - addCollaborator, - updateCollaboratorRole, - removeCollaborator, -} from "./collaboratorsHttp"; import { updateUserDID } from "./userHttp"; +import { storeDidLog, getDidLog } from "./didLogsHttp"; +import { didResourceHandler } from "./didResourcesHttp"; +import { assignItem as assignItemHttp, unassignItem as unassignItemHttp, getItemAssignees as getItemAssigneesHttp } from "./assigneesHttp"; +import { heartbeat as presenceHeartbeatHttp, listPresence as listPresenceHttp } from "./presenceHttp"; +import { getListActivity as getListActivityHttp } from "./activityHttp"; +// memoriesHttp handlers now consolidated into missionControlApi.memoryHandler +import { + getUserLists as agentGetUserLists, + agentListHandler, + agentItemHandler, + teamHandler, + corsHandler as agentCorsHandler, +} from "./agentApi"; +import { + v1AuthCors, + apiKeysHandler, + apiKeyByIdHandler, + agentsHandler, + tasksHandler, + activityHandler, + memoryHandler, + runsHandler, + runsDashboardHandler, + schedulesHandler, + runRetentionHandler, +} from "./missionControlApi"; // Rate limit configuration const RATE_LIMITS = { @@ -235,24 +256,15 @@ const verify = httpAction(async (ctx, request) => { subOrgId: result.subOrgId, }); - // No server-side signing during auth: derive a did:key from the user's - // Turnkey Ed25519 account and fall back to did:temp if lookup fails. - let userDid = `did:temp:${result.subOrgId}`; - try { - const didResult = await ctx.runAction(internal.didCreation.createDIDKey, { - subOrgId: result.subOrgId, - }); - userDid = didResult.did; - console.log(`[authHttp] Created did:key: ${userDid}`); - } catch (err) { - console.error("[authHttp] did:key creation failed, using temp DID:", err); - } + // DID creation happens client-side after auth completes. + // Server stores user without a DID; client creates did:webvh + // using BrowserWebVHSigner and calls /api/user/updateDID. - // Create/update user + // Create/update user (no DID yet — client will create did:webvh and call /api/user/updateDID) await ctx.runMutation(api.auth.upsertUser, { turnkeySubOrgId: result.subOrgId, email: result.email, - did: userDid, + did: undefined, displayName: result.email.split("@")[0], }); @@ -273,7 +285,7 @@ const verify = httpAction(async (ctx, request) => { user: { turnkeySubOrgId: result.subOrgId, email: result.email, - did: userDid, + did: null, displayName: result.email.split("@")[0], }, }, @@ -377,16 +389,128 @@ http.route({ path: "/api/items/remove", method: "OPTIONS", handler: corsHandler http.route({ path: "/api/items/reorder", method: "POST", handler: reorderItems }); http.route({ path: "/api/items/reorder", method: "OPTIONS", handler: corsHandler }); -// --- Collaborator endpoints --- -http.route({ path: "/api/collaborators/add", method: "POST", handler: addCollaborator }); -http.route({ path: "/api/collaborators/add", method: "OPTIONS", handler: corsHandler }); -http.route({ path: "/api/collaborators/updateRole", method: "POST", handler: updateCollaboratorRole }); -http.route({ path: "/api/collaborators/updateRole", method: "OPTIONS", handler: corsHandler }); -http.route({ path: "/api/collaborators/remove", method: "POST", handler: removeCollaborator }); -http.route({ path: "/api/collaborators/remove", method: "OPTIONS", handler: corsHandler }); - // --- User endpoints --- http.route({ path: "/api/user/updateDID", method: "POST", handler: updateUserDID }); http.route({ path: "/api/user/updateDID", method: "OPTIONS", handler: corsHandler }); +// --- DID log endpoints --- +http.route({ path: "/api/did/log", method: "POST", handler: storeDidLog }); +http.route({ path: "/api/did/log", method: "GET", handler: getDidLog }); +http.route({ path: "/api/did/log", method: "OPTIONS", handler: corsHandler }); + +// --- Assignee endpoints --- +http.route({ path: "/api/assignees/assign", method: "POST", handler: assignItemHttp }); +http.route({ path: "/api/assignees/assign", method: "OPTIONS", handler: corsHandler }); +http.route({ path: "/api/assignees/unassign", method: "POST", handler: unassignItemHttp }); +http.route({ path: "/api/assignees/unassign", method: "OPTIONS", handler: corsHandler }); +http.route({ path: "/api/assignees/list", method: "POST", handler: getItemAssigneesHttp }); +http.route({ path: "/api/assignees/list", method: "OPTIONS", handler: corsHandler }); + +// --- Presence endpoints --- +http.route({ path: "/api/presence/heartbeat", method: "POST", handler: presenceHeartbeatHttp }); +http.route({ path: "/api/presence/heartbeat", method: "OPTIONS", handler: corsHandler }); +http.route({ path: "/api/presence/list", method: "POST", handler: listPresenceHttp }); +http.route({ path: "/api/presence/list", method: "OPTIONS", handler: corsHandler }); + +// --- Activity endpoints --- +http.route({ path: "/api/activity/list", method: "POST", handler: getListActivityHttp }); +http.route({ path: "/api/activity/list", method: "OPTIONS", handler: corsHandler }); + +// ============================================================================ +// Agent API endpoints (RESTful API for programmatic access) +// All endpoints require JWT authentication via Authorization header. +// ============================================================================ + +// --- Agent List endpoints --- +// GET /api/agent/lists - Get all lists for the authenticated user +http.route({ path: "/api/agent/lists", method: "GET", handler: agentGetUserLists }); +http.route({ path: "/api/agent/lists", method: "OPTIONS", handler: agentCorsHandler }); + +// Note: Convex httpRouter doesn't support path parameters like :id +// So we use prefix matching and parse the ID in the handler + +// GET /api/agent/lists/:id - Get a specific list with items +// GET /api/agent/lists/:id/items - Get items for a list +// POST /api/agent/lists/:id/items - Add item to a list +http.route({ pathPrefix: "/api/agent/lists/", method: "GET", handler: agentListHandler }); +http.route({ pathPrefix: "/api/agent/lists/", method: "POST", handler: agentListHandler }); +http.route({ pathPrefix: "/api/agent/lists/", method: "OPTIONS", handler: agentCorsHandler }); + +// PATCH /api/agent/items/:id - Update an item (check/uncheck/edit) +// DELETE /api/agent/items/:id - Delete an item +http.route({ pathPrefix: "/api/agent/items/", method: "PATCH", handler: agentItemHandler }); +http.route({ pathPrefix: "/api/agent/items/", method: "DELETE", handler: agentItemHandler }); +http.route({ pathPrefix: "/api/agent/items/", method: "OPTIONS", handler: agentCorsHandler }); + +// GET /api/agent/team - Team dashboard cards + tree + summary +// POST /api/agent/team/status - Agent status heartbeat/update +http.route({ path: "/api/agent/team", method: "GET", handler: teamHandler }); +http.route({ path: "/api/agent/team", method: "OPTIONS", handler: agentCorsHandler }); +http.route({ path: "/api/agent/team/status", method: "POST", handler: teamHandler }); +http.route({ path: "/api/agent/team/status", method: "OPTIONS", handler: agentCorsHandler }); + +// ============================================================================ +// Mission Control REST v1 endpoints +// Supports JWT auth + scoped API key auth (X-API-Key) +// ============================================================================ +http.route({ path: "/api/v1/auth/keys", method: "GET", handler: apiKeysHandler }); +http.route({ path: "/api/v1/auth/keys", method: "POST", handler: apiKeysHandler }); +http.route({ path: "/api/v1/auth/keys", method: "OPTIONS", handler: v1AuthCors }); +http.route({ pathPrefix: "/api/v1/auth/keys/", method: "DELETE", handler: apiKeyByIdHandler }); +http.route({ pathPrefix: "/api/v1/auth/keys/", method: "POST", handler: apiKeyByIdHandler }); +http.route({ pathPrefix: "/api/v1/auth/keys/", method: "OPTIONS", handler: v1AuthCors }); + +http.route({ path: "/api/v1/runs/retention", method: "GET", handler: runRetentionHandler }); +http.route({ path: "/api/v1/runs/retention", method: "PUT", handler: runRetentionHandler }); +http.route({ path: "/api/v1/runs/retention", method: "POST", handler: runRetentionHandler }); +http.route({ path: "/api/v1/runs/retention", method: "OPTIONS", handler: v1AuthCors }); + +http.route({ path: "/api/v1/agents", method: "GET", handler: agentsHandler }); +http.route({ path: "/api/v1/agents", method: "POST", handler: agentsHandler }); +http.route({ path: "/api/v1/agents", method: "OPTIONS", handler: v1AuthCors }); + +http.route({ path: "/api/v1/tasks", method: "GET", handler: tasksHandler }); +http.route({ pathPrefix: "/api/v1/tasks/", method: "GET", handler: tasksHandler }); +http.route({ path: "/api/v1/tasks", method: "OPTIONS", handler: v1AuthCors }); +http.route({ pathPrefix: "/api/v1/tasks/", method: "OPTIONS", handler: v1AuthCors }); + +http.route({ path: "/api/v1/activity", method: "GET", handler: activityHandler }); +http.route({ path: "/api/v1/activity", method: "OPTIONS", handler: v1AuthCors }); + +http.route({ path: "/api/v1/memory", method: "GET", handler: memoryHandler }); +http.route({ path: "/api/v1/memory", method: "POST", handler: memoryHandler }); +http.route({ pathPrefix: "/api/v1/memory/", method: "GET", handler: memoryHandler }); +http.route({ pathPrefix: "/api/v1/memory/", method: "POST", handler: memoryHandler }); +http.route({ pathPrefix: "/api/v1/memory/", method: "PATCH", handler: memoryHandler }); +http.route({ pathPrefix: "/api/v1/memory/", method: "DELETE", handler: memoryHandler }); +http.route({ path: "/api/v1/memory", method: "OPTIONS", handler: v1AuthCors }); +http.route({ pathPrefix: "/api/v1/memory/", method: "OPTIONS", handler: v1AuthCors }); + +http.route({ path: "/api/v1/schedules", method: "GET", handler: schedulesHandler }); +http.route({ pathPrefix: "/api/v1/schedules/", method: "POST", handler: schedulesHandler }); +http.route({ path: "/api/v1/schedules", method: "OPTIONS", handler: v1AuthCors }); +http.route({ pathPrefix: "/api/v1/schedules/", method: "OPTIONS", handler: v1AuthCors }); + +http.route({ path: "/api/v1/runs", method: "GET", handler: runsHandler }); +http.route({ path: "/api/v1/runs", method: "POST", handler: runsHandler }); +http.route({ pathPrefix: "/api/v1/runs/", method: "POST", handler: runsHandler }); +http.route({ pathPrefix: "/api/v1/runs/", method: "PATCH", handler: runsHandler }); +http.route({ pathPrefix: "/api/v1/runs/", method: "DELETE", handler: runsHandler }); +http.route({ path: "/api/v1/runs", method: "OPTIONS", handler: v1AuthCors }); +http.route({ pathPrefix: "/api/v1/runs/", method: "OPTIONS", handler: v1AuthCors }); + +http.route({ path: "/api/v1/dashboard/runs", method: "GET", handler: runsDashboardHandler }); +http.route({ path: "/api/v1/dashboard/runs", method: "OPTIONS", handler: v1AuthCors }); + +// ============================================================================ +// DID Resolution & Resource endpoints (public, no auth) +// Serves /{userPath}/did.jsonl and /{userPath}/resources/list-{id} +// These MUST come after all /api/ routes to avoid conflicts. +// ============================================================================ +// DID paths are proxied via /d/ prefix to satisfy Convex's pathPrefix-must-end-with-/ rule. +// The production server (server.ts) rewrites /user-*/did.jsonl → /d/user-*/did.jsonl +http.route({ pathPrefix: "/d/", method: "GET", handler: didResourceHandler }); +http.route({ pathPrefix: "/d/", method: "POST", handler: didResourceHandler }); +http.route({ pathPrefix: "/d/", method: "OPTIONS", handler: didResourceHandler }); + export default http; diff --git a/convex/init.txt b/convex/init.txt new file mode 100644 index 0000000..e69de29 diff --git a/convex/invites.ts b/convex/invites.ts deleted file mode 100644 index 932ca58..0000000 --- a/convex/invites.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; - -// 24 hours in milliseconds -const INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000; - -/** - * Create an invite for a list. - * Generates a unique token valid for 24 hours. - * Now supports role-based invites (Phase 3). - */ -export const createInvite = mutation({ - args: { - listId: v.id("lists"), - token: v.string(), // Generated by frontend (crypto.randomUUID()) - role: v.optional(v.union(v.literal("editor"), v.literal("viewer"))), // Role to grant (defaults to editor) - createdAt: v.number(), - }, - handler: async (ctx, args) => { - // Verify the list exists - const list = await ctx.db.get(args.listId); - if (!list) { - throw new Error("List not found"); - } - - // Note: Removed the single-collaborator check for unlimited collaborators (Phase 3) - - return await ctx.db.insert("invites", { - listId: args.listId, - token: args.token, - role: args.role ?? "editor", // Default to editor for backwards compat - createdAt: args.createdAt, - expiresAt: args.createdAt + INVITE_EXPIRY_MS, - usedAt: undefined, - usedByDid: undefined, - }); - }, -}); - -/** - * Validate an invite token. - * Checks: exists, not expired, not used. - * Updated for unlimited collaborators (Phase 3). - */ -export const validateInvite = query({ - args: { - listId: v.id("lists"), - token: v.string(), - currentTime: v.number(), - userDid: v.optional(v.string()), // To check if already a collaborator - }, - handler: async (ctx, args) => { - // Find the invite - const invite = await ctx.db - .query("invites") - .withIndex("by_token", (q) => q.eq("token", args.token)) - .first(); - - if (!invite) { - return { valid: false, error: "Invite not found" }; - } - - // Check if invite is for the correct list - if (invite.listId !== args.listId) { - return { valid: false, error: "Invite not found" }; - } - - // Check if expired - if (args.currentTime > invite.expiresAt) { - return { valid: false, error: "This invite has expired" }; - } - - // Check if already used - if (invite.usedAt) { - return { valid: false, error: "This invite has already been used" }; - } - - // Check if list exists - const list = await ctx.db.get(args.listId); - if (!list) { - return { valid: false, error: "List not found" }; - } - - // Check if user is already a collaborator (if userDid provided) - if (args.userDid) { - const userDid = args.userDid; // Type narrowing - const existing = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", userDid) - ) - .first(); - - if (existing) { - return { - valid: false, - error: "You are already a collaborator on this list", - }; - } - } - - return { - valid: true, - listName: list.name, - ownerDid: list.ownerDid, - role: invite.role ?? "editor", // Return the role the invite grants - }; - }, -}); - -/** - * Accept an invite. - * Marks the invite as used and adds the user as a collaborator. - * Updated for unlimited collaborators with roles (Phase 3). - */ -export const acceptInvite = mutation({ - args: { - listId: v.id("lists"), - token: v.string(), - userDid: v.string(), - currentTime: v.number(), - }, - handler: async (ctx, args) => { - // Find the invite - const invite = await ctx.db - .query("invites") - .withIndex("by_token", (q) => q.eq("token", args.token)) - .first(); - - if (!invite) { - throw new Error("Invite not found"); - } - - // Verify invite is for the correct list - if (invite.listId !== args.listId) { - throw new Error("Invite not found"); - } - - // Check if expired - if (args.currentTime > invite.expiresAt) { - throw new Error("This invite has expired"); - } - - // Check if already used - if (invite.usedAt) { - throw new Error("This invite has already been used"); - } - - // Check if list exists - const list = await ctx.db.get(args.listId); - if (!list) { - throw new Error("List not found"); - } - - // Check if user is the owner (can't join your own list) - if (list.ownerDid === args.userDid) { - throw new Error("You cannot join your own list"); - } - - // Check if user is already a collaborator (Phase 3) - const existing = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", args.userDid) - ) - .first(); - - const newRole = invite.role ?? "editor"; - - if (existing) { - // If already a collaborator, check if this invite offers a role upgrade - const isUpgrade = newRole === "editor" && existing.role === "viewer"; - if (isUpgrade) { - // Upgrade their role - await ctx.db.patch(existing._id, { role: "editor" }); - } - // Either way, mark the invite as used (don't throw error) - } else { - // Add new collaborator - await ctx.db.insert("collaborators", { - listId: args.listId, - userDid: args.userDid, - role: newRole, - joinedAt: args.currentTime, - invitedByDid: list.ownerDid, - }); - } - - // Mark invite as used - await ctx.db.patch(invite._id, { - usedAt: args.currentTime, - usedByDid: args.userDid, - }); - }, -}); - -/** - * Get an invite by token (for display purposes). - */ -export const getInviteByToken = query({ - args: { token: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("invites") - .withIndex("by_token", (q) => q.eq("token", args.token)) - .first(); - }, -}); diff --git a/convex/items.ts b/convex/items.ts index 28f2528..2fa30d5 100644 --- a/convex/items.ts +++ b/convex/items.ts @@ -1,52 +1,116 @@ import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; import { mutation, query } from "./_generated/server"; +import { withMutationObservability } from "./lib/observability"; +import { canUserEditList } from "./lib/permissions"; /** - * Helper to check if a user can edit a list (owner or editor). - * Checks collaborators table first, then falls back to legacy fields. + * Creates a Verifiable Credential for item authorship (creation). + * + * This follows the W3C VC Data Model structure with a placeholder proof. + * The proof can be replaced with a cryptographic signature when server-side + * signing is implemented. + * + * @see https://www.w3.org/TR/vc-data-model/ */ -async function canUserEditList( - ctx: MutationCtx | QueryCtx, +function createItemAuthorshipVC( + itemId: Id<"items">, listId: Id<"lists">, - userDid: string, - legacyDid?: string -): Promise { - const didsToCheck = [userDid]; - if (legacyDid) { - didsToCheck.push(legacyDid); - } - - // Check collaborators table (Phase 3) - for (const did of didsToCheck) { - const collab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", listId).eq("userDid", did) - ) - .first(); - - if (collab && (collab.role === "owner" || collab.role === "editor")) { - return true; - } - } + creatorDid: string, + itemName: string, + createdAt: number +): { + type: string; + issuer: string; + issuanceDate: number; + action: string; + actorDid: string; + proof?: string; +} { + // Build the full W3C VC for signing/verification + const fullVc = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://originals.tech/credentials/v1" + ], + type: ["VerifiableCredential", "ItemAuthorshipCredential"], + id: `urn:uuid:${crypto.randomUUID()}`, + issuer: creatorDid, + issuanceDate: new Date(createdAt).toISOString(), + credentialSubject: { + id: creatorDid, + itemId: itemId.toString(), + listId: listId.toString(), + itemName, + action: "created", + }, + }; - // Fallback: Check legacy fields for unmigrated lists - const list = await ctx.db.get(listId); - if (!list) { - return false; - } + // Return the structured VC object for storage + return { + type: "ItemAuthorshipCredential", + issuer: creatorDid, + issuanceDate: createdAt, + action: "created", + actorDid: creatorDid, + proof: JSON.stringify(fullVc), + }; +} - for (const did of didsToCheck) { - if (list.ownerDid === did) { - return true; - } - } +/** + * Creates a Verifiable Credential for item completion. + * + * This follows the W3C VC Data Model structure with a placeholder proof. + * The proof can be replaced with a cryptographic signature when server-side + * signing is implemented. + * + * @see https://www.w3.org/TR/vc-data-model/ + */ +function createItemCompletionVC( + itemId: Id<"items">, + listId: Id<"lists">, + completerDid: string, + itemName: string, + checkedAt: number +): { + type: string; + issuer: string; + issuanceDate: number; + action: string; + actorDid: string; + proof?: string; +} { + // Build the full W3C VC for signing/verification + const fullVc = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://originals.tech/credentials/v1" + ], + type: ["VerifiableCredential", "ItemCompletionCredential"], + id: `urn:uuid:${crypto.randomUUID()}`, + issuer: completerDid, + issuanceDate: new Date(checkedAt).toISOString(), + credentialSubject: { + id: completerDid, + itemId: itemId.toString(), + listId: listId.toString(), + itemName, + action: "completed", + }, + }; - return false; + // Return the structured VC object for storage + return { + type: "ItemCompletionCredential", + issuer: completerDid, + issuanceDate: checkedAt, + action: "completed", + actorDid: completerDid, + proof: JSON.stringify(fullVc), + }; } + /** * Add an item to a list. * Supports legacy DID for migrated users. @@ -58,8 +122,21 @@ export const addItem = mutation({ createdByDid: v.string(), legacyDid: v.optional(v.string()), createdAt: v.number(), + // Optional enhanced fields + description: v.optional(v.string()), + dueDate: v.optional(v.number()), + url: v.optional(v.string()), + recurrence: v.optional(v.object({ + frequency: v.union(v.literal("daily"), v.literal("weekly"), v.literal("monthly")), + interval: v.optional(v.number()), + nextDue: v.optional(v.number()), + endDate: v.optional(v.number()), + })), + priority: v.optional(v.union(v.literal("high"), v.literal("medium"), v.literal("low"))), + assigneeDid: v.optional(v.string()), + parentId: v.optional(v.id("items")), // For sub-items }, - handler: async (ctx, args) => { + handler: async (ctx, args) => withMutationObservability("items.addItem", async () => { // Verify the list exists const list = await ctx.db.get(args.listId); if (!list) { @@ -77,18 +154,27 @@ export const addItem = mutation({ throw new Error("Not authorized to add items to this list"); } - // Get max order to add new item at the end + // If it's a sub-item, verify parent exists and belongs to same list + if (args.parentId) { + const parent = await ctx.db.get(args.parentId); + if (!parent || parent.listId !== args.listId) { + throw new Error("Parent item not found or belongs to different list"); + } + } + + // Get min order to add new item at the top (for items with same parent) const existingItems = await ctx.db .query("items") .withIndex("by_list", (q) => q.eq("listId", args.listId)) .collect(); - const maxOrder = existingItems.reduce( - (max, item) => Math.max(max, item.order ?? 0), + const sameParentItems = existingItems.filter(i => i.parentId === args.parentId); + const minOrder = sameParentItems.reduce( + (min, item) => Math.min(min, item.order ?? 0), 0 ); const now = Date.now(); - return await ctx.db.insert("items", { + const itemId = await ctx.db.insert("items", { listId: args.listId, name: args.name, checked: false, @@ -96,15 +182,131 @@ export const addItem = mutation({ checkedByDid: undefined, createdAt: args.createdAt, checkedAt: undefined, - order: maxOrder + 1, + order: minOrder - 1, updatedAt: now, + // Enhanced fields + description: args.description, + dueDate: args.dueDate, + url: args.url, + recurrence: args.recurrence, + priority: args.priority, + assigneeDid: args.assigneeDid, + parentId: args.parentId, }); + + // Issue Verifiable Credential proving item authorship + const authorshipVC = createItemAuthorshipVC( + itemId, + args.listId, + args.createdByDid, + args.name, + args.createdAt + ); + + // Store the VC proof on the item + await ctx.db.patch(itemId, { vcProofs: [authorshipVC] }); + + return itemId; + }), +}); + +/** + * Update an item's details (name, description, due date, url, recurrence, priority). + * Supports legacy DID for migrated users. + */ +export const updateItem = mutation({ + args: { + itemId: v.id("items"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + // Fields that can be updated + name: v.optional(v.string()), + description: v.optional(v.string()), + dueDate: v.optional(v.number()), + url: v.optional(v.string()), + recurrence: v.optional(v.object({ + frequency: v.union(v.literal("daily"), v.literal("weekly"), v.literal("monthly")), + interval: v.optional(v.number()), + nextDue: v.optional(v.number()), + endDate: v.optional(v.number()), + })), + priority: v.optional(v.union(v.literal("high"), v.literal("medium"), v.literal("low"))), + groceryAisle: v.optional(v.string()), + assigneeDid: v.optional(v.string()), + clearGroceryAisle: v.optional(v.boolean()), + clearDueDate: v.optional(v.boolean()), + clearRecurrence: v.optional(v.boolean()), + clearUrl: v.optional(v.boolean()), + clearPriority: v.optional(v.boolean()), + clearAssigneeDid: v.optional(v.boolean()), }, + handler: async (ctx, args) => withMutationObservability("items.updateItem", async () => { + const item = await ctx.db.get(args.itemId); + if (!item) { + throw new Error("Item not found"); + } + + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to update this item"); + } + + const updates: Record = { + updatedAt: Date.now(), + }; + + if (args.name !== undefined) updates.name = args.name; + if (args.description !== undefined) updates.description = args.description; + if (args.dueDate !== undefined) updates.dueDate = args.dueDate; + if (args.url !== undefined) updates.url = args.url; + if (args.recurrence !== undefined) updates.recurrence = args.recurrence; + if (args.priority !== undefined) updates.priority = args.priority; + if (args.groceryAisle !== undefined) updates.groceryAisle = args.groceryAisle; + if (args.assigneeDid !== undefined) updates.assigneeDid = args.assigneeDid; + + // Clear fields if requested + if (args.clearDueDate) updates.dueDate = undefined; + if (args.clearRecurrence) updates.recurrence = undefined; + if (args.clearUrl) updates.url = undefined; + if (args.clearPriority) updates.priority = undefined; + if (args.clearAssigneeDid) updates.assigneeDid = undefined; + if (args.clearGroceryAisle) updates.groceryAisle = undefined; + + await ctx.db.patch(args.itemId, updates); + return args.itemId; + }), }); +/** + * Calculate the next due date based on recurrence settings. + */ +function calculateNextDueDate( + currentDueDate: number | undefined, + frequency: "daily" | "weekly" | "monthly", + interval: number = 1 +): number { + // Start from current due date or now if not set + const baseDate = new Date(currentDueDate ?? Date.now()); + + switch (frequency) { + case "daily": + baseDate.setDate(baseDate.getDate() + interval); + break; + case "weekly": + baseDate.setDate(baseDate.getDate() + (7 * interval)); + break; + case "monthly": + baseDate.setMonth(baseDate.getMonth() + interval); + break; + } + + return baseDate.getTime(); +} + /** * Check (mark as complete) an item. * Supports legacy DID for migrated users. + * If the item has recurrence settings, creates a new unchecked copy with the next due date. */ export const checkItem = mutation({ args: { @@ -113,7 +315,7 @@ export const checkItem = mutation({ legacyDid: v.optional(v.string()), checkedAt: v.number(), }, - handler: async (ctx, args) => { + handler: async (ctx, args) => withMutationObservability("items.checkItem", async () => { const item = await ctx.db.get(args.itemId); if (!item) { throw new Error("Item not found"); @@ -130,13 +332,75 @@ export const checkItem = mutation({ throw new Error("Not authorized to check items in this list"); } + const now = Date.now(); + + // Issue Verifiable Credential proving item completion + const completionVC = createItemCompletionVC( + args.itemId, + item.listId, + args.checkedByDid, + item.name, + args.checkedAt + ); + + // Append completion VC to existing proofs (filter out any legacy string-format proofs) + const existingProofs = (item.vcProofs ?? []).filter( + (p): p is NonNullable[number] => typeof p === "object" && p !== null + ); + const updatedProofs = [...existingProofs, completionVC]; + + // Mark the current item as checked and add completion VC await ctx.db.patch(args.itemId, { checked: true, checkedByDid: args.checkedByDid, checkedAt: args.checkedAt, - updatedAt: Date.now(), + updatedAt: now, + vcProofs: updatedProofs, }); - }, + + // If item has recurrence, create a new unchecked copy with next due date + if (item.recurrence) { + const nextDueDate = calculateNextDueDate( + item.dueDate, + item.recurrence.frequency, + item.recurrence.interval ?? 1 + ); + + // Check if end date has passed - if so, don't create next occurrence + const endDate = item.recurrence.endDate; + if (!endDate || nextDueDate <= endDate) { + // Get min order to add new item at the top + const existingItems = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", item.listId)) + .collect(); + const sameParentItems = existingItems.filter(i => i.parentId === item.parentId); + const minOrder = sameParentItems.reduce( + (min, i) => Math.min(min, i.order ?? 0), + 0 + ); + + // Create the new recurring item + await ctx.db.insert("items", { + listId: item.listId, + name: item.name, + checked: false, + createdByDid: args.checkedByDid, + createdAt: now, + order: minOrder - 1, + updatedAt: now, + description: item.description, + dueDate: nextDueDate, + url: item.url, + recurrence: item.recurrence, + priority: item.priority, + assigneeDid: item.assigneeDid, + tags: item.tags, + parentId: item.parentId, + }); + } + } + }), }); /** @@ -269,6 +533,37 @@ export const reorderItems = mutation({ }, }); +/** + * Set the grocery aisle override for an item. + * Allows users to manually classify items into a different aisle. + * Pass null/undefined aisleId to clear the override. + */ +export const setAisleOverride = mutation({ + args: { + itemId: v.id("items"), + aisleId: v.optional(v.string()), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error("Item not found"); + + const canEdit = await canUserEditList( + ctx, + item.listId, + args.userDid, + args.legacyDid + ); + if (!canEdit) throw new Error("Not authorized to edit this item"); + + await ctx.db.patch(args.itemId, { + groceryAisle: args.aisleId ?? undefined, + updatedAt: Date.now(), + }); + }, +}); + /** * Get an item by ID for sync conflict checking. * Returns null if item doesn't exist (was deleted). @@ -279,3 +574,366 @@ export const getItemForSync = query({ return await ctx.db.get(args.itemId); }, }); + +/** + * Get sub-items for a parent item. + */ +export const getSubItems = query({ + args: { parentId: v.id("items") }, + handler: async (ctx, args) => { + return await ctx.db + .query("items") + .withIndex("by_parent", (q) => q.eq("parentId", args.parentId)) + .collect(); + }, +}); + +/** + * Batch check multiple items at once. + * Handles recurring items by creating new copies with next due dates. + */ +export const batchCheckItems = mutation({ + args: { + itemIds: v.array(v.id("items")), + checkedByDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const checkedAt = Date.now(); + let listId: Id<"lists"> | null = null; + + for (const itemId of args.itemIds) { + const item = await ctx.db.get(itemId); + if (!item) continue; + + // Verify authorization once per list + if (listId !== item.listId) { + listId = item.listId; + const canEdit = await canUserEditList(ctx, item.listId, args.checkedByDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to check items in this list"); + } + } + + await ctx.db.patch(itemId, { + checked: true, + checkedByDid: args.checkedByDid, + checkedAt, + updatedAt: checkedAt, + }); + + // If item has recurrence, create a new unchecked copy with next due date + if (item.recurrence) { + const nextDueDate = calculateNextDueDate( + item.dueDate, + item.recurrence.frequency, + item.recurrence.interval ?? 1 + ); + + // Check if end date has passed + const endDate = item.recurrence.endDate; + if (!endDate || nextDueDate <= endDate) { + // Get min order to add new item at the top + const existingItems = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", item.listId)) + .collect(); + const sameParentItems = existingItems.filter(i => i.parentId === item.parentId); + const minOrder = sameParentItems.reduce( + (min, i) => Math.min(min, i.order ?? 0), + 0 + ); + + // Create the new recurring item + await ctx.db.insert("items", { + listId: item.listId, + name: item.name, + checked: false, + createdByDid: args.checkedByDid, + createdAt: checkedAt, + order: minOrder - 1, + updatedAt: checkedAt, + description: item.description, + dueDate: nextDueDate, + url: item.url, + recurrence: item.recurrence, + priority: item.priority, + assigneeDid: item.assigneeDid, + tags: item.tags, + parentId: item.parentId, + }); + } + } + } + }, +}); + +/** + * Batch uncheck multiple items at once. + */ +export const batchUncheckItems = mutation({ + args: { + itemIds: v.array(v.id("items")), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + let listId: Id<"lists"> | null = null; + + for (const itemId of args.itemIds) { + const item = await ctx.db.get(itemId); + if (!item) continue; + + if (listId !== item.listId) { + listId = item.listId; + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to uncheck items in this list"); + } + } + + await ctx.db.patch(itemId, { + checked: false, + checkedByDid: undefined, + checkedAt: undefined, + updatedAt: now, + }); + } + }, +}); + +/** + * Batch delete multiple items at once. + */ +export const batchDeleteItems = mutation({ + args: { + itemIds: v.array(v.id("items")), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + let listId: Id<"lists"> | null = null; + + for (const itemId of args.itemIds) { + const item = await ctx.db.get(itemId); + if (!item) continue; + + if (listId !== item.listId) { + listId = item.listId; + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to delete items in this list"); + } + } + + // Also delete any sub-items + const subItems = await ctx.db + .query("items") + .withIndex("by_parent", (q) => q.eq("parentId", itemId)) + .collect(); + + for (const subItem of subItems) { + await ctx.db.delete(subItem._id); + } + + await ctx.db.delete(itemId); + } + }, +}); + +/** + * Get items with due dates for calendar view. + */ +export const getItemsWithDueDates = query({ + args: { + listId: v.id("lists"), + startDate: v.optional(v.number()), + endDate: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const items = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .collect(); + + // Filter items with due dates + let filtered = items.filter((item) => item.dueDate !== undefined); + + // Apply date range filter if provided + if (args.startDate !== undefined) { + filtered = filtered.filter((item) => item.dueDate! >= args.startDate!); + } + if (args.endDate !== undefined) { + filtered = filtered.filter((item) => item.dueDate! <= args.endDate!); + } + + return filtered.sort((a, b) => (a.dueDate ?? 0) - (b.dueDate ?? 0)); + }, +}); + +/** + * Get all high-priority items across all lists the user has access to. + * Used for Priority Focus mode. + */ +export const getHighPriorityItems = query({ + args: { + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + // DIDs to check: current DID and optionally legacy DID + const didsToCheck = [args.userDid]; + if (args.legacyDid) { + didsToCheck.push(args.legacyDid); + } + + // Get all list IDs the user has access to (owned + bookmarked) + const listIds = new Set>(); + + for (const did of didsToCheck) { + const ownedLists = await ctx.db + .query("lists") + .withIndex("by_owner", (q) => q.eq("ownerDid", did)) + .collect(); + + for (const list of ownedLists) { + listIds.add(list._id); + } + + const bookmarks = await ctx.db + .query("bookmarks") + .withIndex("by_user", (q) => q.eq("userDid", did)) + .collect(); + + for (const bm of bookmarks) { + listIds.add(bm.listId); + } + } + + // Now fetch high-priority items from all accessible lists + const highPriorityItems: Array<{ + item: Awaited>>; + listName: string; + listId: Id<"lists">; + }> = []; + + for (const listId of listIds) { + const list = await ctx.db.get(listId); + if (!list) continue; + + const items = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", listId)) + .collect(); + + // Filter for high priority, unchecked items without a parent (top-level only) + const highPriority = items.filter( + (item) => item.priority === "high" && !item.checked && !item.parentId + ); + + for (const item of highPriority) { + highPriorityItems.push({ + item, + listName: list.name, + listId: list._id, + }); + } + } + + // Sort by due date (soonest first), then by creation date + return highPriorityItems.sort((a, b) => { + // Items with due dates come first + if (a.item?.dueDate && !b.item?.dueDate) return -1; + if (!a.item?.dueDate && b.item?.dueDate) return 1; + if (a.item?.dueDate && b.item?.dueDate) { + return a.item.dueDate - b.item.dueDate; + } + // Then by creation date (oldest first for backlog items) + return (a.item?.createdAt ?? 0) - (b.item?.createdAt ?? 0); + }); + }, +}); + +/** + * Promote an item to a top-level item (remove parent). + */ +export const promoteItem = mutation({ + args: { + itemId: v.id("items"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) { + throw new Error("Item not found"); + } + + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to edit this item"); + } + + // Remove parent to make it top-level + await ctx.db.patch(args.itemId, { + parentId: undefined, + updatedAt: Date.now(), + }); + }, +}); + +/** + * Demote an item to become a subtask of another item. + * Ensures we don't exceed max nesting depth (2 levels). + */ +export const demoteItem = mutation({ + args: { + itemId: v.id("items"), + newParentId: v.id("items"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) { + throw new Error("Item not found"); + } + + const newParent = await ctx.db.get(args.newParentId); + if (!newParent) { + throw new Error("Parent item not found"); + } + + // Verify both items are in the same list + if (item.listId !== newParent.listId) { + throw new Error("Items must be in the same list"); + } + + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to edit this item"); + } + + // Check nesting depth: new parent can't already have a parent (max 2 levels) + if (newParent.parentId) { + throw new Error("Cannot nest more than 2 levels deep"); + } + + // Prevent circular nesting: can't make an item a child of its own child + const childItems = await ctx.db + .query("items") + .withIndex("by_parent", (q) => q.eq("parentId", args.itemId)) + .collect(); + + if (childItems.some(child => child._id === args.newParentId)) { + throw new Error("Cannot create circular dependency"); + } + + // Set the new parent + await ctx.db.patch(args.itemId, { + parentId: args.newParentId, + updatedAt: Date.now(), + }); + }, +}); diff --git a/convex/lib/artifactRetention.test.ts b/convex/lib/artifactRetention.test.ts new file mode 100644 index 0000000..31f2ac8 --- /dev/null +++ b/convex/lib/artifactRetention.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { artifactFingerprint, clampRetentionDays, computeRetentionCutoff, isValidArtifactRef, normalizeArtifactRefs, selectStaleArtifacts, shouldInsertDeletionLog } from "./artifactRetention"; + +describe("artifact retention helpers", () => { + test("clamps retention day boundaries to [1, 365]", () => { + expect(clampRetentionDays(undefined, 30)).toBe(30); + expect(clampRetentionDays(0, 30)).toBe(1); + expect(clampRetentionDays(999, 30)).toBe(365); + }); + + test("uses strict < cutoff semantics", () => { + const cutoff = computeRetentionCutoff(1_000_000, 1); + const artifacts = [ + { type: "log" as const, ref: "old", createdAt: cutoff - 1 }, + { type: "log" as const, ref: "edge", createdAt: cutoff }, + ]; + expect(selectStaleArtifacts(artifacts, cutoff).map((a) => a.ref)).toEqual(["old"]); + }); + + test("normalizes artifact schema", () => { + expect(isValidArtifactRef({ type: "log", ref: "ok", createdAt: 1 })).toBe(true); + expect(normalizeArtifactRefs([{ type: "log", ref: "ok", createdAt: 1 }, { type: "oops", ref: "no", createdAt: 2 }])).toEqual([ + { type: "log", ref: "ok", createdAt: 1 }, + ]); + }); + + test("fingerprint supports idempotency checks", () => { + const a = [{ type: "log" as const, ref: "1", createdAt: 1 }, { type: "file" as const, ref: "2", createdAt: 2 }]; + const b = [...a].reverse(); + expect(artifactFingerprint(a)).toBe(artifactFingerprint(b)); + expect(shouldInsertDeletionLog(a, b)).toBe(false); + }); +}); diff --git a/convex/lib/artifactRetention.ts b/convex/lib/artifactRetention.ts new file mode 100644 index 0000000..0810551 --- /dev/null +++ b/convex/lib/artifactRetention.ts @@ -0,0 +1,47 @@ +export const ARTIFACT_TYPES = ["screenshot", "log", "diff", "file", "url"] as const; + +export type ArtifactType = (typeof ARTIFACT_TYPES)[number]; +export type ArtifactRef = { + type: ArtifactType; + ref: string; + label?: string; + createdAt: number; +}; + +export function clampRetentionDays(value: number | undefined, fallback: number): number { + return Math.min(Math.max(Math.floor(value ?? fallback), 1), 365); +} + +export function computeRetentionCutoff(now: number, retentionDays: number): number { + return now - retentionDays * 24 * 60 * 60 * 1000; +} + +export function isValidArtifactRef(value: unknown): value is ArtifactRef { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + typeof v.ref === "string" && + v.ref.length > 0 && + typeof v.createdAt === "number" && + Number.isFinite(v.createdAt) && + ARTIFACT_TYPES.includes(v.type as ArtifactType) && + (v.label === undefined || typeof v.label === "string") + ); +} + +export function normalizeArtifactRefs(input: unknown): ArtifactRef[] { + if (!Array.isArray(input)) return []; + return input.filter(isValidArtifactRef); +} + +export function selectStaleArtifacts(artifacts: ArtifactRef[], cutoff: number): ArtifactRef[] { + return artifacts.filter((a) => a.createdAt < cutoff); +} + +export function artifactFingerprint(artifacts: ArtifactRef[]): string { + return artifacts.map((a) => `${a.type}|${a.ref}|${a.label ?? ""}|${a.createdAt}`).sort().join("\n"); +} + +export function shouldInsertDeletionLog(existingArtifacts: ArtifactRef[], candidateArtifacts: ArtifactRef[]): boolean { + return artifactFingerprint(existingArtifacts) !== artifactFingerprint(candidateArtifacts); +} diff --git a/convex/lib/authUser.ts b/convex/lib/authUser.ts new file mode 100644 index 0000000..7f6c817 --- /dev/null +++ b/convex/lib/authUser.ts @@ -0,0 +1,32 @@ +import type { ActionCtx } from "../_generated/server"; +import { api } from "../_generated/api"; +import { AuthError, requireAuth } from "./auth"; + +export type AuthenticatedUser = { + did: string; + legacyDid?: string; + turnkeySubOrgId?: string; +}; + +/** + * Resolve authenticated user from JWT and users table. + */ +export async function requireAuthenticatedUser( + ctx: ActionCtx, + request: Request +): Promise { + const auth = await requireAuth(request); + const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + turnkeySubOrgId: auth.turnkeySubOrgId, + }) as { did: string; legacyDid?: string } | null; + + if (!user) { + throw new AuthError("User not found", "UNAUTHORIZED"); + } + + return { + did: user.did, + legacyDid: user.legacyDid, + turnkeySubOrgId: auth.turnkeySubOrgId, + }; +} diff --git a/convex/lib/memorySync.test.ts b/convex/lib/memorySync.test.ts new file mode 100644 index 0000000..b794475 --- /dev/null +++ b/convex/lib/memorySync.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; + +import { selectMemoryChangesSince, type MemorySyncRow } from "./memorySync"; + +function row(updatedAt: number, id?: string): MemorySyncRow { + return { + _id: id ?? `m-${updatedAt}`, + ownerDid: "did:example:owner", + authorDid: "did:example:author", + title: `t-${updatedAt}`, + content: `c-${updatedAt}`, + updatedAt, + }; +} + +describe("memory sync cursor semantics", () => { + test("returns ascending updates with cursor at newest delivered item", () => { + const rows = [row(400), row(300), row(200), row(100)]; + + const result = selectMemoryChangesSince(rows, 0, 3); + + expect(result.changes.map((c) => c.updatedAt)).toEqual([100, 200, 300]); + expect(result.cursor).toBe(300); + }); + + test("supports lossless paging with since+limit", () => { + const rows = [row(500), row(400), row(300), row(200), row(100)]; + + const page1 = selectMemoryChangesSince(rows, 0, 2); + const page2 = selectMemoryChangesSince(rows, page1.cursor, 2); + const page3 = selectMemoryChangesSince(rows, page2.cursor, 2); + + expect(page1.changes.map((c) => c.updatedAt)).toEqual([100, 200]); + expect(page2.changes.map((c) => c.updatedAt)).toEqual([300, 400]); + expect(page3.changes.map((c) => c.updatedAt)).toEqual([500]); + }); + + test("returns stable cursor when no changes exist", () => { + const rows = [row(300), row(200), row(100)]; + + const result = selectMemoryChangesSince(rows, 300, 50); + + expect(result.changes.length).toBe(0); + expect(result.cursor).toBe(300); + }); +}); diff --git a/convex/lib/memorySync.ts b/convex/lib/memorySync.ts new file mode 100644 index 0000000..1266803 --- /dev/null +++ b/convex/lib/memorySync.ts @@ -0,0 +1,46 @@ +export type MemorySyncRow = { + _id: TId; + ownerDid: string; + authorDid: string; + externalId?: string; + title: string; + content: string; + tags?: string[]; + source?: "manual" | "openclaw" | "clawboot" | "import" | "api"; + sourceRef?: string; + updatedAt: number; + externalUpdatedAt?: number; + syncStatus?: "synced" | "conflict" | "pending"; + conflictNote?: string; +}; + +export function selectMemoryChangesSince( + rows: MemorySyncRow[], + since: number, + limit: number, +) { + const changes = rows + .filter((row) => row.updatedAt > since) + .sort((a, b) => a.updatedAt - b.updatedAt) + .slice(0, limit) + .map((row) => ({ + id: row._id, + ownerDid: row.ownerDid, + authorDid: row.authorDid, + externalId: row.externalId, + title: row.title, + content: row.content, + tags: row.tags, + source: row.source, + sourceRef: row.sourceRef, + updatedAt: row.updatedAt, + externalUpdatedAt: row.externalUpdatedAt, + syncStatus: row.syncStatus, + conflictNote: row.conflictNote, + })); + + return { + changes, + cursor: changes.length ? changes[changes.length - 1].updatedAt : since, + }; +} diff --git a/convex/lib/observability.ts b/convex/lib/observability.ts new file mode 100644 index 0000000..4456386 --- /dev/null +++ b/convex/lib/observability.ts @@ -0,0 +1,50 @@ +type Tags = Record; + +function normalize(tags?: Tags): Record | undefined { + if (!tags) return undefined; + + const clean = Object.entries(tags).reduce>((acc, [k, v]) => { + if (v === undefined || v === null) return acc; + acc[k] = String(v); + return acc; + }, {}); + + return Object.keys(clean).length ? clean : undefined; +} + +export function emitServerMetric( + name: string, + type: "counter" | "gauge" | "histogram", + value: number, + tags?: Tags, +): void { + console.info("[obs]", JSON.stringify({ + source: "convex", + name, + type, + value, + tags: normalize(tags), + ts: Date.now(), + })); +} + +export async function withMutationObservability( + mutationName: string, + fn: () => Promise, +): Promise { + const startedAt = Date.now(); + emitServerMetric("mutation_total", "counter", 1, { mutationName }); + + try { + const result = await fn(); + emitServerMetric("mutation_latency_ms", "histogram", Date.now() - startedAt, { mutationName }); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : "unknown_error"; + emitServerMetric("mutation_error_total", "counter", 1, { + mutationName, + errorCode: message.slice(0, 80), + }); + throw err; + } +} diff --git a/convex/lib/permissions.ts b/convex/lib/permissions.ts new file mode 100644 index 0000000..9207121 --- /dev/null +++ b/convex/lib/permissions.ts @@ -0,0 +1,26 @@ +import type { Id } from "../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../_generated/server"; + +/** + * Check if a user can edit a list. + * Owner can always edit. If the list has an active publication, anyone can edit. + */ +export async function canUserEditList( + ctx: MutationCtx | QueryCtx, + listId: Id<"lists">, + userDid: string, + legacyDid?: string +): Promise { + const list = await ctx.db.get(listId); + if (!list) return false; + + const didsToCheck = [userDid, ...(legacyDid ? [legacyDid] : [])]; + if (didsToCheck.includes(list.ownerDid)) return true; + + const pub = await ctx.db + .query("publications") + .withIndex("by_list", (q) => q.eq("listId", listId)) + .first(); + + return !!pub && pub.status === "active"; +} diff --git a/convex/lib/presenceSessions.test.ts b/convex/lib/presenceSessions.test.ts new file mode 100644 index 0000000..1ac2dd0 --- /dev/null +++ b/convex/lib/presenceSessions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { dedupeActivePresenceSessions, isSessionActive } from "./presenceSessions"; + +describe("presence session helpers", () => { + test("isSessionActive uses strict expiresAt > now semantics", () => { + expect(isSessionActive({ userDid: "did:a", sessionId: "s1", lastSeenAt: 100, expiresAt: 200 }, 199)).toBe(true); + expect(isSessionActive({ userDid: "did:a", sessionId: "s1", lastSeenAt: 100, expiresAt: 200 }, 200)).toBe(false); + }); + + test("dedupes by userDid and keeps most recent active session", () => { + const sessions = [ + { userDid: "did:a", sessionId: "s-old", lastSeenAt: 100, expiresAt: 500 }, + { userDid: "did:a", sessionId: "s-new", lastSeenAt: 300, expiresAt: 600 }, + { userDid: "did:b", sessionId: "s-b", lastSeenAt: 250, expiresAt: 700 }, + ]; + + expect(dedupeActivePresenceSessions(sessions, 200)).toEqual([ + { userDid: "did:a", sessionId: "s-new", lastSeenAt: 300, expiresAt: 600 }, + { userDid: "did:b", sessionId: "s-b", lastSeenAt: 250, expiresAt: 700 }, + ]); + }); + + test("drops expired sessions before dedupe", () => { + const sessions = [ + { userDid: "did:a", sessionId: "s-expired", lastSeenAt: 100, expiresAt: 120 }, + { userDid: "did:a", sessionId: "s-active", lastSeenAt: 130, expiresAt: 300 }, + { userDid: "did:b", sessionId: "s-expired-b", lastSeenAt: 150, expiresAt: 150 }, + ]; + + expect(dedupeActivePresenceSessions(sessions, 150)).toEqual([ + { userDid: "did:a", sessionId: "s-active", lastSeenAt: 130, expiresAt: 300 }, + ]); + }); +}); diff --git a/convex/lib/presenceSessions.ts b/convex/lib/presenceSessions.ts new file mode 100644 index 0000000..13b26f6 --- /dev/null +++ b/convex/lib/presenceSessions.ts @@ -0,0 +1,28 @@ +type PresenceSession = { + _id?: string; + _creationTime?: number; + listId?: string; + userDid: string; + sessionId: string; + lastSeenAt: number; + expiresAt: number; +}; + +export function isSessionActive(session: PresenceSession, now: number) { + return session.expiresAt > now; +} + +export function dedupeActivePresenceSessions(sessions: T[], now: number): T[] { + const latestByUser = new Map(); + + for (const session of sessions) { + if (!isSessionActive(session, now)) continue; + + const existing = latestByUser.get(session.userDid); + if (!existing || session.lastSeenAt > existing.lastSeenAt) { + latestByUser.set(session.userDid, session); + } + } + + return Array.from(latestByUser.values()).sort((a, b) => b.lastSeenAt - a.lastSeenAt); +} diff --git a/convex/lists.ts b/convex/lists.ts index be1ee6c..14a145e 100644 --- a/convex/lists.ts +++ b/convex/lists.ts @@ -1,11 +1,56 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; -import type { Doc } from "./_generated/dataModel"; +import type { Doc, Id } from "./_generated/dataModel"; +import { withMutationObservability } from "./lib/observability"; + +/** + * Creates a placeholder Verifiable Credential for list ownership. + */ +function createListOwnershipVC( + listId: Id<"lists">, + assetDid: string, + ownerDid: string, + listName: string, + createdAt: number +): { + type: string; + issuer: string; + issuanceDate: number; + credentialSubject: { id: string; ownerDid: string }; + proof?: string; +} { + const fullVc = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://originals.tech/credentials/v1" + ], + type: ["VerifiableCredential", "ListOwnershipCredential"], + id: `urn:uuid:${crypto.randomUUID()}`, + issuer: ownerDid, + issuanceDate: new Date(createdAt).toISOString(), + credentialSubject: { + id: ownerDid, + listId: listId.toString(), + assetDid, + listName, + role: "owner", + }, + }; + + return { + type: "ListOwnershipCredential", + issuer: ownerDid, + issuanceDate: createdAt, + credentialSubject: { + id: assetDid, + ownerDid, + }, + proof: JSON.stringify(fullVc), + }; +} /** * Create a new list. - * The list is created as an Originals asset (assetDid) by the frontend. - * Also adds the owner to the collaborators table with "owner" role. */ export const createList = mutation({ args: { @@ -15,8 +60,7 @@ export const createList = mutation({ categoryId: v.optional(v.id("categories")), createdAt: v.number(), }, - handler: async (ctx, args) => { - // Create the list + handler: async (ctx, args) => withMutationObservability("lists.createList", async () => { const listId = await ctx.db.insert("lists", { assetDid: args.assetDid, name: args.name, @@ -25,22 +69,85 @@ export const createList = mutation({ createdAt: args.createdAt, }); - // Add owner to collaborators table (Phase 3) - await ctx.db.insert("collaborators", { + const vcProof = createListOwnershipVC( listId, - userDid: args.ownerDid, - role: "owner", - joinedAt: args.createdAt, - invitedByDid: undefined, - }); + args.assetDid, + args.ownerDid, + args.name, + args.createdAt + ); + + await ctx.db.patch(listId, { vcProof }); return listId; + }), +}); + +/** + * Rename a list. Only the owner can rename. + */ +export const renameList = mutation({ + args: { + listId: v.id("lists"), + name: v.string(), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const list = await ctx.db.get(args.listId); + if (!list) throw new Error("List not found"); + + const dids = [args.userDid]; + if (args.legacyDid) dids.push(args.legacyDid); + + if (!dids.includes(list.ownerDid)) { + throw new Error("Only the list owner can rename this list"); + } + + const vcProof = createListOwnershipVC( + args.listId, + list.assetDid ?? "", + list.ownerDid, + args.name, + list.createdAt + ); + + await ctx.db.patch(args.listId, { name: args.name, vcProof }); + }, +}); + +/** + * Update the category of a list. Only owner can change. + */ +export const updateListCategory = mutation({ + args: { + listId: v.id("lists"), + categoryId: v.optional(v.id("categories")), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const list = await ctx.db.get(args.listId); + if (!list) throw new Error("List not found"); + + const dids = [args.userDid]; + if (args.legacyDid) dids.push(args.legacyDid); + + if (!dids.includes(list.ownerDid)) { + throw new Error("Only the list owner can change the category"); + } + + if (args.categoryId) { + const category = await ctx.db.get(args.categoryId); + if (!category) throw new Error("Category not found"); + } + + await ctx.db.patch(args.listId, { categoryId: args.categoryId }); }, }); /** * Get a list by its ID. - * Returns the list with owner and collaborator info. */ export const getList = query({ args: { listId: v.id("lists") }, @@ -50,64 +157,52 @@ export const getList = query({ }); /** - * Get all lists where user is a collaborator (owner, editor, or viewer). - * Uses the collaborators table (Phase 3) with fallback to legacy fields. - * Supports migrated users by checking both current DID and legacy DID. + * Get all lists where user is the owner, plus any bookmarked published lists. */ export const getUserLists = query({ args: { userDid: v.string(), - // Optional legacy DID for migrated users (their old localStorage DID) legacyDid: v.optional(v.string()), - // Optional wallet DID (client-generated did:peer:xxx, may differ from canonical did:temp:xxx) walletDid: v.optional(v.string()), }, handler: async (ctx, args) => { - // DIDs to check: current DID and optionally legacy/wallet DIDs const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); - } - if (args.walletDid) { - didsToCheck.push(args.walletDid); - } + if (args.legacyDid) didsToCheck.push(args.legacyDid); + if (args.walletDid) didsToCheck.push(args.walletDid); const listMap = new Map>(); - // Primary: Query from collaborators table (Phase 3) + // Get lists where user is owner for (const did of didsToCheck) { - const collabs = await ctx.db - .query("collaborators") - .withIndex("by_user", (q) => q.eq("userDid", did)) + const ownedLists = await ctx.db + .query("lists") + .withIndex("by_owner", (q) => q.eq("ownerDid", did)) .collect(); - for (const collab of collabs) { - if (!listMap.has(collab.listId.toString())) { - const list = await ctx.db.get(collab.listId); - if (list) { - listMap.set(collab.listId.toString(), list); - } + for (const list of ownedLists) { + if (!listMap.has(list._id.toString())) { + listMap.set(list._id.toString(), list); } } } - // Fallback: Also check legacy ownerDid field for unmigrated lists + // Get bookmarked lists for (const did of didsToCheck) { - // Get lists where user is owner (legacy field) - const ownedLists = await ctx.db - .query("lists") - .withIndex("by_owner", (q) => q.eq("ownerDid", did)) + const bookmarks = await ctx.db + .query("bookmarks") + .withIndex("by_user", (q) => q.eq("userDid", did)) .collect(); - // Add to results, avoiding duplicates - for (const list of ownedLists) { - if (!listMap.has(list._id.toString())) { - listMap.set(list._id.toString(), list); + for (const bookmark of bookmarks) { + if (!listMap.has(bookmark.listId.toString())) { + const list = await ctx.db.get(bookmark.listId); + if (list) { + listMap.set(bookmark.listId.toString(), list); + } } } } - // Convert to array and sort by createdAt descending (newest first) return Array.from(listMap.values()).sort( (a, b) => b.createdAt - a.createdAt ); @@ -115,137 +210,112 @@ export const getUserLists = query({ }); /** - * Delete a list and all its items, invites, and collaborators. + * Delete a list and all its items. * Only the owner can delete a list. - * Supports migrated users by checking both current DID and legacy DID. */ export const deleteList = mutation({ args: { listId: v.id("lists"), userDid: v.string(), - // Optional legacy DID for migrated users legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); - if (!list) { - throw new Error("List not found"); - } + if (!list) throw new Error("List not found"); - // Check ownership via collaborators table first (Phase 3) - const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); - } - - let isOwner = false; - - // Check collaborators table - for (const did of didsToCheck) { - const collab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", did) - ) - .first(); - - if (collab?.role === "owner") { - isOwner = true; - break; - } - } + const dids = [args.userDid]; + if (args.legacyDid) dids.push(args.legacyDid); - // Fallback: Check legacy ownerDid field - if (!isOwner) { - isOwner = - list.ownerDid === args.userDid || - (args.legacyDid !== undefined && list.ownerDid === args.legacyDid); - } - - if (!isOwner) { + if (!dids.includes(list.ownerDid)) { throw new Error("Only the list owner can delete this list"); } - // Delete all items in the list + // Delete all items const items = await ctx.db .query("items") .withIndex("by_list", (q) => q.eq("listId", args.listId)) .collect(); - for (const item of items) { await ctx.db.delete(item._id); } - // Delete all pending invites for this list - const invites = await ctx.db - .query("invites") + // Delete publications + const pubs = await ctx.db + .query("publications") .withIndex("by_list", (q) => q.eq("listId", args.listId)) .collect(); - - for (const invite of invites) { - await ctx.db.delete(invite._id); + for (const pub of pubs) { + await ctx.db.delete(pub._id); } - // Delete all collaborators for this list (Phase 3) - const collaborators = await ctx.db - .query("collaborators") - .withIndex("by_list", (q) => q.eq("listId", args.listId)) - .collect(); - - for (const collab of collaborators) { - await ctx.db.delete(collab._id); + // Delete bookmarks referencing this list + const bookmarks = await ctx.db.query("bookmarks").collect(); + for (const bm of bookmarks) { + if (bm.listId === args.listId) { + await ctx.db.delete(bm._id); + } } - // Delete the list itself await ctx.db.delete(args.listId); }, }); /** - * @deprecated Use collaborators.addCollaborator instead (Phase 3). - * This function is kept for backwards compatibility with existing invites flow. + * Add a custom grocery aisle to a list. */ -export const addCollaborator = mutation({ +export const addCustomAisle = mutation({ args: { listId: v.id("lists"), - collaboratorDid: v.string(), - role: v.optional(v.union(v.literal("editor"), v.literal("viewer"))), - invitedByDid: v.optional(v.string()), - joinedAt: v.optional(v.number()), + name: v.string(), + emoji: v.string(), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); - if (!list) { - throw new Error("List not found"); - } + if (!list) throw new Error("List not found"); - // Check if the collaborator is the owner (can't collaborate with yourself) - if (list.ownerDid === args.collaboratorDid) { - throw new Error("Cannot add yourself as a collaborator"); - } + const existing = list.customAisles ?? []; + const id = `custom_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const maxOrder = existing.length > 0 ? Math.max(...existing.map(a => a.order)) : 49; - // Check if already exists in collaborators table - const existingCollab = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", args.listId).eq("userDid", args.collaboratorDid) - ) - .first(); + await ctx.db.patch(args.listId, { + customAisles: [...existing, { id, name: args.name, emoji: args.emoji, order: maxOrder + 1 }], + }); - if (existingCollab) { - throw new Error("User is already a collaborator on this list"); - } + return id; + }, +}); - const role = args.role ?? "editor"; - const joinedAt = args.joinedAt ?? Date.now(); +/** + * Update the item view mode for a list. + */ +export const updateItemViewMode = mutation({ + args: { + listId: v.id("lists"), + itemViewMode: v.union(v.literal("alphabetical"), v.literal("categorized")), + }, + handler: async (ctx, args) => { + const list = await ctx.db.get(args.listId); + if (!list) throw new Error("List not found"); + + await ctx.db.patch(args.listId, { itemViewMode: args.itemViewMode }); + }, +}); + +/** + * Remove a custom grocery aisle from a list. + */ +export const removeCustomAisle = mutation({ + args: { + listId: v.id("lists"), + aisleId: v.string(), + }, + handler: async (ctx, args) => { + const list = await ctx.db.get(args.listId); + if (!list) throw new Error("List not found"); - // Add to collaborators table (Phase 3) - await ctx.db.insert("collaborators", { - listId: args.listId, - userDid: args.collaboratorDid, - role, - joinedAt, - invitedByDid: args.invitedByDid ?? list.ownerDid, + const existing = list.customAisles ?? []; + await ctx.db.patch(args.listId, { + customAisles: existing.filter(a => a.id !== args.aisleId), }); }, }); diff --git a/convex/memories.ts b/convex/memories.ts new file mode 100644 index 0000000..fa9b243 --- /dev/null +++ b/convex/memories.ts @@ -0,0 +1,258 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { selectMemoryChangesSince } from "./lib/memorySync"; + +const memorySource = v.union(v.literal("manual"), v.literal("openclaw"), v.literal("clawboot"), v.literal("import"), v.literal("api")); +const conflictPolicy = v.union(v.literal("lww"), v.literal("preserve_both")); + +function normalizeTags(tags?: string[]) { + const cleaned = tags?.map((t) => t.trim().toLowerCase()).filter(Boolean) ?? []; + return cleaned.length ? Array.from(new Set(cleaned)) : undefined; +} + +function computeSearchText(title: string, content: string, tags?: string[]) { + const tagText = tags?.length ? `\n${tags.join(" ")}` : ""; + return `${title}\n${content}${tagText}`; +} + +export const createMemory = mutation({ + args: { + ownerDid: v.string(), + authorDid: v.string(), + title: v.string(), + content: v.string(), + tags: v.optional(v.array(v.string())), + source: v.optional(memorySource), + sourceRef: v.optional(v.string()), + externalId: v.optional(v.string()), + externalUpdatedAt: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const title = args.title.trim(); + const content = args.content.trim(); + if (!title || !content) throw new Error("title and content are required"); + const tags = normalizeTags(args.tags); + return await ctx.db.insert("memories", { + ownerDid: args.ownerDid, + authorDid: args.authorDid, + title, + content, + searchText: computeSearchText(title, content, tags), + tags, + source: args.source, + sourceRef: args.sourceRef, + externalId: args.externalId, + externalUpdatedAt: args.externalUpdatedAt, + lastSyncedAt: args.source === "openclaw" ? now : undefined, + syncStatus: args.source === "openclaw" ? "synced" : undefined, + conflictNote: undefined, + createdAt: now, + updatedAt: now, + }); + } +}); + +export const upsertOpenClawMemory = mutation({ + args: { + ownerDid: v.string(), + authorDid: v.string(), + externalId: v.string(), + title: v.string(), + content: v.string(), + tags: v.optional(v.array(v.string())), + sourceRef: v.optional(v.string()), + externalUpdatedAt: v.number(), + policy: v.optional(conflictPolicy), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const title = args.title.trim(); + const content = args.content.trim(); + if (!title || !content) throw new Error("title and content are required"); + + const tags = normalizeTags(args.tags); + const policy = args.policy ?? "lww"; + const existing = await ctx.db + .query("memories") + .withIndex("by_owner_external", (q) => q.eq("ownerDid", args.ownerDid).eq("externalId", args.externalId)) + .first(); + + if (!existing) { + const id = await ctx.db.insert("memories", { + ownerDid: args.ownerDid, + authorDid: args.authorDid, + title, + content, + searchText: computeSearchText(title, content, tags), + tags, + source: "openclaw", + sourceRef: args.sourceRef, + externalId: args.externalId, + externalUpdatedAt: args.externalUpdatedAt, + lastSyncedAt: now, + syncStatus: "synced", + conflictNote: undefined, + createdAt: now, + updatedAt: now, + }); + return { id, status: "created" as const }; + } + + const localIsNewer = (existing.updatedAt ?? 0) > args.externalUpdatedAt; + const contentChanged = existing.title !== title || existing.content !== content; + + if (localIsNewer && contentChanged) { + if (policy === "preserve_both") { + const conflictId = await ctx.db.insert("memories", { + ownerDid: args.ownerDid, + authorDid: args.authorDid, + title: `${title} (remote conflicted copy)`, + content, + searchText: computeSearchText(`${title} (remote conflicted copy)`, content, tags), + tags, + source: "openclaw", + sourceRef: args.sourceRef, + externalId: `${args.externalId}:conflict:${now}`, + externalUpdatedAt: args.externalUpdatedAt, + lastSyncedAt: now, + syncStatus: "conflict", + conflictNote: "Remote update older than local edit; preserved as conflicted copy.", + createdAt: now, + updatedAt: now, + }); + + await ctx.db.patch(existing._id, { + syncStatus: "conflict", + conflictNote: "Remote update older than local edit; local version kept.", + lastSyncedAt: now, + }); + + return { id: existing._id, status: "conflict_preserved" as const, conflictId }; + } + + await ctx.db.patch(existing._id, { + syncStatus: "conflict", + conflictNote: "Skipped stale remote update (LWW kept newer local version).", + lastSyncedAt: now, + }); + return { id: existing._id, status: "conflict_skipped" as const }; + } + + await ctx.db.patch(existing._id, { + title, + content, + tags, + source: "openclaw", + sourceRef: args.sourceRef, + externalUpdatedAt: args.externalUpdatedAt, + searchText: computeSearchText(title, content, tags), + syncStatus: "synced", + conflictNote: undefined, + lastSyncedAt: now, + updatedAt: Math.max(now, args.externalUpdatedAt), + }); + + return { id: existing._id, status: "updated" as const }; + }, +}); + +export const listMemories = query({ + args: { + ownerDid: v.string(), + query: v.optional(v.string()), + tag: v.optional(v.string()), + source: v.optional(memorySource), + limit: v.optional(v.number()), + syncStatus: v.optional(v.union(v.literal("synced"), v.literal("conflict"), v.literal("pending"))), + startDate: v.optional(v.number()), + endDate: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = Math.min(Math.max(args.limit ?? 50, 1), 100); + const queryText = args.query?.trim(); + let rows; + if (queryText) { + rows = await ctx.db.query("memories").withSearchIndex("search_content", (s) => { + let q = s.search("searchText", queryText).eq("ownerDid", args.ownerDid); + if (args.source) q = q.eq("source", args.source); + return q; + }).take(200); + } else { + rows = await ctx.db.query("memories").withIndex("by_owner_time", (i) => i.eq("ownerDid", args.ownerDid)).order("desc").take(200); + } + + const tag = args.tag?.trim().toLowerCase(); + const memories = rows + .filter((m) => (tag ? (m.tags ?? []).includes(tag) : true)) + .filter((m) => (args.syncStatus ? m.syncStatus === args.syncStatus : true)) + .filter((m) => (args.startDate !== undefined ? m.updatedAt >= args.startDate : true)) + .filter((m) => (args.endDate !== undefined ? m.updatedAt <= args.endDate : true)) + .slice(0, limit); + const availableTags = Array.from(new Set(memories.flatMap((m) => m.tags ?? []))).sort((a, b) => a.localeCompare(b)); + const conflictCount = rows.filter((m) => m.syncStatus === "conflict").length; + return { memories, availableTags, conflictCount }; + } +}); + +export const updateMemory = mutation({ + args: { + memoryId: v.id("memories"), + ownerDid: v.string(), + authorDid: v.string(), + title: v.optional(v.string()), + content: v.optional(v.string()), + tags: v.optional(v.array(v.string())), + }, + handler: async (ctx, args) => { + const memory = await ctx.db.get(args.memoryId); + if (!memory || memory.ownerDid !== args.ownerDid) throw new Error("Memory not found"); + + const nextTitle = args.title !== undefined ? args.title.trim() : memory.title; + const nextContent = args.content !== undefined ? args.content.trim() : memory.content; + if (!nextTitle || !nextContent) throw new Error("title and content are required"); + + const nextTags = args.tags !== undefined ? normalizeTags(args.tags) : memory.tags; + const now = Date.now(); + + await ctx.db.patch(args.memoryId, { + title: nextTitle, + content: nextContent, + tags: nextTags, + authorDid: args.authorDid, + searchText: computeSearchText(nextTitle, nextContent, nextTags), + syncStatus: "pending", + conflictNote: undefined, + updatedAt: now, + }); + + return { ok: true, id: args.memoryId, updatedAt: now }; + }, +}); + +export const deleteMemory = mutation({ + args: { + memoryId: v.id("memories"), + ownerDid: v.string(), + }, + handler: async (ctx, args) => { + const memory = await ctx.db.get(args.memoryId); + if (!memory || memory.ownerDid !== args.ownerDid) throw new Error("Memory not found"); + await ctx.db.delete(args.memoryId); + return { ok: true, id: args.memoryId }; + }, +}); + +export const listMemoryChangesSince = query({ + args: { ownerDid: v.string(), since: v.optional(v.number()), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const limit = Math.min(Math.max(args.limit ?? 100, 1), 250); + const rows = await ctx.db + .query("memories") + .withIndex("by_owner_time", (q) => q.eq("ownerDid", args.ownerDid)) + .order("desc") + .take(400); + + return selectMemoryChangesSince(rows, args.since ?? 0, limit); + }, +}); \ No newline at end of file diff --git a/convex/memoriesHttp.ts b/convex/memoriesHttp.ts new file mode 100644 index 0000000..8ed6543 --- /dev/null +++ b/convex/memoriesHttp.ts @@ -0,0 +1,52 @@ +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; +import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; +import { requireAuthenticatedUser } from "./lib/authUser"; +import { errorResponse, jsonResponse } from "./lib/httpResponses"; + +export const createMemory = httpAction(async (ctx, request) => { + try { + const user = await requireAuthenticatedUser(ctx, request); + const body = await request.json() as { title?: string; content?: string; tags?: string[]; source?: "manual"|"openclaw"|"clawboot"|"import"|"api"; sourceRef?: string; authorDid?: string }; + if (!body.title || !body.content) return errorResponse(request, "title and content are required", 400); + + const memoryId = await ctx.runMutation((api as any).memories.createMemory, { + ownerDid: user.did, + authorDid: body.authorDid ?? user.did, + title: body.title, + content: body.content, + tags: body.tags, + source: body.source ?? "manual", + sourceRef: body.sourceRef, + }); + + return jsonResponse(request, { memoryId }, 201); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed to create memory", 500); + } +}); + +export const listMemories = httpAction(async (ctx, request) => { + try { + const user = await requireAuthenticatedUser(ctx, request); + const url = new URL(request.url); + const q = url.searchParams.get("q") ?? undefined; + const tag = url.searchParams.get("tag") ?? undefined; + const source = (url.searchParams.get("source") ?? undefined) as any; + const limit = Number(url.searchParams.get("limit") ?? "50"); + + const result = await ctx.runQuery((api as any).memories.listMemories, { + ownerDid: user.did, + query: q, + tag, + source, + limit, + }); + + return jsonResponse(request, result); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed to list memories", 500); + } +}); diff --git a/convex/migrations/migrateCollaborators.ts b/convex/migrations/migrateCollaborators.ts deleted file mode 100644 index 053e5d8..0000000 --- a/convex/migrations/migrateCollaborators.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { mutation } from "../_generated/server"; - -/** - * @deprecated This migration has been completed and collaboratorDid has been removed from the schema. - * - * Original purpose: Move from single collaboratorDid on lists to collaborators table. - * - * For each existing list: - * 1. Add owner to collaborators table with role "owner" - * 2. Add collaboratorDid to collaborators table with role "editor" (if exists) - * - * This migration was run BEFORE removing collaboratorDid from the lists schema. - * The collaboratorDid field has now been removed from schema (Phase 6.2). - */ -export const migrateToCollaborators = mutation({ - handler: async (ctx) => { - const lists = await ctx.db.query("lists").collect(); - let migratedCount = 0; - let skippedCount = 0; - - for (const list of lists) { - // Check if owner already exists in collaborators table - const existingOwner = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", list._id).eq("userDid", list.ownerDid) - ) - .first(); - - if (existingOwner) { - // Already migrated, skip - skippedCount++; - continue; - } - - // Add owner as collaborator with "owner" role - await ctx.db.insert("collaborators", { - listId: list._id, - userDid: list.ownerDid, - role: "owner", - joinedAt: list.createdAt, - invitedByDid: undefined, // Owner invited themselves (created the list) - }); - - // Note: The collaboratorDid field has been removed from the schema. - // This migration can no longer migrate legacy collaborators. - // All existing data should have been migrated before the schema change. - - migratedCount++; - } - - return { - success: true, - totalLists: lists.length, - migratedCount, - skippedCount, - }; - }, -}); - -/** - * Check migration status: returns lists that haven't been migrated yet. - * Checks if owner exists in collaborators table for each list. - */ -export const checkMigrationStatus = mutation({ - handler: async (ctx) => { - const lists = await ctx.db.query("lists").collect(); - const notMigrated: string[] = []; - - for (const list of lists) { - const existingOwner = await ctx.db - .query("collaborators") - .withIndex("by_list_user", (q) => - q.eq("listId", list._id).eq("userDid", list.ownerDid) - ) - .first(); - - if (!existingOwner) { - notMigrated.push(list._id); - } - } - - return { - totalLists: lists.length, - migratedCount: lists.length - notMigrated.length, - notMigratedCount: notMigrated.length, - notMigratedIds: notMigrated, - }; - }, -}); diff --git a/convex/missionControl.ts b/convex/missionControl.ts new file mode 100644 index 0000000..27eac5c --- /dev/null +++ b/convex/missionControl.ts @@ -0,0 +1,181 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; +import { dedupeActivePresenceSessions } from "./lib/presenceSessions"; +import { emitServerMetric } from "./lib/observability"; + +const PRESENCE_TTL_MS = 90_000; + +async function requireListAccess(ctx: any, listId: Id<"lists">, userDid: string) { + const list = await ctx.db.get(listId); + if (!list) throw new Error("List not found"); + + if (list.ownerDid === userDid) return list; + + const publication = await ctx.db + .query("publications") + .withIndex("by_list", (q: any) => q.eq("listId", listId)) + .first(); + + if (publication?.status === "active") return list; + + throw new Error("Not authorized for this list"); +} + +async function emitActivePresenceSessionsGauge(ctx: any, listId: Id<"lists">, now: number) { + const sessions = await ctx.db + .query("presenceSessions") + .withIndex("by_list", (q: any) => q.eq("listId", listId)) + .collect(); + + const activeCount = sessions.filter((session: any) => session.expiresAt > now).length; + emitServerMetric("active_presence_sessions", "gauge", activeCount); +} + +export const setItemAssignee = mutation({ + args: { + itemId: v.id("items"), + actorDid: v.string(), + assigneeDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error("Item not found"); + + await requireListAccess(ctx, item.listId, args.actorDid); + + await ctx.db.patch(args.itemId, { + assigneeDid: args.assigneeDid, + updatedAt: Date.now(), + }); + + await ctx.db.insert("activityEvents", { + listId: item.listId, + itemId: args.itemId, + eventType: "assigned", + actorDid: args.actorDid, + assigneeDid: args.assigneeDid, + createdAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +export const recordPresenceHeartbeat = mutation({ + args: { + listId: v.id("lists"), + userDid: v.string(), + sessionId: v.string(), + }, + handler: async (ctx, args) => { + await requireListAccess(ctx, args.listId, args.userDid); + + const now = Date.now(); + const expiresAt = now + PRESENCE_TTL_MS; + + const expired = await ctx.db + .query("presenceSessions") + .withIndex("by_list_expires", (q) => q.eq("listId", args.listId).lt("expiresAt", now)) + .collect(); + + for (const session of expired) { + await ctx.db.delete(session._id); + } + + const existing = await ctx.db + .query("presenceSessions") + .withIndex("by_session", (q) => q.eq("sessionId", args.sessionId)) + .first(); + + if (existing) { + if (existing.listId !== args.listId || existing.userDid !== args.userDid) { + throw new Error("Session conflict"); + } + await ctx.db.patch(existing._id, { lastSeenAt: now, expiresAt }); + } else { + await ctx.db.insert("presenceSessions", { + listId: args.listId, + userDid: args.userDid, + sessionId: args.sessionId, + lastSeenAt: now, + expiresAt, + }); + } + + await emitActivePresenceSessionsGauge(ctx, args.listId, now); + + return { ok: true, expiresAt }; + }, +}); + +export const clearPresenceSession = mutation({ + args: { + listId: v.id("lists"), + userDid: v.string(), + sessionId: v.string(), + }, + handler: async (ctx, args) => { + await requireListAccess(ctx, args.listId, args.userDid); + + const existing = await ctx.db + .query("presenceSessions") + .withIndex("by_session", (q) => q.eq("sessionId", args.sessionId)) + .first(); + + if (!existing) { + return { ok: true }; + } + + if (existing.listId !== args.listId || existing.userDid !== args.userDid) { + throw new Error("Not authorized to clear this session"); + } + + await ctx.db.delete(existing._id); + + await emitActivePresenceSessionsGauge(ctx, args.listId, Date.now()); + + return { ok: true }; + }, +}); + +export const getActivePresence = query({ + args: { + listId: v.id("lists"), + userDid: v.string(), + now: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await requireListAccess(ctx, args.listId, args.userDid); + + const now = args.now ?? Date.now(); + + const sessions = await ctx.db + .query("presenceSessions") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .collect(); + + return dedupeActivePresenceSessions(sessions, now); + }, +}); + +export const getActivityFeed = query({ + args: { + listId: v.id("lists"), + userDid: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await requireListAccess(ctx, args.listId, args.userDid); + + const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); + + const rows = await ctx.db + .query("activityEvents") + .withIndex("by_list_created", (q) => q.eq("listId", args.listId)) + .order("desc") + .take(limit); + + return rows; + }, +}); diff --git a/convex/missionControlApi.ts b/convex/missionControlApi.ts new file mode 100644 index 0000000..71e790d --- /dev/null +++ b/convex/missionControlApi.ts @@ -0,0 +1,1043 @@ +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import type { ActionCtx } from "./_generated/server"; +import { requireAuth, AuthError, unauthorizedResponseWithCors } from "./lib/auth"; +import { errorResponse, getCorsHeaders, jsonResponse } from "./lib/httpResponses"; +import { emitServerMetric } from "./lib/observability"; +import { normalizeArtifactRefs } from "./lib/artifactRetention"; + +const ALL_SCOPES = [ + "tasks:read", + "tasks:write", + "activity:read", + "memory:read", + "memory:write", + "agents:read", + "agents:write", + "runs:read", + "runs:write", + "runs:control", + "dashboard:read", + "schedule:read", + "schedule:write", +] as const; + +type Scope = (typeof ALL_SCOPES)[number]; + +type AuthContext = { + userDid: string; + authMode: "jwt" | "api_key"; + scopes: Scope[]; + keyId?: Id<"apiKeys">; +}; + +const encoder = new TextEncoder(); + +function normalizeScopes(scopes?: unknown): Scope[] { + if (!Array.isArray(scopes)) return []; + const valid = new Set(ALL_SCOPES); + return scopes.filter((s): s is Scope => typeof s === "string" && valid.has(s as Scope)); +} + +function randomToken(bytes = 24): string { + const data = new Uint8Array(bytes); + crypto.getRandomValues(data); + return Array.from(data).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); + return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function getUserDidFromJwt(ctx: ActionCtx, request: Request): Promise { + const auth = await requireAuth(request); + const user = await ctx.runQuery((api as any).auth.getUserByTurnkeyId, { + turnkeySubOrgId: auth.turnkeySubOrgId, + }) as { did: string; legacyDid?: string } | null; + + if (!user?.did) throw new Error("User not found"); + return user.did; +} + +function requireScopes(authCtx: AuthContext, needed: Scope[]): string | null { + if (authCtx.authMode === "jwt") return null; + for (const scope of needed) { + if (!authCtx.scopes.includes(scope)) return scope; + } + return null; +} + +async function authenticate(ctx: ActionCtx, request: Request): Promise { + const apiKeyHeader = request.headers.get("x-api-key") || request.headers.get("X-API-Key"); + if (apiKeyHeader) { + const hash = await sha256Hex(apiKeyHeader); + const key = await ctx.runQuery((api as any).missionControlCore.getApiKeyByHash, { keyHash: hash }) as any; + if (!key) throw new AuthError("Invalid API key", "INVALID_TOKEN"); + if (key.revokedAt) throw new AuthError("API key revoked", "INVALID_TOKEN"); + if (key.rotationGraceEndsAt && key.rotationGraceEndsAt < Date.now()) { + throw new AuthError("API key rotation grace period ended", "EXPIRED_TOKEN"); + } + if (key.expiresAt && key.expiresAt < Date.now()) throw new AuthError("API key expired", "EXPIRED_TOKEN"); + + await ctx.runMutation((api as any).missionControlCore.touchApiKeyUsage, { keyId: key._id }); + + return { + authMode: "api_key", + userDid: key.ownerDid, + scopes: normalizeScopes(key.scopes), + keyId: key._id, + }; + } + + const userDid = await getUserDidFromJwt(ctx, request); + return { authMode: "jwt", userDid, scopes: [...ALL_SCOPES] }; +} + +function parseItemId(pathname: string): string | null { + const match = pathname.match(/\/api\/v1\/tasks\/([a-z0-9]+)/); + return match ? match[1] : null; +} + +function parseRunId(pathname: string): string | null { + const match = pathname.match(/\/api\/v1\/runs\/([a-z0-9]+)/); + return match ? match[1] : null; +} + +function parseOptionalNumber(value: string | null): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function parseScheduleEntryId(pathname: string): string | null { + const match = pathname.match(/\/api\/v1\/schedules\/([a-z0-9]+)/); + return match ? match[1] : null; +} + +function parseMemoryId(pathname: string): string | null { + const match = pathname.match(/\/api\/v1\/memory\/([a-z0-9]+)/); + return match ? match[1] : null; +} + +function parseApiKeyPath(pathname: string): { keyId: string | null; action: "delete" | "rotate" | "finalize" | null } { + const rotateMatch = pathname.match(/\/api\/v1\/auth\/keys\/([a-z0-9]+)\/rotate$/); + if (rotateMatch) return { keyId: rotateMatch[1], action: "rotate" }; + + const finalizeMatch = pathname.match(/\/api\/v1\/auth\/keys\/([a-z0-9]+)\/finalize-rotation$/); + if (finalizeMatch) return { keyId: finalizeMatch[1], action: "finalize" }; + + const deleteMatch = pathname.match(/\/api\/v1\/auth\/keys\/([a-z0-9]+)$/); + if (deleteMatch) return { keyId: deleteMatch[1], action: "delete" }; + + return { keyId: null, action: null }; +} + +export const v1AuthCors = httpAction(async (_ctx, request) => { + return new Response(null, { + status: 204, + headers: { + ...getCorsHeaders(request), + "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key", + "Access-Control-Max-Age": "86400", + }, + }); +}); + +export const apiKeysHandler = httpAction(async (ctx, request) => { + try { + const userDid = await getUserDidFromJwt(ctx, request); + + if (request.method === "GET") { + const [keys, rotationEvents] = await Promise.all([ + ctx.runQuery((api as any).missionControlCore.listApiKeys, { ownerDid: userDid }) as Promise, + ctx.runQuery((api as any).missionControlCore.listApiKeyRotationEvents, { ownerDid: userDid, limit: 20 }) as Promise, + ]); + + return jsonResponse(request, { + apiKeys: keys.map((k) => ({ + _id: k._id, + label: k.label, + keyPrefix: k.keyPrefix, + scopes: k.scopes, + agentProfileId: k.agentProfileId, + rotatedFromKeyId: k.rotatedFromKeyId, + rotatedToKeyId: k.rotatedToKeyId, + rotationGraceEndsAt: k.rotationGraceEndsAt, + createdAt: k.createdAt, + lastUsedAt: k.lastUsedAt, + revokedAt: k.revokedAt, + expiresAt: k.expiresAt, + })), + rotationEvents, + }); + } + + if (request.method === "POST") { + const body = await request.json() as { + label: string; + scopes?: Scope[]; + agentProfileId?: Id<"agentProfiles">; + expiresAt?: number; + }; + if (!body.label) return errorResponse(request, "label is required", 400); + + const scopes = body.scopes?.length ? normalizeScopes(body.scopes) : ["tasks:read", "tasks:write"]; + if (!scopes.length) return errorResponse(request, "At least one valid scope is required", 400); + + const rawKey = `pa_${randomToken(8)}_${randomToken(24)}`; + const keyPrefix = rawKey.slice(0, 12); + const keyHash = await sha256Hex(rawKey); + + const keyId = await ctx.runMutation((api as any).missionControlCore.createApiKeyRecord, { + ownerDid: userDid, + label: body.label, + keyPrefix, + keyHash, + scopes, + agentProfileId: body.agentProfileId, + expiresAt: body.expiresAt, + }); + + return jsonResponse(request, { keyId, apiKey: rawKey, keyPrefix, scopes }, 201); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const apiKeyByIdHandler = httpAction(async (ctx, request) => { + try { + const userDid = await getUserDidFromJwt(ctx, request); + const path = new URL(request.url).pathname; + const { keyId, action } = parseApiKeyPath(path); + if (!keyId || !action) return errorResponse(request, "key id required", 400); + + if (request.method === "DELETE" && action === "delete") { + await ctx.runMutation((api as any).missionControlCore.revokeApiKey, { + keyId, + ownerDid: userDid, + }); + return jsonResponse(request, { success: true }); + } + + if (request.method === "POST" && action === "rotate") { + const body = await request.json().catch(() => ({})) as { + label?: string; + gracePeriodHours?: number; + expiresAt?: number; + }; + + const existing = await ctx.runQuery((api as any).missionControlCore.listApiKeys, { ownerDid: userDid }) as any[]; + const oldKey = existing.find((k) => k._id === keyId); + if (!oldKey) return errorResponse(request, "API key not found", 404); + if (oldKey.revokedAt) return errorResponse(request, "Cannot rotate revoked API key", 400); + if (oldKey.rotatedToKeyId) return errorResponse(request, "API key rotation already in progress", 409); + + const now = Date.now(); + if (body.gracePeriodHours !== undefined && !Number.isFinite(body.gracePeriodHours)) { + return errorResponse(request, "gracePeriodHours must be a finite number", 400); + } + const gracePeriodHours = Math.min(Math.max(Math.floor(body.gracePeriodHours ?? 24), 1), 168); + const graceEndsAt = now + gracePeriodHours * 60 * 60 * 1000; + + if (body.expiresAt !== undefined) { + if (!Number.isFinite(body.expiresAt)) { + return errorResponse(request, "expiresAt must be a unix epoch timestamp in milliseconds", 400); + } + if (body.expiresAt <= now) { + return errorResponse(request, "expiresAt must be in the future", 400); + } + if (body.expiresAt <= graceEndsAt) { + return errorResponse(request, "expiresAt must be after the old key grace window ends", 400); + } + } + + const label = typeof body.label === "string" && body.label.trim().length + ? body.label.trim() + : `${oldKey.label} (rotated)`; + + const rawKey = `pa_${randomToken(8)}_${randomToken(24)}`; + const keyPrefix = rawKey.slice(0, 12); + const keyHash = await sha256Hex(rawKey); + + const result = await ctx.runMutation((api as any).missionControlCore.createRotatedApiKey, { + ownerDid: userDid, + rotatedByDid: userDid, + oldKeyId: keyId, + label, + keyPrefix, + keyHash, + scopes: oldKey.scopes, + agentProfileId: oldKey.agentProfileId, + expiresAt: body.expiresAt, + graceEndsAt, + }) as any; + + return jsonResponse(request, { + success: true, + rotationEventId: result.rotationEventId, + oldKeyId: keyId, + oldKeyGraceEndsAt: graceEndsAt, + newKeyId: result.newKeyId, + apiKey: rawKey, + keyPrefix, + zeroDowntime: true, + next: "Use the new key in production, then POST /api/v1/auth/keys/:id/finalize-rotation to revoke the old key.", + }, 201); + } + + if (request.method === "POST" && action === "finalize") { + const result = await ctx.runMutation((api as any).missionControlCore.finalizeApiKeyRotation, { + ownerDid: userDid, + oldKeyId: keyId, + }) as any; + return jsonResponse(request, { success: true, ...result }); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + if (error instanceof Error) { + if ( + error.message.includes("already in progress") + || error.message.includes("must be in the future") + || error.message.includes("must outlive") + || error.message.includes("Cannot rotate revoked API key") + ) { + const status = error.message.includes("already in progress") ? 409 : 400; + return errorResponse(request, error.message, status); + } + return errorResponse(request, error.message, 500); + } + return errorResponse(request, "Failed", 500); + } +}); + +export const runRetentionHandler = httpAction(async (ctx, request) => { + try { + const userDid = await getUserDidFromJwt(ctx, request); + + if (request.method === "GET") { + const [settings, logs] = await Promise.all([ + ctx.runQuery((api as any).missionControlCore.getMissionControlSettings, { ownerDid: userDid }), + ctx.runQuery((api as any).missionControlCore.listArtifactDeletionLogs, { ownerDid: userDid, limit: 25 }), + ]); + + const deletionLogs = logs.map((log: any) => ({ + ...log, + deletedArtifacts: normalizeArtifactRefs(log.deletedArtifacts), + })); + + return jsonResponse(request, { settings, deletionLogs }); + } + + if (request.method === "PUT") { + const body = await request.json() as { artifactRetentionDays?: number }; + if (!body.artifactRetentionDays || !Number.isFinite(body.artifactRetentionDays)) { + return errorResponse(request, "artifactRetentionDays is required", 400); + } + + const result = await ctx.runMutation((api as any).missionControlCore.upsertMissionControlSettings, { + ownerDid: userDid, + updatedByDid: userDid, + artifactRetentionDays: body.artifactRetentionDays, + }); + + return jsonResponse(request, { success: true, ...result }); + } + + if (request.method === "POST") { + const body = await request.json().catch(() => ({})) as { + retentionDays?: number; + dryRun?: boolean; + maxRuns?: number; + }; + + const result = await ctx.runMutation((api as any).missionControlCore.applyArtifactRetention, { + ownerDid: userDid, + actorDid: userDid, + retentionDays: body.retentionDays, + dryRun: body.dryRun ?? true, + maxRuns: body.maxRuns, + }); + + return jsonResponse(request, result, 200); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const agentsHandler = httpAction(async (ctx, request) => { + try { + const authCtx = await authenticate(ctx, request); + const missing = requireScopes(authCtx, request.method === "GET" ? ["agents:read"] : ["agents:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + if (request.method === "GET") { + const profiles = await ctx.runQuery((api as any).missionControlCore.listAgentProfiles, { ownerDid: authCtx.userDid }); + return jsonResponse(request, { agents: profiles }); + } + + if (request.method === "POST") { + const body = await request.json() as { + agentSlug: string; + displayName: string; + description?: string; + capabilities?: string[]; + metadata?: string; + }; + if (!body.agentSlug || !body.displayName) return errorResponse(request, "agentSlug and displayName are required", 400); + + const agentId = await ctx.runMutation((api as any).missionControlCore.upsertAgentProfile, { + ownerDid: authCtx.userDid, + agentSlug: body.agentSlug, + displayName: body.displayName, + description: body.description, + capabilities: body.capabilities, + metadata: body.metadata, + }); + + return jsonResponse(request, { agentId }, 201); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const tasksHandler = httpAction(async (ctx, request) => { + try { + const authCtx = await authenticate(ctx, request); + const url = new URL(request.url); + + if (request.method === "GET") { + const itemId = parseItemId(url.pathname); + if (itemId) { + const missing = requireScopes(authCtx, ["tasks:read"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const item = await ctx.runQuery((api as any).missionControlCore.getTaskById, { + itemId, + userDid: authCtx.userDid, + }); + if (!item) return errorResponse(request, "Task not found", 404); + return jsonResponse(request, { task: item }); + } + + const listId = url.searchParams.get("listId"); + if (!listId) return errorResponse(request, "listId query param required", 400); + const missing = requireScopes(authCtx, ["tasks:read"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const tasks = await ctx.runQuery((api as any).missionControlCore.listTasksForList, { + listId, + userDid: authCtx.userDid, + limit: Number(url.searchParams.get("limit") ?? "100"), + }); + return jsonResponse(request, { tasks }); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const activityHandler = httpAction(async (ctx, request) => { + try { + const authCtx = await authenticate(ctx, request); + const missing = requireScopes(authCtx, ["activity:read"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + const url = new URL(request.url); + const listId = url.searchParams.get("listId"); + if (!listId) return errorResponse(request, "listId query param required", 400); + + const events = await ctx.runQuery((api as any).missionControlCore.listActivityEvents, { + listId, + userDid: authCtx.userDid, + limit: Number(url.searchParams.get("limit") ?? "100"), + }); + + return jsonResponse(request, { events }); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const memoryHandler = httpAction(async (ctx, request) => { + try { + const authCtx = await authenticate(ctx, request); + const url = new URL(request.url); + const isSyncRoute = url.pathname.endsWith("/sync"); + + if (request.method === "GET") { + const missing = requireScopes(authCtx, ["memory:read"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + if (isSyncRoute) { + const since = parseOptionalNumber(url.searchParams.get("since")); + const limit = Number(url.searchParams.get("limit") ?? "100"); + const result = await ctx.runQuery((api as any).memories.listMemoryChangesSince, { + ownerDid: authCtx.userDid, + since, + limit, + }); + return jsonResponse(request, { + ...result, + sync: { + mode: "bidirectional", + policy: "lww", + }, + }); + } + + const agentSlug = url.searchParams.get("agentSlug"); + const key = url.searchParams.get("key") ?? undefined; + if (!agentSlug) { + const memories = await ctx.runQuery((api as any).memories.listMemories, { + ownerDid: authCtx.userDid, + query: url.searchParams.get("q") ?? undefined, + tag: url.searchParams.get("tag") ?? undefined, + source: url.searchParams.get("source") ?? undefined, + syncStatus: url.searchParams.get("syncStatus") ?? undefined, + startDate: parseOptionalNumber(url.searchParams.get("startDate")), + endDate: parseOptionalNumber(url.searchParams.get("endDate")), + limit: Number(url.searchParams.get("limit") ?? "50"), + }); + return jsonResponse(request, memories); + } + + const memory = await ctx.runQuery((api as any).missionControlCore.getAgentMemory, { + ownerDid: authCtx.userDid, + agentSlug, + key, + }); + return jsonResponse(request, { memory }); + } + + if (request.method === "POST") { + const missing = requireScopes(authCtx, ["memory:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + if (isSyncRoute) { + const body = await request.json() as { + policy?: "lww" | "preserve_both"; + entries: Array<{ + externalId: string; + title: string; + content: string; + tags?: string[]; + sourceRef?: string; + updatedAt: number; + authorDid?: string; + }>; + }; + + if (!Array.isArray(body.entries)) return errorResponse(request, "entries array is required", 400); + + const results: Array<{ externalId: string; status: string; id: string; conflictId?: string }> = []; + for (const entry of body.entries) { + if (!entry?.externalId || !entry?.title || typeof entry?.content !== "string" || !Number.isFinite(entry?.updatedAt)) { + return errorResponse(request, "Each entry requires externalId, title, content, and updatedAt", 400); + } + const res = await ctx.runMutation((api as any).memories.upsertOpenClawMemory, { + ownerDid: authCtx.userDid, + authorDid: entry.authorDid ?? authCtx.userDid, + externalId: entry.externalId, + title: entry.title, + content: entry.content, + tags: entry.tags, + sourceRef: entry.sourceRef, + externalUpdatedAt: entry.updatedAt, + policy: body.policy ?? "lww", + }) as { id: string; status: string; conflictId?: string }; + results.push({ externalId: entry.externalId, status: res.status, id: res.id, conflictId: res.conflictId }); + } + + const conflicts = results.filter((r) => r.status.startsWith("conflict")).length; + return jsonResponse(request, { + applied: results.length, + conflicts, + policy: body.policy ?? "lww", + results, + }, 200); + } + + const body = await request.json() as { agentSlug: string; key: string; value: string; listId?: Id<"lists"> }; + if (!body.agentSlug || !body.key || typeof body.value !== "string") { + return errorResponse(request, "agentSlug, key, and value are required", 400); + } + + const id = await ctx.runMutation((api as any).missionControlCore.addAgentMemory, { + ownerDid: authCtx.userDid, + listId: body.listId, + agentSlug: body.agentSlug, + key: body.key, + value: body.value, + }); + return jsonResponse(request, { id }, 201); + } + + if (request.method === "PATCH") { + const missing = requireScopes(authCtx, ["memory:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + const memoryId = parseMemoryId(url.pathname); + if (!memoryId) return errorResponse(request, "memory id is required", 400); + + const body = await request.json().catch(() => ({})) as { + title?: string; + content?: string; + tags?: string[]; + }; + + const result = await ctx.runMutation((api as any).memories.updateMemory, { + memoryId, + ownerDid: authCtx.userDid, + authorDid: authCtx.userDid, + title: body.title, + content: body.content, + tags: body.tags, + }); + + return jsonResponse(request, result); + } + + if (request.method === "DELETE") { + const missing = requireScopes(authCtx, ["memory:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + const memoryId = parseMemoryId(url.pathname); + if (!memoryId) return errorResponse(request, "memory id is required", 400); + + const result = await ctx.runMutation((api as any).memories.deleteMemory, { + memoryId, + ownerDid: authCtx.userDid, + }); + + return jsonResponse(request, result); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const runsHandler = httpAction(async (ctx, request) => { + try { + const authCtx = await authenticate(ctx, request); + const url = new URL(request.url); + + if (request.method === "GET") { + const missing = requireScopes(authCtx, ["runs:read"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + const result = await ctx.runQuery((api as any).missionControlCore.listMissionRuns, { + ownerDid: authCtx.userDid, + listId: url.searchParams.get("listId") ?? undefined, + itemId: url.searchParams.get("itemId") ?? undefined, + status: url.searchParams.get("status") ?? undefined, + startDate: parseOptionalNumber(url.searchParams.get("startDate")), + endDate: parseOptionalNumber(url.searchParams.get("endDate")), + limit: Number(url.searchParams.get("limit") ?? "25"), + page: Number(url.searchParams.get("page") ?? "1"), + }); + return jsonResponse(request, result); + } + + if (request.method === "PATCH") { + const missing = requireScopes(authCtx, ["runs:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const runId = parseRunId(url.pathname); + if (!runId) return errorResponse(request, "runId is required", 400); + + const body = await request.json() as { + provider?: string; + computerId?: string; + costEstimate?: number; + tokenUsage?: number; + }; + + const result = await ctx.runMutation((api as any).missionControlCore.updateMissionRun, { + ownerDid: authCtx.userDid, + runId, + provider: body.provider, + computerId: body.computerId, + costEstimate: body.costEstimate, + tokenUsage: body.tokenUsage, + }); + return jsonResponse(request, result); + } + + if (request.method === "DELETE") { + const missing = requireScopes(authCtx, ["runs:control"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const runId = parseRunId(url.pathname); + if (!runId) return errorResponse(request, "runId is required", 400); + const result = await ctx.runMutation((api as any).missionControlCore.deleteMissionRun, { + ownerDid: authCtx.userDid, + runId, + }); + return jsonResponse(request, result); + } + + if (request.method === "POST") { + const path = url.pathname; + const runId = parseRunId(path); + const isActionPath = path.includes("/heartbeat") || path.includes("/transition") || path.includes("/retry") || path.includes("/artifacts") || path.includes("/monitor") || path.includes("/pause") || path.includes("/kill") || path.includes("/escalate") || path.includes("/reassign"); + + if (!isActionPath) { + const missing = requireScopes(authCtx, ["runs:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + const body = await request.json() as { + listId: Id<"lists">; + itemId?: Id<"items">; + agentSlug: string; + provider?: string; + computerId?: string; + parentRunId?: Id<"missionRuns">; + heartbeatIntervalMs?: number; + }; + + if (!body.listId || !body.agentSlug) { + return errorResponse(request, "listId and agentSlug are required", 400); + } + + const created = await ctx.runMutation((api as any).missionControlCore.createMissionRun, { + ownerDid: authCtx.userDid, + listId: body.listId, + itemId: body.itemId, + agentSlug: body.agentSlug, + provider: body.provider, + computerId: body.computerId, + parentRunId: body.parentRunId, + heartbeatIntervalMs: body.heartbeatIntervalMs, + }); + + return jsonResponse(request, created, 201); + } + + if (path.endsWith("/monitor")) { + const missing = requireScopes(authCtx, ["runs:control"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const body = await request.json().catch(() => ({})) as { now?: number }; + const result = await ctx.runMutation((api as any).missionControlCore.monitorMissionRunHeartbeats, { + ownerDid: authCtx.userDid, + now: body.now, + }); + emitServerMetric("run_control_action_total", "counter", 1, { action: "monitor", result: "success" }); + return jsonResponse(request, result); + } + + if (!runId) return errorResponse(request, "runId is required", 400); + + if (path.endsWith("/pause")) { + const missing = requireScopes(authCtx, ["runs:control"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const body = await request.json().catch(() => ({})) as { reason?: string }; + const result = await ctx.runMutation((api as any).missionControlCore.transitionMissionRun, { + ownerDid: authCtx.userDid, + runId, + nextStatus: "blocked", + }); + await ctx.runMutation((api as any).missionControlCore.appendMissionRunArtifact, { + ownerDid: authCtx.userDid, + runId, + type: "log", + ref: `pause:${body.reason ?? "operator_requested"}`, + label: "runtime_control", + }); + emitServerMetric("run_control_action_total", "counter", 1, { action: "pause", result: "success" }); + return jsonResponse(request, { ok: true, action: "pause", ...result }); + } + + if (path.endsWith("/kill")) { + const missing = requireScopes(authCtx, ["runs:control"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const body = await request.json().catch(() => ({})) as { reason?: string }; + const result = await ctx.runMutation((api as any).missionControlCore.transitionMissionRun, { + ownerDid: authCtx.userDid, + runId, + nextStatus: "failed", + terminalReason: "killed", + }); + await ctx.runMutation((api as any).missionControlCore.appendMissionRunArtifact, { + ownerDid: authCtx.userDid, + runId, + type: "log", + ref: `kill:${body.reason ?? "operator_requested"}`, + label: "runtime_control", + }); + emitServerMetric("run_control_action_total", "counter", 1, { action: "kill", result: "success" }); + return jsonResponse(request, { ok: true, action: "kill", ...result }); + } + + if (path.endsWith("/escalate")) { + const missing = requireScopes(authCtx, ["runs:control"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const body = await request.json().catch(() => ({})) as { targetAgentSlug?: string; reason?: string }; + const now = Date.now(); + const result = await ctx.runMutation((api as any).missionControlCore.transitionMissionRun, { + ownerDid: authCtx.userDid, + runId, + nextStatus: "failed", + terminalReason: "escalated", + escalationAt: now, + }); + const run = await ctx.runQuery((api as any).missionControlCore.getMissionRunById, { + ownerDid: authCtx.userDid, + runId, + }) as { agentSlug?: string } | null; + if (run?.agentSlug) { + await ctx.runMutation((api as any).missionControlCore.controlAgentLaunch, { + ownerDid: authCtx.userDid, + actorDid: authCtx.userDid, + agentSlug: run.agentSlug, + action: "escalate", + targetAgentSlug: body.targetAgentSlug, + reason: body.reason, + }); + } + emitServerMetric("run_control_action_total", "counter", 1, { action: "escalate", result: "success" }); + return jsonResponse(request, { ok: true, action: "escalate", ...result }); + } + + if (path.endsWith("/reassign")) { + const missing = requireScopes(authCtx, ["runs:control"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const body = await request.json().catch(() => ({})) as { targetAgentSlug?: string; reason?: string }; + if (!body.targetAgentSlug) return errorResponse(request, "targetAgentSlug is required", 400); + const run = await ctx.runQuery((api as any).missionControlCore.getMissionRunById, { + ownerDid: authCtx.userDid, + runId, + }) as { agentSlug?: string } | null; + if (!run?.agentSlug) return errorResponse(request, "Run not found", 404); + await ctx.runMutation((api as any).missionControlCore.controlAgentLaunch, { + ownerDid: authCtx.userDid, + actorDid: authCtx.userDid, + agentSlug: run.agentSlug, + action: "reassign", + targetAgentSlug: body.targetAgentSlug, + reason: body.reason, + }); + await ctx.runMutation((api as any).missionControlCore.appendMissionRunArtifact, { + ownerDid: authCtx.userDid, + runId, + type: "log", + ref: `reassign:${body.targetAgentSlug}:${body.reason ?? "operator_requested"}`, + label: "runtime_control", + }); + emitServerMetric("run_control_action_total", "counter", 1, { action: "reassign", result: "success" }); + return jsonResponse(request, { ok: true, action: "reassign", runId, targetAgentSlug: body.targetAgentSlug }); + } + + if (path.endsWith("/heartbeat")) { + const missing = requireScopes(authCtx, ["runs:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const body = await request.json().catch(() => ({})) as { at?: number }; + const result = await ctx.runMutation((api as any).missionControlCore.recordMissionRunHeartbeat, { + ownerDid: authCtx.userDid, + runId, + at: body.at, + }); + return jsonResponse(request, result); + } + + if (path.endsWith("/transition")) { + const missing = requireScopes(authCtx, ["runs:control"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const body = await request.json() as { + nextStatus: "starting" | "running" | "degraded" | "blocked" | "failed" | "finished"; + terminalReason?: "completed" | "killed" | "timeout" | "error" | "escalated"; + costEstimate?: number; + tokenUsage?: number; + escalationAt?: number; + }; + const result = await ctx.runMutation((api as any).missionControlCore.transitionMissionRun, { + ownerDid: authCtx.userDid, + runId, + ...body, + }); + emitServerMetric("run_control_action_total", "counter", 1, { action: "transition", result: "success" }); + return jsonResponse(request, result); + } + + if (path.endsWith("/retry")) { + const missing = requireScopes(authCtx, ["runs:control"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const result = await ctx.runMutation((api as any).missionControlCore.createRetryForMissionRun, { + ownerDid: authCtx.userDid, + runId, + }); + emitServerMetric("run_control_action_total", "counter", 1, { action: "retry", result: "success" }); + return jsonResponse(request, result); + } + + if (path.endsWith("/artifacts")) { + const missing = requireScopes(authCtx, ["runs:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + const body = await request.json() as { + type: "screenshot" | "log" | "diff" | "file" | "url"; + ref: string; + label?: string; + }; + if (!body.ref || !body.type) return errorResponse(request, "type and ref are required", 400); + const result = await ctx.runMutation((api as any).missionControlCore.appendMissionRunArtifact, { + ownerDid: authCtx.userDid, + runId, + type: body.type, + ref: body.ref, + label: body.label, + }); + return jsonResponse(request, result); + } + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const schedulesHandler = httpAction(async (ctx, request) => { + try { + const authCtx = await authenticate(ctx, request); + const url = new URL(request.url); + + if (request.method === "GET") { + const missing = requireScopes(authCtx, ["schedule:read"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + const ownerDid = url.searchParams.get("ownerDid") ?? authCtx.userDid; + if (ownerDid !== authCtx.userDid) return errorResponse(request, "Forbidden", 403); + const listId = url.searchParams.get("listId") ?? undefined; + + const schedules = await ctx.runQuery((api as any).scheduleEntries.listForOwner, { + ownerDid, + actorDid: authCtx.userDid, + listId, + }); + + return jsonResponse(request, { schedules }); + } + + if (request.method === "POST") { + const path = url.pathname; + + if (path.endsWith("/sync")) { + const missing = requireScopes(authCtx, ["schedule:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + const body = await request.json() as { + ownerDid?: string; + agentDid?: string; + entries: Array<{ + externalId: string; + title: string; + cronExpr?: string; + nextRunAt?: number; + lastRunAt?: number; + lastStatus?: "ok" | "error" | "skipped"; + enabled: boolean; + listId?: Id<"lists">; + }>; + }; + + if (!Array.isArray(body.entries)) return errorResponse(request, "entries array is required", 400); + const ownerDid = body.ownerDid ?? authCtx.userDid; + if (ownerDid !== authCtx.userDid) return errorResponse(request, "Forbidden", 403); + + const result = await ctx.runMutation((api as any).scheduleEntries.syncCronSnapshot, { + ownerDid, + actorDid: authCtx.userDid, + agentDid: body.agentDid, + entries: body.entries, + }); + + return jsonResponse(request, result); + } + + if (path.endsWith("/toggle")) { + const missing = requireScopes(authCtx, ["schedule:write"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + const entryId = parseScheduleEntryId(path); + if (!entryId) return errorResponse(request, "schedule entry id is required", 400); + + const body = await request.json() as { enabled: boolean }; + if (typeof body.enabled !== "boolean") return errorResponse(request, "enabled boolean is required", 400); + + const existing = await ctx.runQuery((api as any).scheduleEntries.listForOwner, { + ownerDid: authCtx.userDid, + actorDid: authCtx.userDid, + }) as Array; + const row = existing.find((r) => r._id === entryId); + if (!row) return errorResponse(request, "Schedule entry not found", 404); + + await ctx.runMutation((api as any).scheduleEntries.updateScheduleEntry, { + entryId, + actorDid: authCtx.userDid, + enabled: body.enabled, + }); + + return jsonResponse(request, { + ok: true, + schedule: { + _id: row._id, + externalId: row.externalId, + title: row.title, + cronExpr: row.cronExpr, + enabled: body.enabled, + }, + writeback: { + flow: "openclaw-cron-metadata", + externalId: row.externalId, + enabled: body.enabled, + }, + }); + } + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const runsDashboardHandler = httpAction(async (ctx, request) => { + try { + if (request.method !== "GET") return errorResponse(request, "Method not allowed", 405); + const authCtx = await authenticate(ctx, request); + const missing = requireScopes(authCtx, ["dashboard:read"]); + if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + + const url = new URL(request.url); + const windowMs = Number(url.searchParams.get("windowMs") ?? String(24 * 60 * 60 * 1000)); + const dashboard = await ctx.runQuery((api as any).missionControlCore.getMissionRunsDashboard, { + ownerDid: authCtx.userDid, + windowMs, + }); + return jsonResponse(request, { dashboard }); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); diff --git a/convex/missionControlCore.ts b/convex/missionControlCore.ts new file mode 100644 index 0000000..6486a3b --- /dev/null +++ b/convex/missionControlCore.ts @@ -0,0 +1,1032 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; +import { emitServerMetric } from "./lib/observability"; +import { clampRetentionDays, computeRetentionCutoff, normalizeArtifactRefs, selectStaleArtifacts, shouldInsertDeletionLog } from "./lib/artifactRetention"; + +async function hasListAccess(ctx: any, listId: Id<"lists">, userDid: string) { + const list = await ctx.db.get(listId); + if (!list) return false; + if (list.ownerDid === userDid) return true; + + const pub = await ctx.db + .query("publications") + .withIndex("by_list", (q: any) => q.eq("listId", listId)) + .first(); + + return pub?.status === "active"; +} + +export const upsertAgentProfile = mutation({ + args: { + ownerDid: v.string(), + agentSlug: v.string(), + displayName: v.string(), + description: v.optional(v.string()), + capabilities: v.optional(v.array(v.string())), + metadata: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const existing = await ctx.db + .query("agentProfiles") + .withIndex("by_owner_slug", (q) => q.eq("ownerDid", args.ownerDid).eq("agentSlug", args.agentSlug)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + displayName: args.displayName, + description: args.description, + capabilities: args.capabilities, + metadata: args.metadata, + updatedAt: now, + }); + return existing._id; + } + + return await ctx.db.insert("agentProfiles", { + ownerDid: args.ownerDid, + agentSlug: args.agentSlug, + displayName: args.displayName, + description: args.description, + capabilities: args.capabilities, + metadata: args.metadata, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const getAgentProfile = query({ + args: { ownerDid: v.string(), agentSlug: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("agentProfiles") + .withIndex("by_owner_slug", (q) => q.eq("ownerDid", args.ownerDid).eq("agentSlug", args.agentSlug)) + .first(); + }, +}); + +export const listAgentProfiles = query({ + args: { ownerDid: v.string() }, + handler: async (ctx, args) => { + return await ctx.db.query("agentProfiles").withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)).collect(); + }, +}); + +export const createApiKeyRecord = mutation({ + args: { + ownerDid: v.string(), + label: v.string(), + keyPrefix: v.string(), + keyHash: v.string(), + scopes: v.array(v.string()), + agentProfileId: v.optional(v.id("agentProfiles")), + expiresAt: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + return await ctx.db.insert("apiKeys", { + ownerDid: args.ownerDid, + label: args.label, + keyPrefix: args.keyPrefix, + keyHash: args.keyHash, + scopes: args.scopes, + agentProfileId: args.agentProfileId, + createdAt: now, + lastUsedAt: undefined, + revokedAt: undefined, + expiresAt: args.expiresAt, + }); + }, +}); + +export const listApiKeys = query({ + args: { ownerDid: v.string() }, + handler: async (ctx, args) => { + return await ctx.db.query("apiKeys").withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)).collect(); + }, +}); + +export const getApiKeyByHash = query({ + args: { keyHash: v.string() }, + handler: async (ctx, args) => { + return await ctx.db.query("apiKeys").withIndex("by_hash", (q) => q.eq("keyHash", args.keyHash)).first(); + }, +}); + +export const revokeApiKey = mutation({ + args: { keyId: v.id("apiKeys"), ownerDid: v.string() }, + handler: async (ctx, args) => { + const key = await ctx.db.get(args.keyId); + if (!key || key.ownerDid !== args.ownerDid) throw new Error("API key not found"); + await ctx.db.patch(args.keyId, { revokedAt: Date.now() }); + return { ok: true }; + }, +}); + +export const touchApiKeyUsage = mutation({ + args: { keyId: v.id("apiKeys") }, + handler: async (ctx, args) => { + await ctx.db.patch(args.keyId, { lastUsedAt: Date.now() }); + return { ok: true }; + }, +}); + +export const createRotatedApiKey = mutation({ + args: { + ownerDid: v.string(), + rotatedByDid: v.string(), + oldKeyId: v.id("apiKeys"), + label: v.string(), + keyPrefix: v.string(), + keyHash: v.string(), + scopes: v.array(v.string()), + agentProfileId: v.optional(v.id("agentProfiles")), + expiresAt: v.optional(v.number()), + graceEndsAt: v.number(), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const oldKey = await ctx.db.get(args.oldKeyId); + if (!oldKey || oldKey.ownerDid !== args.ownerDid) throw new Error("API key not found"); + if (oldKey.revokedAt) throw new Error("Cannot rotate revoked API key"); + if (oldKey.rotatedToKeyId) throw new Error("API key rotation already in progress"); + if (!Number.isFinite(args.graceEndsAt) || args.graceEndsAt <= now) { + throw new Error("Rotation grace window must end in the future"); + } + if (args.expiresAt !== undefined) { + if (!Number.isFinite(args.expiresAt) || args.expiresAt <= now) { + throw new Error("Rotated API key expiry must be in the future"); + } + if (args.expiresAt <= args.graceEndsAt) { + throw new Error("Rotated API key must outlive the old key grace window"); + } + } + + const newKeyId = await ctx.db.insert("apiKeys", { + ownerDid: args.ownerDid, + label: args.label, + keyPrefix: args.keyPrefix, + keyHash: args.keyHash, + scopes: args.scopes, + agentProfileId: args.agentProfileId, + rotatedFromKeyId: oldKey._id, + createdAt: now, + expiresAt: args.expiresAt, + }); + + await ctx.db.patch(oldKey._id, { + rotatedToKeyId: newKeyId, + rotationGraceEndsAt: args.graceEndsAt, + }); + + const rotationEventId = await ctx.db.insert("apiKeyRotationEvents", { + ownerDid: args.ownerDid, + oldKeyId: oldKey._id, + newKeyId, + rotatedByDid: args.rotatedByDid, + graceEndsAt: args.graceEndsAt, + createdAt: now, + updatedAt: now, + }); + + return { newKeyId, rotationEventId }; + }, +}); + +export const finalizeApiKeyRotation = mutation({ + args: { ownerDid: v.string(), oldKeyId: v.id("apiKeys") }, + handler: async (ctx, args) => { + const oldKey = await ctx.db.get(args.oldKeyId); + if (!oldKey || oldKey.ownerDid !== args.ownerDid) throw new Error("API key not found"); + if (!oldKey.rotatedToKeyId) throw new Error("API key is not in rotation"); + + const now = Date.now(); + const revokedAt = oldKey.revokedAt ?? now; + if (!oldKey.revokedAt) { + await ctx.db.patch(oldKey._id, { revokedAt }); + } + + const event = await ctx.db + .query("apiKeyRotationEvents") + .withIndex("by_old_key", (q) => q.eq("oldKeyId", oldKey._id)) + .first(); + + if (event && !event.oldKeyRevokedAt) { + await ctx.db.patch(event._id, { oldKeyRevokedAt: revokedAt, updatedAt: now }); + } + + return { ok: true, revokedAt, alreadyRevoked: Boolean(oldKey.revokedAt) }; + }, +}); + +export const listApiKeyRotationEvents = query({ + args: { ownerDid: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const limit = Math.min(Math.max(args.limit ?? 20, 1), 100); + return await ctx.db + .query("apiKeyRotationEvents") + .withIndex("by_owner_created", (q) => q.eq("ownerDid", args.ownerDid)) + .order("desc") + .take(limit); + }, +}); + +const DEFAULT_ARTIFACT_RETENTION_DAYS = 30; + +export const getMissionControlSettings = query({ + args: { ownerDid: v.string() }, + handler: async (ctx, args) => { + const settings = await ctx.db + .query("missionControlSettings") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .first(); + + return settings ?? { artifactRetentionDays: DEFAULT_ARTIFACT_RETENTION_DAYS }; + }, +}); + +export const upsertMissionControlSettings = mutation({ + args: { ownerDid: v.string(), updatedByDid: v.string(), artifactRetentionDays: v.number() }, + handler: async (ctx, args) => { + const now = Date.now(); + const artifactRetentionDays = clampRetentionDays(args.artifactRetentionDays, DEFAULT_ARTIFACT_RETENTION_DAYS); + const existing = await ctx.db + .query("missionControlSettings") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { artifactRetentionDays, updatedByDid: args.updatedByDid, updatedAt: now }); + return { ok: true, artifactRetentionDays }; + } + + await ctx.db.insert("missionControlSettings", { + ownerDid: args.ownerDid, + artifactRetentionDays, + updatedByDid: args.updatedByDid, + createdAt: now, + updatedAt: now, + }); + + return { ok: true, artifactRetentionDays }; + }, +}); + +export const applyArtifactRetention = mutation({ + args: { + ownerDid: v.string(), + actorDid: v.string(), + retentionDays: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + maxRuns: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const settings = await ctx.db + .query("missionControlSettings") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .first(); + + const retentionDays = clampRetentionDays(args.retentionDays, settings?.artifactRetentionDays ?? DEFAULT_ARTIFACT_RETENTION_DAYS); + const now = Date.now(); + const cutoff = computeRetentionCutoff(now, retentionDays); + const dryRun = args.dryRun ?? true; + const maxRuns = Math.min(Math.max(args.maxRuns ?? 250, 1), 1000); + + const runs = await ctx.db + .query("missionRuns") + .withIndex("by_owner_created", (q) => q.eq("ownerDid", args.ownerDid)) + .order("desc") + .take(maxRuns); + + let runsTouched = 0; + let deletedArtifacts = 0; + + for (const run of runs) { + const artifacts = normalizeArtifactRefs(run.artifactRefs ?? []); + const staleArtifacts = selectStaleArtifacts(artifacts, cutoff); + if (!staleArtifacts.length) continue; + + const existingLog = await ctx.db + .query("missionArtifactDeletionLogs") + .withIndex("by_run_cutoff_mode", (q) => q.eq("runId", run._id).eq("retentionCutoffAt", cutoff).eq("dryRun", dryRun)) + .first(); + + if (!existingLog || shouldInsertDeletionLog(existingLog.deletedArtifacts, staleArtifacts)) { + await ctx.db.insert("missionArtifactDeletionLogs", { + ownerDid: args.ownerDid, + runId: run._id, + deletedCount: staleArtifacts.length, + dryRun, + retentionCutoffAt: cutoff, + actorDid: args.actorDid, + trigger: "operator", + deletedArtifacts: staleArtifacts, + createdAt: now, + }); + } + + runsTouched += 1; + deletedArtifacts += staleArtifacts.length; + + if (!dryRun) { + await ctx.db.patch(run._id, { + artifactRefs: artifacts.filter((a) => a.createdAt >= cutoff), + updatedAt: now, + }); + } + } + + return { ok: true, dryRun, retentionDays, retentionCutoffAt: cutoff, runsScanned: runs.length, runsTouched, deletedArtifacts }; + }, +}); + +export const listArtifactDeletionLogs = query({ + args: { ownerDid: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); + return await ctx.db + .query("missionArtifactDeletionLogs") + .withIndex("by_owner_created", (q) => q.eq("ownerDid", args.ownerDid)) + .order("desc") + .take(limit); + }, +}); + +export const listTasksForList = query({ + args: { listId: v.id("lists"), userDid: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const ok = await hasListAccess(ctx, args.listId, args.userDid); + if (!ok) throw new Error("Not authorized"); + + const limit = Math.min(Math.max(args.limit ?? 100, 1), 200); + return await ctx.db.query("items").withIndex("by_list", (q) => q.eq("listId", args.listId)).take(limit); + }, +}); + +export const getTaskById = query({ + args: { itemId: v.id("items"), userDid: v.string() }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) return null; + const ok = await hasListAccess(ctx, item.listId, args.userDid); + if (!ok) throw new Error("Not authorized"); + return item; + }, +}); + +export const addAgentMemory = mutation({ + args: { + ownerDid: v.string(), + listId: v.optional(v.id("lists")), + agentSlug: v.string(), + key: v.string(), + value: v.string(), + }, + handler: async (ctx, args) => { + if (args.listId) { + const ok = await hasListAccess(ctx, args.listId, args.ownerDid); + if (!ok) throw new Error("Not authorized"); + } + + const now = Date.now(); + const existing = await ctx.db + .query("agentMemory") + .withIndex("by_owner_agent_key", (q) => q.eq("ownerDid", args.ownerDid).eq("agentSlug", args.agentSlug).eq("key", args.key)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { value: args.value, listId: args.listId, updatedAt: now }); + return existing._id; + } + + return await ctx.db.insert("agentMemory", { + ownerDid: args.ownerDid, + listId: args.listId, + agentSlug: args.agentSlug, + key: args.key, + value: args.value, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const getAgentMemory = query({ + args: { ownerDid: v.string(), agentSlug: v.string(), key: v.optional(v.string()) }, + handler: async (ctx, args) => { + if (args.key) { + return await ctx.db + .query("agentMemory") + .withIndex("by_owner_agent_key", (q) => q.eq("ownerDid", args.ownerDid).eq("agentSlug", args.agentSlug).eq("key", args.key!)) + .first(); + } + + return await ctx.db + .query("agentMemory") + .withIndex("by_owner_agent", (q) => q.eq("ownerDid", args.ownerDid).eq("agentSlug", args.agentSlug)) + .collect(); + }, +}); + +export const listActivityEvents = query({ + args: { listId: v.id("lists"), userDid: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const ok = await hasListAccess(ctx, args.listId, args.userDid); + if (!ok) throw new Error("Not authorized"); + + const limit = Math.min(Math.max(args.limit ?? 100, 1), 200); + return await ctx.db + .query("activityEvents") + .withIndex("by_list_created", (q) => q.eq("listId", args.listId)) + .order("desc") + .take(limit); + }, +}); + +type TerminalReason = "completed" | "killed" | "timeout" | "error" | "escalated"; +type RunStatus = "starting" | "running" | "degraded" | "blocked" | "failed" | "finished"; + +const HEARTBEAT_INTERVAL_DEFAULT_MS = 30_000; +const HEARTBEAT_DEGRADED_DEFAULT_MISSES = 2; +const HEARTBEAT_FAILED_DEFAULT_MISSES = 5; +const MAX_AUTO_RETRIES = 2; + +function isTerminal(status: RunStatus) { + return status === "failed" || status === "finished" || status === "blocked"; +} + +export const createMissionRun = mutation({ + args: { + ownerDid: v.string(), + listId: v.id("lists"), + itemId: v.optional(v.id("items")), + agentSlug: v.string(), + provider: v.optional(v.string()), + computerId: v.optional(v.string()), + parentRunId: v.optional(v.id("missionRuns")), + heartbeatIntervalMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const ok = await hasListAccess(ctx, args.listId, args.ownerDid); + if (!ok) throw new Error("Not authorized"); + + const now = Date.now(); + let attempt = 1; + + if (args.parentRunId) { + const parent = await ctx.db.get(args.parentRunId); + if (!parent || parent.ownerDid !== args.ownerDid) throw new Error("parentRunId not found"); + attempt = (parent.attempt ?? 1) + 1; + } + + const runId = await ctx.db.insert("missionRuns", { + ownerDid: args.ownerDid, + listId: args.listId, + itemId: args.itemId, + agentSlug: args.agentSlug, + provider: args.provider, + computerId: args.computerId, + status: "starting", + startedAt: now, + endedAt: undefined, + attempt, + parentRunId: args.parentRunId, + durationMs: undefined, + terminalReason: undefined, + heartbeatIntervalMs: args.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_DEFAULT_MS, + heartbeatMisses: 0, + heartbeatDegradedThreshold: HEARTBEAT_DEGRADED_DEFAULT_MISSES, + heartbeatFailedThreshold: HEARTBEAT_FAILED_DEFAULT_MISSES, + lastHeartbeatAt: now, + transientFailureCount: 0, + escalationAt: undefined, + artifactRefs: [], + createdAt: now, + updatedAt: now, + }); + + return { runId, attempt }; + }, +}); + +export const updateMissionRun = mutation({ + args: { + runId: v.id("missionRuns"), + ownerDid: v.string(), + provider: v.optional(v.string()), + computerId: v.optional(v.string()), + costEstimate: v.optional(v.number()), + tokenUsage: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const run = await ctx.db.get(args.runId); + if (!run || run.ownerDid !== args.ownerDid) throw new Error("Run not found"); + + await ctx.db.patch(args.runId, { + provider: args.provider ?? run.provider, + computerId: args.computerId ?? run.computerId, + costEstimate: args.costEstimate ?? run.costEstimate, + tokenUsage: args.tokenUsage ?? run.tokenUsage, + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +export const deleteMissionRun = mutation({ + args: { + runId: v.id("missionRuns"), + ownerDid: v.string(), + }, + handler: async (ctx, args) => { + const run = await ctx.db.get(args.runId); + if (!run || run.ownerDid !== args.ownerDid) throw new Error("Run not found"); + await ctx.db.delete(args.runId); + return { ok: true }; + }, +}); + +export const transitionMissionRun = mutation({ + args: { + runId: v.id("missionRuns"), + ownerDid: v.string(), + nextStatus: v.union( + v.literal("starting"), + v.literal("running"), + v.literal("degraded"), + v.literal("blocked"), + v.literal("failed"), + v.literal("finished") + ), + terminalReason: v.optional(v.union( + v.literal("completed"), + v.literal("killed"), + v.literal("timeout"), + v.literal("error"), + v.literal("escalated") + )), + costEstimate: v.optional(v.number()), + tokenUsage: v.optional(v.number()), + escalationAt: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const run = await ctx.db.get(args.runId); + if (!run || run.ownerDid !== args.ownerDid) throw new Error("Run not found"); + + if (isTerminal(run.status as RunStatus)) { + throw new Error("Run is already terminal"); + } + + const allowedTransitions: Record = { + starting: ["running", "failed", "blocked"], + running: ["degraded", "blocked", "failed", "finished"], + degraded: ["running", "blocked", "failed", "finished"], + blocked: [], + failed: [], + finished: [], + }; + + if (!allowedTransitions[run.status as RunStatus].includes(args.nextStatus as RunStatus)) { + throw new Error(`Invalid state transition: ${run.status} -> ${args.nextStatus}`); + } + + const now = Date.now(); + const terminal = isTerminal(args.nextStatus as RunStatus); + const terminalReason = terminal ? (args.terminalReason ?? (args.nextStatus === "finished" ? "completed" : "error")) : undefined; + + await ctx.db.patch(args.runId, { + status: args.nextStatus, + terminalReason, + endedAt: terminal ? now : undefined, + durationMs: terminal ? Math.max(0, now - run.startedAt) : run.durationMs, + costEstimate: args.costEstimate ?? run.costEstimate, + tokenUsage: args.tokenUsage ?? run.tokenUsage, + escalationAt: args.escalationAt ?? run.escalationAt, + updatedAt: now, + }); + + return { ok: true }; + }, +}); + +export const recordMissionRunHeartbeat = mutation({ + args: { + runId: v.id("missionRuns"), + ownerDid: v.string(), + at: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const run = await ctx.db.get(args.runId); + if (!run || run.ownerDid !== args.ownerDid) throw new Error("Run not found"); + if (isTerminal(run.status as RunStatus)) return { ok: true, status: run.status }; + + const now = args.at ?? Date.now(); + const misses = 0; + const shouldReturnToRunning = run.status === "degraded" || run.status === "starting"; + + await ctx.db.patch(args.runId, { + status: shouldReturnToRunning ? "running" : run.status, + heartbeatMisses: misses, + lastHeartbeatAt: now, + updatedAt: now, + }); + + return { ok: true, status: shouldReturnToRunning ? "running" : run.status }; + }, +}); + +export const monitorMissionRunHeartbeats = mutation({ + args: { + ownerDid: v.string(), + now: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const now = args.now ?? Date.now(); + const runs = await ctx.db + .query("missionRuns") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(); + + let degraded = 0; + let failed = 0; + + for (const run of runs) { + if (isTerminal(run.status as RunStatus)) continue; + + const intervalMs = run.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_DEFAULT_MS; + const degradedThreshold = run.heartbeatDegradedThreshold ?? HEARTBEAT_DEGRADED_DEFAULT_MISSES; + const failedThreshold = run.heartbeatFailedThreshold ?? HEARTBEAT_FAILED_DEFAULT_MISSES; + const lastBeat = run.lastHeartbeatAt ?? run.startedAt; + const elapsed = Math.max(0, now - lastBeat); + const misses = Math.floor(elapsed / intervalMs); + + let nextStatus: RunStatus = run.status as RunStatus; + let terminalReason: TerminalReason | undefined; + let escalationAt = run.escalationAt; + + if (misses >= failedThreshold) { + nextStatus = "failed"; + terminalReason = "timeout"; + if ((run.attempt ?? 1) > MAX_AUTO_RETRIES) { + escalationAt = now; + } + failed += 1; + } else if (misses >= degradedThreshold) { + if (run.status !== "degraded") degraded += 1; + nextStatus = "degraded"; + } + + if (nextStatus !== run.status || misses !== (run.heartbeatMisses ?? 0)) { + await ctx.db.patch(run._id, { + status: nextStatus, + heartbeatMisses: misses, + terminalReason, + escalationAt, + endedAt: nextStatus === "failed" ? now : run.endedAt, + durationMs: nextStatus === "failed" ? Math.max(0, now - run.startedAt) : run.durationMs, + updatedAt: now, + }); + } + } + + return { ok: true, degraded, failed, checkedAt: now }; + }, +}); + +export const createRetryForMissionRun = mutation({ + args: { + runId: v.id("missionRuns"), + ownerDid: v.string(), + }, + handler: async (ctx, args) => { + const run = await ctx.db.get(args.runId); + if (!run || run.ownerDid !== args.ownerDid) throw new Error("Run not found"); + + if (run.status !== "failed") throw new Error("Retry only allowed for failed runs"); + if (run.terminalReason && run.terminalReason !== "timeout" && run.terminalReason !== "error") { + throw new Error("Retry only allowed for transient failures"); + } + + if ((run.attempt ?? 1) > MAX_AUTO_RETRIES) { + await ctx.db.patch(args.runId, { + terminalReason: "escalated", + escalationAt: Date.now(), + updatedAt: Date.now(), + }); + return { escalated: true, reason: "max retries reached" }; + } + + const now = Date.now(); + const newRunId = await ctx.db.insert("missionRuns", { + ownerDid: run.ownerDid, + listId: run.listId, + itemId: run.itemId, + agentSlug: run.agentSlug, + provider: run.provider, + computerId: run.computerId, + status: "starting", + startedAt: now, + endedAt: undefined, + attempt: (run.attempt ?? 1) + 1, + parentRunId: run._id, + durationMs: undefined, + terminalReason: undefined, + costEstimate: undefined, + tokenUsage: undefined, + heartbeatIntervalMs: run.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_DEFAULT_MS, + heartbeatMisses: 0, + heartbeatDegradedThreshold: run.heartbeatDegradedThreshold ?? HEARTBEAT_DEGRADED_DEFAULT_MISSES, + heartbeatFailedThreshold: run.heartbeatFailedThreshold ?? HEARTBEAT_FAILED_DEFAULT_MISSES, + lastHeartbeatAt: now, + transientFailureCount: (run.transientFailureCount ?? 0) + 1, + escalationAt: undefined, + artifactRefs: [], + createdAt: now, + updatedAt: now, + }); + + return { escalated: false, runId: newRunId }; + }, +}); + +export const appendMissionRunArtifact = mutation({ + args: { + runId: v.id("missionRuns"), + ownerDid: v.string(), + type: v.union(v.literal("screenshot"), v.literal("log"), v.literal("diff"), v.literal("file"), v.literal("url")), + ref: v.string(), + label: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const run = await ctx.db.get(args.runId); + if (!run || run.ownerDid !== args.ownerDid) throw new Error("Run not found"); + + const now = Date.now(); + const artifactRefs = [...(run.artifactRefs ?? []), { type: args.type, ref: args.ref, label: args.label, createdAt: now }]; + await ctx.db.patch(args.runId, { artifactRefs, updatedAt: now }); + return { ok: true, artifactCount: artifactRefs.length }; + }, +}); + +export const getMissionRunById = query({ + args: { + ownerDid: v.string(), + runId: v.id("missionRuns"), + }, + handler: async (ctx, args) => { + const run = await ctx.db.get(args.runId); + if (!run || run.ownerDid !== args.ownerDid) return null; + return run; + }, +}); + +export const listMissionRuns = query({ + args: { + ownerDid: v.string(), + listId: v.optional(v.id("lists")), + itemId: v.optional(v.id("items")), + status: v.optional(v.union( + v.literal("starting"), + v.literal("running"), + v.literal("degraded"), + v.literal("blocked"), + v.literal("failed"), + v.literal("finished") + )), + startDate: v.optional(v.number()), + endDate: v.optional(v.number()), + limit: v.optional(v.number()), + page: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const pageSize = Math.min(Math.max(args.limit ?? 25, 1), 100); + const page = Math.max(args.page ?? 1, 1); + + let rows; + if (args.status) { + rows = await ctx.db + .query("missionRuns") + .withIndex("by_owner_status", (q) => q.eq("ownerDid", args.ownerDid).eq("status", args.status!)) + .collect(); + } else if (args.listId) { + rows = await ctx.db + .query("missionRuns") + .withIndex("by_owner_list", (q) => q.eq("ownerDid", args.ownerDid).eq("listId", args.listId!)) + .collect(); + } else if (args.itemId) { + rows = await ctx.db + .query("missionRuns") + .withIndex("by_owner_item", (q) => q.eq("ownerDid", args.ownerDid).eq("itemId", args.itemId!)) + .collect(); + } else { + rows = await ctx.db + .query("missionRuns") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(); + } + + const start = args.startDate; + const end = args.endDate; + const filtered = rows + .filter((run) => { + if (start !== undefined && run.createdAt < start) return false; + if (end !== undefined && run.createdAt > end) return false; + return true; + }) + .sort((a, b) => b.createdAt - a.createdAt); + + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(page, totalPages); + const offset = (safePage - 1) * pageSize; + const runs = filtered.slice(offset, offset + pageSize); + + return { + runs, + pagination: { + page: safePage, + pageSize, + total, + totalPages, + hasNext: safePage < totalPages, + hasPrev: safePage > 1, + }, + }; + }, +}); + +export const getMissionRunsDashboard = query({ + args: { ownerDid: v.string(), windowMs: v.optional(v.number()) }, + handler: async (ctx, args) => { + const now = Date.now(); + const windowMs = args.windowMs ?? 24 * 60 * 60 * 1000; + const from = now - windowMs; + + const rows = await ctx.db + .query("missionRuns") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(); + + const runs = rows.filter((r) => r.createdAt >= from); + const terminal = runs.filter((r) => isTerminal(r.status as RunStatus)); + const succeeded = terminal.filter((r) => r.status === "finished").length; + const failed = terminal.filter((r) => r.status === "failed").length; + const blocked = terminal.filter((r) => r.status === "blocked").length; + const timeout = terminal.filter((r) => r.terminalReason === "timeout").length; + const intervention = terminal.filter((r) => r.terminalReason === "killed" || r.terminalReason === "escalated" || r.status === "blocked").length; + + const successRate = terminal.length ? succeeded / terminal.length : 0; + const timeoutRate = terminal.length ? timeout / terminal.length : 0; + const interventionRate = terminal.length ? intervention / terminal.length : 0; + + const activeRuns = runs.filter((r) => !isTerminal(r.status as RunStatus)); + const degradedRuns = activeRuns.filter((r) => r.status === "degraded"); + + let staleRuns = 0; + for (const run of activeRuns) { + const heartbeatAgeMs = Math.max(0, now - (run.lastHeartbeatAt ?? run.startedAt)); + emitServerMetric("agent_heartbeat_age_ms", "gauge", heartbeatAgeMs, { + agentSlug: run.agentSlug, + }); + + const intervalMs = run.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_DEFAULT_MS; + const degradedThreshold = run.heartbeatDegradedThreshold ?? HEARTBEAT_DEGRADED_DEFAULT_MISSES; + if (heartbeatAgeMs >= intervalMs * degradedThreshold) { + staleRuns += 1; + } + } + emitServerMetric("agent_stale_total", "gauge", staleRuns); + + return { + windowMs, + totals: { + runs: runs.length, + terminal: terminal.length, + active: activeRuns.length, + succeeded, + failed, + blocked, + timedOut: timeout, + }, + rates: { + successRate, + timeoutRate, + interventionRate, + }, + activeByStatus: { + starting: activeRuns.filter((r) => r.status === "starting").length, + running: activeRuns.filter((r) => r.status === "running").length, + degraded: degradedRuns.length, + }, + degradedRuns: degradedRuns + .sort((a, b) => (b.lastHeartbeatAt ?? 0) - (a.lastHeartbeatAt ?? 0)) + .slice(0, 20), + updatedAt: now, + }; + }, +}); + + +type LaunchAction = "pause" | "resume" | "kill" | "reassign" | "escalate"; + +export const controlAgentLaunch = mutation({ + args: { + ownerDid: v.string(), + actorDid: v.string(), + agentSlug: v.string(), + action: v.union( + v.literal("pause"), + v.literal("resume"), + v.literal("kill"), + v.literal("reassign"), + v.literal("escalate") + ), + targetAgentSlug: v.optional(v.string()), + reason: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const profile = await ctx.db + .query("agentProfiles") + .withIndex("by_owner_slug", (q) => q.eq("ownerDid", args.ownerDid).eq("agentSlug", args.agentSlug)) + .first(); + + if (!profile) throw new Error("Agent profile not found"); + + const patch: Record = { + updatedAt: now, + lastStatusAt: now, + }; + + if (args.action === "pause") { + patch.status = "idle"; + patch.launchState = "paused"; + patch.pausedAt = now; + } else if (args.action === "resume") { + patch.launchState = "running"; + patch.pausedAt = undefined; + patch.killedAt = undefined; + patch.escalatedAt = undefined; + if ((profile.status ?? "idle") === "idle" && profile.currentTask) patch.status = "working"; + } else if (args.action === "kill") { + patch.status = "error"; + patch.launchState = "killed"; + patch.killedAt = now; + patch.archivedAt = now; + patch.currentTask = undefined; + } else if (args.action === "reassign") { + patch.parentAgentSlug = args.targetAgentSlug; + patch.launchState = "running"; + patch.archivedAt = undefined; + } else if (args.action === "escalate") { + patch.status = "error"; + patch.launchState = "escalated"; + patch.escalationLevel = (profile.escalationLevel ?? 0) + 1; + patch.escalatedToAgentSlug = args.targetAgentSlug; + patch.escalatedAt = now; + patch.archivedAt = undefined; + } + + await ctx.db.patch(profile._id, patch as any); + + await ctx.db.insert("agentControlEvents", { + ownerDid: args.ownerDid, + actorDid: args.actorDid, + agentProfileId: profile._id, + agentSlug: args.agentSlug, + action: args.action, + targetAgentSlug: args.targetAgentSlug, + reason: args.reason, + createdAt: now, + }); + + return { ok: true, agentId: profile._id, action: args.action as LaunchAction }; + }, +}); + +export const listAgentControlEvents = query({ + args: { + ownerDid: v.string(), + agentSlug: v.optional(v.string()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); + if (args.agentSlug) { + return await ctx.db + .query("agentControlEvents") + .withIndex("by_owner_agent_created", (q) => q.eq("ownerDid", args.ownerDid).eq("agentSlug", args.agentSlug!)) + .order("desc") + .take(limit); + } + + return await ctx.db + .query("agentControlEvents") + .withIndex("by_owner_created", (q) => q.eq("ownerDid", args.ownerDid)) + .order("desc") + .take(limit); + }, +}); diff --git a/convex/notificationActions.ts b/convex/notificationActions.ts new file mode 100644 index 0000000..f2ba9cb --- /dev/null +++ b/convex/notificationActions.ts @@ -0,0 +1,197 @@ +"use node"; +/** + * Push notification actions (Node.js) — APNs + Web Push. + * Queries/mutations are in notifications.ts. + */ + +import { v } from "convex/values"; +import { action } from "./_generated/server"; +import { internal } from "./_generated/api"; + +// ─── Send push notification (action) ──────────────────────────────── + +export const sendPushNotification = action({ + args: { + userDid: v.string(), + title: v.string(), + body: v.string(), + data: v.optional(v.any()), + }, + handler: async (ctx, args): Promise> => { + const tokens: Array<{ token: string; platform: string; webPushKeys?: { p256dh: string; auth: string } }> = await ctx.runQuery(internal.notifications.getTokensForUser, { + userDid: args.userDid, + }); + + const results: PromiseSettledResult[] = await Promise.allSettled( + tokens.map((tok: { token: string; platform: string; webPushKeys?: { p256dh: string; auth: string } }) => { + if (tok.platform === "ios") { + return sendAPNs(args.title, args.body, args.data, tok.token); + } else { + return sendWebPush(args.title, args.body, args.data, tok.token, tok.webPushKeys!); + } + }) + ); + + return results.map((r: PromiseSettledResult, i: number) => ({ + token: tokens[i].token.substring(0, 10) + "...", + platform: tokens[i].platform, + status: r.status, + reason: r.status === "rejected" ? String(r.reason) : undefined, + })); + }, +}); + +export const sendListNotification = action({ + args: { + listId: v.id("lists"), + excludeDid: v.optional(v.string()), + title: v.string(), + body: v.string(), + data: v.optional(v.any()), + }, + handler: async (ctx, args): Promise> => { + type TokenRecord = { userDid: string; token: string; platform: string; webPushKeys?: { p256dh: string; auth: string } }; + const tokens: TokenRecord[] = await ctx.runQuery(internal.notifications.getTokensForList, { + listId: args.listId, + }); + + const filtered: TokenRecord[] = args.excludeDid + ? tokens.filter((t: TokenRecord) => t.userDid !== args.excludeDid) + : tokens; + + const results: PromiseSettledResult[] = await Promise.allSettled( + filtered.map((tok: TokenRecord) => { + if (tok.platform === "ios") { + return sendAPNs(args.title, args.body, args.data, tok.token); + } else { + return sendWebPush(args.title, args.body, args.data, tok.token, tok.webPushKeys!); + } + }) + ); + + return results.map((r: PromiseSettledResult, i: number) => ({ + platform: filtered[i].platform, + status: r.status, + })); + }, +}); + +// ─── APNs HTTP/2 sender ───────────────────────────────────────────── + +async function createAPNsJWT(): Promise { + const keyId = process.env.APNS_KEY_ID!; + const teamId = process.env.APNS_TEAM_ID!; + const privateKeyPem = process.env.APNS_PRIVATE_KEY!; + + const pemBody = privateKeyPem + .replace(/-----BEGIN PRIVATE KEY-----/, "") + .replace(/-----END PRIVATE KEY-----/, "") + .replace(/\s/g, ""); + const keyBytes = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0)); + + const key = await crypto.subtle.importKey( + "pkcs8", + keyBytes.buffer, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"] + ); + + const header = { alg: "ES256", kid: keyId }; + const payload = { iss: teamId, iat: Math.floor(Date.now() / 1000) }; + + const encode = (obj: unknown) => + btoa(JSON.stringify(obj)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const headerB64 = encode(header); + const payloadB64 = encode(payload); + const signingInput = `${headerB64}.${payloadB64}`; + + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + key, + new TextEncoder().encode(signingInput) + ); + + const sigB64 = btoa(String.fromCharCode(...new Uint8Array(signature))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + return `${signingInput}.${sigB64}`; +} + +async function sendAPNs( + title: string, + body: string, + data: unknown, + deviceToken: string +): Promise { + const bundleId = process.env.APNS_BUNDLE_ID!; + const jwt = await createAPNsJWT(); + + const url = `https://api.push.apple.com/3/device/${deviceToken}`; + + const apnsPayload = { + aps: { + alert: { title, body }, + sound: "default", + badge: 1, + }, + ...(data && typeof data === "object" ? data : {}), + }; + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `bearer ${jwt}`, + "apns-topic": bundleId, + "apns-push-type": "alert", + "apns-priority": "10", + "Content-Type": "application/json", + }, + body: JSON.stringify(apnsPayload), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`APNs error ${res.status}: ${text}`); + } +} + +// ─── Web Push (VAPID) sender ──────────────────────────────────────── + +async function sendWebPush( + title: string, + body: string, + data: unknown, + endpoint: string, + keys: { p256dh: string; auth: string } +): Promise { + const vapidPublicKey = process.env.VAPID_PUBLIC_KEY!; + const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY!; + + const payload = JSON.stringify({ title, body, data }); + + const webpush = await import("web-push"); + + webpush.setVapidDetails( + "mailto:kgbot007@icloud.com", + vapidPublicKey, + vapidPrivateKey + ); + + await webpush.sendNotification( + { + endpoint, + keys: { + p256dh: keys.p256dh, + auth: keys.auth, + }, + }, + payload + ); +} diff --git a/convex/notifications.ts b/convex/notifications.ts new file mode 100644 index 0000000..840ef12 --- /dev/null +++ b/convex/notifications.ts @@ -0,0 +1,167 @@ +/** + * Push notification management — queries & mutations (non-Node.js). + * Actions that need Node.js are in notificationActions.ts. + */ + +import { v } from "convex/values"; +import { mutation, query, internalQuery } from "./_generated/server"; + +// ─── Token registration ───────────────────────────────────────────── + +export const registerPushToken = mutation({ + args: { + userDid: v.string(), + token: v.string(), + platform: v.union(v.literal("ios"), v.literal("android"), v.literal("web")), + webPushKeys: v.optional( + v.object({ p256dh: v.string(), auth: v.string() }) + ), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("pushTokens") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + userDid: args.userDid, + platform: args.platform, + webPushKeys: args.webPushKeys, + }); + return existing._id; + } + + return await ctx.db.insert("pushTokens", { + userDid: args.userDid, + token: args.token, + platform: args.platform, + webPushKeys: args.webPushKeys, + createdAt: Date.now(), + }); + }, +}); + +export const unregisterPushToken = mutation({ + args: { + token: v.string(), + userDid: v.string(), + }, + handler: async (ctx, args) => { + const tok = await ctx.db + .query("pushTokens") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + if (tok && tok.userDid === args.userDid) { + await ctx.db.delete(tok._id); + } + }, +}); + +// ─── Queries ───────────────────────────────────────────────────────── + +export const hasSubscription = query({ + args: { userDid: v.string() }, + handler: async (ctx, args) => { + const legacySub = await ctx.db + .query("pushSubscriptions") + .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) + .first(); + if (legacySub) return true; + + const token = await ctx.db + .query("pushTokens") + .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) + .first(); + return token !== null; + }, +}); + +export const getUserSubscriptions = query({ + args: { userDid: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("pushTokens") + .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) + .collect(); + }, +}); + +export const getTokensForUser = internalQuery({ + args: { userDid: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("pushTokens") + .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) + .collect(); + }, +}); + +export const getTokensForList = internalQuery({ + args: { listId: v.id("lists") }, + handler: async (ctx, args) => { + // Get tokens for the list owner + const list = await ctx.db.get(args.listId); + if (!list) return []; + + const tokens = []; + // Owner's tokens + const ownerTokens = await ctx.db + .query("pushTokens") + .withIndex("by_user", (q) => q.eq("userDid", list.ownerDid)) + .collect(); + tokens.push(...ownerTokens); + + // Tokens for users who bookmarked this list + const bookmarks = await ctx.db.query("bookmarks").collect(); + for (const bm of bookmarks) { + if (bm.listId === args.listId && bm.userDid !== list.ownerDid) { + const userTokens = await ctx.db + .query("pushTokens") + .withIndex("by_user", (q) => q.eq("userDid", bm.userDid)) + .collect(); + tokens.push(...userTokens); + } + } + return tokens; + }, +}); + +// ─── Legacy compatibility ──────────────────────────────────────────── + +export const saveSubscription = mutation({ + args: { + userDid: v.string(), + endpoint: v.string(), + keys: v.object({ p256dh: v.string(), auth: v.string() }), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("pushSubscriptions") + .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint)) + .first(); + if (existing) { + await ctx.db.patch(existing._id, { userDid: args.userDid, keys: args.keys }); + return existing._id; + } + return await ctx.db.insert("pushSubscriptions", { + userDid: args.userDid, + endpoint: args.endpoint, + keys: args.keys, + createdAt: Date.now(), + }); + }, +}); + +export const removeSubscription = mutation({ + args: { endpoint: v.string(), userDid: v.string() }, + handler: async (ctx, args) => { + const sub = await ctx.db + .query("pushSubscriptions") + .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint)) + .first(); + if (sub && sub.userDid === args.userDid) { + await ctx.db.delete(sub._id); + } + }, +}); diff --git a/convex/presence.ts b/convex/presence.ts new file mode 100644 index 0000000..7b1a2bb --- /dev/null +++ b/convex/presence.ts @@ -0,0 +1,98 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { canUserEditList } from "./lib/permissions"; + +const ACTIVE_WINDOW_MS = 60_000; + +export const heartbeat = mutation({ + args: { + listId: v.id("lists"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + status: v.optional(v.union(v.literal("active"), v.literal("idle"), v.literal("offline"))), + }, + handler: async (ctx, args) => { + const canAccess = await canUserEditList(ctx, args.listId, args.userDid, args.legacyDid); + if (!canAccess) throw new Error("Not authorized to update presence"); + + const now = Date.now(); + const status = args.status ?? "active"; + + const existing = await ctx.db + .query("presence") + .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", args.userDid)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { status, lastSeenAt: now, updatedAt: now }); + } else { + await ctx.db.insert("presence", { + listId: args.listId, + userDid: args.userDid, + status, + lastSeenAt: now, + updatedAt: now, + }); + } + + await ctx.db.insert("activities", { + listId: args.listId, + actorDid: args.userDid, + type: "presence_heartbeat", + metadata: { status }, + createdAt: now, + }); + + return { success: true, status, lastSeenAt: now }; + }, +}); + +export const markOffline = mutation({ + args: { + listId: v.id("lists"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const canAccess = await canUserEditList(ctx, args.listId, args.userDid, args.legacyDid); + if (!canAccess) throw new Error("Not authorized to update presence"); + + const existing = await ctx.db + .query("presence") + .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", args.userDid)) + .first(); + + const now = Date.now(); + if (existing) { + await ctx.db.patch(existing._id, { status: "offline", updatedAt: now, lastSeenAt: now }); + } + + await ctx.db.insert("activities", { + listId: args.listId, + actorDid: args.userDid, + type: "presence_offline", + metadata: { status: "offline" }, + createdAt: now, + }); + + return { success: true }; + }, +}); + +export const getListPresence = query({ + args: { listId: v.id("lists") }, + handler: async (ctx, args) => { + const now = Date.now(); + const rows = await ctx.db + .query("presence") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .collect(); + + return rows.map((row) => { + const computedStatus = row.lastSeenAt >= now - ACTIVE_WINDOW_MS + ? row.status === "offline" ? "offline" : "active" + : "idle"; + return { ...row, computedStatus }; + }); + }, +}); diff --git a/convex/presenceHttp.ts b/convex/presenceHttp.ts new file mode 100644 index 0000000..cbdc636 --- /dev/null +++ b/convex/presenceHttp.ts @@ -0,0 +1,47 @@ +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; +import { requireAuthenticatedUser } from "./lib/authUser"; +import { jsonResponse, errorResponse } from "./lib/httpResponses"; + +export const heartbeat = httpAction(async (ctx, request) => { + try { + const user = await requireAuthenticatedUser(ctx, request); + const body = await request.json(); + const { listId, status } = body as { listId: string; status?: "active" | "idle" | "offline" }; + if (!listId) return errorResponse(request, "listId is required"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await ctx.runMutation((api as any).presence.heartbeat, { + listId: listId as Id<"lists">, + userDid: user.did, + legacyDid: user.legacyDid, + status, + }); + + return jsonResponse(request, result); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed to update presence", 500); + } +}); + +export const listPresence = httpAction(async (ctx, request) => { + try { + await requireAuthenticatedUser(ctx, request); + const body = await request.json(); + const { listId } = body as { listId: string }; + if (!listId) return errorResponse(request, "listId is required"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const presence = await ctx.runQuery((api as any).presence.getListPresence, { + listId: listId as Id<"lists">, + }); + + return jsonResponse(request, { presence }); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed to read presence", 500); + } +}); diff --git a/convex/publication.ts b/convex/publication.ts index 9812b52..6285ae1 100644 --- a/convex/publication.ts +++ b/convex/publication.ts @@ -16,8 +16,8 @@ export const publishList = mutation({ args: { listId: v.id("lists"), webvhDid: v.string(), - didDocument: v.string(), - didLog: v.string(), + didDocument: v.optional(v.string()), + didLog: v.optional(v.string()), publisherDid: v.string(), }, handler: async (ctx, args) => { @@ -174,6 +174,9 @@ export const getPublicList = query({ ownerDid: list.ownerDid, ownerName: owner?.displayName ?? "Unknown", createdAt: list.createdAt, + assetDid: list.assetDid, + customAisles: list.customAisles, + itemViewMode: list.itemViewMode, }, items: enrichedItems, publication: { @@ -186,6 +189,85 @@ export const getPublicList = query({ }, }); +/** + * Bookmark a published list so it shows in the user's list view. + */ +export const bookmarkList = mutation({ + args: { + listId: v.id("lists"), + userDid: v.string(), + }, + handler: async (ctx, args) => { + // Verify list exists and is published + const pub = await ctx.db + .query("publications") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .first(); + + if (!pub || pub.status !== "active") { + throw new Error("List is not published"); + } + + // Check if already bookmarked + const existing = await ctx.db + .query("bookmarks") + .withIndex("by_user_list", (q) => + q.eq("userDid", args.userDid).eq("listId", args.listId) + ) + .first(); + + if (existing) return existing._id; + + return await ctx.db.insert("bookmarks", { + userDid: args.userDid, + listId: args.listId, + bookmarkedAt: Date.now(), + }); + }, +}); + +/** + * Remove a bookmark. + */ +export const unbookmarkList = mutation({ + args: { + listId: v.id("lists"), + userDid: v.string(), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("bookmarks") + .withIndex("by_user_list", (q) => + q.eq("userDid", args.userDid).eq("listId", args.listId) + ) + .first(); + + if (existing) { + await ctx.db.delete(existing._id); + } + }, +}); + +/** + * Check if a list is bookmarked by the user. + */ +export const isBookmarked = query({ + args: { + listId: v.id("lists"), + userDid: v.string(), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("bookmarks") + .withIndex("by_user_list", (q) => + q.eq("userDid", args.userDid).eq("listId", args.listId) + ) + .first(); + + return !!existing; + }, +}); + /** * Get publication status for a list. * Returns publication info if the list is published, null otherwise. @@ -206,6 +288,26 @@ export const getPublicationStatus = query({ publishedAt: pub.publishedAt, status: pub.status, publishedByDid: pub.publishedByDid, + anchorStatus: pub.anchorStatus ?? "none", + anchorTxId: pub.anchorTxId, + anchorBlockHeight: pub.anchorBlockHeight, + anchorTimestamp: pub.anchorTimestamp, }; }, }); + +/** + * Get all bookmarked list IDs for a user. + */ +export const getUserBookmarkIds = query({ + args: { + userDid: v.string(), + }, + handler: async (ctx, args) => { + const bookmarks = await ctx.db + .query("bookmarks") + .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) + .collect(); + return bookmarks.map((b) => b.listId); + }, +}); diff --git a/convex/scheduleEntries.ts b/convex/scheduleEntries.ts new file mode 100644 index 0000000..2afffc9 --- /dev/null +++ b/convex/scheduleEntries.ts @@ -0,0 +1,204 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; + +async function requireListAccess(ctx: any, listId: Id<"lists">, userDid: string) { + const list = await ctx.db.get(listId); + if (!list) throw new Error("List not found"); + if (list.ownerDid === userDid) return list; + + const publication = await ctx.db + .query("publications") + .withIndex("by_list", (q: any) => q.eq("listId", listId)) + .first(); + + if (publication?.status === "active") return list; + throw new Error("Not authorized for this list"); +} + +export const listForList = query({ + args: { + listId: v.id("lists"), + userDid: v.string(), + monthStart: v.optional(v.number()), + monthEnd: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const list = await requireListAccess(ctx, args.listId, args.userDid); + const rows = await ctx.db + .query("scheduleEntries") + .withIndex("by_owner", (q) => q.eq("ownerDid", list.ownerDid)) + .collect(); + + return rows.filter((entry) => { + if (entry.listId && entry.listId !== args.listId) return false; + const t = entry.scheduledAt ?? entry.nextRunAt; + if (!t) return true; + if (args.monthStart !== undefined && t < args.monthStart) return false; + if (args.monthEnd !== undefined && t > args.monthEnd) return false; + return true; + }); + }, +}); + +export const listForOwner = query({ + args: { + ownerDid: v.string(), + actorDid: v.string(), + listId: v.optional(v.id("lists")), + }, + handler: async (ctx, args) => { + if (args.ownerDid !== args.actorDid) throw new Error("Not authorized"); + const rows = await ctx.db + .query("scheduleEntries") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(); + + return rows + .filter((entry) => (args.listId ? entry.listId === args.listId : true)) + .sort((a, b) => { + const at = a.nextRunAt ?? a.scheduledAt ?? a.updatedAt; + const bt = b.nextRunAt ?? b.scheduledAt ?? b.updatedAt; + return at - bt; + }); + }, +}); + +export const createScheduleEntry = mutation({ + args: { + ownerDid: v.string(), + actorDid: v.string(), + listId: v.optional(v.id("lists")), + agentDid: v.optional(v.string()), + title: v.string(), + description: v.optional(v.string()), + scheduleType: v.union(v.literal("cron"), v.literal("once"), v.literal("recurring")), + cronExpr: v.optional(v.string()), + scheduledAt: v.optional(v.number()), + nextRunAt: v.optional(v.number()), + enabled: v.boolean(), + externalId: v.optional(v.string()), + source: v.optional(v.union(v.literal("manual"), v.literal("openclaw"), v.literal("import"))), + }, + handler: async (ctx, args) => { + if (args.ownerDid !== args.actorDid) throw new Error("Not authorized"); + const now = Date.now(); + return await ctx.db.insert("scheduleEntries", { + ownerDid: args.ownerDid, + listId: args.listId, + agentDid: args.agentDid, + title: args.title, + description: args.description, + scheduleType: args.scheduleType, + cronExpr: args.cronExpr, + scheduledAt: args.scheduledAt, + nextRunAt: args.nextRunAt, + lastRunAt: undefined, + lastStatus: undefined, + enabled: args.enabled, + externalId: args.externalId, + source: args.source ?? "manual", + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const updateScheduleEntry = mutation({ + args: { + entryId: v.id("scheduleEntries"), + actorDid: v.string(), + enabled: v.optional(v.boolean()), + nextRunAt: v.optional(v.number()), + lastRunAt: v.optional(v.number()), + lastStatus: v.optional(v.union(v.literal("ok"), v.literal("error"), v.literal("skipped"))), + }, + handler: async (ctx, args) => { + const entry = await ctx.db.get(args.entryId); + if (!entry) throw new Error("Schedule entry not found"); + if (entry.ownerDid !== args.actorDid) throw new Error("Not authorized"); + + await ctx.db.patch(args.entryId, { + enabled: args.enabled ?? entry.enabled, + nextRunAt: args.nextRunAt ?? entry.nextRunAt, + lastRunAt: args.lastRunAt ?? entry.lastRunAt, + lastStatus: args.lastStatus ?? entry.lastStatus, + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +export const syncCronSnapshot = mutation({ + args: { + ownerDid: v.string(), + actorDid: v.string(), + agentDid: v.optional(v.string()), + entries: v.array(v.object({ + externalId: v.string(), + title: v.string(), + cronExpr: v.optional(v.string()), + nextRunAt: v.optional(v.number()), + lastRunAt: v.optional(v.number()), + lastStatus: v.optional(v.union(v.literal("ok"), v.literal("error"), v.literal("skipped"))), + enabled: v.boolean(), + listId: v.optional(v.id("lists")), + })), + }, + handler: async (ctx, args) => { + if (args.ownerDid !== args.actorDid) throw new Error("Not authorized"); + const now = Date.now(); + const touched = new Set(); + + for (const payload of args.entries) { + touched.add(payload.externalId); + const existing = await ctx.db + .query("scheduleEntries") + .withIndex("by_owner_external", (q) => q.eq("ownerDid", args.ownerDid).eq("externalId", payload.externalId)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + title: payload.title, + cronExpr: payload.cronExpr, + nextRunAt: payload.nextRunAt, + lastRunAt: payload.lastRunAt, + lastStatus: payload.lastStatus, + enabled: payload.enabled, + listId: payload.listId, + source: "openclaw", + agentDid: args.agentDid ?? existing.agentDid, + updatedAt: now, + }); + } else { + await ctx.db.insert("scheduleEntries", { + ownerDid: args.ownerDid, + listId: payload.listId, + agentDid: args.agentDid, + title: payload.title, + description: undefined, + scheduleType: "cron", + cronExpr: payload.cronExpr, + scheduledAt: undefined, + lastRunAt: payload.lastRunAt, + nextRunAt: payload.nextRunAt, + lastStatus: payload.lastStatus, + enabled: payload.enabled, + externalId: payload.externalId, + source: "openclaw", + createdAt: now, + updatedAt: now, + }); + } + } + + const rows = await ctx.db.query("scheduleEntries").withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)).collect(); + for (const row of rows) { + if (row.source !== "openclaw" || !row.externalId || touched.has(row.externalId)) continue; + await ctx.db.patch(row._id, { enabled: false, updatedAt: now }); + } + + return { ok: true, synced: args.entries.length }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 7301749..30033d0 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,14 +1,24 @@ +/** + * Poo App Database Schema + * + * Core tables for the collaborative list-sharing app with DID-based identity. + * Uses Convex for real-time sync and offline support. + */ + import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; -// Collaborator roles -export const roleValidator = v.union( - v.literal("owner"), - v.literal("editor"), - v.literal("viewer") -); - export default defineSchema({ + // DID logs table - stores did:webvh logs for resolution + didLogs: defineTable({ + userDid: v.string(), // The user's did:webvh + path: v.string(), // URL path slug (e.g. "user-abc123") + log: v.string(), // JSONL content (one JSON object per line) + updatedAt: v.number(), + }) + .index("by_path", ["path"]) + .index("by_user_did", ["userDid"]), + // Rate limits table - for tracking auth endpoint rate limits (Phase 9.2) rateLimits: defineTable({ key: v.string(), // Unique identifier (IP address or session ID) @@ -35,7 +45,7 @@ export default defineSchema({ // Users table - for display name lookup by DID users: defineTable({ - did: v.string(), // did:peer:... from Originals SDK or Turnkey + did: v.optional(v.string()), // did:webvh:... created client-side (null until first login completes) displayName: v.string(), createdAt: v.number(), // Turnkey auth fields (added in Phase 1.3) @@ -61,17 +71,14 @@ export default defineSchema({ .index("by_owner", ["ownerDid"]) .index("by_owner_name", ["ownerDid", "name"]), - // Collaborators junction table - unlimited collaborators per list (Phase 3) - collaborators: defineTable({ - listId: v.id("lists"), + // Bookmarks table - tracks which published lists a user has bookmarked + bookmarks: defineTable({ userDid: v.string(), - role: v.union(v.literal("owner"), v.literal("editor"), v.literal("viewer")), - joinedAt: v.number(), - invitedByDid: v.optional(v.string()), // Who sent the invite + listId: v.id("lists"), + bookmarkedAt: v.number(), }) - .index("by_list", ["listId"]) .index("by_user", ["userDid"]) - .index("by_list_user", ["listId", "userDid"]), + .index("by_user_list", ["userDid", "listId"]), // Lists table - each list is an Originals asset lists: defineTable({ @@ -80,6 +87,26 @@ export default defineSchema({ ownerDid: v.string(), // Creator's DID categoryId: v.optional(v.id("categories")), // User's category for this list (Phase 2) createdAt: v.number(), + // VC proof for list ownership (Phase 6 - Provenance Chain) + vcProof: v.optional(v.object({ + type: v.string(), // e.g., "VerifiableCredential" + issuer: v.string(), // DID of issuer + issuanceDate: v.number(), // When VC was issued + credentialSubject: v.object({ + id: v.string(), // Subject DID (list assetDid) + ownerDid: v.string(), // Owner DID + }), + proof: v.optional(v.string()), // JWT or linked data proof + })), + // Custom grocery aisles created by users for this list + customAisles: v.optional(v.array(v.object({ + id: v.string(), + name: v.string(), + emoji: v.string(), + order: v.number(), + }))), + // Item view mode preference: "alphabetical" (flat A-Z) or "categorized" (grouped by category) + itemViewMode: v.optional(v.union(v.literal("alphabetical"), v.literal("categorized"))), }) .index("by_owner", ["ownerDid"]) .index("by_asset_did", ["assetDid"]) @@ -96,21 +123,425 @@ export default defineSchema({ checkedAt: v.optional(v.number()), order: v.optional(v.number()), // Position in list (lower = higher in list) updatedAt: v.optional(v.number()), // Timestamp of last update (Phase 5.8 conflict resolution) - }).index("by_list", ["listId"]), + // New fields for enhanced items + description: v.optional(v.string()), // Notes/details for the item + dueDate: v.optional(v.number()), // Due date timestamp + url: v.optional(v.string()), // Link to PR, URL, or reference + recurrence: v.optional(v.object({ + frequency: v.union(v.literal("daily"), v.literal("weekly"), v.literal("monthly")), + interval: v.optional(v.number()), // Every N days/weeks/months (default 1) + nextDue: v.optional(v.number()), // Next occurrence timestamp + endDate: v.optional(v.number()), // Optional end date - stop recurring after this + })), + // Priority levels (high/medium/low) + priority: v.optional(v.union(v.literal("high"), v.literal("medium"), v.literal("low"))), + // Tags - array of tag IDs + tags: v.optional(v.array(v.id("tags"))), + // Grocery aisle override — user-assigned aisle that takes priority over keyword auto-classification + groceryAisle: v.optional(v.string()), + // Parent item ID for sub-items + parentId: v.optional(v.id("items")), + // Optional assignee DID for Mission Control workflows + assigneeDid: v.optional(v.string()), + // Attachments - stored file IDs + attachments: v.optional(v.array(v.id("_storage"))), + // VC proofs for item actions (Phase 6 - Provenance Chain) + vcProofs: v.optional(v.array(v.object({ + type: v.string(), // e.g., "ItemCreation", "ItemCompletion" + issuer: v.string(), // DID of issuer + issuanceDate: v.number(), // When action VC was issued + action: v.string(), // "created", "completed", "modified" + actorDid: v.string(), // Who performed the action + proof: v.optional(v.string()), // JWT or linked data proof + }))), + }) + .index("by_list", ["listId"]) + .index("by_parent", ["parentId"]) + .index("by_due_date", ["listId", "dueDate"]), + + // Item assignees table - tracks who is assigned to each item (Phase 1 foundation) + itemAssignees: defineTable({ + itemId: v.id("items"), + listId: v.id("lists"), + assigneeDid: v.string(), + assignedByDid: v.string(), + assignedAt: v.number(), + }) + .index("by_item", ["itemId"]) + .index("by_list", ["listId"]) + .index("by_assignee", ["assigneeDid"]) + .index("by_item_assignee", ["itemId", "assigneeDid"]), + + // Activity stream table - immutable audit/events for collaborative timelines + activities: defineTable({ + listId: v.id("lists"), + itemId: v.optional(v.id("items")), + actorDid: v.string(), + type: v.union( + v.literal("item_assigned"), + v.literal("item_unassigned"), + v.literal("presence_heartbeat"), + v.literal("presence_offline"), + v.literal("item_updated"), + v.literal("list_updated") + ), + metadata: v.optional(v.object({ + assigneeDid: v.optional(v.string()), + status: v.optional(v.union(v.literal("active"), v.literal("idle"), v.literal("offline"))), + note: v.optional(v.string()), + })), + createdAt: v.number(), + }) + .index("by_list", ["listId"]) + .index("by_item", ["itemId"]) + .index("by_actor", ["actorDid"]) + .index("by_list_created", ["listId", "createdAt"]), - // Invites table - for sharing lists with partners - invites: defineTable({ + // Presence table - ephemeral collaborator presence state per list + presence: defineTable({ listId: v.id("lists"), - token: v.string(), // Random unique string (uuid v4) - role: v.optional(v.union(v.literal("editor"), v.literal("viewer"))), // Role granted on accept (Phase 3, optional for backwards compat) + userDid: v.string(), + status: v.union(v.literal("active"), v.literal("idle"), v.literal("offline")), + lastSeenAt: v.number(), + updatedAt: v.number(), + }) + .index("by_list", ["listId"]) + .index("by_list_user", ["listId", "userDid"]) + .index("by_last_seen", ["lastSeenAt"]), + + // Tags table - for categorizing items + tags: defineTable({ + listId: v.id("lists"), + name: v.string(), + color: v.string(), // Hex color code + createdByDid: v.string(), createdAt: v.number(), - expiresAt: v.number(), // createdAt + 24 hours - usedAt: v.optional(v.number()), - usedByDid: v.optional(v.string()), }) - .index("by_token", ["token"]) + .index("by_list", ["listId"]) + .index("by_list_name", ["listId", "name"]), + + // List templates table - save lists as reusable templates + listTemplates: defineTable({ + name: v.string(), + description: v.optional(v.string()), + ownerDid: v.string(), + items: v.array(v.object({ + name: v.string(), + description: v.optional(v.string()), + priority: v.optional(v.union(v.literal("high"), v.literal("medium"), v.literal("low"))), + order: v.number(), + })), + createdAt: v.number(), + updatedAt: v.optional(v.number()), + isPublic: v.optional(v.boolean()), // Allow others to use this template + }) + .index("by_owner", ["ownerDid"]) + .index("by_public", ["isPublic"]), + + // Push notification subscriptions (web push) + pushSubscriptions: defineTable({ + userDid: v.string(), + endpoint: v.string(), + keys: v.object({ + p256dh: v.string(), + auth: v.string(), + }), + createdAt: v.number(), + }) + .index("by_user", ["userDid"]) + .index("by_endpoint", ["endpoint"]), + + // Push tokens (native iOS APNs + Android/Web) + pushTokens: defineTable({ + userDid: v.string(), + token: v.string(), // APNs device token or web push endpoint + platform: v.union(v.literal("ios"), v.literal("android"), v.literal("web")), + // For web push, store subscription details + webPushKeys: v.optional(v.object({ + p256dh: v.string(), + auth: v.string(), + })), + createdAt: v.number(), + }) + .index("by_user", ["userDid"]) + .index("by_token", ["token"]), + + // Mission Control activity feed events (Phase 1 runtime wiring) + activityEvents: defineTable({ + listId: v.id("lists"), + itemId: v.optional(v.id("items")), + eventType: v.union( + v.literal("created"), + v.literal("completed"), + v.literal("uncompleted"), + v.literal("assigned"), + v.literal("commented"), + v.literal("edited") + ), + actorDid: v.string(), + assigneeDid: v.optional(v.string()), + metadata: v.optional(v.string()), + createdAt: v.number(), + }) + .index("by_list_created", ["listId", "createdAt"]) + .index("by_item_created", ["itemId", "createdAt"]), + + // Mission Control run sessions + lifecycle telemetry (P0-6 hardening) + missionRuns: defineTable({ + ownerDid: v.string(), + listId: v.id("lists"), + itemId: v.optional(v.id("items")), + agentSlug: v.string(), + provider: v.optional(v.string()), + computerId: v.optional(v.string()), + status: v.union( + v.literal("starting"), + v.literal("running"), + v.literal("degraded"), + v.literal("blocked"), + v.literal("failed"), + v.literal("finished") + ), + startedAt: v.number(), + endedAt: v.optional(v.number()), + attempt: v.number(), + parentRunId: v.optional(v.id("missionRuns")), + durationMs: v.optional(v.number()), + terminalReason: v.optional(v.union( + v.literal("completed"), + v.literal("killed"), + v.literal("timeout"), + v.literal("error"), + v.literal("escalated") + )), + costEstimate: v.optional(v.number()), + tokenUsage: v.optional(v.number()), + heartbeatIntervalMs: v.optional(v.number()), + heartbeatMisses: v.optional(v.number()), + heartbeatDegradedThreshold: v.optional(v.number()), + heartbeatFailedThreshold: v.optional(v.number()), + lastHeartbeatAt: v.optional(v.number()), + transientFailureCount: v.optional(v.number()), + escalationAt: v.optional(v.number()), + artifactRefs: v.optional(v.array(v.object({ + type: v.union( + v.literal("screenshot"), + v.literal("log"), + v.literal("diff"), + v.literal("file"), + v.literal("url") + ), + ref: v.string(), + label: v.optional(v.string()), + createdAt: v.number(), + }))), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_owner", ["ownerDid"]) + .index("by_owner_status", ["ownerDid", "status"]) + .index("by_owner_list", ["ownerDid", "listId"]) + .index("by_owner_item", ["ownerDid", "itemId"]) + .index("by_owner_agent", ["ownerDid", "agentSlug"]) + .index("by_parent", ["parentRunId"]) + .index("by_owner_created", ["ownerDid", "createdAt"]) + .index("by_owner_heartbeat", ["ownerDid", "lastHeartbeatAt"]), + + // Mission Control presence sessions with TTL-like expiry tracking + presenceSessions: defineTable({ + listId: v.id("lists"), + userDid: v.string(), + sessionId: v.string(), + lastSeenAt: v.number(), + expiresAt: v.number(), + }) + .index("by_list", ["listId"]) + .index("by_list_expires", ["listId", "expiresAt"]) + .index("by_session", ["sessionId"]), + + // Agent identity + registration profiles for Mission Control API clients + agentProfiles: defineTable({ + ownerDid: v.string(), + agentSlug: v.string(), + displayName: v.string(), + description: v.optional(v.string()), + capabilities: v.optional(v.array(v.string())), + metadata: v.optional(v.string()), + status: v.optional(v.union(v.literal("idle"), v.literal("working"), v.literal("error"))), + currentTask: v.optional(v.string()), + parentAgentSlug: v.optional(v.string()), + lastHeartbeatAt: v.optional(v.number()), + lastStatusAt: v.optional(v.number()), + archivedAt: v.optional(v.number()), + launchState: v.optional(v.union(v.literal("running"), v.literal("paused"), v.literal("killed"), v.literal("escalated"))), + escalationLevel: v.optional(v.number()), + escalatedToAgentSlug: v.optional(v.string()), + pausedAt: v.optional(v.number()), + killedAt: v.optional(v.number()), + escalatedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_owner", ["ownerDid"]) + .index("by_owner_slug", ["ownerDid", "agentSlug"]) + .index("by_owner_parent", ["ownerDid", "parentAgentSlug"]) + .index("by_owner_status", ["ownerDid", "status"]), + + // Launch control events for pause/kill/reassign/escalate workflows + agentControlEvents: defineTable({ + ownerDid: v.string(), + actorDid: v.string(), + agentProfileId: v.id("agentProfiles"), + agentSlug: v.string(), + action: v.union( + v.literal("pause"), + v.literal("resume"), + v.literal("kill"), + v.literal("reassign"), + v.literal("escalate") + ), + targetAgentSlug: v.optional(v.string()), + reason: v.optional(v.string()), + createdAt: v.number(), + }) + .index("by_owner_created", ["ownerDid", "createdAt"]) + .index("by_owner_agent_created", ["ownerDid", "agentSlug", "createdAt"]) + .index("by_agent", ["agentProfileId"]), + + // API keys for scoped Mission Control REST access (stored hashed only) + apiKeys: defineTable({ + ownerDid: v.string(), + label: v.string(), + keyPrefix: v.string(), + keyHash: v.string(), + scopes: v.array(v.string()), + agentProfileId: v.optional(v.id("agentProfiles")), + rotatedFromKeyId: v.optional(v.id("apiKeys")), + rotatedToKeyId: v.optional(v.id("apiKeys")), + rotationGraceEndsAt: v.optional(v.number()), + createdAt: v.number(), + lastUsedAt: v.optional(v.number()), + revokedAt: v.optional(v.number()), + expiresAt: v.optional(v.number()), + }) + .index("by_owner", ["ownerDid"]) + .index("by_hash", ["keyHash"]) + .index("by_prefix", ["keyPrefix"]), + + apiKeyRotationEvents: defineTable({ + ownerDid: v.string(), + oldKeyId: v.id("apiKeys"), + newKeyId: v.id("apiKeys"), + rotatedByDid: v.string(), + graceEndsAt: v.number(), + oldKeyRevokedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_owner_created", ["ownerDid", "createdAt"]) + .index("by_old_key", ["oldKeyId"]), + + missionControlSettings: defineTable({ + ownerDid: v.string(), + artifactRetentionDays: v.number(), + updatedByDid: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_owner", ["ownerDid"]), + + missionArtifactDeletionLogs: defineTable({ + ownerDid: v.string(), + runId: v.id("missionRuns"), + deletedCount: v.number(), + dryRun: v.boolean(), + retentionCutoffAt: v.number(), + actorDid: v.string(), + trigger: v.union(v.literal("operator"), v.literal("system")), + deletedArtifacts: v.array(v.object({ + type: v.union(v.literal("screenshot"), v.literal("log"), v.literal("diff"), v.literal("file"), v.literal("url")), + ref: v.string(), + label: v.optional(v.string()), + createdAt: v.number(), + })), + createdAt: v.number(), + }) + .index("by_owner_created", ["ownerDid", "createdAt"]) + .index("by_run_cutoff_mode", ["runId", "retentionCutoffAt", "dryRun"]), + + // Agent memory KV entries for long-lived runtime context + agentMemory: defineTable({ + ownerDid: v.string(), + listId: v.optional(v.id("lists")), + agentSlug: v.string(), + key: v.string(), + value: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_owner_agent", ["ownerDid", "agentSlug"]) + .index("by_owner_agent_key", ["ownerDid", "agentSlug", "key"]) .index("by_list", ["listId"]), + // Mission Control memory browser entries (Phase 3) + memories: defineTable({ + ownerDid: v.string(), + authorDid: v.string(), + title: v.string(), + content: v.string(), + searchText: v.string(), + tags: v.optional(v.array(v.string())), + source: v.optional(v.union( + v.literal("manual"), + v.literal("openclaw"), + v.literal("clawboot"), + v.literal("import"), + v.literal("api") + )), + sourceRef: v.optional(v.string()), + externalId: v.optional(v.string()), + externalUpdatedAt: v.optional(v.number()), + lastSyncedAt: v.optional(v.number()), + syncStatus: v.optional(v.union(v.literal("synced"), v.literal("conflict"), v.literal("pending"))), + conflictNote: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_owner", ["ownerDid"]) + .index("by_owner_time", ["ownerDid", "updatedAt"]) + .index("by_owner_source", ["ownerDid", "source"]) + .index("by_owner_author", ["ownerDid", "authorDid"]) + .index("by_owner_external", ["ownerDid", "externalId"]) + .index("by_owner_sync_status", ["ownerDid", "syncStatus"]) + .searchIndex("search_content", { + searchField: "searchText", + filterFields: ["ownerDid", "source", "authorDid", "syncStatus"], + }), + + // Mission Control schedule entries (Phase 4 schedule/calendar) + scheduleEntries: defineTable({ + ownerDid: v.string(), + listId: v.optional(v.id("lists")), + agentDid: v.optional(v.string()), + title: v.string(), + description: v.optional(v.string()), + scheduleType: v.union(v.literal("cron"), v.literal("once"), v.literal("recurring")), + cronExpr: v.optional(v.string()), + scheduledAt: v.optional(v.number()), + lastRunAt: v.optional(v.number()), + nextRunAt: v.optional(v.number()), + lastStatus: v.optional(v.union(v.literal("ok"), v.literal("error"), v.literal("skipped"))), + enabled: v.boolean(), + externalId: v.optional(v.string()), + source: v.optional(v.union(v.literal("manual"), v.literal("openclaw"), v.literal("import"))), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_owner", ["ownerDid"]) + .index("by_owner_list", ["ownerDid", "listId"]) + .index("by_owner_external", ["ownerDid", "externalId"]) + .index("by_next_run", ["ownerDid", "nextRunAt"]), + // Publications table - did:webvh publication tracking (Phase 4) publications: defineTable({ listId: v.id("lists"), @@ -120,8 +551,59 @@ export default defineSchema({ status: v.union(v.literal("active"), v.literal("unpublished")), didDocument: v.optional(v.string()), // Cached DID document JSON didLog: v.optional(v.string()), // DID log for verification + // Bitcoin anchor tracking + anchorStatus: v.optional(v.union(v.literal("pending"), v.literal("verified"), v.literal("none"))), + anchorTxId: v.optional(v.string()), // Bitcoin transaction ID + anchorBlockHeight: v.optional(v.number()), // Block height where anchor was confirmed + anchorTimestamp: v.optional(v.number()), // When anchor was confirmed }) .index("by_list", ["listId"]) .index("by_webvh_did", ["webvhDid"]) .index("by_status", ["status"]), + + // Comments table - threaded discussions on items + comments: defineTable({ + itemId: v.id("items"), + userDid: v.string(), // Author of the comment + text: v.string(), + createdAt: v.number(), + }) + .index("by_item", ["itemId"]) + .index("by_user", ["userDid"]), + + // Bitcoin anchors table - list/item state anchored to Bitcoin signet (Phase 5 + 6) + bitcoinAnchors: defineTable({ + // Reference to what is being anchored (list or item) + listId: v.optional(v.id("lists")), + itemId: v.optional(v.id("items")), + // State hash and snapshot + contentHash: v.string(), // SHA-256 hash of list/item state at anchor time + stateSnapshot: v.optional(v.string()), // JSON of state at anchor time (for verification) + // Network and status + network: v.optional(v.union(v.literal("signet"), v.literal("mainnet"), v.literal("regtest"))), + status: v.union( + v.literal("pending"), // Anchor requested, awaiting inscription + v.literal("inscribed"), // Successfully inscribed on Bitcoin + v.literal("confirmed"), // Inscription confirmed (1+ blocks) + v.literal("failed") // Inscription failed + ), + // Bitcoin transaction data (populated after inscription) + txid: v.optional(v.string()), // Bitcoin transaction ID + inscriptionId: v.optional(v.string()), // Ordinals inscription ID + blockHeight: v.optional(v.number()), // Block height when confirmed + confirmations: v.optional(v.number()), // Number of confirmations + // Metadata + requestedByDid: v.string(), // User who triggered the anchor + createdAt: v.number(), + updatedAt: v.optional(v.number()), + inscribedAt: v.optional(v.number()), // When inscribed to mempool + confirmedAt: v.optional(v.number()), // When confirmed on-chain + // Error info for failed anchors + error: v.optional(v.string()), + }) + .index("by_list", ["listId"]) + .index("by_item", ["itemId"]) + .index("by_status", ["status"]) + .index("by_txid", ["txid"]) + .index("by_list_created", ["listId", "createdAt"]), }); diff --git a/convex/tags.ts b/convex/tags.ts new file mode 100644 index 0000000..67d73a2 --- /dev/null +++ b/convex/tags.ts @@ -0,0 +1,222 @@ +/** + * Tag management for categorizing items. + */ + +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import type { Id, Doc } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; + +/** + * Helper to check if a user can edit a list (owner or editor). + */ +async function canUserEditList( + ctx: MutationCtx | QueryCtx, + listId: Id<"lists">, + userDid: string, + legacyDid?: string +): Promise { + const list = await ctx.db.get(listId); + if (!list) return false; + + const dids = [userDid]; + if (legacyDid) dids.push(legacyDid); + + if (dids.includes(list.ownerDid)) return true; + + const pub = await ctx.db + .query("publications") + .withIndex("by_list", (q) => q.eq("listId", listId)) + .first(); + + return pub?.status === "active"; +} + +// Predefined tag colors +export const TAG_COLORS = [ + "#ef4444", // red + "#f97316", // orange + "#eab308", // yellow + "#22c55e", // green + "#14b8a6", // teal + "#3b82f6", // blue + "#8b5cf6", // purple + "#ec4899", // pink + "#6b7280", // gray +]; + +/** + * Create a new tag for a list. + */ +export const createTag = mutation({ + args: { + listId: v.id("lists"), + name: v.string(), + color: v.string(), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const canEdit = await canUserEditList(ctx, args.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to create tags for this list"); + } + + // Check if tag with same name already exists + const existing = await ctx.db + .query("tags") + .withIndex("by_list_name", (q) => q.eq("listId", args.listId).eq("name", args.name)) + .first(); + + if (existing) { + throw new Error("A tag with this name already exists"); + } + + return await ctx.db.insert("tags", { + listId: args.listId, + name: args.name, + color: args.color, + createdByDid: args.userDid, + createdAt: Date.now(), + }); + }, +}); + +/** + * Update a tag's name or color. + */ +export const updateTag = mutation({ + args: { + tagId: v.id("tags"), + name: v.optional(v.string()), + color: v.optional(v.string()), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const tag = await ctx.db.get(args.tagId); + if (!tag) throw new Error("Tag not found"); + + const canEdit = await canUserEditList(ctx, tag.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to update this tag"); + } + + const updates: Partial> = {}; + if (args.name !== undefined) updates.name = args.name; + if (args.color !== undefined) updates.color = args.color; + + await ctx.db.patch(args.tagId, updates); + return args.tagId; + }, +}); + +/** + * Delete a tag and remove it from all items. + */ +export const deleteTag = mutation({ + args: { + tagId: v.id("tags"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const tag = await ctx.db.get(args.tagId); + if (!tag) throw new Error("Tag not found"); + + const canEdit = await canUserEditList(ctx, tag.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to delete this tag"); + } + + // Remove tag from all items that have it + const items = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", tag.listId)) + .collect(); + + for (const item of items) { + if (item.tags?.includes(args.tagId)) { + await ctx.db.patch(item._id, { + tags: item.tags.filter((t) => t !== args.tagId), + }); + } + } + + await ctx.db.delete(args.tagId); + }, +}); + +/** + * Get all tags for a list. + */ +export const getListTags = query({ + args: { listId: v.id("lists") }, + handler: async (ctx, args) => { + return await ctx.db + .query("tags") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .collect(); + }, +}); + +/** + * Add a tag to an item. + */ +export const addTagToItem = mutation({ + args: { + itemId: v.id("items"), + tagId: v.id("tags"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error("Item not found"); + + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to update this item"); + } + + const tag = await ctx.db.get(args.tagId); + if (!tag || tag.listId !== item.listId) { + throw new Error("Tag not found or belongs to different list"); + } + + const currentTags = item.tags ?? []; + if (!currentTags.includes(args.tagId)) { + await ctx.db.patch(args.itemId, { + tags: [...currentTags, args.tagId], + updatedAt: Date.now(), + }); + } + }, +}); + +/** + * Remove a tag from an item. + */ +export const removeTagFromItem = mutation({ + args: { + itemId: v.id("items"), + tagId: v.id("tags"), + userDid: v.string(), + legacyDid: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error("Item not found"); + + const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + if (!canEdit) { + throw new Error("Not authorized to update this item"); + } + + const currentTags = item.tags ?? []; + await ctx.db.patch(args.itemId, { + tags: currentTags.filter((t) => t !== args.tagId), + updatedAt: Date.now(), + }); + }, +}); diff --git a/convex/templates.ts b/convex/templates.ts new file mode 100644 index 0000000..33ae13f --- /dev/null +++ b/convex/templates.ts @@ -0,0 +1,211 @@ +/** + * List templates - save and reuse list structures. + */ + +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +// Id type used in function arguments via v.id() + +const templateItemValidator = v.object({ + name: v.string(), + description: v.optional(v.string()), + priority: v.optional(v.union(v.literal("high"), v.literal("medium"), v.literal("low"))), + order: v.number(), +}); + +/** + * Create a template from an existing list. + */ +export const createFromList = mutation({ + args: { + listId: v.id("lists"), + templateName: v.string(), + description: v.optional(v.string()), + isPublic: v.optional(v.boolean()), + userDid: v.string(), + }, + handler: async (ctx, args) => { + const list = await ctx.db.get(args.listId); + if (!list) throw new Error("List not found"); + + // Get all items from the list + const items = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .collect(); + + // Filter out checked items and sub-items, only keep top-level unchecked items + const templateItems = items + .filter((item) => !item.checked && !item.parentId) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((item, index) => ({ + name: item.name, + description: item.description, + priority: item.priority, + order: index, + })); + + return await ctx.db.insert("listTemplates", { + name: args.templateName, + description: args.description, + ownerDid: args.userDid, + items: templateItems, + createdAt: Date.now(), + isPublic: args.isPublic ?? false, + }); + }, +}); + +/** + * Create a new template manually. + */ +export const createTemplate = mutation({ + args: { + name: v.string(), + description: v.optional(v.string()), + items: v.array(templateItemValidator), + isPublic: v.optional(v.boolean()), + userDid: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db.insert("listTemplates", { + name: args.name, + description: args.description, + ownerDid: args.userDid, + items: args.items, + createdAt: Date.now(), + isPublic: args.isPublic ?? false, + }); + }, +}); + +/** + * Update a template. + */ +export const updateTemplate = mutation({ + args: { + templateId: v.id("listTemplates"), + name: v.optional(v.string()), + description: v.optional(v.string()), + items: v.optional(v.array(templateItemValidator)), + isPublic: v.optional(v.boolean()), + userDid: v.string(), + }, + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + if (!template) throw new Error("Template not found"); + if (template.ownerDid !== args.userDid) { + throw new Error("Not authorized to update this template"); + } + + const updates: Record = { updatedAt: Date.now() }; + if (args.name !== undefined) updates.name = args.name; + if (args.description !== undefined) updates.description = args.description; + if (args.items !== undefined) updates.items = args.items; + if (args.isPublic !== undefined) updates.isPublic = args.isPublic; + + await ctx.db.patch(args.templateId, updates); + return args.templateId; + }, +}); + +/** + * Delete a template. + */ +export const deleteTemplate = mutation({ + args: { + templateId: v.id("listTemplates"), + userDid: v.string(), + }, + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + if (!template) throw new Error("Template not found"); + if (template.ownerDid !== args.userDid) { + throw new Error("Not authorized to delete this template"); + } + + await ctx.db.delete(args.templateId); + }, +}); + +/** + * Get user's templates. + */ +export const getUserTemplates = query({ + args: { userDid: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("listTemplates") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.userDid)) + .collect(); + }, +}); + +/** + * Get public templates. + */ +export const getPublicTemplates = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("listTemplates") + .withIndex("by_public", (q) => q.eq("isPublic", true)) + .collect(); + }, +}); + +/** + * Get a single template. + */ +export const getTemplate = query({ + args: { templateId: v.id("listTemplates") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.templateId); + }, +}); + +/** + * Create a new list from a template. + */ +export const createListFromTemplate = mutation({ + args: { + templateId: v.id("listTemplates"), + listName: v.string(), + userDid: v.string(), + }, + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + if (!template) throw new Error("Template not found"); + + // Check if template is accessible + if (!template.isPublic && template.ownerDid !== args.userDid) { + throw new Error("Not authorized to use this template"); + } + + const now = Date.now(); + + // Create the list + const listId = await ctx.db.insert("lists", { + assetDid: `did:peer:temp-${now}`, // Will be replaced with proper DID + name: args.listName, + ownerDid: args.userDid, + createdAt: now, + }); + + // Create items from template + for (const templateItem of template.items) { + await ctx.db.insert("items", { + listId, + name: templateItem.name, + description: templateItem.description, + priority: templateItem.priority, + checked: false, + createdByDid: args.userDid, + createdAt: now, + order: templateItem.order, + }); + } + + return listId; + }, +}); diff --git a/convex/users.ts b/convex/users.ts index daf9b9e..c05faf4 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -5,6 +5,7 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; /** * Look up display names for a list of DIDs. @@ -67,24 +68,23 @@ export const getUserStats = query({ ) .collect(); - // Get all collaborations where user is a member - const collaborations = await ctx.db - .query("collaborators") - .filter((q) => - q.or( - q.eq(q.field("userDid"), userDid), - legacyDid ? q.eq(q.field("userDid"), legacyDid) : q.eq(1, 0) - ) - ) - .collect(); + // Get bookmarked lists + const didsToCheck = [userDid]; + if (legacyDid) didsToCheck.push(legacyDid); + + const bookmarkedListIds: Id<"lists">[] = []; + for (const did of didsToCheck) { + const bookmarks = await ctx.db + .query("bookmarks") + .withIndex("by_user", (q) => q.eq("userDid", did)) + .collect(); + bookmarkedListIds.push(...bookmarks.map((b) => b.listId)); + } - // Combine list IDs from owned and collaborated lists const ownedListIds = new Set(ownedLists.map((l) => l._id)); - const collaboratedListIds = collaborations - .map((c) => c.listId) - .filter((id) => !ownedListIds.has(id)); + const sharedListIds = bookmarkedListIds.filter((id) => !ownedListIds.has(id)); - const allListIds = [...ownedListIds, ...collaboratedListIds]; + const allListIds = [...ownedListIds, ...sharedListIds]; // Count items across all lists let totalItems = 0; @@ -103,7 +103,7 @@ export const getUserStats = query({ return { totalLists: allListIds.length, ownedLists: ownedLists.length, - sharedLists: collaboratedListIds.length, + sharedLists: sharedListIds.length, totalItems, completedItems, pendingItems: totalItems - completedItems, diff --git a/docs/mission-control/mission-runs-api.md b/docs/mission-control/mission-runs-api.md new file mode 100644 index 0000000..fbc85b4 --- /dev/null +++ b/docs/mission-control/mission-runs-api.md @@ -0,0 +1,93 @@ +# Mission Runs API (v1 hardening) + +## List runs +`GET /api/v1/runs` + +Query params: +- `status` (optional) +- `listId` (optional) +- `itemId` (optional) +- `startDate` / `endDate` (optional, unix ms) +- `page` (optional, default `1`) +- `limit` (optional, default `25`, max `100`) + +## Create run +`POST /api/v1/runs` + +Body: +- `listId` (required) +- `agentSlug` (required) +- `itemId`, `provider`, `computerId`, `parentRunId`, `heartbeatIntervalMs` (optional) + +Requires scope: `runs:write`. + +## Edit run metadata +`PATCH /api/v1/runs/:id` + +Body fields (all optional): +- `provider` +- `computerId` +- `costEstimate` +- `tokenUsage` + +Requires scope: `runs:write`. + +## Run controls +- `POST /api/v1/runs/:id/pause` +- `POST /api/v1/runs/:id/kill` +- `POST /api/v1/runs/:id/escalate` +- `POST /api/v1/runs/:id/reassign` (body: `targetAgentSlug` required) +- `POST /api/v1/runs/:id/retry` +- `POST /api/v1/runs/:id/transition` +- `POST /api/v1/runs/:id/heartbeat` +- `POST /api/v1/runs/:id/artifacts` +- `POST /api/v1/runs/monitor` + +Control endpoints require scope: `runs:control`. + +## Retention + audit +- `GET /api/v1/runs/retention` (settings + deletion logs, **JWT only**) +- `PUT /api/v1/runs/retention` (update policy, **JWT only**) +- `POST /api/v1/runs/retention` (apply retention dry-run/live, **JWT only**) + +### API key rotation contract + guardrails +`POST /api/v1/auth/keys/:id/rotate` hardening now enforces: +- active key only (revoked keys are rejected) +- only one in-flight rotation per old key (`409` if already rotating) +- `gracePeriodHours` must be finite (clamped to `1..168`) +- optional `expiresAt` must be a future unix-ms timestamp and **must be later than** the grace-window end + +`POST /api/v1/auth/keys/:id/finalize-rotation` is idempotent and returns the effective `revokedAt` timestamp. + +Behavior guarantees: +- Retention day input is clamped to `1..365` and cutoff logic is strict (`createdAt < cutoff` is stale). +- Deletion logs are idempotent per `(runId, retentionCutoffAt, dryRun)` + artifact fingerprint to avoid duplicate audit rows during retries. +- Deletion log artifacts are schema-normalized before response serialization to harden API consumers. + +### Readiness drill auth notes +`scripts/mission-control-readiness-drill.mjs` supports split-auth checks so launch gates can validate key rotation and retention/audit integration: +- `MISSION_CONTROL_API_KEY` for API-key scoped routes (dashboard/runs + run controls) +- `MISSION_CONTROL_JWT` for JWT-only routes (`/api/v1/auth/keys`, `/api/v1/runs/retention`) +- `MISSION_CONTROL_BASE_URL` required for remote checks + +Live mode (`MISSION_CONTROL_DRILL_DRY_RUN=false`) also runs zero-downtime rotation assertions: +1. create temporary API key +2. rotate key and assert old+new key overlap during grace window +3. finalize rotation and assert old key is rejected while new key remains valid +4. best-effort cleanup of temporary keys + +## Dashboard +`GET /api/v1/dashboard/runs` + +Returns run-health aggregates: +- success rate +- intervention rate +- timeout rate +- active/degraded run slices + +Requires scope: `dashboard:read`. + +## Delete run +`DELETE /api/v1/runs/:id` + +Requires scope: `runs:control`. diff --git a/docs/mission-control/phase1-observability-alert-routing.json b/docs/mission-control/phase1-observability-alert-routing.json new file mode 100644 index 0000000..7cbc126 --- /dev/null +++ b/docs/mission-control/phase1-observability-alert-routing.json @@ -0,0 +1,82 @@ +{ + "version": 1, + "phase": "phase1", + "routing": { + "staging": { + "channel": "slack://aviary-mission-control-dev", + "escalation": "none" + }, + "production": { + "channel": "slack://aviary-oncall-mission-control", + "pager": "pagerduty://mission-control-primary", + "escalation": "15m" + } + }, + "alerts": [ + { + "name": "phase1_mutation_error_rate_high", + "severity": "high", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + }, + { + "name": "phase1_subscription_latency_p95_high", + "severity": "high", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + }, + { + "name": "phase1_data_integrity_anomaly", + "severity": "critical", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + }, + { + "name": "phase1_agent_heartbeat_stale", + "severity": "high", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + }, + { + "name": "phase1_run_control_failure", + "severity": "critical", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + } + ] +} diff --git a/docs/mission-control/phase1-observability-dashboard-config.json b/docs/mission-control/phase1-observability-dashboard-config.json new file mode 100644 index 0000000..8cff3f2 --- /dev/null +++ b/docs/mission-control/phase1-observability-dashboard-config.json @@ -0,0 +1,231 @@ +{ + "version": 1, + "phase": "phase1", + "dashboard": { + "title": "Mission Control \u2014 Phase 1 Baseline", + "tags": [ + "mission-control", + "phase1", + "observability" + ], + "panels": [ + { + "id": "realtime_health", + "title": "Realtime Health", + "charts": [ + { + "metric": "subscription_latency_ms", + "view": [ + "p50", + "p95" + ], + "unit": "ms" + }, + { + "metric": "mutation_error_total/mutation_total", + "view": [ + "rate_5m", + "rate_1h" + ], + "unit": "%" + }, + { + "metric": "active_presence_sessions", + "view": [ + "current" + ], + "unit": "count" + } + ] + }, + { + "id": "run_health", + "title": "Run Health", + "charts": [ + { + "metric": "agent_heartbeat_age_ms", + "view": [ + "p95", + "max" + ], + "unit": "ms" + }, + { + "metric": "agent_stale_total", + "view": [ + "current" + ], + "unit": "count" + }, + { + "metric": "run_control_action_total", + "view": [ + "rate_5m" + ], + "groupBy": [ + "action", + "result" + ] + } + ] + }, + { + "id": "collaboration_throughput", + "title": "Collaboration Throughput", + "charts": [ + { + "metric": "activity_event_total", + "view": [ + "per_minute" + ], + "groupBy": [ + "action" + ] + }, + { + "metric": "activity_event_total", + "filter": "action=assigned", + "view": [ + "per_day" + ] + }, + { + "metric": "activity_event_total", + "filter": "action=completed", + "view": [ + "per_day" + ] + } + ] + }, + { + "id": "data_integrity", + "title": "Data Integrity", + "charts": [ + { + "metric": "invalid_assignee_reference_total", + "view": [ + "current" + ], + "unit": "count" + }, + { + "metric": "duplicate_activity_event_total", + "view": [ + "current" + ], + "unit": "count" + }, + { + "metric": "out_of_order_activity_timestamps_total", + "view": [ + "current" + ], + "unit": "count" + } + ] + }, + { + "id": "user_experience", + "title": "User Experience", + "charts": [ + { + "metric": "activity_panel_open_latency_ms", + "view": [ + "p95" + ], + "unit": "ms" + }, + { + "metric": "list_render_latency_ms", + "view": [ + "p95" + ], + "unit": "ms" + }, + { + "metric": "client_error_total", + "view": [ + "rate_5m" + ], + "groupBy": [ + "route" + ] + } + ] + } + ] + }, + "alerts": [ + { + "name": "phase1_mutation_error_rate_high", + "condition": "(sum(rate(mutation_error_total[10m])) / clamp_min(sum(rate(mutation_total[10m])), 1)) > 0.02", + "severity": "high", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + }, + { + "name": "phase1_subscription_latency_p95_high", + "condition": "histogram_quantile(0.95, rate(subscription_latency_ms_bucket[10m])) > 1200", + "severity": "high", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + }, + { + "name": "phase1_data_integrity_anomaly", + "condition": "(max_over_time(invalid_assignee_reference_total[15m]) + max_over_time(duplicate_activity_event_total[15m]) + max_over_time(out_of_order_activity_timestamps_total[15m])) > 0", + "severity": "critical", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + }, + { + "name": "phase1_agent_heartbeat_stale", + "condition": "max_over_time(agent_stale_total[10m]) > 0", + "severity": "high", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + }, + { + "name": "phase1_run_control_failure", + "condition": "sum(rate(run_control_action_total{result=\"failed\"}[10m])) > 0", + "severity": "critical", + "route": { + "staging": [ + "slack://aviary-mission-control-dev" + ], + "production": [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary" + ] + } + } + ] +} diff --git a/docs/mission-control/phase1-observability-dashboard-plan.md b/docs/mission-control/phase1-observability-dashboard-plan.md new file mode 100644 index 0000000..38a675f --- /dev/null +++ b/docs/mission-control/phase1-observability-dashboard-plan.md @@ -0,0 +1,78 @@ +# Phase 1 Observability Dashboard Instrumentation Plan + +Status: Block 1 scaffold (implementation planning pass) + +## Objective +Ship Phase 1 with a Mission Control internal dashboard that covers: +- Realtime health +- Collaboration throughput +- Data integrity +- User experience latency/errors + +## Panel spec (from PRD) → instrumentation mapping + +### 1) Realtime Health +- **subscription_latency_ms** (P50/P95) + - Source: client metric emitted on subscription update callbacks. + - Dimensions: `route`, `listId`, `env`. +- **mutation_error_rate** (5m/1h) + - Source: Convex mutation wrapper + HTTP action error middleware. + - Dimensions: `mutationName`, `errorCode`, `env`. +- **active_presence_sessions** + - Source: `presence` table count of rows with `lastSeen > now - ttl`. + +### 2) Collaboration Throughput +- **activity_events_per_minute** by `action` + - Source: write path that inserts `activity` row. +- **assignments_per_day** + - Source: activity rows where `action = assigned`. +- **completion_events_per_day** + - Source: activity rows where `action = completed`. + +### 3) Data Integrity +- **invalid_assignee_reference_pct** + - Source: scheduled integrity query joining `items.assigneeDid` against known users/collaborators. +- **duplicate_activity_event_count** + - Source: detector for duplicate action signature `(listId,itemId,actorDid,action,tsBucket)`. +- **out_of_order_activity_timestamps** + - Source: detector where per-item activity timestamp decreases. + +### 4) User Experience +- **activity_panel_open_latency_ms** (P95) + - Source: client perf marks around panel open and first paint/data-ready. +- **list_render_latency_ms** (P95) + - Source: client perf marks from route enter to list first interactive render. +- **client_error_rate_by_route** + - Source: window error/rejection hooks + route tagging. + +## Alert thresholds (initial) +- mutation error rate > 2% for 10m +- subscription latency P95 > 1200ms for 10m +- data integrity anomaly count > 0 for 15m + +Routing: +- staging: internal dev channel only +- production: on-call channel + pager integration + acknowledgement required + +## Proposed implementation slices +1. **Metrics contract + naming** + - Add `docs/mission-control/phase1-observability-metrics.json` as canonical names/dimensions. +2. **Server instrumentation baseline** + - Add lightweight wrappers in Convex mutation/action entry points. +3. **Client instrumentation baseline** + - Add perf mark helper and route-aware error capture utility. +4. **Integrity detectors** + - Add periodic query jobs (or on-demand admin queries) for duplicate/out-of-order/invalid-assignee checks. +5. **Dashboard provisioning** + - Add provider-specific dashboard config from the metrics contract. + +## Blockers / assumptions +- Telemetry backend provider not yet fixed in repo (Datadog/Grafana/OTel pipeline TBD). +- Need decision on where alert routing config is managed for staging vs production. +- Assumes Phase 1 schema additions (`activity`, `presence`, `items.assigneeDid`) are available. + +## Definition of done for this plan +- Metric names frozen +- Data source owner per metric assigned +- Dashboard panels and alerts provisionable from config +- Runbook links attached to each alert diff --git a/docs/mission-control/phase1-observability-metrics.json b/docs/mission-control/phase1-observability-metrics.json new file mode 100644 index 0000000..40a8354 --- /dev/null +++ b/docs/mission-control/phase1-observability-metrics.json @@ -0,0 +1,176 @@ +{ + "version": 1, + "phase": "phase1", + "metrics": [ + { + "name": "subscription_latency_ms", + "type": "histogram", + "dimensions": [ + "route", + "listId", + "env" + ], + "status": "planned" + }, + { + "name": "mutation_total", + "type": "counter", + "dimensions": [ + "mutationName", + "env" + ], + "status": "implemented" + }, + { + "name": "mutation_error_total", + "type": "counter", + "dimensions": [ + "mutationName", + "errorCode", + "env" + ], + "status": "implemented" + }, + { + "name": "mutation_latency_ms", + "type": "histogram", + "dimensions": [ + "mutationName", + "env" + ], + "status": "implemented" + }, + { + "name": "active_presence_sessions", + "type": "gauge", + "dimensions": [ + "env" + ], + "status": "implemented" + }, + { + "name": "activity_event_total", + "type": "counter", + "dimensions": [ + "action", + "env" + ], + "status": "implemented" + }, + { + "name": "invalid_assignee_reference_total", + "type": "gauge", + "dimensions": [ + "env" + ], + "status": "planned" + }, + { + "name": "duplicate_activity_event_total", + "type": "gauge", + "dimensions": [ + "env" + ], + "status": "planned" + }, + { + "name": "out_of_order_activity_timestamps_total", + "type": "gauge", + "dimensions": [ + "env" + ], + "status": "planned" + }, + { + "name": "activity_panel_open_latency_ms", + "type": "histogram", + "dimensions": [ + "route", + "env" + ], + "status": "implemented" + }, + { + "name": "list_render_latency_ms", + "type": "histogram", + "dimensions": [ + "route", + "env" + ], + "status": "implemented" + }, + { + "name": "client_error_total", + "type": "counter", + "dimensions": [ + "route", + "errorType", + "env" + ], + "status": "implemented" + }, + { + "name": "route_view_total", + "type": "counter", + "dimensions": [ + "route", + "env" + ], + "status": "implemented" + }, + { + "name": "agent_heartbeat_age_ms", + "type": "gauge", + "dimensions": [ + "agentSlug", + "env" + ], + "status": "implemented" + }, + { + "name": "agent_stale_total", + "type": "gauge", + "dimensions": [ + "env" + ], + "status": "implemented" + }, + { + "name": "run_control_action_total", + "type": "counter", + "dimensions": [ + "action", + "result", + "env" + ], + "status": "implemented" + } + ], + "alerts": [ + { + "name": "phase1_mutation_error_rate_high", + "expr": "mutation_error_total / mutation_total > 0.02", + "window": "10m" + }, + { + "name": "phase1_subscription_latency_p95_high", + "expr": "p95(subscription_latency_ms) > 1200", + "window": "10m" + }, + { + "name": "phase1_data_integrity_anomaly", + "expr": "invalid_assignee_reference_total + duplicate_activity_event_total + out_of_order_activity_timestamps_total > 0", + "window": "15m" + }, + { + "name": "phase1_agent_heartbeat_stale", + "expr": "agent_stale_total > 0", + "window": "10m" + }, + { + "name": "phase1_run_control_failure", + "expr": "sum(rate(run_control_action_total{result=\"failed\"}[10m])) > 0", + "window": "10m" + } + ] +} diff --git a/docs/mission-control/phase1-observability-runbook.md b/docs/mission-control/phase1-observability-runbook.md new file mode 100644 index 0000000..6d32cbb --- /dev/null +++ b/docs/mission-control/phase1-observability-runbook.md @@ -0,0 +1,61 @@ +# Phase 1 Observability Runbook (Baseline) + +## What shipped in this baseline + +### Client metric collection points +- `src/App.tsx` + - `client_error_total` from `window.error` and `unhandledrejection` listeners with route tags. + - `route_view_total` per route navigation (supporting route-level context for UX/error trends). +- `src/pages/ListView.tsx` + - `list_render_latency_ms` measured once when list + items become ready. + - `active_presence_sessions` gauge emitted as `1` on list mount, `0` on unmount (initial baseline signal). +- `src/components/ItemDetailsModal.tsx` + - `activity_panel_open_latency_ms` measured from modal open to first animation frame. + +### Server metric collection points +- `convex/lib/observability.ts` + - `mutation_total`, `mutation_error_total`, `mutation_latency_ms` helpers. +- Instrumented mutations: + - `convex/items.ts`: `items.addItem`, `items.updateItem`, `items.checkItem` + - `convex/lists.ts`: `lists.createList` + - `convex/missionControl.ts`: assignee + presence events emit `activity_event_total`, and presence session lifecycle emits `active_presence_sessions`. + - `convex/missionControlCore.ts`: dashboard query emits `agent_heartbeat_age_ms` and `agent_stale_total`. + - `convex/missionControlApi.ts`: run-control endpoints emit `run_control_action_total` on successful operations. + +All baseline metrics emit as JSON logs with `[obs]` prefix. This is intentionally provider-neutral and immediately runnable. + +## Dashboard + alerts mapping +- Metrics contract: `docs/mission-control/phase1-observability-metrics.json` +- Dashboard spec/config: `docs/mission-control/phase1-observability-dashboard-config.json` +- Alert routing config: `docs/mission-control/phase1-observability-alert-routing.json` +- Planning context: `docs/mission-control/phase1-observability-dashboard-plan.md` +- Consistency validator (catalog ↔ dashboard ↔ alerts ↔ routing ↔ provisioned endpoints): + - `npm run mission-control:validate-observability` + - Enforces route parity between dashboard + routing files and fails if a route target is not declared in the routing endpoint catalog (`routing.staging/production channel|pager`). + - Enforces severity-to-routing policy for production: `low|medium → slack`, `high|critical → slack + pagerduty`. +- Policy unit tests: + - `npm run mission-control:test-observability` + +## Runnable path (today) +1. Start app and Convex dev stack. +2. Perform key flows: + - open list + - open item details panel + - add/update/check item + - create list +3. Collect logs: + - browser console `[obs]` events + - Convex function logs `[obs]` events +4. Feed logs to your sink of choice (Datadog/Grafana Loki/OTel collector). +5. Create dashboard panels + alerts directly from `phase1-observability-dashboard-config.json`. + +## E2E fixture/perf gate path +- Default Mission Control e2e uses seeded local auth fixture (`e2e/fixtures/auth.ts`). +- For production-sized perf runs, set fixture path: + - `MISSION_CONTROL_FIXTURE_PATH=e2e/fixtures/mission-control.production.json npm run test:e2e -- e2e/mission-control-phase1.spec.ts` + +## Known gaps (next pass) +- `subscription_latency_ms` not yet wired to Convex subscription timing hooks. +- Data integrity detectors (`invalid_assignee_reference_total`, `duplicate_activity_event_total`, `out_of_order_activity_timestamps_total`) still need scheduled jobs. +- `run_control_action_total` currently emits success paths; explicit failure-path metric emission remains to be wired for rejected/invalid control attempts. +- Alert acknowledgement + incident note enforcement depends on external paging provider setup. diff --git a/docs/mission-control/phase1-production-readiness-drill.md b/docs/mission-control/phase1-production-readiness-drill.md new file mode 100644 index 0000000..b4cca50 --- /dev/null +++ b/docs/mission-control/phase1-production-readiness-drill.md @@ -0,0 +1,18 @@ +# Mission Control Production Readiness Drill (P1-3) + +1. Validate alert configs: + - `npm run mission-control:validate-observability` +2. Execute run-control drill: + - `npm run mission-control:readiness-drill` + - Live mode now validates `pause`, `kill` (when >=2 runs are available), and `escalate` control paths. + - Dry-run mode still verifies API wiring without mutating runs. + - Local automation check: `npm run mission-control:test-readiness-drill` +3. Verify Team Dashboard run-health cards: + - stale / critical / errored / stuck-working counts +4. Operator checklist: + - [ ] pause path tested + - [ ] kill path tested + - [ ] escalation path tested + - [ ] alerts routed to Slack + PagerDuty + +Stop rollout if any run-control path fails or critical stale agents remain unresolved. diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..bcd4043 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,58 @@ +# E2E Test Notes + +## Mission Control Phase 1 seeded auth fixture + +`e2e/mission-control-phase1.spec.ts` supports a deterministic seeded auth session for OTP-gated environments. + +Set these env vars before running Playwright: + +- `E2E_AUTH_TOKEN` +- `E2E_AUTH_EMAIL` +- `E2E_AUTH_SUBORG_ID` +- `E2E_AUTH_DID` +- `E2E_AUTH_DISPLAY_NAME` (optional) + +Example: + +```bash +E2E_AUTH_TOKEN="" \ +E2E_AUTH_EMAIL="e2e-mission-control@aviary.tech" \ +E2E_AUTH_SUBORG_ID="suborg_e2e_mission_control" \ +E2E_AUTH_DID="did:webvh:e2e:mission-control" \ +npm run test:e2e -- e2e/mission-control-phase1.spec.ts +``` + +When these vars are present, tests seed `lisa-auth-state` + `lisa-jwt-token` in localStorage using your real backend JWT and skip OTP bootstrap. + +If these vars are absent, the fixture falls back to a fake local token (fine for local/dev auth, but cloud environments that validate JWTs will redirect to OTP and AC tests will skip with an explicit reason). + +`mission-control-phase1.spec.ts` now always runs **AC0 auth readiness probe** in CI: it captures deterministic auth diagnostics artifacts (`auth-diagnostics-*.json`, `auth-gate-*.png`, `auth-gate-*.html`) when the app is OTP-gated so failures/skips are actionable without reproducing locally. + +## Mission Control AC5 perf fixture + +Set `MISSION_CONTROL_FIXTURE_PATH` to a JSON file for AC5 perf gate tuning (example: `e2e/fixtures/mission-control.production.json`). + +Supported fields: +- `listOpenRuns` +- `listOpenP95Ms` +- `activityOpenRuns` +- `activityOpenP95Ms` +- `itemsPerList` +- `seededListCount` (optional, defaults to `listOpenRuns`) + +The loader validates shape/ranges and fails fast for runaway seed plans (`seededListCount * itemsPerList > 3000`) so production-sized fixture jobs error clearly instead of hanging/flaking. + +You can also override any AC5 gate values directly in CI without changing fixture files: + +- `MISSION_CONTROL_PERF_LIST_OPEN_RUNS` +- `MISSION_CONTROL_PERF_LIST_OPEN_P95_MS` +- `MISSION_CONTROL_PERF_ACTIVITY_OPEN_RUNS` +- `MISSION_CONTROL_PERF_ACTIVITY_OPEN_P95_MS` +- `MISSION_CONTROL_PERF_ITEMS_PER_LIST` +- `MISSION_CONTROL_PERF_SEEDED_LIST_COUNT` + +AC5 tests now emit newline-delimited JSON perf gate artifacts by default at: + +- `test-results/mission-control-perf-gates.ndjson` + +Set `MISSION_CONTROL_PERF_REPORT_PATH` to customize this output path in CI artifact collection. diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts new file mode 100644 index 0000000..1f09a1f --- /dev/null +++ b/e2e/fixtures/auth.ts @@ -0,0 +1,65 @@ +import type { Page } from "@playwright/test"; + +export interface SeededAuthUser { + turnkeySubOrgId: string; + email: string; + did: string; + displayName: string; +} + +function envAuthSeed(): { user: SeededAuthUser; token: string } | null { + const token = process.env.E2E_AUTH_TOKEN; + const email = process.env.E2E_AUTH_EMAIL; + const turnkeySubOrgId = process.env.E2E_AUTH_SUBORG_ID; + const did = process.env.E2E_AUTH_DID; + + if (!token) return null; + + if (!email || !turnkeySubOrgId || !did) { + throw new Error( + "E2E_AUTH_TOKEN is set, but E2E_AUTH_EMAIL/E2E_AUTH_SUBORG_ID/E2E_AUTH_DID are missing." + ); + } + + return { + token, + user: { + turnkeySubOrgId, + email, + did, + displayName: process.env.E2E_AUTH_DISPLAY_NAME ?? "E2E Mission Control", + }, + }; +} + +export function buildFakeJwt(expSecondsFromNow = 60 * 60 * 24): string { + const header = { alg: "HS256", typ: "JWT" }; + const payload = { + sub: "e2e-user", + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + }; + const encode = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString("base64url"); + return `${encode(header)}.${encode(payload)}.e2e-signature`; +} + +export async function seedAuthSession(page: Page, user?: Partial) { + const seededFromEnv = envAuthSeed(); + + const authUser: SeededAuthUser = { + turnkeySubOrgId: user?.turnkeySubOrgId ?? seededFromEnv?.user.turnkeySubOrgId ?? "e2e-suborg-001", + email: user?.email ?? seededFromEnv?.user.email ?? "e2e+mission-control@poo.app", + did: user?.did ?? seededFromEnv?.user.did ?? "did:webvh:e2e.poo.app:users:e2e-suborg-001", + displayName: user?.displayName ?? seededFromEnv?.user.displayName ?? "E2E Mission Control", + }; + + const token = seededFromEnv?.token ?? buildFakeJwt(); + const authState = { + user: authUser, + token, + }; + + await page.addInitScript(({ state, jwt }) => { + localStorage.setItem("lisa-auth-state", JSON.stringify(state)); + localStorage.setItem("lisa-jwt-token", jwt); + }, { state: authState, jwt: token }); +} diff --git a/e2e/fixtures/mission-control-perf-fixture.ts b/e2e/fixtures/mission-control-perf-fixture.ts new file mode 100644 index 0000000..69a793e --- /dev/null +++ b/e2e/fixtures/mission-control-perf-fixture.ts @@ -0,0 +1,164 @@ +import { readFileSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; + +export interface PerfFixture { + listOpenRuns: number; + listOpenP95Ms: number; + activityOpenRuns: number; + activityOpenP95Ms: number; + itemsPerList: number; + seededListCount: number; +} + +export const DEFAULT_PERF_FIXTURE: PerfFixture = { + listOpenRuns: 6, + listOpenP95Ms: 500, + activityOpenRuns: 6, + activityOpenP95Ms: 700, + itemsPerList: 1, + seededListCount: 6, +}; + +const HARD_LIMITS = { + runs: 100, + latencyMs: 10_000, + itemsPerList: 300, + seededListCount: 100, +}; + +const ENV_OVERRIDES: Array<{ env: keyof NodeJS.ProcessEnv; field: keyof PerfFixture }> = [ + { env: "MISSION_CONTROL_PERF_LIST_OPEN_RUNS", field: "listOpenRuns" }, + { env: "MISSION_CONTROL_PERF_LIST_OPEN_P95_MS", field: "listOpenP95Ms" }, + { env: "MISSION_CONTROL_PERF_ACTIVITY_OPEN_RUNS", field: "activityOpenRuns" }, + { env: "MISSION_CONTROL_PERF_ACTIVITY_OPEN_P95_MS", field: "activityOpenP95Ms" }, + { env: "MISSION_CONTROL_PERF_ITEMS_PER_LIST", field: "itemsPerList" }, + { env: "MISSION_CONTROL_PERF_SEEDED_LIST_COUNT", field: "seededListCount" }, +]; + +function asBoundedPositiveInt(value: unknown, fieldName: string, fallback: number, max: number): number { + if (value === undefined) return fallback; + + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`[mission-control/perf-fixture] ${fieldName} must be a finite number.`); + } + + const asInt = Math.floor(value); + if (asInt <= 0) { + throw new Error(`[mission-control/perf-fixture] ${fieldName} must be > 0.`); + } + + if (asInt > max) { + throw new Error(`[mission-control/perf-fixture] ${fieldName} must be <= ${max}. Received ${asInt}.`); + } + + return asInt; +} + +function getFieldLimit(fieldName: keyof PerfFixture) { + if (fieldName === "listOpenRuns" || fieldName === "activityOpenRuns") return HARD_LIMITS.runs; + if (fieldName === "listOpenP95Ms" || fieldName === "activityOpenP95Ms") return HARD_LIMITS.latencyMs; + if (fieldName === "itemsPerList") return HARD_LIMITS.itemsPerList; + return HARD_LIMITS.seededListCount; +} + +function parsePerfFixture(rawFixture: unknown): PerfFixture { + if (!rawFixture || typeof rawFixture !== "object") { + throw new Error("[mission-control/perf-fixture] fixture JSON must be an object."); + } + + const fixture = rawFixture as Record; + + const parsed: PerfFixture = { + listOpenRuns: asBoundedPositiveInt( + fixture.listOpenRuns, + "listOpenRuns", + DEFAULT_PERF_FIXTURE.listOpenRuns, + HARD_LIMITS.runs, + ), + listOpenP95Ms: asBoundedPositiveInt( + fixture.listOpenP95Ms, + "listOpenP95Ms", + DEFAULT_PERF_FIXTURE.listOpenP95Ms, + HARD_LIMITS.latencyMs, + ), + activityOpenRuns: asBoundedPositiveInt( + fixture.activityOpenRuns, + "activityOpenRuns", + DEFAULT_PERF_FIXTURE.activityOpenRuns, + HARD_LIMITS.runs, + ), + activityOpenP95Ms: asBoundedPositiveInt( + fixture.activityOpenP95Ms, + "activityOpenP95Ms", + DEFAULT_PERF_FIXTURE.activityOpenP95Ms, + HARD_LIMITS.latencyMs, + ), + itemsPerList: asBoundedPositiveInt( + fixture.itemsPerList, + "itemsPerList", + DEFAULT_PERF_FIXTURE.itemsPerList, + HARD_LIMITS.itemsPerList, + ), + seededListCount: asBoundedPositiveInt( + fixture.seededListCount, + "seededListCount", + typeof fixture.listOpenRuns === "number" ? Math.floor(fixture.listOpenRuns) : DEFAULT_PERF_FIXTURE.seededListCount, + HARD_LIMITS.seededListCount, + ), + }; + + const totalSeededItems = parsed.seededListCount * parsed.itemsPerList; + if (totalSeededItems > 3_000) { + throw new Error(`[mission-control/perf-fixture] seededListCount * itemsPerList must be <= 3000. Received ${totalSeededItems}.`); + } + + return parsed; +} + +function applyEnvOverrides(base: PerfFixture, env: NodeJS.ProcessEnv): PerfFixture { + const next = { ...base }; + + for (const { env: envKey, field } of ENV_OVERRIDES) { + const raw = env[envKey]; + if (raw === undefined || raw.trim() === "") continue; + + const parsed = Number(raw); + next[field] = asBoundedPositiveInt(parsed, envKey, next[field], getFieldLimit(field)); + } + + const totalSeededItems = next.seededListCount * next.itemsPerList; + if (totalSeededItems > 3_000) { + throw new Error(`[mission-control/perf-fixture] seededListCount * itemsPerList must be <= 3000. Received ${totalSeededItems}.`); + } + + return next; +} + +export function loadPerfFixtureFromEnv(env: NodeJS.ProcessEnv = process.env): PerfFixture { + const fixturePath = env.MISSION_CONTROL_FIXTURE_PATH; + let fixture = DEFAULT_PERF_FIXTURE; + + if (fixturePath) { + const absolutePath = isAbsolute(fixturePath) ? fixturePath : resolve(process.cwd(), fixturePath); + + let raw: string; + try { + raw = readFileSync(absolutePath, "utf8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[mission-control/perf-fixture] failed to read ${absolutePath}: ${message}`); + } + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[mission-control/perf-fixture] invalid JSON in ${absolutePath}: ${message}`); + } + + fixture = parsePerfFixture(parsedJson); + } + + return applyEnvOverrides(fixture, env); +} diff --git a/e2e/fixtures/mission-control-perf-report.ts b/e2e/fixtures/mission-control-perf-report.ts new file mode 100644 index 0000000..724931f --- /dev/null +++ b/e2e/fixtures/mission-control-perf-report.ts @@ -0,0 +1,49 @@ +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import type { TestInfo } from "@playwright/test"; + +export interface PerfGateResult { + gate: "ac5a_list_open" | "ac5b_activity_open"; + p95Ms: number; + thresholdMs: number; + samplesMs: number[]; + fixturePath: string; + seededListCount?: number; + itemsPerList?: number; +} + +export function computeP95(samples: number[]): number { + const sorted = [...samples].sort((a, b) => a - b); + const idx = Math.ceil(sorted.length * 0.95) - 1; + return sorted[Math.max(0, idx)] ?? 0; +} + +export function writePerfGateResult(testInfo: TestInfo, result: PerfGateResult): string { + const payload = { + schema: "mission-control.perf-gate.v1", + timestamp: new Date().toISOString(), + status: result.p95Ms < result.thresholdMs ? "pass" : "fail", + ...result, + samplesMs: [...result.samplesMs].sort((a, b) => a - b), + testId: testInfo.testId, + title: testInfo.title, + project: testInfo.project.name, + retry: testInfo.retry, + }; + + const defaultPath = resolve(process.cwd(), "test-results", "mission-control-perf-gates.ndjson"); + const outPath = process.env.MISSION_CONTROL_PERF_REPORT_PATH + ? resolve(process.cwd(), process.env.MISSION_CONTROL_PERF_REPORT_PATH) + : defaultPath; + + mkdirSync(dirname(outPath), { recursive: true }); + appendFileSync(outPath, `${JSON.stringify(payload)}\n`, "utf8"); + + testInfo.attachments.push({ + name: `perf-gate-${result.gate}`, + contentType: "application/json", + body: Buffer.from(JSON.stringify(payload, null, 2)), + }); + + return outPath; +} diff --git a/e2e/fixtures/mission-control.production.json b/e2e/fixtures/mission-control.production.json new file mode 100644 index 0000000..9850232 --- /dev/null +++ b/e2e/fixtures/mission-control.production.json @@ -0,0 +1,8 @@ +{ + "listOpenRuns": 10, + "listOpenP95Ms": 500, + "activityOpenRuns": 10, + "activityOpenP95Ms": 700, + "itemsPerList": 50, + "seededListCount": 10 +} diff --git a/e2e/mission-control-perf-fixture.spec.ts b/e2e/mission-control-perf-fixture.spec.ts new file mode 100644 index 0000000..735df2b --- /dev/null +++ b/e2e/mission-control-perf-fixture.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_PERF_FIXTURE, loadPerfFixtureFromEnv } from "./fixtures/mission-control-perf-fixture"; + +test.describe("mission-control perf fixture parser", () => { + test("returns hardened defaults when env path is missing", () => { + expect(loadPerfFixtureFromEnv({})).toEqual(DEFAULT_PERF_FIXTURE); + }); + + test("loads valid fixture and applies seededListCount fallback from listOpenRuns", () => { + const dir = mkdtempSync(join(tmpdir(), "mc-perf-fixture-")); + const fixturePath = join(dir, "fixture.json"); + + try { + writeFileSync( + fixturePath, + JSON.stringify({ + listOpenRuns: 10, + listOpenP95Ms: 500, + activityOpenRuns: 8, + activityOpenP95Ms: 700, + itemsPerList: 40, + }), + ); + + const fixture = loadPerfFixtureFromEnv({ MISSION_CONTROL_FIXTURE_PATH: fixturePath }); + expect(fixture.seededListCount).toBe(10); + expect(fixture.itemsPerList).toBe(40); + expect(fixture.listOpenRuns).toBe(10); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("applies env overrides on top of fixture values", () => { + const dir = mkdtempSync(join(tmpdir(), "mc-perf-fixture-")); + const fixturePath = join(dir, "fixture.json"); + + try { + writeFileSync( + fixturePath, + JSON.stringify({ + listOpenRuns: 10, + listOpenP95Ms: 500, + activityOpenRuns: 8, + activityOpenP95Ms: 700, + itemsPerList: 3, + seededListCount: 10, + }), + ); + + const fixture = loadPerfFixtureFromEnv({ + MISSION_CONTROL_FIXTURE_PATH: fixturePath, + MISSION_CONTROL_PERF_LIST_OPEN_P95_MS: "420", + MISSION_CONTROL_PERF_SEEDED_LIST_COUNT: "12", + }); + + expect(fixture.listOpenP95Ms).toBe(420); + expect(fixture.seededListCount).toBe(12); + expect(fixture.listOpenRuns).toBe(10); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("rejects runaway production seeding plans", () => { + const dir = mkdtempSync(join(tmpdir(), "mc-perf-fixture-")); + const fixturePath = join(dir, "fixture.json"); + + try { + writeFileSync( + fixturePath, + JSON.stringify({ + seededListCount: 100, + itemsPerList: 100, + }), + ); + + expect(() => loadPerfFixtureFromEnv({ MISSION_CONTROL_FIXTURE_PATH: fixturePath })).toThrow( + /seededListCount \* itemsPerList must be <= 3000/i, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/e2e/mission-control-phase1.spec.ts b/e2e/mission-control-phase1.spec.ts new file mode 100644 index 0000000..665a544 --- /dev/null +++ b/e2e/mission-control-phase1.spec.ts @@ -0,0 +1,330 @@ +import { test, expect, type Page, type TestInfo } from "@playwright/test"; +import { seedAuthSession } from "./fixtures/auth"; +import { loadPerfFixtureFromEnv } from "./fixtures/mission-control-perf-fixture"; +import { computeP95, writePerfGateResult } from "./fixtures/mission-control-perf-report"; + +async function attachAuthDiagnostics(page: Page, testInfo: TestInfo, reason: string) { + const now = Date.now(); + + const diagnostics = { + reason, + url: page.url(), + hasOtpUi: + (await page.getByRole("button", { name: /send code|verify code/i }).count()) > 0 + || (await page.getByLabel(/email/i).count()) > 0 + || (await page.getByLabel(/verification code|otp/i).count()) > 0, + hasAppShell: (await page.getByRole("heading", { name: /your lists/i }).count()) > 0, + hasAuthEnvToken: Boolean(process.env.E2E_AUTH_TOKEN), + authEnv: { + email: process.env.E2E_AUTH_EMAIL ?? null, + subOrgId: process.env.E2E_AUTH_SUBORG_ID ?? null, + did: process.env.E2E_AUTH_DID ?? null, + }, + localStorageKeys: await page.evaluate(() => Object.keys(localStorage)), + }; + + await testInfo.attach(`auth-diagnostics-${now}.json`, { + body: Buffer.from(JSON.stringify(diagnostics, null, 2), "utf8"), + contentType: "application/json", + }); + + await testInfo.attach(`auth-gate-${now}.png`, { + body: await page.screenshot({ fullPage: true }), + contentType: "image/png", + }); + + await testInfo.attach(`auth-gate-${now}.html`, { + body: Buffer.from(await page.content(), "utf8"), + contentType: "text/html", + }); +} + +async function openAuthenticatedApp(page: Page, testInfo: TestInfo, displayName: string) { + await seedAuthSession(page, { + displayName, + email: `e2e+${displayName.toLowerCase().replace(/\s+/g, "-")}@poo.app`, + }); + + await page.goto("/"); + await page.goto("/app"); + + const inAppShell = (await page.getByRole("heading", { name: /your lists/i }).count()) > 0; + if (inAppShell) { + await expect(page.getByRole("heading", { name: /your lists/i })).toBeVisible({ timeout: 15000 }); + return { ready: true as const }; + } + + const hasOtpUi = + (await page.getByRole("button", { name: /send code|verify code/i }).count()) > 0 + || (await page.getByLabel(/email/i).count()) > 0 + || (await page.getByLabel(/verification code|otp/i).count()) > 0; + + const usingSeededEnvAuth = Boolean(process.env.E2E_AUTH_TOKEN); + if (hasOtpUi && !usingSeededEnvAuth) { + const reason = + "Environment requires server-validated auth. Set E2E_AUTH_TOKEN + E2E_AUTH_EMAIL + E2E_AUTH_SUBORG_ID + E2E_AUTH_DID to run Mission Control AC paths."; + await attachAuthDiagnostics(page, testInfo, reason); + return { + ready: false as const, + reason, + }; + } + + if (hasOtpUi && usingSeededEnvAuth) { + const reason = + "Seeded auth env vars are present, but app still shows OTP UI. Verify E2E_AUTH_* values match backend environment."; + await attachAuthDiagnostics(page, testInfo, reason); + return { + ready: false as const, + reason, + }; + } + + const reason = "Authenticated app shell unavailable; no lists shell or OTP UI detected."; + await attachAuthDiagnostics(page, testInfo, reason); + return { + ready: false as const, + reason, + }; +} + +async function createList(page: Page, listName: string) { + const newListButton = page.getByRole("button", { name: /new list|new List/i }).first(); + await newListButton.click(); + await page.getByLabel("List name").fill(listName); + await page.getByRole("button", { name: /create list/i }).click(); + await expect(page.getByRole("heading", { name: listName })).toBeVisible({ timeout: 20000 }); +} + +async function ensureListMutationReady(page: Page, testInfo: TestInfo, listName: string) { + try { + await createList(page, listName); + return { ready: true as const }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const reason = `List mutations unavailable in current env; skipping write-dependent AC path. ${message}`; + await attachAuthDiagnostics(page, testInfo, reason); + return { + ready: false as const, + reason, + }; + } +} + +async function createItem(page: Page, itemName: string) { + await page.getByPlaceholder("Add an item...").fill(itemName); + await page.getByRole("button", { name: "Add" }).click(); + await expect(page.getByText(itemName)).toBeVisible({ timeout: 5000 }); +} + +async function seedPerfLists(page: Page, listCount: number, itemsPerList: number, runId: string) { + const seededListNames: string[] = []; + + for (let i = 0; i < listCount; i += 1) { + const listName = `Perf List ${runId}-${i + 1}`; + seededListNames.push(listName); + await createList(page, listName); + + for (let j = 0; j < itemsPerList; j += 1) { + await createItem(page, `Perf Item ${i + 1}.${j + 1}`); + } + + await page.getByRole("link", { name: "Back to lists" }).click(); + await expect(page.getByRole("heading", { name: "Your Lists" })).toBeVisible({ timeout: 10000 }); + } + + return seededListNames; +} + +test.describe("Mission Control Phase 1 acceptance", () => { + const perfFixture = loadPerfFixtureFromEnv(); + + test("AC0 auth readiness probe: capture deterministic diagnostics and proceed when shell is available", async ({ page }, testInfo) => { + const setup = await openAuthenticatedApp(page, testInfo, "MC Auth Probe"); + if (setup.ready) { + await expect(page.getByRole("heading", { name: /your lists/i })).toBeVisible(); + return; + } + + testInfo.annotations.push({ type: "auth-gated", description: setup.reason }); + expect(setup.ready).toBe(false); + }); + + test("baseline harness boots app shell", async ({ page }) => { + await seedAuthSession(page); + await page.goto("/"); + await expect(page).toHaveURL(/\/(app)?/); + }); + + test("AC1 assignee round-trip: assignee updates propagate to all active clients in <1s", async ({ page }, testInfo) => { + const setup = await openAuthenticatedApp(page, testInfo, "MC Assignee User"); + test.skip(!setup.ready, !setup.ready ? setup.reason : ""); + const listWrite = await ensureListMutationReady(page, testInfo, "MC Assignee List"); + test.skip(!listWrite.ready, !listWrite.ready ? listWrite.reason : ""); + await createItem(page, "MC Assigned Item"); + + const hasAssigneeUi = (await page.getByRole("button", { name: /assign/i }).count()) > 0 + || (await page.getByText(/assignee/i).count()) > 0; + + test.skip(!hasAssigneeUi, "Assignee UI is not shipped in current build; keeping runnable AC1 harness."); + + const start = Date.now(); + await page.getByRole("button", { name: /assign/i }).first().click(); + await expect(page.getByText(/assigned/i)).toBeVisible({ timeout: 1000 }); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(1000); + }); + + test("AC2 activity log completeness: created|completed|assigned|commented|edited each writes exactly one activity row", async ({ page }, testInfo) => { + const setup = await openAuthenticatedApp(page, testInfo, "MC Activity User"); + test.skip(!setup.ready, !setup.ready ? setup.reason : ""); + const listWrite = await ensureListMutationReady(page, testInfo, "MC Activity List"); + test.skip(!listWrite.ready, !listWrite.ready ? listWrite.reason : ""); + await createItem(page, "Activity Item"); + + await page.getByRole("button", { name: "Check item" }).first().click(); + await page.getByRole("button", { name: "Uncheck item" }).first().click(); + + const hasCommentUi = (await page.getByPlaceholder(/add a comment/i).count()) > 0; + if (hasCommentUi) { + await page.getByPlaceholder(/add a comment/i).first().fill("mission-control-comment"); + await page.keyboard.press("Enter"); + } + + const hasActivityPanel = (await page.getByRole("button", { name: /activity/i }).count()) > 0; + test.skip(!hasActivityPanel, "Activity panel not available yet; AC2 action harness is in place."); + + await page.getByRole("button", { name: /activity/i }).first().click(); + + await expect(page.getByText(/created/i)).toHaveCount(1); + await expect(page.getByText(/completed/i)).toHaveCount(1); + if (hasCommentUi) { + await expect(page.getByText(/commented/i)).toHaveCount(1); + } + await expect(page.getByText(/edited|renamed/i)).toHaveCount(1); + }); + + test("AC3 presence freshness: presence disappears <= 90s after list close", async ({ browser }, testInfo) => { + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + const pageA = await contextA.newPage(); + const pageB = await contextB.newPage(); + + await seedAuthSession(pageA, { displayName: "MC Presence A" }); + await seedAuthSession(pageB, { displayName: "MC Presence B" }); + + const setup = await openAuthenticatedApp(pageA, testInfo, "MC Presence A"); + test.skip(!setup.ready, !setup.ready ? setup.reason : ""); + const listWrite = await ensureListMutationReady(pageA, testInfo, "MC Presence List"); + test.skip(!listWrite.ready, !listWrite.ready ? listWrite.reason : ""); + + const hasPresenceUi = (await pageA.getByText(/online|active now|viewing/i).count()) > 0; + test.skip(!hasPresenceUi, "Presence indicators are not yet wired in e2e environment."); + + await pageB.goto(pageA.url()); + await pageB.close(); + + await expect(pageA.getByText(/online|active now|viewing/i)).not.toContainText("2", { + timeout: 90000, + }); + + await contextA.close(); + await contextB.close(); + }); + + test("AC4 no-regression core UX: non-collab user flow has no required new fields and no agent UI by default", async ({ page }, testInfo) => { + const setup = await openAuthenticatedApp(page, testInfo, "MC No Regression"); + test.skip(!setup.ready, !setup.ready ? setup.reason : ""); + const listWrite = await ensureListMutationReady(page, testInfo, "MC Core Flow"); + test.skip(!listWrite.ready, !listWrite.ready ? listWrite.reason : ""); + await createItem(page, "Core Item"); + + await page.getByRole("button", { name: "Check item" }).first().click(); + await expect(page.getByRole("button", { name: "Uncheck item" })).toBeVisible(); + + await expect(page.getByText(/assignee required/i)).toHaveCount(0); + await expect(page.getByLabel(/assignee/i)).toHaveCount(0); + await expect(page.getByText(/mission control agent/i)).toHaveCount(0); + await expect(page.getByRole("button", { name: /agent/i })).toHaveCount(0); + }); + + test("AC5a perf floor harness: P95 list open <500ms", async ({ page }, testInfo) => { + const setup = await openAuthenticatedApp(page, testInfo, "MC Perf User"); + test.skip(!setup.ready, !setup.ready ? setup.reason : ""); + + const samples: number[] = []; + const runs = perfFixture.listOpenRuns; + const thresholdMs = perfFixture.listOpenP95Ms; + const itemsPerList = perfFixture.itemsPerList; + const seededListCount = Math.max(perfFixture.seededListCount, runs); + + const runLabel = `${testInfo.project.name}-w${testInfo.workerIndex}`; + const listWrite = await ensureListMutationReady(page, testInfo, `MC Perf Warmup ${runLabel}`); + test.skip(!listWrite.ready, !listWrite.ready ? listWrite.reason : ""); + + await page.getByRole("link", { name: "Back to lists" }).click(); + await expect(page.getByRole("heading", { name: "Your Lists" })).toBeVisible({ timeout: 10000 }); + + const seededListNames = await seedPerfLists(page, seededListCount, itemsPerList, runLabel); + + for (let i = 0; i < runs; i += 1) { + const listName = seededListNames[i % seededListNames.length]; + + const t0 = performance.now(); + await page.getByRole("heading", { name: listName }).click(); + await expect(page.getByRole("heading", { name: listName })).toBeVisible({ timeout: 10000 }); + samples.push(Math.round(performance.now() - t0)); + + await page.getByRole("link", { name: "Back to lists" }).click(); + await expect(page.getByRole("heading", { name: "Your Lists" })).toBeVisible({ timeout: 10000 }); + } + + const listOpenP95 = computeP95(samples); + const reportPath = writePerfGateResult(testInfo, { + gate: "ac5a_list_open", + p95Ms: listOpenP95, + thresholdMs, + samplesMs: samples, + fixturePath: process.env.MISSION_CONTROL_FIXTURE_PATH ?? "none", + seededListCount, + itemsPerList, + }); + + test.info().annotations.push({ type: "metric", description: `list_open_p95_ms=${listOpenP95};threshold_ms=${thresholdMs};report=${reportPath}` }); + expect(listOpenP95).toBeLessThan(thresholdMs); + }); + + test("AC5b perf floor harness: activity panel load P95 <700ms", async ({ page }, testInfo) => { + const setup = await openAuthenticatedApp(page, testInfo, "MC Perf Activity User"); + test.skip(!setup.ready, !setup.ready ? setup.reason : ""); + const listWrite = await ensureListMutationReady(page, testInfo, "MC Perf Activity List"); + test.skip(!listWrite.ready, !listWrite.ready ? listWrite.reason : ""); + + const hasActivityPanel = (await page.getByRole("button", { name: /activity/i }).count()) > 0; + test.skip(!hasActivityPanel, "Activity panel UI is not in current build; harness reserved for Phase 1 completion."); + + const samples: number[] = []; + const runs = perfFixture.activityOpenRuns; + const thresholdMs = perfFixture.activityOpenP95Ms; + + for (let i = 0; i < runs; i += 1) { + const t0 = performance.now(); + await page.getByRole("button", { name: /activity/i }).first().click(); + await expect(page.getByText(/activity/i)).toBeVisible({ timeout: 5000 }); + samples.push(Math.round(performance.now() - t0)); + await page.keyboard.press("Escape"); + } + + const activityOpenP95 = computeP95(samples); + const reportPath = writePerfGateResult(testInfo, { + gate: "ac5b_activity_open", + p95Ms: activityOpenP95, + thresholdMs, + samplesMs: samples, + fixturePath: process.env.MISSION_CONTROL_FIXTURE_PATH ?? "none", + }); + + test.info().annotations.push({ type: "metric", description: `activity_open_p95_ms=${activityOpenP95};threshold_ms=${thresholdMs};report=${reportPath}` }); + expect(activityOpenP95).toBeLessThan(thresholdMs); + }); +}); diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..f470299 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3c6385e --- /dev/null +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,378 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = 8DHAG5YC38; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = app.trypoo.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = 8DHAG5YC38; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.trypoo.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/ios/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..78621cd --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "687e5bf31b9ac33d40b132d2e1c0bbb1c56cfd28040af862d3275bd28e7489d8", + "pins" : [ + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "13a39179b3df796f3bb2e70c47ccdd92593f34d2", + "version" : "8.0.2" + } + } + ], + "version" : 3 +} diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift new file mode 100644 index 0000000..c3cd83b --- /dev/null +++ b/ios/App/App/AppDelegate.swift @@ -0,0 +1,49 @@ +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + +} diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000..adf6ba0 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9b7d382 --- /dev/null +++ b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/App/Assets.xcassets/Contents.json b/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 0000000..d7d96a6 --- /dev/null +++ b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 0000000..33ea6c9 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 0000000..33ea6c9 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 0000000..33ea6c9 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/ios/App/App/Base.lproj/LaunchScreen.storyboard b/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..e7ae5d7 --- /dev/null +++ b/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 0000000..b44df7b --- /dev/null +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist new file mode 100644 index 0000000..dded981 --- /dev/null +++ b/ios/App/App/Info.plist @@ -0,0 +1,57 @@ + + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Poo App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + NSCameraUsageDescription + Poo App needs camera access to take photos for your list items. + NSPhotoLibraryUsageDescription + Poo App needs photo library access to attach images to your list items. + NSPhotoLibraryAddUsageDescription + Poo App needs permission to save photos to your library. + + diff --git a/ios/App/App/capacitor.config.json b/ios/App/App/capacitor.config.json new file mode 100644 index 0000000..82c2a39 --- /dev/null +++ b/ios/App/App/capacitor.config.json @@ -0,0 +1,45 @@ +{ + "appId": "app.trypoo.app", + "appName": "Poo App", + "webDir": "dist", + "server": { + "androidScheme": "https", + "iosScheme": "https", + "allowNavigation": [ + "convex-backend-production-8e02.up.railway.app", + "convex.x51.ca" + ] + }, + "plugins": { + "CapacitorHttp": { + "enabled": true + }, + "SplashScreen": { + "launchShowDuration": 2000, + "backgroundColor": "#FFFFFF", + "androidScaleType": "CENTER_CROP", + "showSpinner": false, + "splashFullScreen": true, + "splashImmersive": true + }, + "Keyboard": { + "resize": "native", + "resizeOnFullScreen": true + } + }, + "ios": { + "allowsLinkPreview": false + }, + "packageClassList": [ + "AppPlugin", + "CAPCameraPlugin", + "HapticsPlugin", + "KeyboardPlugin", + "CAPNetworkPlugin", + "PreferencesPlugin", + "PushNotificationsPlugin", + "SharePlugin", + "StatusBarPlugin", + "NativeBiometric" + ] +} diff --git a/ios/App/App/config.xml b/ios/App/App/config.xml new file mode 100644 index 0000000..1b1b0e0 --- /dev/null +++ b/ios/App/App/config.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ios/App/App/public/assets/convex-vendor-l0sNRNKZ.js b/ios/App/App/public/assets/convex-vendor-l0sNRNKZ.js new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/App/App/public/assets/convex-vendor-l0sNRNKZ.js @@ -0,0 +1 @@ + diff --git a/ios/App/App/public/assets/time-C9oM0N83.js b/ios/App/App/public/assets/time-C9oM0N83.js new file mode 100644 index 0000000..1751786 --- /dev/null +++ b/ios/App/App/public/assets/time-C9oM0N83.js @@ -0,0 +1 @@ +function i(n){const s=Date.now()-n,e=Math.floor(s/1e3),o=Math.floor(e/60),t=Math.floor(o/60),a=Math.floor(t/24);if(e<60)return"just now";if(o<60)return o===1?"1 minute ago":`${o} minutes ago`;if(t<24)return t===1?"1 hour ago":`${t} hours ago`;if(a<2)return"yesterday";const r=new Date(n),f=r.toLocaleString("en-US",{month:"short"}),u=r.getDate();return`${f} ${u}`}export{i as f}; diff --git a/ios/App/App/public/cordova.js b/ios/App/App/public/cordova.js new file mode 100644 index 0000000..e69de29 diff --git a/ios/App/App/public/cordova_plugins.js b/ios/App/App/public/cordova_plugins.js new file mode 100644 index 0000000..e69de29 diff --git a/ios/App/App/public/icons/icon-128.png b/ios/App/App/public/icons/icon-128.png new file mode 100644 index 0000000..c23daa5 Binary files /dev/null and b/ios/App/App/public/icons/icon-128.png differ diff --git a/ios/App/App/public/icons/icon-144.png b/ios/App/App/public/icons/icon-144.png new file mode 100644 index 0000000..c79899d Binary files /dev/null and b/ios/App/App/public/icons/icon-144.png differ diff --git a/ios/App/App/public/icons/icon-152.png b/ios/App/App/public/icons/icon-152.png new file mode 100644 index 0000000..028a23b Binary files /dev/null and b/ios/App/App/public/icons/icon-152.png differ diff --git a/ios/App/App/public/icons/icon-192.png b/ios/App/App/public/icons/icon-192.png new file mode 100644 index 0000000..db5e468 Binary files /dev/null and b/ios/App/App/public/icons/icon-192.png differ diff --git a/ios/App/App/public/icons/icon-384.png b/ios/App/App/public/icons/icon-384.png new file mode 100644 index 0000000..1e43f88 Binary files /dev/null and b/ios/App/App/public/icons/icon-384.png differ diff --git a/ios/App/App/public/icons/icon-512.png b/ios/App/App/public/icons/icon-512.png new file mode 100644 index 0000000..a6bf7d7 Binary files /dev/null and b/ios/App/App/public/icons/icon-512.png differ diff --git a/ios/App/App/public/icons/icon-72.png b/ios/App/App/public/icons/icon-72.png new file mode 100644 index 0000000..bbeb8c0 Binary files /dev/null and b/ios/App/App/public/icons/icon-72.png differ diff --git a/ios/App/App/public/icons/icon-96.png b/ios/App/App/public/icons/icon-96.png new file mode 100644 index 0000000..1f9ffa5 Binary files /dev/null and b/ios/App/App/public/icons/icon-96.png differ diff --git a/ios/App/App/public/icons/new-list.png b/ios/App/App/public/icons/new-list.png new file mode 100644 index 0000000..4fcee0e Binary files /dev/null and b/ios/App/App/public/icons/new-list.png differ diff --git a/ios/App/App/public/index.html b/ios/App/App/public/index.html new file mode 100644 index 0000000..27013c2 --- /dev/null +++ b/ios/App/App/public/index.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 💩 Poo App - Organize Your Life + + + + + + + + + +
+ + diff --git a/ios/App/App/public/manifest.json b/ios/App/App/public/manifest.json new file mode 100644 index 0000000..e85104d --- /dev/null +++ b/ios/App/App/public/manifest.json @@ -0,0 +1,86 @@ +{ + "name": "PooApp", + "short_name": "PooApp", + "description": "Organize your life while you poop. The world's most productive todo app.", + "start_url": "/", + "display": "standalone", + "background_color": "#FFFBEB", + "theme_color": "#78350F", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon-72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "screenshots": [ + { + "src": "/screenshots/home.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide" + }, + { + "src": "/screenshots/mobile.png", + "sizes": "750x1334", + "type": "image/png", + "form_factor": "narrow" + } + ], + "categories": ["productivity", "utilities"], + "shortcuts": [ + { + "name": "New List", + "short_name": "New", + "description": "Create a new list", + "url": "/app?action=new", + "icons": [{"src": "/icons/new-list.png", "sizes": "96x96"}] + } + ], + "related_applications": [], + "prefer_related_applications": false +} diff --git a/ios/App/App/public/screenshots/home.png b/ios/App/App/public/screenshots/home.png new file mode 100644 index 0000000..da98053 Binary files /dev/null and b/ios/App/App/public/screenshots/home.png differ diff --git a/ios/App/App/public/screenshots/mobile.png b/ios/App/App/public/screenshots/mobile.png new file mode 100644 index 0000000..8bdfab3 Binary files /dev/null and b/ios/App/App/public/screenshots/mobile.png differ diff --git a/ios/App/App/public/sw.js b/ios/App/App/public/sw.js new file mode 100644 index 0000000..0427247 --- /dev/null +++ b/ios/App/App/public/sw.js @@ -0,0 +1 @@ +(()=>{var c="lisa-v4",r=["/","/manifest.json","/pwa-192x192.png","/pwa-512x512.png"];self.addEventListener("install",e=>{e.waitUntil(caches.open(c).then(async t=>(await Promise.allSettled(r.map(i=>t.add(i).catch(n=>{console.warn("[SW] Failed to pre-cache:",i,n)}))),self.skipWaiting())))});self.addEventListener("activate",e=>{e.waitUntil(Promise.all([caches.keys().then(t=>Promise.all(t.filter(i=>i!==c).map(i=>(console.log("[SW] Deleting old cache:",i),caches.delete(i))))),self.clients.claim()]))});self.addEventListener("fetch",e=>{let{request:t}=e,i=new URL(t.url);if(t.method==="GET"&&t.url.startsWith("http")&&!t.url.includes("convex.cloud")){if(i.pathname.startsWith("/assets/")&&/[-\.][a-f0-9]{8,}\.(js|css|woff2?)$/.test(i.pathname)){e.respondWith(caches.match(t).then(n=>n||fetch(t).then(a=>{if(a.ok){let o=a.clone();caches.open(c).then(s=>{s.put(t,o)})}return a})));return}if(t.mode==="navigate"){e.respondWith(fetch(t).then(n=>{if(n.ok){let a=n.clone();caches.open(c).then(o=>{o.put(t,a)})}return n}).catch(async()=>{let n=await caches.match(t);if(n)return n;let a=await caches.match("/");return a||new Response("Offline",{status:503,statusText:"Service Unavailable",headers:{"Content-Type":"text/plain"}})}));return}if(i.origin===self.location.origin){e.respondWith(caches.match(t).then(n=>n||fetch(t).then(a=>{if(a.ok){let o=a.clone();caches.open(c).then(s=>{s.put(t,o)})}return a})));return}}});self.addEventListener("message",e=>{e.data==="skipWaiting"&&self.skipWaiting(),e.data==="clearCaches"&&caches.keys().then(t=>{t.forEach(i=>caches.delete(i))})});self.addEventListener("push",e=>{if(e.data)try{let t=e.data.json(),i=t.title||"\u{1F4A9} Poo App",n={body:t.body||"You have a notification",icon:"/pwa-192x192.png",badge:"/pwa-192x192.png",tag:t.tag||"poo-notification",data:{url:t.url||"/",itemId:t.itemId,listId:t.listId},requireInteraction:t.requireInteraction||!1,vibrate:[100,50,100]};e.waitUntil(self.registration.showNotification(i,n))}catch{let t=e.data.text();e.waitUntil(self.registration.showNotification("\u{1F4A9} Poo App",{body:t,icon:"/pwa-192x192.png"}))}});self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url||"/app";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(i=>{for(let n of i)if("focus"in n){n.focus(),"navigate"in n&&n.navigate(t);return}return self.clients.openWindow(t)}))});})(); diff --git a/ios/App/App/public/vite.svg b/ios/App/App/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/ios/App/App/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ios/App/CapApp-SPM/.gitignore b/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 0000000..8d373ee --- /dev/null +++ b/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,43 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.0.2"), + .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), + .package(name: "CapacitorCamera", path: "../../../node_modules/@capacitor/camera"), + .package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"), + .package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"), + .package(name: "CapacitorNetwork", path: "../../../node_modules/@capacitor/network"), + .package(name: "CapacitorPreferences", path: "../../../node_modules/@capacitor/preferences"), + .package(name: "CapacitorPushNotifications", path: "../../../node_modules/@capacitor/push-notifications"), + .package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorCamera", package: "CapacitorCamera"), + .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), + .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), + .product(name: "CapacitorNetwork", package: "CapacitorNetwork"), + .product(name: "CapacitorPreferences", package: "CapacitorPreferences"), + .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"), + .product(name: "CapacitorShare", package: "CapacitorShare"), + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") + ] + ) + ] +) diff --git a/ios/App/CapApp-SPM/README.md b/ios/App/CapApp-SPM/README.md new file mode 100644 index 0000000..5e22a2f --- /dev/null +++ b/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This SPM is used to host SPM dependencies for you Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 0000000..945afec --- /dev/null +++ b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/ios/Gemfile b/ios/Gemfile new file mode 100644 index 0000000..82d1e30 --- /dev/null +++ b/ios/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "fastlane" +gem "cocoapods" diff --git a/ios/capacitor-cordova-ios-plugins/CordovaPluginsResources.podspec b/ios/capacitor-cordova-ios-plugins/CordovaPluginsResources.podspec new file mode 100644 index 0000000..fa16d3c --- /dev/null +++ b/ios/capacitor-cordova-ios-plugins/CordovaPluginsResources.podspec @@ -0,0 +1,10 @@ +Pod::Spec.new do |s| + s.name = 'CordovaPluginsResources' + s.version = '0.0.105' + s.summary = 'Resources for Cordova plugins' + s.license = 'MIT' + s.homepage = 'https://capacitorjs.com/' + s.authors = { 'Ionic Team' => 'hi@ionicframework.com' } + s.source = { :git => 'https://github.com/ionic-team/capacitor.git', :tag => s.version.to_s } + s.resources = ['resources/*'] +end diff --git a/ios/capacitor-cordova-ios-plugins/resources/.gitkeep b/ios/capacitor-cordova-ios-plugins/resources/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/capacitor-cordova-ios-plugins/resources/.gitkeep @@ -0,0 +1 @@ + diff --git a/ios/capacitor-cordova-ios-plugins/sources/.gitkeep b/ios/capacitor-cordova-ios-plugins/sources/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/capacitor-cordova-ios-plugins/sources/.gitkeep @@ -0,0 +1 @@ + diff --git a/ios/debug.xcconfig b/ios/debug.xcconfig new file mode 100644 index 0000000..53ce18d --- /dev/null +++ b/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/ios/fastlane/.gitignore b/ios/fastlane/.gitignore new file mode 100644 index 0000000..8057b8d --- /dev/null +++ b/ios/fastlane/.gitignore @@ -0,0 +1,4 @@ +report.xml +Preview.html +screenshots/ +test_output/ diff --git a/ios/fastlane/Appfile b/ios/fastlane/Appfile new file mode 100644 index 0000000..bb7f5d6 --- /dev/null +++ b/ios/fastlane/Appfile @@ -0,0 +1,3 @@ +app_identifier("app.trypoo.app") +apple_id("b.richter3@gmail.com") +team_id("8DHAG5YC38") diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile new file mode 100644 index 0000000..681139c --- /dev/null +++ b/ios/fastlane/Fastfile @@ -0,0 +1,51 @@ +default_platform(:ios) + +platform :ios do + desc "Build and upload to TestFlight" + lane :beta do + setup_ci + + api_key = app_store_connect_api_key( + key_id: ENV["APP_STORE_CONNECT_KEY_ID"], + issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"], + key_content: ENV["APP_STORE_CONNECT_API_PRIVATE_KEY"], + is_key_content_base64: true + ) + + sync_code_signing( + type: "appstore", + readonly: true, + git_basic_authorization: ENV["MATCH_GIT_BASIC_AUTHORIZATION"] + ) + + # Use CI run number for build version, fallback to timestamp locally + build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.to_i.to_s + increment_build_number( + build_number: build_number, + xcodeproj: "App/App.xcodeproj" + ) + + update_code_signing_settings( + use_automatic_signing: false, + path: "App/App.xcodeproj", + team_id: ENV["APPLE_TEAM_ID"], + bundle_identifier: "app.trypoo.app", + profile_name: "match AppStore app.trypoo.app", + code_sign_identity: "iPhone Distribution" + ) + + build_app( + project: "App/App.xcodeproj", + scheme: "App", + export_method: "app-store", + clean: true, + xcargs: "DEVELOPMENT_TEAM=#{ENV['APPLE_TEAM_ID']}" + ) + + upload_to_testflight( + api_key: api_key, + skip_waiting_for_build_processing: true, + changelog: "Latest build from main" + ) + end +end diff --git a/ios/fastlane/Matchfile b/ios/fastlane/Matchfile new file mode 100644 index 0000000..1a5f005 --- /dev/null +++ b/ios/fastlane/Matchfile @@ -0,0 +1,5 @@ +git_url(ENV["MATCH_GIT_URL"] || "https://github.com/aviarytech/certs.git") +storage_mode("git") +type("appstore") +app_identifier("app.trypoo.app") +team_id("8DHAG5YC38") diff --git a/mobile/DEEP_LINKS.md b/mobile/DEEP_LINKS.md new file mode 100644 index 0000000..8525bbb --- /dev/null +++ b/mobile/DEEP_LINKS.md @@ -0,0 +1,156 @@ +# Deep Linking Configuration + +This document describes the native configuration required for deep linking support in the Poo App. + +## Overview + +The app supports two types of deep links: +- **Custom URL scheme:** `pooapp://` - Opens the app directly +- **Universal links (iOS) / App Links (Android):** `https://trypoo.app/list/*` - Seamless web-to-app transitions + +## iOS Configuration + +### 1. URL Scheme (`pooapp://`) + +Add the following to `ios/App/App/Info.plist`: + +```xml +CFBundleURLTypes + + + CFBundleURLName + app.trypoo + CFBundleURLSchemes + + pooapp + + + +``` + +### 2. Universal Links (`https://trypoo.app/*`) + +1. Add Associated Domains capability to `ios/App/App.entitlements`: + +```xml +com.apple.developer.associated-domains + + applinks:trypoo.app + +``` + +2. Host an Apple App Site Association (AASA) file at `https://trypoo.app/.well-known/apple-app-site-association`: + +```json +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "TEAM_ID.app.trypoo", + "paths": ["/list/*", "/join/*", "/public/*"] + } + ] + } +} +``` + +Replace `TEAM_ID` with your Apple Developer Team ID. + +## Android Configuration + +### 1. Intent Filters for URL Scheme and App Links + +Add the following intent filters to `android/app/src/main/AndroidManifest.xml` inside the `` tag: + +```xml + + + + + + + + + + + + + + + + + +``` + +### 2. Digital Asset Links + +Host an `assetlinks.json` file at `https://trypoo.app/.well-known/assetlinks.json`: + +```json +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "app.trypoo", + "sha256_cert_fingerprints": [ + "CERTIFICATE_FINGERPRINT_HERE" + ] + } + } +] +``` + +Get your certificate fingerprint by running: +```bash +keytool -list -v -keystore my-release-key.keystore +``` + +## Applying Changes + +These native configurations will be applied when: +1. You regenerate the Capacitor native projects: `npx cap sync` +2. You manually update the native project files + +After making changes to native files, rebuild the app: +```bash +npx cap sync +npx cap open ios # or npx cap open android +# Then build from Xcode or Android Studio +``` + +## Testing Deep Links + +### Custom scheme (`pooapp://`) +```bash +# iOS Simulator +xcrun simctl openurl booted pooapp://list/123 + +# Android +adb shell am start -W -a android.intent.action.VIEW -d "pooapp://list/123" app.trypoo +``` + +### Universal/App Links +```bash +# iOS Simulator +xcrun simctl openurl booted https://trypoo.app/list/123 + +# Android +adb shell am start -W -a android.intent.action.VIEW -d "https://trypoo.app/list/123" app.trypoo +``` + +## Notes + +- Universal Links (iOS) and App Links (Android) require the domain verification files to be hosted at the specified URLs +- The app will gracefully fall back to web browser if the app is not installed +- Deep linking only works on native platforms (iOS/Android), not in web builds diff --git a/mobile/PROJECT_GOALS.md b/mobile/PROJECT_GOALS.md new file mode 100644 index 0000000..e97f416 --- /dev/null +++ b/mobile/PROJECT_GOALS.md @@ -0,0 +1,42 @@ +# Poo App Mobile — Project Goals + +**Date:** 2026-02-07 +**Status:** Active + +## Vision +Build a native mobile app for Poo App (iOS + Android) that maximizes code reuse from the existing React + Vite + Convex web app. + +## Key Goals + +1. **Native Mobile Experience** — Push notifications, offline support, app store distribution (Apple App Store + Google Play) +2. **Maximum Code Sharing** — Reuse the existing React/Vite/Convex codebase with minimal duplication +3. **Feature Parity** — All web app features available on mobile from day one +4. **Ship MVP Fast** — Leverage existing codebase to get to market quickly + +## Success Criteria +- App available on both iOS and Android app stores +- Push notifications working on both platforms +- Offline-capable (read cached data, queue writes) +- Same authentication flow as web +- <10% platform-specific code +- MVP shipped within 6 weeks + +## Success Metrics +- **App Store Approval:** App approved and published on both Apple App Store and Google Play Store +- **Performance:** < 3 second cold start time on both platforms +- **Push Notifications:** Working reliably on iOS (APNs) and Android (FCM) +- **Offline Support:** Users can view cached lists without network connectivity +- **Feature Parity:** All core web app features available on mobile (create/edit/check items, share lists, auth) +- **Bundle Size:** < 5% increase in overall bundle size from adding Capacitor/native layers +- **Stability:** < 1% crash rate in production +- **User Adoption:** Measurable installs within first month of launch + +## Non-Goals (for MVP) +- Tablet-optimized layouts +- Platform-specific UI (Material/Cupertino) — web UI is fine +- Widgets or watch apps +- Background sync beyond basic offline queue + +## Stakeholders +- **Brian** — Founder/developer, decision-maker +- **Krusty** — AI agent/developer, implementation diff --git a/mobile/PUSH_NOTIFICATIONS.md b/mobile/PUSH_NOTIFICATIONS.md new file mode 100644 index 0000000..d864f02 --- /dev/null +++ b/mobile/PUSH_NOTIFICATIONS.md @@ -0,0 +1,219 @@ +# Push Notifications Setup Guide + +This document outlines the setup requirements for native push notifications in the Poo App using Capacitor. + +## Overview + +The app uses `@capacitor/push-notifications` to handle native push notifications on iOS and Android. The scaffolding has been added, but platform-specific configuration is required for full functionality. + +## Code Structure + +- **Service**: `src/lib/pushNotifications.ts` - Core push notification logic +- **Initialization**: `src/App.tsx` - Initializes push notifications after user authentication +- **Settings UI**: `src/components/Settings.tsx` - Contains notification toggle (currently web push only) + +## iOS Setup (Required) + +### 1. Apple Developer Portal Configuration + +1. Log in to [Apple Developer Portal](https://developer.apple.com) +2. Navigate to **Certificates, Identifiers & Profiles** +3. Create or update your App ID: + - Enable **Push Notifications** capability +4. Create an **APNs Key** (preferred) or **APNs Certificate**: + - **Key**: More flexible, doesn't expire annually + - **Certificate**: Traditional method, expires yearly +5. Download the key/certificate and keep it secure + +### 2. Xcode Configuration + +1. Open the iOS project in Xcode: + ```bash + npx cap open ios + ``` +2. Select the project in the navigator +3. Go to **Signing & Capabilities** +4. Add **Push Notifications** capability if not present +5. Ensure your provisioning profile includes push notifications + +### 3. Backend Integration + +You'll need to send the APNs key/certificate to your backend for push delivery. The push token is logged in the `registration` listener in `pushNotifications.ts`. + +## Android Setup (Required) + +### 1. Firebase Project Setup + +1. Go to [Firebase Console](https://console.firebase.google.com) +2. Create a new project or select existing one +3. Add an Android app to your Firebase project: + - **Package name**: Must match your `android/app/build.gradle` `applicationId` + - Example: `com.aviarytech.pooapp` +4. Download `google-services.json` + +### 2. Add google-services.json + +1. Place the downloaded `google-services.json` in: + ``` + android/app/google-services.json + ``` +2. Verify the file is gitignored (it contains API keys) + +### 3. Firebase Cloud Messaging (FCM) + +The `@capacitor/push-notifications` plugin handles FCM registration automatically once `google-services.json` is in place. + +## Backend Requirements + +### Store Push Tokens + +When a device registers for push notifications, the token is logged in the console. You need to: + +1. Send this token to your Convex backend +2. Store it in the user's record +3. Example Convex mutation: + +```typescript +// convex/users.ts +export const storePushToken = mutation({ + args: { token: v.string(), platform: v.union(v.literal('ios'), v.literal('android')) }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error('Not authenticated'); + + const user = await ctx.db + .query('users') + .withIndex('by_did', (q) => q.eq('did', identity.subject)) + .first(); + + if (!user) throw new Error('User not found'); + + // Store or update push token + await ctx.db.patch(user._id, { + pushToken: args.token, + pushPlatform: args.platform, + pushTokenUpdatedAt: Date.now(), + }); + }, +}); +``` + +### Send Push Notifications + +Create Convex actions or scheduled functions to send pushes: + +**For iOS (APNs):** +- Use `node-apn` or HTTP/2 API +- Requires the APNs key/certificate from Apple Developer Portal + +**For Android (FCM):** +- Use `firebase-admin` SDK +- Requires Firebase service account key + +Example payload structure: +```json +{ + "notification": { + "title": "Task Due Soon!", + "body": "Your task 'Buy groceries' is due in 30 minutes" + }, + "data": { + "listId": "123", + "itemId": "456" + } +} +``` + +## Testing + +### iOS Simulator Limitations + +- **Push notifications DO NOT work in the iOS Simulator** +- You must test on a **physical iOS device** +- Simulator will show permission prompts but won't receive actual pushes + +### Android Emulator + +- **Push notifications CAN work in Android emulators** (with Play Services) +- Use an emulator with **Google Play Store** installed +- Ensure `google-services.json` is in place + +### Testing Commands + +**Run on iOS device:** +```bash +npx cap run ios +# Select your connected device (not simulator) +``` + +**Run on Android emulator:** +```bash +npx cap run android +# Select emulator with Play Services +``` + +### Test Push Flow + +1. Build and deploy the app to a physical device (iOS) or emulator (Android) +2. Log in to the app +3. Check console logs for: + - Permission request result + - Registration success with token +4. Copy the token from logs +5. Use Firebase Console (FCM) or your backend to send a test push +6. Verify: + - Notification appears when app is in background + - `pushNotificationReceived` listener fires when app is in foreground + - Tapping notification navigates correctly + +## Debugging Tips + +### Check Logs + +**iOS (Xcode console):** +```bash +npx cap open ios +# Run app and check Xcode console for push logs +``` + +**Android (logcat):** +```bash +npx cap run android +# Check Android Studio logcat or: +adb logcat | grep -i push +``` + +### Common Issues + +1. **"Push registration error"**: + - iOS: Check provisioning profile and APNs capability + - Android: Verify `google-services.json` is present and package name matches + +2. **Notifications not appearing**: + - Check device notification settings (may be blocked) + - Verify push token was sent to backend + - Check backend push sending logs + +3. **Permission denied**: + - iOS: User must accept permission prompt (can only ask once!) + - Android: Usually auto-granted, but check device settings + +## Next Steps + +1. ✅ Install `@capacitor/push-notifications` (done) +2. ✅ Create `pushNotifications.ts` service (done) +3. ✅ Initialize in `App.tsx` (done) +4. ⬜ Set up Apple Developer Portal APNs key/certificate +5. ⬜ Configure Firebase project and add `google-services.json` +6. ⬜ Create Convex mutations to store push tokens +7. ⬜ Create Convex actions to send pushes via APNs/FCM +8. ⬜ Test on physical devices/emulators +9. ⬜ Integrate push token storage with auth flow +10. ⬜ Add UI toggle in Settings for enabling/disabling native push (optional) + +## References + +- [Capacitor Push Notifications Plugin](https://capacitorjs.com/docs/apis/push-notifications) +- [Apple Push Notification Service](https://developer.apple.com/documentation/usernotifications) +- [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) +- [Convex Actions for External APIs](https://docs.convex.dev/functions/actions) diff --git a/package-lock.json b/package-lock.json index 5b9b712..ba03a21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,25 +8,41 @@ "name": "lisa-temp", "version": "0.0.0", "dependencies": { + "@capacitor/android": "^8.0.2", + "@capacitor/app": "^8.0.0", + "@capacitor/camera": "^8.0.0", + "@capacitor/cli": "^8.0.2", + "@capacitor/core": "^8.0.2", + "@capacitor/haptics": "^8.0.0", + "@capacitor/ios": "^8.0.2", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/network": "^8.0.0", + "@capacitor/preferences": "^8.0.0", + "@capacitor/push-notifications": "^8.0.0", + "@capacitor/share": "^8.0.0", + "@capacitor/status-bar": "^8.0.0", "@originals/auth": "^1.8.2", "@originals/sdk": "^1.8.2", "@turnkey/core": "^1.11.0", + "capacitor-native-biometric": "^4.2.2", "convex": "^1.31.6", "idb": "^8.0.3", "jose": "^6.1.3", - "pg": "^8.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.12.0", - "serve": "^14.2.5" + "serve": "^14.2.5", + "web-push": "^3.6.7" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@napi-rs/canvas": "^0.1.90", "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.1.18", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.23", "eslint": "^9.39.1", @@ -337,6 +353,158 @@ "node": ">=6.9.0" } }, + "node_modules/@capacitor/android": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.0.2.tgz", + "integrity": "sha512-0D7j0YvzjnfCMKLvFkAbx8b3Vwx+QfHFG5NzoXpI9sAl3zWiLsfa+NX4x92Fy+k4MGjLSMAfLThCqILYGDDsgw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.0.0" + } + }, + "node_modules/@capacitor/app": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.0.tgz", + "integrity": "sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/camera": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.0.0.tgz", + "integrity": "sha512-Iu8j2oxoIhY2mLuoEckbL7PFgw1XFm1nqmeWdIkILpcT3H9A+BrSDUDlzWqM/EeaDKo6JnhR59tYHwUhOdXaUg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.0.2.tgz", + "integrity": "sha512-/8qLYxhytMyUKTHK8i6YU+DMD3AuFiQgSuJCyMltcg9MN3W9En7zqQZSo/WN4eC7qif/oyZACzm7OkAZKani7g==", + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@capacitor/core": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.0.2.tgz", + "integrity": "sha512-EXZfxkL6GFJS2cb7TIBR7RiHA5iz6ufDcl1VmUpI2pga3lJ5Ck2+iqbx7N+osL3XYem9ad4XCidJEMm64DX6UQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/haptics": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.0.tgz", + "integrity": "sha512-DY1IUOjke1T4ITl7mFHQIKCaJJyHYAYRYHG9bVApU7PDOZiMVGMp48Yjzdqjya+wv/AHS5mDabSTUmhJ5uDvBA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.0.2.tgz", + "integrity": "sha512-7EM7vBxXI3Ku49aYCJcS9su5Y3i6UmXpx7e0y+oQV9PzCnZ6l5B0ACJ+gXAU0bM3q7/f+kGBsOtXMid84rU6MQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.0.0" + } + }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.0.tgz", + "integrity": "sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/network": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/network/-/network-8.0.0.tgz", + "integrity": "sha512-fgvB7pNKn8pKavuzys218j4YuA5euNfavp7nS3NuwWKWNupZAlbucfnl75lazxCyVF/ZRjzYVTb4vtTEfFrK1A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/preferences": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.0.tgz", + "integrity": "sha512-NsE7Srk9Zr0SxiVelHGiAJR7M238eyCD6dI/sDhu3ckKwFrXn8/GRyGr+SZcnGLlQKy948li8Pfcfr0dqxNf1g==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/push-notifications": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.0.tgz", + "integrity": "sha512-xJWQLqAfC8b2ETqAPmwDnkKB4t/lVrbYc2D8VpA2fSu10JFSL/R722Vk0Lfl9Lo9WusmyIiQbVfILNQ3iFNGKw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/share": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.0.tgz", + "integrity": "sha512-VU+xT4LFwr4keIC0UKDqGQVAiNlAHwoTMQg8wVVSxtn/k32VOvvtqFfu63qnXr40WKytZWrxJfVESvRjd761yg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.0.tgz", + "integrity": "sha512-aIj3bc7z8lfPgOen8HlrBrkfnxpFnh21OCx6jCUx4Mvv+B6eEkUQ49b32DOddgVfr+igRHLX2SYi7duqIsNDXg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@digitalbazaar/http-client": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-3.4.1.tgz", @@ -985,6 +1153,182 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/@ionic/utils-terminal/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -1006,6 +1350,18 @@ "node": "20 || >=22" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1065,6 +1421,267 @@ "node": ">= 18" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.90.tgz", + "integrity": "sha512-vO9j7TfwF9qYCoTOPO39yPLreTRslBVOaeIwhDZkizDvBb0MounnTl0yeWUMBxP4Pnkg9Sv+3eQwpxNUmTwt0w==", + "dev": true, + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.90", + "@napi-rs/canvas-darwin-arm64": "0.1.90", + "@napi-rs/canvas-darwin-x64": "0.1.90", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.90", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.90", + "@napi-rs/canvas-linux-arm64-musl": "0.1.90", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.90", + "@napi-rs/canvas-linux-x64-gnu": "0.1.90", + "@napi-rs/canvas-linux-x64-musl": "0.1.90", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.90", + "@napi-rs/canvas-win32-x64-msvc": "0.1.90" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.90.tgz", + "integrity": "sha512-3JBULVF+BIgr7yy7Rf8UjfbkfFx4CtXrkJFD1MDgKJ83b56o0U9ciT8ZGTCNmwWkzu8RbNKlyqPP3KYRG88y7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.90.tgz", + "integrity": "sha512-L8XVTXl+8vd8u7nPqcX77NyG5RuFdVsJapQrKV9WE3jBayq1aSMht/IH7Dwiz/RNJ86E5ZSg9pyUPFIlx52PZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.90.tgz", + "integrity": "sha512-h0ukhlnGhacbn798VWYTQZpf6JPDzQYaow+vtQ2Fat7j7ImDdpg6tfeqvOTO1r8wS+s+VhBIFITC7aA1Aik0ZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.90.tgz", + "integrity": "sha512-JCvTl99b/RfdBtgftqrf+5UNF7GIbp7c5YBFZ+Bd6++4Y3phaXG/4vD9ZcF1bw1P4VpALagHmxvodHuQ9/TfTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.90.tgz", + "integrity": "sha512-vbWFp8lrP8NIM5L4zNOwnsqKIkJo0+GIRUDcLFV9XEJCptCc1FY6/tM02PT7GN4PBgochUPB1nBHdji6q3ieyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.90.tgz", + "integrity": "sha512-8Bc0BgGEeOaux4EfIfNzcRRw0JE+lO9v6RWQFCJNM9dJFE4QJffTf88hnmbOaI6TEMpgWOKipbha3dpIdUqb/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.90.tgz", + "integrity": "sha512-0iiVDG5IH+gJb/YUrY/pRdbsjcgvwUmeckL/0gShWAA7004ygX2ST69M1wcfyxXrzFYjdF8S/Sn6aCAeBi89XQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.90.tgz", + "integrity": "sha512-SkKmlHMvA5spXuKfh7p6TsScDf7lp5XlMbiUhjdCtWdOS6Qke/A4qGVOciy6piIUCJibL+YX+IgdGqzm2Mpx/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.90.tgz", + "integrity": "sha512-o6QgS10gAS4vvELGDOOWYfmERXtkVRYFWBCjomILWfMgCvBVutn8M97fsMW5CrEuJI8YuxuJ7U+/DQ9oG93vDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.90.tgz", + "integrity": "sha512-2UHO/DC1oyuSjeCAhHA0bTD9qsg58kknRqjJqRfvIEFtdqdtNTcWXMCT9rQCuJ8Yx5ldhyh2SSp7+UDqD2tXZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.90", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.90.tgz", + "integrity": "sha512-48CxEbzua5BP4+OumSZdi3+9fNiRO8cGNBlO2bKwx1PoyD1R2AXzPtqd/no1f1uSl0W2+ihOO1v3pqT3USbmgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -2429,6 +3046,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2440,7 +3066,6 @@ "version": "24.10.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2466,6 +3091,22 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "license": "MIT" + }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -3107,6 +3748,15 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@zeit/schemas": { "version": "2.36.0", "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", @@ -3175,6 +3825,15 @@ "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3358,6 +4017,24 @@ "util": "^0.12.5" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -3480,6 +4157,15 @@ "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", "license": "MIT" }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bip174": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", @@ -3596,6 +4282,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3851,6 +4549,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3979,6 +4686,24 @@ "integrity": "sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==", "license": "Apache-2.0" }, + "node_modules/capacitor-native-biometric": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/capacitor-native-biometric/-/capacitor-native-biometric-4.2.2.tgz", + "integrity": "sha512-stg0h48UxgkNuNcCAgCXLp2DUspRQs79bCBPntpCBhsDxk2bhDRUu+J/QpFtDQHG4M4DioSUcYaAsVw2N6N7wA==", + "license": "MIT", + "dependencies": { + "@capacitor/core": "^3.4.3" + } + }, + "node_modules/capacitor-native-biometric/node_modules/@capacitor/core": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-3.9.0.tgz", + "integrity": "sha512-j1lL0+/7stY8YhIq1Lm6xixvUqIn89vtyH5ZpJNNmcZ0kwz6K9eLkcG6fvq1UWMDgSVZg9JrRGSFhb4LLoYOsw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/cbor-js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cbor-js/-/cbor-js-0.1.0.tgz", @@ -4031,6 +4756,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cipher-base": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", @@ -4093,6 +4827,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -4365,7 +5108,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4413,6 +5155,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -4567,6 +5318,18 @@ "dev": true, "license": "ISC" }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/elliptic": { "version": "6.6.1", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", @@ -4623,6 +5386,15 @@ "node": ">=8.6" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5093,6 +5865,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5227,6 +6008,20 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -5398,7 +6193,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/h3": { @@ -5536,6 +6330,15 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", @@ -5543,6 +6346,19 @@ "dev": true, "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5625,7 +6441,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -5973,6 +6788,18 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonld": { "version": "8.3.3", "resolved": "https://registry.npmjs.org/jsonld/-/jsonld-8.3.3.tgz", @@ -6086,6 +6913,15 @@ "integrity": "sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==", "license": "MIT" }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ky": { "version": "0.33.3", "resolved": "https://registry.npmjs.org/ky/-/ky-0.33.3.tgz", @@ -6620,7 +7456,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, "license": "ISC" }, "node_modules/minimalistic-crypto-utils": { @@ -6660,6 +7495,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6695,6 +7542,40 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/native-run/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6967,6 +7848,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7101,6 +7999,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -7226,95 +8130,12 @@ "node": ">= 0.10" } }, - "node_modules/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.3.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7417,6 +8238,20 @@ "node": ">=18" } }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7463,45 +8298,6 @@ "dev": true, "license": "MIT" }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7560,6 +8356,28 @@ ], "license": "MIT" }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -7785,7 +8603,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -7886,6 +8703,25 @@ "node": ">=4" } }, + "node_modules/rimraf": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ripemd160": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", @@ -8055,6 +8891,18 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "license": "ISC" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8314,6 +9162,29 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/slow-redact": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.2.tgz", @@ -8376,7 +9247,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -8506,6 +9376,31 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -8515,6 +9410,15 @@ "real-require": "^0.2.0" } }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -8566,6 +9470,15 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -8737,9 +9650,17 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unstorage": { "version": "1.17.4", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", @@ -8845,6 +9766,15 @@ "node": "20 || >=22" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -8934,7 +9864,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -9210,6 +10139,43 @@ "dev": true, "license": "MIT" }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/web-push/node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -9374,10 +10340,42 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -9390,6 +10388,16 @@ "dev": true, "license": "ISC" }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index bc58cdb..2761d2a 100644 --- a/package.json +++ b/package.json @@ -6,36 +6,61 @@ "packageManager": "bun@1.3.5", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "npx convex codegen 2>/dev/null; tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", "start": "serve dist -s", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "test:e2e:mission-control": "playwright test e2e/mission-control-phase1.spec.ts", + "test:e2e:mission-control:ac5": "playwright test e2e/mission-control-phase1.spec.ts --grep \"AC5\"", + "mission-control:test-observability": "node --test scripts/mission-control-alert-severity-policy.test.mjs", + "mission-control:validate-observability": "node scripts/validate-mission-control-observability.mjs", "env:dev": "bash -c 'export $(grep -v \"^#\" .env.local | grep -E \"^(TURNKEY_|JWT_SECRET|WEBVH_DOMAIN)\" | xargs) && for k in TURNKEY_API_PUBLIC_KEY TURNKEY_API_PRIVATE_KEY TURNKEY_ORGANIZATION_ID JWT_SECRET WEBVH_DOMAIN; do npx convex env set \"$k\" \"${!k}\"; done'", "env:prod": "bash -c 'export $(grep -v \"^#\" .env.local | grep -E \"^(TURNKEY_|JWT_SECRET|WEBVH_DOMAIN)\" | xargs) && for k in TURNKEY_API_PUBLIC_KEY TURNKEY_API_PRIVATE_KEY TURNKEY_ORGANIZATION_ID JWT_SECRET WEBVH_DOMAIN; do npx convex env set --prod \"$k\" \"${!k}\"; done'", "env:turnkey:dev": "bash ./scripts/sync-convex-turnkey-env.sh .env.local dev", - "env:turnkey:prod": "bash ./scripts/sync-convex-turnkey-env.sh .env.local prod" + "env:turnkey:prod": "bash ./scripts/sync-convex-turnkey-env.sh .env.local prod", + "cap:sync": "npx cap sync", + "cap:build": "npm run build && npx cap sync", + "mission-control:readiness-drill": "node scripts/mission-control-readiness-drill.mjs", + "mission-control:test-readiness-drill": "node --test scripts/mission-control-readiness-drill.test.mjs" }, "dependencies": { + "@capacitor/android": "^8.0.2", + "@capacitor/app": "^8.0.0", + "@capacitor/camera": "^8.0.0", + "@capacitor/cli": "^8.0.2", + "@capacitor/core": "^8.0.2", + "@capacitor/haptics": "^8.0.0", + "@capacitor/ios": "^8.0.2", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/network": "^8.0.0", + "@capacitor/preferences": "^8.0.0", + "@capacitor/push-notifications": "^8.0.0", + "@capacitor/share": "^8.0.0", + "@capacitor/status-bar": "^8.0.0", "@originals/auth": "^1.8.2", "@originals/sdk": "^1.8.2", "@turnkey/core": "^1.11.0", + "capacitor-native-biometric": "^4.2.2", "convex": "^1.31.6", "idb": "^8.0.3", "jose": "^6.1.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.12.0", - "serve": "^14.2.5" + "serve": "^14.2.5", + "web-push": "^3.6.7" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@napi-rs/canvas": "^0.1.90", "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.1.18", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.23", "eslint": "^9.39.1", diff --git a/playwright.config.ts b/playwright.config.ts index 57cc283..f57f400 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,9 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: "html", + reporter: process.env.CI + ? [["line"], ["junit", { outputFile: "test-results/junit.xml" }], ["html", { outputFolder: "playwright-report", open: "never" }]] + : "html", use: { baseURL: "http://localhost:5173", trace: "on-first-retry", diff --git a/public/icons/icon-128.png b/public/icons/icon-128.png new file mode 100644 index 0000000..c23daa5 Binary files /dev/null and b/public/icons/icon-128.png differ diff --git a/public/icons/icon-144.png b/public/icons/icon-144.png new file mode 100644 index 0000000..c79899d Binary files /dev/null and b/public/icons/icon-144.png differ diff --git a/public/icons/icon-152.png b/public/icons/icon-152.png new file mode 100644 index 0000000..028a23b Binary files /dev/null and b/public/icons/icon-152.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..db5e468 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-384.png b/public/icons/icon-384.png new file mode 100644 index 0000000..1e43f88 Binary files /dev/null and b/public/icons/icon-384.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..a6bf7d7 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-72.png b/public/icons/icon-72.png new file mode 100644 index 0000000..bbeb8c0 Binary files /dev/null and b/public/icons/icon-72.png differ diff --git a/public/icons/icon-96.png b/public/icons/icon-96.png new file mode 100644 index 0000000..1f9ffa5 Binary files /dev/null and b/public/icons/icon-96.png differ diff --git a/public/icons/new-list.png b/public/icons/new-list.png new file mode 100644 index 0000000..4fcee0e Binary files /dev/null and b/public/icons/new-list.png differ diff --git a/public/screenshots/home.png b/public/screenshots/home.png new file mode 100644 index 0000000..da98053 Binary files /dev/null and b/public/screenshots/home.png differ diff --git a/public/screenshots/mobile.png b/public/screenshots/mobile.png new file mode 100644 index 0000000..8bdfab3 Binary files /dev/null and b/public/screenshots/mobile.png differ diff --git a/resources/README.md b/resources/README.md new file mode 100644 index 0000000..05f97f4 --- /dev/null +++ b/resources/README.md @@ -0,0 +1,35 @@ +# Mobile App Resources + +This directory contains generated assets for the mobile app. + +## Regenerating Assets + +To regenerate all icons and splash screens: + +```bash +node scripts/generate-icons.js +``` + +## Icon Sizes + +### iOS +- 20, 29, 40, 58, 60, 76, 80, 87, 120, 152, 167, 180, 1024px + +### Android +- 48, 72, 96, 144, 192, 512px + +## Splash Screens + +Multiple sizes for different device orientations: +- Universal: 2732x2732 +- iPhone Portrait: 1242x2688 +- iPhone Landscape: 2688x1242 +- iPhone Small: 828x1792 + +Both light and dark versions are provided. + +## Design + +- **Theme**: Poo App 💩 +- **Icon**: Brown/amber gradient (#8B4513 to #D2691E) with white poop emoji, rounded corners +- **Splash**: Centered poop emoji on white (light) or dark (#1a1a1a) background diff --git a/resources/icon/icon-1024.png b/resources/icon/icon-1024.png new file mode 100644 index 0000000..5f0bdd3 Binary files /dev/null and b/resources/icon/icon-1024.png differ diff --git a/resources/icon/icon-120.png b/resources/icon/icon-120.png new file mode 100644 index 0000000..5087054 Binary files /dev/null and b/resources/icon/icon-120.png differ diff --git a/resources/icon/icon-144.png b/resources/icon/icon-144.png new file mode 100644 index 0000000..17123b2 Binary files /dev/null and b/resources/icon/icon-144.png differ diff --git a/resources/icon/icon-152.png b/resources/icon/icon-152.png new file mode 100644 index 0000000..73b4ba0 Binary files /dev/null and b/resources/icon/icon-152.png differ diff --git a/resources/icon/icon-167.png b/resources/icon/icon-167.png new file mode 100644 index 0000000..f889934 Binary files /dev/null and b/resources/icon/icon-167.png differ diff --git a/resources/icon/icon-180.png b/resources/icon/icon-180.png new file mode 100644 index 0000000..0e8d4d3 Binary files /dev/null and b/resources/icon/icon-180.png differ diff --git a/resources/icon/icon-192.png b/resources/icon/icon-192.png new file mode 100644 index 0000000..69eb43b Binary files /dev/null and b/resources/icon/icon-192.png differ diff --git a/resources/icon/icon-20.png b/resources/icon/icon-20.png new file mode 100644 index 0000000..6e0da47 Binary files /dev/null and b/resources/icon/icon-20.png differ diff --git a/resources/icon/icon-29.png b/resources/icon/icon-29.png new file mode 100644 index 0000000..1d0734c Binary files /dev/null and b/resources/icon/icon-29.png differ diff --git a/resources/icon/icon-40.png b/resources/icon/icon-40.png new file mode 100644 index 0000000..f0d63aa Binary files /dev/null and b/resources/icon/icon-40.png differ diff --git a/resources/icon/icon-48.png b/resources/icon/icon-48.png new file mode 100644 index 0000000..2d7a1aa Binary files /dev/null and b/resources/icon/icon-48.png differ diff --git a/resources/icon/icon-512.png b/resources/icon/icon-512.png new file mode 100644 index 0000000..438e997 Binary files /dev/null and b/resources/icon/icon-512.png differ diff --git a/resources/icon/icon-58.png b/resources/icon/icon-58.png new file mode 100644 index 0000000..10df7d5 Binary files /dev/null and b/resources/icon/icon-58.png differ diff --git a/resources/icon/icon-60.png b/resources/icon/icon-60.png new file mode 100644 index 0000000..ee94d3b Binary files /dev/null and b/resources/icon/icon-60.png differ diff --git a/resources/icon/icon-72.png b/resources/icon/icon-72.png new file mode 100644 index 0000000..691ffe9 Binary files /dev/null and b/resources/icon/icon-72.png differ diff --git a/resources/icon/icon-76.png b/resources/icon/icon-76.png new file mode 100644 index 0000000..a7e594a Binary files /dev/null and b/resources/icon/icon-76.png differ diff --git a/resources/icon/icon-80.png b/resources/icon/icon-80.png new file mode 100644 index 0000000..6ebf042 Binary files /dev/null and b/resources/icon/icon-80.png differ diff --git a/resources/icon/icon-87.png b/resources/icon/icon-87.png new file mode 100644 index 0000000..7ec079b Binary files /dev/null and b/resources/icon/icon-87.png differ diff --git a/resources/icon/icon-96.png b/resources/icon/icon-96.png new file mode 100644 index 0000000..00105ae Binary files /dev/null and b/resources/icon/icon-96.png differ diff --git a/resources/splash/splash-iphone-landscape-dark.png b/resources/splash/splash-iphone-landscape-dark.png new file mode 100644 index 0000000..c84bf0c Binary files /dev/null and b/resources/splash/splash-iphone-landscape-dark.png differ diff --git a/resources/splash/splash-iphone-landscape-light.png b/resources/splash/splash-iphone-landscape-light.png new file mode 100644 index 0000000..2e347f9 Binary files /dev/null and b/resources/splash/splash-iphone-landscape-light.png differ diff --git a/resources/splash/splash-iphone-portrait-dark.png b/resources/splash/splash-iphone-portrait-dark.png new file mode 100644 index 0000000..45f80f8 Binary files /dev/null and b/resources/splash/splash-iphone-portrait-dark.png differ diff --git a/resources/splash/splash-iphone-portrait-light.png b/resources/splash/splash-iphone-portrait-light.png new file mode 100644 index 0000000..199d4d9 Binary files /dev/null and b/resources/splash/splash-iphone-portrait-light.png differ diff --git a/resources/splash/splash-iphone-small-dark.png b/resources/splash/splash-iphone-small-dark.png new file mode 100644 index 0000000..938ee0f Binary files /dev/null and b/resources/splash/splash-iphone-small-dark.png differ diff --git a/resources/splash/splash-iphone-small-light.png b/resources/splash/splash-iphone-small-light.png new file mode 100644 index 0000000..d240479 Binary files /dev/null and b/resources/splash/splash-iphone-small-light.png differ diff --git a/resources/splash/splash-universal-dark.png b/resources/splash/splash-universal-dark.png new file mode 100644 index 0000000..5160ee4 Binary files /dev/null and b/resources/splash/splash-universal-dark.png differ diff --git a/resources/splash/splash-universal-light.png b/resources/splash/splash-universal-light.png new file mode 100644 index 0000000..09779f7 Binary files /dev/null and b/resources/splash/splash-universal-light.png differ diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js new file mode 100755 index 0000000..d630164 --- /dev/null +++ b/scripts/generate-icons.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import { createCanvas } from '@napi-rs/canvas'; +import { writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +const ICON_DIR = 'resources/icon'; +const SPLASH_DIR = 'resources/splash'; + +// iOS icon sizes +const IOS_SIZES = [20, 29, 40, 58, 60, 76, 80, 87, 120, 152, 167, 180, 1024]; + +// Android icon sizes +const ANDROID_SIZES = [48, 72, 96, 144, 192, 512]; + +// Splash screen sizes +const SPLASH_SIZES = [ + { name: 'universal', width: 2732, height: 2732 }, + { name: 'iphone-portrait', width: 1242, height: 2688 }, + { name: 'iphone-landscape', width: 2688, height: 1242 }, + { name: 'iphone-small', width: 828, height: 1792 }, +]; + +// Create directories +mkdirSync(ICON_DIR, { recursive: true }); +mkdirSync(SPLASH_DIR, { recursive: true }); + +console.log('🎨 Generating app icons...'); + +/** + * Generate a single icon + */ +function generateIcon(size) { + const canvas = createCanvas(size, size); + const ctx = canvas.getContext('2d'); + + // Create gradient background + const gradient = ctx.createLinearGradient(0, 0, size, size); + gradient.addColorStop(0, '#8B4513'); // Saddle brown + gradient.addColorStop(1, '#D2691E'); // Chocolate/amber + + // Fill background with gradient + ctx.fillStyle = gradient; + + // Add rounded corners + const cornerRadius = size * 0.225; // iOS standard ~22.5% + ctx.beginPath(); + ctx.moveTo(cornerRadius, 0); + ctx.lineTo(size - cornerRadius, 0); + ctx.quadraticCurveTo(size, 0, size, cornerRadius); + ctx.lineTo(size, size - cornerRadius); + ctx.quadraticCurveTo(size, size, size - cornerRadius, size); + ctx.lineTo(cornerRadius, size); + ctx.quadraticCurveTo(0, size, 0, size - cornerRadius); + ctx.lineTo(0, cornerRadius); + ctx.quadraticCurveTo(0, 0, cornerRadius, 0); + ctx.closePath(); + ctx.fill(); + + // Draw emoji + const fontSize = size * 0.6; + ctx.font = `${fontSize}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#FFFFFF'; + ctx.fillText('💩', size / 2, size / 2); + + return canvas; +} + +/** + * Generate a splash screen + */ +function generateSplash(width, height, isDark = false) { + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + // Background color + ctx.fillStyle = isDark ? '#1a1a1a' : '#FFFFFF'; + ctx.fillRect(0, 0, width, height); + + // Draw centered emoji + const size = Math.min(width, height); + const fontSize = size * 0.3; + ctx.font = `${fontSize}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = isDark ? '#FFFFFF' : '#8B4513'; + ctx.fillText('💩', width / 2, height / 2); + + return canvas; +} + +// Generate iOS icons +console.log('📱 Generating iOS icons...'); +for (const size of IOS_SIZES) { + const canvas = generateIcon(size); + const buffer = canvas.toBuffer('image/png'); + const filename = `icon-${size}.png`; + writeFileSync(join(ICON_DIR, filename), buffer); + console.log(` ✓ ${filename} (${size}x${size})`); +} + +// Generate Android icons +console.log('🤖 Generating Android icons...'); +for (const size of ANDROID_SIZES) { + const canvas = generateIcon(size); + const buffer = canvas.toBuffer('image/png'); + const filename = `icon-${size}.png`; + writeFileSync(join(ICON_DIR, filename), buffer); + console.log(` ✓ ${filename} (${size}x${size})`); +} + +// Generate splash screens +console.log('🖼️ Generating splash screens...'); +for (const { name, width, height } of SPLASH_SIZES) { + // Light version + const lightCanvas = generateSplash(width, height, false); + const lightBuffer = lightCanvas.toBuffer('image/png'); + const lightFilename = `splash-${name}-light.png`; + writeFileSync(join(SPLASH_DIR, lightFilename), lightBuffer); + console.log(` ✓ ${lightFilename} (${width}x${height})`); + + // Dark version + const darkCanvas = generateSplash(width, height, true); + const darkBuffer = darkCanvas.toBuffer('image/png'); + const darkFilename = `splash-${name}-dark.png`; + writeFileSync(join(SPLASH_DIR, darkFilename), darkBuffer); + console.log(` ✓ ${darkFilename} (${width}x${height})`); +} + +console.log('\n✨ Done! Icons and splash screens generated in:'); +console.log(` 📁 ${ICON_DIR}`); +console.log(` 📁 ${SPLASH_DIR}`); diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..85c4f4a --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import { createCanvas } from '@napi-rs/canvas'; +import { writeFileSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicDir = join(__dirname, '..', 'public'); +const iconsDir = join(publicDir, 'icons'); + +// Ensure icons directory exists +mkdirSync(iconsDir, { recursive: true }); + +// Icon sizes needed for PWA +const sizes = [72, 96, 128, 144, 152, 192, 384, 512]; + +// Generate icons +for (const size of sizes) { + const canvas = createCanvas(size, size); + const ctx = canvas.getContext('2d'); + + // Background - amber/brown gradient to match app theme + const gradient = ctx.createRadialGradient( + size / 2, size / 2, 0, + size / 2, size / 2, size / 2 + ); + gradient.addColorStop(0, '#FEF3C7'); // amber-100 + gradient.addColorStop(1, '#F59E0B'); // amber-500 + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.fill(); + + // Draw poop emoji text + const fontSize = Math.floor(size * 0.65); + ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('💩', size / 2, size / 2 + fontSize * 0.05); + + // Save as PNG + const buffer = canvas.toBuffer('image/png'); + const filename = join(iconsDir, `icon-${size}.png`); + writeFileSync(filename, buffer); + console.log(`Generated: ${filename}`); +} + +// Also generate new-list icon +const newListSize = 96; +const canvas = createCanvas(newListSize, newListSize); +const ctx = canvas.getContext('2d'); + +const gradient = ctx.createRadialGradient( + newListSize / 2, newListSize / 2, 0, + newListSize / 2, newListSize / 2, newListSize / 2 +); +gradient.addColorStop(0, '#D1FAE5'); // emerald-100 +gradient.addColorStop(1, '#10B981'); // emerald-500 +ctx.fillStyle = gradient; +ctx.beginPath(); +ctx.arc(newListSize / 2, newListSize / 2, newListSize / 2, 0, Math.PI * 2); +ctx.fill(); + +// Plus sign +ctx.strokeStyle = 'white'; +ctx.lineWidth = 8; +ctx.lineCap = 'round'; +ctx.beginPath(); +ctx.moveTo(newListSize / 2, newListSize * 0.25); +ctx.lineTo(newListSize / 2, newListSize * 0.75); +ctx.moveTo(newListSize * 0.25, newListSize / 2); +ctx.lineTo(newListSize * 0.75, newListSize / 2); +ctx.stroke(); + +writeFileSync(join(iconsDir, 'new-list.png'), canvas.toBuffer('image/png')); +console.log('Generated: new-list.png'); + +console.log('\n✅ All icons generated successfully!'); diff --git a/scripts/generate-screenshots.mjs b/scripts/generate-screenshots.mjs new file mode 100644 index 0000000..9e15475 --- /dev/null +++ b/scripts/generate-screenshots.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { createCanvas } from '@napi-rs/canvas'; +import { writeFileSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicDir = join(__dirname, '..', 'public'); +const screenshotsDir = join(publicDir, 'screenshots'); + +// Ensure screenshots directory exists +mkdirSync(screenshotsDir, { recursive: true }); + +// Screenshot specs from manifest +const screenshots = [ + { name: 'home.png', width: 1280, height: 720 }, + { name: 'mobile.png', width: 750, height: 1334 } +]; + +for (const { name, width, height } of screenshots) { + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + // Background gradient + const gradient = ctx.createLinearGradient(0, 0, width, height); + gradient.addColorStop(0, '#FFFBEB'); // amber-50 + gradient.addColorStop(1, '#FEF3C7'); // amber-100 + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + + // App name + ctx.fillStyle = '#78350F'; // amber-900 + const fontSize = Math.floor(Math.min(width, height) * 0.08); + ctx.font = `bold ${fontSize}px system-ui, -apple-system, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('💩 Poo App', width / 2, height / 2 - fontSize); + + // Tagline + const taglineSize = Math.floor(fontSize * 0.5); + ctx.font = `${taglineSize}px system-ui, -apple-system, sans-serif`; + ctx.fillStyle = '#92400E'; // amber-800 + ctx.fillText('Organize your life while you poop', width / 2, height / 2 + taglineSize); + + writeFileSync(join(screenshotsDir, name), canvas.toBuffer('image/png')); + console.log(`Generated: ${name}`); +} + +console.log('\n✅ Screenshots generated!'); diff --git a/scripts/lib/mission-control-api-contracts.mjs b/scripts/lib/mission-control-api-contracts.mjs new file mode 100644 index 0000000..3d68a0d --- /dev/null +++ b/scripts/lib/mission-control-api-contracts.mjs @@ -0,0 +1,20 @@ +export function validateApiKeyInventoryPayload(payload) { + if (!payload || !Array.isArray(payload.apiKeys) || !Array.isArray(payload.rotationEvents)) { + throw new Error("api key inventory contract mismatch (apiKeys[] + rotationEvents[] required)"); + } +} + +export function validateRotateApiKeyResponse(payload) { + if (!payload || payload.success !== true) throw new Error("rotation response contract mismatch (success=true)"); + if (payload.zeroDowntime !== true) throw new Error("rotation response contract mismatch (zeroDowntime=true)"); + if (typeof payload.newKeyId !== "string" || !payload.newKeyId) throw new Error("rotation response contract mismatch (newKeyId)"); + if (typeof payload.apiKey !== "string" || !payload.apiKey.startsWith("pa_")) throw new Error("rotation response contract mismatch (apiKey)"); + if (typeof payload.oldKeyId !== "string" || !payload.oldKeyId) throw new Error("rotation response contract mismatch (oldKeyId)"); +} + +export function validateFinalizeRotationResponse(payload) { + if (!payload || payload.success !== true) throw new Error("rotation finalize contract mismatch (success=true)"); + if (typeof payload.revokedAt !== "number" || !Number.isFinite(payload.revokedAt)) { + throw new Error("rotation finalize contract mismatch (revokedAt)"); + } +} diff --git a/scripts/lib/mission-control-api-contracts.test.mjs b/scripts/lib/mission-control-api-contracts.test.mjs new file mode 100644 index 0000000..b480ed3 --- /dev/null +++ b/scripts/lib/mission-control-api-contracts.test.mjs @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + validateApiKeyInventoryPayload, + validateFinalizeRotationResponse, + validateRotateApiKeyResponse, +} from "./mission-control-api-contracts.mjs"; + +test("validateApiKeyInventoryPayload accepts expected shape", () => { + assert.doesNotThrow(() => validateApiKeyInventoryPayload({ apiKeys: [], rotationEvents: [] })); +}); + +test("validateApiKeyInventoryPayload rejects malformed payload", () => { + assert.throws(() => validateApiKeyInventoryPayload({ apiKeys: {} })); +}); + +test("validateRotateApiKeyResponse accepts zero-downtime response", () => { + assert.doesNotThrow(() => validateRotateApiKeyResponse({ + success: true, + zeroDowntime: true, + oldKeyId: "abc123", + newKeyId: "def456", + apiKey: "pa_deadbeef_token", + })); +}); + +test("validateRotateApiKeyResponse rejects missing contract fields", () => { + assert.throws(() => validateRotateApiKeyResponse({ success: true })); +}); + +test("validateFinalizeRotationResponse accepts finalize contract", () => { + assert.doesNotThrow(() => validateFinalizeRotationResponse({ success: true, revokedAt: Date.now() })); +}); + +test("validateFinalizeRotationResponse rejects malformed contract", () => { + assert.throws(() => validateFinalizeRotationResponse({ success: true, revokedAt: "now" })); +}); diff --git a/scripts/mission-control-alert-severity-policy.mjs b/scripts/mission-control-alert-severity-policy.mjs new file mode 100644 index 0000000..366d2d8 --- /dev/null +++ b/scripts/mission-control-alert-severity-policy.mjs @@ -0,0 +1,40 @@ +const SEVERITY_TO_REQUIRED_SCHEMES = { + low: ["slack"], + medium: ["slack"], + high: ["slack", "pagerduty"], + critical: ["slack", "pagerduty"], +}; + +export function normalizeSeverity(value) { + return String(value ?? "").trim().toLowerCase(); +} + +export function requiredSchemesForSeverity(severity) { + const normalized = normalizeSeverity(severity); + return SEVERITY_TO_REQUIRED_SCHEMES[normalized] ?? []; +} + +export function routeSchemes(routeList) { + return [...new Set((routeList ?? []) + .map((route) => String(route).trim()) + .filter(Boolean) + .map((route) => route.split("://")[0]))].sort(); +} + +export function validateSeverityRoutePolicy({ name, severity, productionRoutes }) { + const requiredSchemes = requiredSchemesForSeverity(severity); + if (requiredSchemes.length === 0) { + return [`Alert ${name} has unsupported severity: ${severity}`]; + } + + const present = new Set(routeSchemes(productionRoutes)); + const missing = requiredSchemes.filter((scheme) => !present.has(scheme)); + + if (missing.length > 0) { + return [ + `Alert ${name} (${normalizeSeverity(severity)}) missing production route scheme(s): ${missing.join(", ")}`, + ]; + } + + return []; +} diff --git a/scripts/mission-control-alert-severity-policy.test.mjs b/scripts/mission-control-alert-severity-policy.test.mjs new file mode 100644 index 0000000..2050adb --- /dev/null +++ b/scripts/mission-control-alert-severity-policy.test.mjs @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + requiredSchemesForSeverity, + routeSchemes, + validateSeverityRoutePolicy, +} from "./mission-control-alert-severity-policy.mjs"; + +test("required schemes by severity are stable", () => { + assert.deepEqual(requiredSchemesForSeverity("low"), ["slack"]); + assert.deepEqual(requiredSchemesForSeverity("medium"), ["slack"]); + assert.deepEqual(requiredSchemesForSeverity("high"), ["slack", "pagerduty"]); + assert.deepEqual(requiredSchemesForSeverity("critical"), ["slack", "pagerduty"]); +}); + +test("route schemes normalize and dedupe", () => { + const schemes = routeSchemes([ + "slack://aviary-oncall-mission-control", + " pagerduty://mission-control-primary ", + "slack://aviary-oncall-mission-control", + ]); + + assert.deepEqual(schemes, ["pagerduty", "slack"]); +}); + +test("high severity requires pagerduty in production", () => { + const errors = validateSeverityRoutePolicy({ + name: "phase1_subscription_latency_p95_high", + severity: "high", + productionRoutes: ["slack://aviary-oncall-mission-control"], + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /missing production route scheme\(s\): pagerduty/); +}); + +test("critical severity passes with slack + pagerduty", () => { + const errors = validateSeverityRoutePolicy({ + name: "phase1_run_control_failure", + severity: "critical", + productionRoutes: [ + "slack://aviary-oncall-mission-control", + "pagerduty://mission-control-primary", + ], + }); + + assert.deepEqual(errors, []); +}); + +test("unsupported severity reports an error", () => { + const errors = validateSeverityRoutePolicy({ + name: "phase1_unknown", + severity: "sev0", + productionRoutes: ["slack://aviary-oncall-mission-control"], + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /unsupported severity/); +}); diff --git a/scripts/mission-control-readiness-drill.mjs b/scripts/mission-control-readiness-drill.mjs new file mode 100644 index 0000000..3618abb --- /dev/null +++ b/scripts/mission-control-readiness-drill.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env node + +import { + validateApiKeyInventoryPayload, + validateFinalizeRotationResponse, + validateRotateApiKeyResponse, +} from "./lib/mission-control-api-contracts.mjs"; + +const baseUrl = process.env.MISSION_CONTROL_BASE_URL; +const apiKey = process.env.MISSION_CONTROL_API_KEY; +const jwtToken = process.env.MISSION_CONTROL_JWT; +const dryRun = process.env.MISSION_CONTROL_DRILL_DRY_RUN !== "false"; + +const skippedChecks = []; +const createdKeys = []; + +function fail(msg, code = 1) { + console.error(`❌ ${msg}`); + process.exit(code); +} + +function ok(msg) { + console.log(`✅ ${msg}`); +} + +function warn(msg) { + console.log(`⚠️ ${msg}`); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +function canAuth(mode) { + if (mode === "apiKey") return Boolean(apiKey); + if (mode === "jwt") return Boolean(jwtToken); + return Boolean(apiKey || jwtToken); +} + +function authHeaders(mode) { + if (mode === "apiKey") return { "X-API-Key": apiKey }; + if (mode === "jwt") return { Authorization: `Bearer ${jwtToken}` }; + + if (apiKey) return { "X-API-Key": apiKey }; + if (jwtToken) return { Authorization: `Bearer ${jwtToken}` }; + return {}; +} + +async function call(path, { method = "GET", body, authMode = "auto" } = {}) { + if (!baseUrl || !canAuth(authMode)) { + return { + skipped: true, + reason: !baseUrl + ? "MISSION_CONTROL_BASE_URL missing" + : authMode === "jwt" + ? "MISSION_CONTROL_JWT missing" + : authMode === "apiKey" + ? "MISSION_CONTROL_API_KEY missing" + : "MISSION_CONTROL_API_KEY or MISSION_CONTROL_JWT missing", + }; + } + + const res = await fetch(`${baseUrl}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...authHeaders(authMode), + }, + body: body ? JSON.stringify(body) : undefined, + }); + + let data; + try { + data = await res.json(); + } catch { + data = { raw: await res.text() }; + } + + return { ok: res.ok, status: res.status, data }; +} + +export function selectRunControlTargets(runsPayload) { + const runs = Array.isArray(runsPayload?.runs) ? runsPayload.runs : []; + return { + primaryRunId: runs[0]?._id ?? null, + killRunId: runs[1]?._id ?? null, + }; +} + +async function checkApiKeyRotationVisibility() { + const result = await call("/api/v1/auth/keys", { authMode: "jwt" }); + if (result.skipped) { + skippedChecks.push(`api key rotation visibility (${result.reason})`); + return; + } + if (!result.ok) fail(`api key rotation visibility failed (${result.status})`); + validateApiKeyInventoryPayload(result.data); + ok("api key inventory + rotation events reachable"); +} + +async function checkRetentionAuditIntegration() { + const settings = await call("/api/v1/runs/retention", { authMode: "jwt" }); + if (settings.skipped) { + skippedChecks.push(`retention settings/audit logs (${settings.reason})`); + return; + } + if (!settings.ok) fail(`retention settings check failed (${settings.status})`); + ok("retention settings + deletion logs reachable"); + + const retention = await call("/api/v1/runs/retention", { + method: "POST", + authMode: "jwt", + body: { dryRun: true, maxRuns: 20 }, + }); + if (!retention.ok) fail(`retention dry-run failed (${retention.status})`); + ok("artifact retention dry-run succeeded"); +} + +async function checkZeroDowntimeRotationFlow() { + if (!jwtToken || !apiKey) { + skippedChecks.push("zero-downtime api key rotation drill (requires both MISSION_CONTROL_JWT and MISSION_CONTROL_API_KEY)"); + return; + } + + const create = await call("/api/v1/auth/keys", { + method: "POST", + authMode: "jwt", + body: { label: `readiness-drill-${Date.now()}`, scopes: ["dashboard:read"] }, + }); + if (!create.ok) fail(`api key create for rotation drill failed (${create.status})`); + assert(typeof create.data?.keyId === "string", "rotation drill create contract mismatch (keyId)"); + assert(typeof create.data?.apiKey === "string", "rotation drill create contract mismatch (apiKey)"); + createdKeys.push(create.data.keyId); + + const rotate = await call(`/api/v1/auth/keys/${create.data.keyId}/rotate`, { + method: "POST", + authMode: "jwt", + body: { gracePeriodHours: 1, label: `readiness-rotated-${Date.now()}` }, + }); + if (!rotate.ok) fail(`api key rotate drill failed (${rotate.status})`); + validateRotateApiKeyResponse(rotate.data); + createdKeys.push(rotate.data.newKeyId); + + const oldDuringGrace = await fetch(`${baseUrl}/api/v1/dashboard/runs`, { + headers: { "X-API-Key": create.data.apiKey }, + }); + assert(oldDuringGrace.ok, `old api key should remain usable during grace (${oldDuringGrace.status})`); + + const newDuringGrace = await fetch(`${baseUrl}/api/v1/dashboard/runs`, { + headers: { "X-API-Key": rotate.data.apiKey }, + }); + assert(newDuringGrace.ok, `new api key should be usable immediately (${newDuringGrace.status})`); + + const finalize = await call(`/api/v1/auth/keys/${create.data.keyId}/finalize-rotation`, { + method: "POST", + authMode: "jwt", + }); + if (!finalize.ok) fail(`api key finalize rotation failed (${finalize.status})`); + validateFinalizeRotationResponse(finalize.data); + + const oldAfterFinalize = await fetch(`${baseUrl}/api/v1/dashboard/runs`, { + headers: { "X-API-Key": create.data.apiKey }, + }); + assert(oldAfterFinalize.status === 401, `old api key should be rejected after finalize (${oldAfterFinalize.status})`); + + const newAfterFinalize = await fetch(`${baseUrl}/api/v1/dashboard/runs`, { + headers: { "X-API-Key": rotate.data.apiKey }, + }); + assert(newAfterFinalize.ok, `new api key should keep working after finalize (${newAfterFinalize.status})`); + + ok("zero-downtime API key rotation flow assertions passed"); +} + +async function cleanupCreatedKeys() { + if (!jwtToken || !baseUrl || createdKeys.length === 0) return; + for (const keyId of createdKeys.reverse()) { + const result = await call(`/api/v1/auth/keys/${keyId}`, { method: "DELETE", authMode: "jwt" }); + if (!result.skipped && !result.ok && result.status !== 400 && result.status !== 404) { + warn(`cleanup failed for ${keyId} (${result.status})`); + } + } +} + +async function main() { + console.log("Mission Control readiness drill"); + console.log(`Mode: ${dryRun ? "dry-run" : "live"}`); + + const dashboard = await call("/api/v1/dashboard/runs", { authMode: "auto" }); + if (dashboard.skipped) { + warn(`Skipping remote checks: ${dashboard.reason}`); + ok("Readiness drill script wiring validated (env-less mode)"); + return; + } + if (!dashboard.ok) fail(`dashboard check failed (${dashboard.status})`); + ok("dashboard/runs reachable"); + + await checkApiKeyRotationVisibility(); + await checkRetentionAuditIntegration(); + + if (dryRun) { + if (skippedChecks.length) { + warn(`Skipped checks: ${skippedChecks.join("; ")}`); + } + ok("Operator control simulation complete (dry-run, no run mutations sent)"); + return; + } + + await checkZeroDowntimeRotationFlow(); + + const runs = await call("/api/v1/runs?limit=2", { authMode: "apiKey" }); + if (runs.skipped) { + skippedChecks.push(`live run control simulation (${runs.reason})`); + warn(`Skipping live run control simulation: ${runs.reason}`); + console.log("🎯 Readiness drill completed with partial coverage"); + return; + } + if (!runs.ok) fail(`run list failed (${runs.status})`); + + const { primaryRunId, killRunId } = selectRunControlTargets(runs.data); + if (!primaryRunId) fail("no runs available to execute live drill", 2); + + const pause = await call(`/api/v1/runs/${primaryRunId}/pause`, { + method: "POST", + authMode: "apiKey", + body: { reason: "readiness_drill" }, + }); + if (!pause.ok) fail(`pause failed (${pause.status})`); + ok("pause action succeeded"); + + if (killRunId) { + const kill = await call(`/api/v1/runs/${killRunId}/kill`, { + method: "POST", + authMode: "apiKey", + body: { reason: "readiness_drill" }, + }); + if (!kill.ok) fail(`kill failed (${kill.status})`); + ok("kill action succeeded"); + } else { + warn("kill action skipped (need at least 2 runs in list response)"); + } + + const escalate = await call(`/api/v1/runs/${primaryRunId}/escalate`, { + method: "POST", + authMode: "apiKey", + body: { reason: "readiness_drill" }, + }); + if (!escalate.ok) fail(`escalate failed (${escalate.status})`); + ok("escalate action succeeded"); + + if (skippedChecks.length) { + warn(`Skipped checks: ${skippedChecks.join("; ")}`); + } + console.log("🎯 Readiness drill completed"); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main() + .catch((error) => fail(error instanceof Error ? error.message : String(error))) + .finally(async () => { + await cleanupCreatedKeys(); + }); +} diff --git a/scripts/mission-control-readiness-drill.test.mjs b/scripts/mission-control-readiness-drill.test.mjs new file mode 100644 index 0000000..8212cc9 --- /dev/null +++ b/scripts/mission-control-readiness-drill.test.mjs @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { selectRunControlTargets } from "./mission-control-readiness-drill.mjs"; + +test("selectRunControlTargets picks primary and optional kill run IDs", () => { + assert.deepEqual(selectRunControlTargets({ runs: [{ _id: "run1" }, { _id: "run2" }] }), { + primaryRunId: "run1", + killRunId: "run2", + }); + + assert.deepEqual(selectRunControlTargets({ runs: [{ _id: "run1" }] }), { + primaryRunId: "run1", + killRunId: null, + }); + + assert.deepEqual(selectRunControlTargets({ runs: [] }), { + primaryRunId: null, + killRunId: null, + }); +}); diff --git a/scripts/validate-mission-control-observability.mjs b/scripts/validate-mission-control-observability.mjs new file mode 100644 index 0000000..98e8f02 --- /dev/null +++ b/scripts/validate-mission-control-observability.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { validateSeverityRoutePolicy } from "./mission-control-alert-severity-policy.mjs"; + +function readJson(path) { + return JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8")); +} + +function unique(values) { + return new Set(values); +} + +function fail(message) { + console.error(`❌ ${message}`); + process.exitCode = 1; +} + +function pass(message) { + console.log(`✅ ${message}`); +} + +function normalizeMetricRef(metricRef) { + return String(metricRef) + .split("/") + .map((part) => part.trim()) + .filter(Boolean); +} + +function normalizeRouteList(routeList) { + return [...new Set((routeList ?? []).map((value) => String(value).trim()).filter(Boolean))].sort(); +} + +const metricsPath = "docs/mission-control/phase1-observability-metrics.json"; +const dashboardPath = "docs/mission-control/phase1-observability-dashboard-config.json"; +const routingPath = "docs/mission-control/phase1-observability-alert-routing.json"; + +const metrics = readJson(metricsPath); +const dashboard = readJson(dashboardPath); +const routing = readJson(routingPath); + +if (!Array.isArray(metrics.metrics) || metrics.metrics.length === 0) { + fail(`${metricsPath} must contain a non-empty metrics array`); + process.exit(process.exitCode ?? 1); +} + +if (!Array.isArray(dashboard.dashboard?.panels) || dashboard.dashboard.panels.length === 0) { + fail(`${dashboardPath} must contain dashboard.panels`); + process.exit(process.exitCode ?? 1); +} + +if (!Array.isArray(routing.alerts) || routing.alerts.length === 0) { + fail(`${routingPath} must contain alerts`); + process.exit(process.exitCode ?? 1); +} + +const metricNames = metrics.metrics.map((m) => m.name); +const uniqueNames = unique(metricNames); +if (uniqueNames.size !== metricNames.length) { + const dupes = metricNames.filter((name, index) => metricNames.indexOf(name) !== index); + fail(`Duplicate metric names found: ${[...new Set(dupes)].join(", ")}`); +} else { + pass(`Metric catalog has ${metricNames.length} unique metrics`); +} + +const chartMetrics = dashboard.dashboard.panels + .flatMap((panel) => panel.charts ?? []) + .flatMap((chart) => normalizeMetricRef(chart.metric)) + .filter(Boolean); + +const missingFromCatalog = [...new Set(chartMetrics.filter((name) => !uniqueNames.has(name)))]; +if (missingFromCatalog.length > 0) { + fail(`Dashboard references metrics missing from catalog: ${missingFromCatalog.join(", ")}`); +} else { + pass("Dashboard chart metrics are all declared in metric catalog"); +} + +const metricAlertNames = new Set((metrics.alerts ?? []).map((a) => a.name)); +const dashboardAlertNames = new Set((dashboard.alerts ?? []).map((a) => a.name)); +const routingAlertNames = new Set((routing.alerts ?? []).map((a) => a.name)); + +const missingInDashboard = [...metricAlertNames].filter((name) => !dashboardAlertNames.has(name)); +const missingInMetrics = [...dashboardAlertNames].filter((name) => !metricAlertNames.has(name)); +const missingInRouting = [...metricAlertNames].filter((name) => !routingAlertNames.has(name)); + +if (missingInDashboard.length > 0) { + fail(`Alerts declared in metrics but missing in dashboard config: ${missingInDashboard.join(", ")}`); +} +if (missingInMetrics.length > 0) { + fail(`Alerts declared in dashboard config but missing in metrics catalog: ${missingInMetrics.join(", ")}`); +} +if (missingInRouting.length > 0) { + fail(`Alerts declared in metrics but missing in routing config: ${missingInRouting.join(", ")}`); +} +if (missingInDashboard.length === 0 && missingInMetrics.length === 0 && missingInRouting.length === 0) { + pass(`Alert definitions are in sync (${metricAlertNames.size} total)`); +} + +const placeholderTokens = ["internal-dev-channel", "on-call-channel", "pager"]; +const routes = (dashboard.alerts ?? []).flatMap((a) => [ + ...(a.route?.staging ?? []), + ...(a.route?.production ?? []), +]); + +const hasPlaceholder = routes.some((r) => placeholderTokens.includes(String(r))); +if (hasPlaceholder) { + fail("Dashboard alert routes still contain placeholder values"); +} else { + pass("Dashboard alert routes are concrete (non-placeholder)"); +} + +const severityAllowed = new Set(["low", "medium", "high", "critical"]); +for (const alert of dashboard.alerts ?? []) { + if (!severityAllowed.has(String(alert.severity))) { + fail(`Dashboard alert ${alert.name} has invalid severity: ${alert.severity}`); + } + if (!alert.condition || typeof alert.condition !== "string") { + fail(`Dashboard alert ${alert.name} missing condition expression`); + } +} +pass("Dashboard alert sanity checks passed"); + +for (const alert of metrics.alerts ?? []) { + if (!/^[0-9]+m$/.test(String(alert.window ?? ""))) { + fail(`Metrics alert ${alert.name} has invalid window format: ${alert.window}`); + } +} +pass("Metrics alert windows are normalized"); + +const endpointCandidates = [ + routing.routing?.staging?.channel, + routing.routing?.staging?.pager, + routing.routing?.production?.channel, + routing.routing?.production?.pager, +] + .map((value) => String(value ?? "").trim()) + .filter(Boolean); +const endpointCatalog = new Set(endpointCandidates); + +if (!endpointCatalog.size) { + fail("Routing endpoint catalog is empty (routing.staging/production channel|pager)"); +} + +const allowedSchemes = new Set(["slack", "pagerduty"]); +for (const endpoint of endpointCatalog) { + const [scheme] = endpoint.split("://"); + if (!allowedSchemes.has(scheme)) { + fail(`Unsupported routing endpoint scheme: ${endpoint}`); + } +} +if (endpointCatalog.size > 0) { + pass(`Routing endpoint catalog validated (${endpointCatalog.size} endpoints)`); +} + +for (const alert of routing.alerts ?? []) { + if (!Array.isArray(alert.route?.staging) || alert.route.staging.length === 0) { + fail(`Routing alert ${alert.name} missing staging route`); + } + if (!Array.isArray(alert.route?.production) || alert.route.production.length === 0) { + fail(`Routing alert ${alert.name} missing production route`); + } + + const inDashboard = (dashboard.alerts ?? []).find((a) => a.name === alert.name); + if (inDashboard && String(inDashboard.severity) !== String(alert.severity)) { + fail(`Severity mismatch for ${alert.name}: dashboard=${inDashboard.severity} routing=${alert.severity}`); + } + + const routingStaging = normalizeRouteList(alert.route?.staging); + const routingProduction = normalizeRouteList(alert.route?.production); + + for (const target of [...routingStaging, ...routingProduction]) { + if (!endpointCatalog.has(target)) { + fail(`Routing alert ${alert.name} references unprovisioned target: ${target}`); + } + } + + if (inDashboard) { + const dashboardStaging = normalizeRouteList(inDashboard.route?.staging); + const dashboardProduction = normalizeRouteList(inDashboard.route?.production); + + if (JSON.stringify(dashboardStaging) !== JSON.stringify(routingStaging)) { + fail(`Staging route mismatch for ${alert.name}: dashboard=${dashboardStaging.join("|")} routing=${routingStaging.join("|")}`); + } + if (JSON.stringify(dashboardProduction) !== JSON.stringify(routingProduction)) { + fail(`Production route mismatch for ${alert.name}: dashboard=${dashboardProduction.join("|")} routing=${routingProduction.join("|")}`); + } + + const policyErrors = validateSeverityRoutePolicy({ + name: alert.name, + severity: alert.severity, + productionRoutes: routingProduction, + }); + for (const error of policyErrors) { + fail(error); + } + } +} +pass("Routing config includes staging and production targets for each alert"); +pass("Alert routes match between dashboard and routing config"); +pass("Severity-based production routing policy is satisfied"); + +if (process.exitCode && process.exitCode !== 0) { + console.error("Mission Control observability validation failed."); + process.exit(process.exitCode); +} + +console.log("Mission Control observability validation passed."); diff --git a/src/App.tsx b/src/App.tsx index b50c1b6..a841cf4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ -import { lazy, Suspense, useState } from 'react' -import { Routes, Route, Link, Navigate } from 'react-router-dom' +import { lazy, Suspense, useState, useEffect } from 'react' +import { Routes, Route, Link, Navigate, useNavigate, useLocation } from 'react-router-dom' import { useAuth } from './hooks/useAuth' import { useSettings } from './hooks/useSettings' import { AuthGuard } from './components/auth/AuthGuard' @@ -8,17 +8,25 @@ import { ProfileBadge } from './components/ProfileBadge' import { OfflineIndicator } from './components/offline/OfflineIndicator' import { ToastContainer } from './components/notifications/Toast' import { Settings } from './components/Settings' - -// Static imports for frequently used routes -import { Home } from './pages/Home' -import { Login } from './pages/Login' -import { Landing } from './pages/Landing' +import { AppLockGuard } from './components/AppLockGuard' +import { useSwipeBack } from './hooks/useSwipeBack' +import { initDeepLinks } from './lib/deeplinks' +import { initPushNotifications } from './lib/pushNotifications' +import { incrementMetric } from './lib/observability' // Lazy-loaded routes for better code splitting +const Home = lazy(() => import('./pages/Home').then(m => ({ default: m.Home }))) +const Login = lazy(() => import('./pages/Login').then(m => ({ default: m.Login }))) +const Landing = lazy(() => import('./pages/Landing').then(m => ({ default: m.Landing }))) const ListView = lazy(() => import('./pages/ListView').then(m => ({ default: m.ListView }))) const JoinList = lazy(() => import('./pages/JoinList').then(m => ({ default: m.JoinList }))) const PublicList = lazy(() => import('./pages/PublicList').then(m => ({ default: m.PublicList }))) +const SharedListResource = lazy(() => import('./components/SharedListResource').then(m => ({ default: m.SharedListResource }))) const Profile = lazy(() => import('./pages/Profile').then(m => ({ default: m.Profile }))) +const Templates = lazy(() => import('./pages/Templates').then(m => ({ default: m.Templates }))) +const PriorityFocus = lazy(() => import('./pages/PriorityFocus').then(m => ({ default: m.PriorityFocus }))) +const TeamDashboard = lazy(() => import('./pages/TeamDashboard').then(m => ({ default: m.TeamDashboard }))) +const Memory = lazy(() => import('./pages/Memory').then(m => ({ default: m.Memory }))) /** * Authenticated layout wrapper with header and navigation. @@ -29,9 +37,17 @@ function AuthenticatedLayout({ children }: { children: React.ReactNode }) { const [isSettingsOpen, setIsSettingsOpen] = useState(false); return ( -
+
+ {/* Skip to main content link */} + + Skip to main content + + {/* Header */} -
+
{/* Main content */} -
+
{children}
@@ -126,9 +142,60 @@ function PageLoadingFallback() { function App() { const { isAuthenticated } = useAuth() + const navigate = useNavigate() + const location = useLocation() + + // Enable swipe-right from left edge to go back (mobile PWA) + useSwipeBack() + + // Initialize deep links for mobile + useEffect(() => { + initDeepLinks(navigate) + }, [navigate]) + + // Initialize push notifications after user is authenticated + useEffect(() => { + if (isAuthenticated) { + initPushNotifications(); + } + }, [isAuthenticated]); + + // Route-aware client error rate baseline + useEffect(() => { + const onError = () => { + incrementMetric('client_error_total', { + route: location.pathname, + errorType: 'window_error', + env: import.meta.env.MODE, + }) + } + + const onUnhandledRejection = () => { + incrementMetric('client_error_total', { + route: location.pathname, + errorType: 'unhandled_rejection', + env: import.meta.env.MODE, + }) + } + + window.addEventListener('error', onError) + window.addEventListener('unhandledrejection', onUnhandledRejection) + + return () => { + window.removeEventListener('error', onError) + window.removeEventListener('unhandledrejection', onUnhandledRejection) + } + }, [location.pathname]) + + useEffect(() => { + incrementMetric('route_view_total', { + route: location.pathname, + env: import.meta.env.MODE, + }) + }, [location.pathname]) return ( - <> + }> @@ -136,6 +203,7 @@ function App() { : } /> } /> } /> + } /> {/* Landing page for unauthenticated, redirect to app if logged in */} : } /> @@ -143,6 +211,10 @@ function App() { {/* Protected routes - require authentication */} } /> } /> + } /> + } /> + } /> + } /> } /> {/* Fallback - redirect to app (AuthGuard will handle login redirect if needed) */} @@ -150,7 +222,7 @@ function App() { - + ) } diff --git a/src/components/AddItemInput.tsx b/src/components/AddItemInput.tsx index b497f0a..977f195 100644 --- a/src/components/AddItemInput.tsx +++ b/src/components/AddItemInput.tsx @@ -3,7 +3,7 @@ * Features improved design, dark mode, and haptic feedback. */ -import { useState, useRef, type FormEvent } from "react"; +import { useState, useRef, type FormEvent, forwardRef, useImperativeHandle } from "react"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; @@ -17,10 +17,13 @@ interface AddItemInputProps { }) => Promise; } -export function AddItemInput({ onAddItem }: AddItemInputProps) { +export const AddItemInput = forwardRef(function AddItemInput({ onAddItem }, ref) { const { did, legacyDid } = useCurrentUser(); const { haptic } = useSettings(); const inputRef = useRef(null); + + // Expose the input ref to parent components + useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); const [name, setName] = useState(""); const [isAdding, setIsAdding] = useState(false); @@ -29,12 +32,17 @@ export function AddItemInput({ onAddItem }: AddItemInputProps) { e.preventDefault(); const trimmedName = name.trim(); - if (!trimmedName || !did) { + if (!trimmedName || !did || isAdding) { return; } haptic('medium'); setIsAdding(true); + setName(""); + + // Keep focus on the input immediately — don't wait for async. + // Clear the value first so the user sees it's ready for the next item. + inputRef.current?.focus(); try { await onAddItem({ @@ -43,31 +51,29 @@ export function AddItemInput({ onAddItem }: AddItemInputProps) { legacyDid: legacyDid ?? undefined, createdAt: Date.now(), }); - - setName(""); haptic('success'); - - // Keep focus on input for quick consecutive adds - inputRef.current?.focus(); } catch (err) { console.error("Failed to add item:", err); haptic('error'); + // Restore the text on error so the user doesn't lose it + setName(trimmedName); } finally { setIsAdding(false); } }; return ( -
+
+ setName(e.target.value)} placeholder="Add item..." className="w-full px-4 py-3.5 bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-xl text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-amber-500 dark:focus:border-amber-500 focus:ring-4 focus:ring-amber-500/10 transition-all disabled:opacity-50" - disabled={isAdding} /> {/* Removed "Press Enter" hint per user feedback */} @@ -96,4 +102,4 @@ export function AddItemInput({ onAddItem }: AddItemInputProps) { ); -} +}); diff --git a/src/components/AppLockGuard.tsx b/src/components/AppLockGuard.tsx new file mode 100644 index 0000000..e7af6dd --- /dev/null +++ b/src/components/AppLockGuard.tsx @@ -0,0 +1,84 @@ +/** + * AppLockGuard - Biometric authentication gate for the app. + * Shows a lock screen and requires Face ID/fingerprint if enabled in settings. + */ + +import { useState, useEffect, type ReactNode } from 'react'; +import { biometrics } from '../lib/biometrics'; +import { getBiometricLockEnabled } from '../lib/storage'; + +interface AppLockGuardProps { + children: ReactNode; +} + +export function AppLockGuard({ children }: AppLockGuardProps) { + const [isLocked, setIsLocked] = useState(true); + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [biometricLockEnabled, setBiometricLockEnabled] = useState(false); + + useEffect(() => { + const lockEnabled = getBiometricLockEnabled(); + setBiometricLockEnabled(lockEnabled); + + if (lockEnabled) { + // Attempt authentication on mount + authenticate(); + } else { + // If lock is not enabled, unlock immediately + setIsLocked(false); + } + }, []); + + const authenticate = async () => { + setIsAuthenticating(true); + try { + const success = await biometrics.authenticate('Unlock Poo App'); + if (success) { + setIsLocked(false); + } + } catch (error) { + console.error('Biometric authentication error:', error); + } finally { + setIsAuthenticating(false); + } + }; + + // If lock is not enabled or already unlocked, render children + if (!biometricLockEnabled || !isLocked) { + return <>{children}; + } + + // Show lock screen + return ( +
+
+
+ 🔒 +
+

+ Poo App is Locked +

+

+ Use Face ID or fingerprint to unlock +

+ +
+
+ ); +} diff --git a/src/components/Attachments.tsx b/src/components/Attachments.tsx new file mode 100644 index 0000000..95ec095 --- /dev/null +++ b/src/components/Attachments.tsx @@ -0,0 +1,313 @@ +/** + * Attachments component for uploading and viewing files on items. + * Uses Convex file storage for secure file handling. + */ + +import { useState, useRef } from "react"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import type { Id } from "../../convex/_generated/dataModel"; +import { useSettings } from "../hooks/useSettings"; + +interface AttachmentsProps { + itemId: Id<"items">; + userDid: string; + legacyDid?: string; + canEdit: boolean; +} + +// Max file size: 10MB +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +// Limit per selection to avoid accidental huge uploads +const MAX_FILES_PER_UPLOAD = 5; + +// Allowed file types +const ALLOWED_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "text/plain", + "application/json", +]; + +export function Attachments({ itemId, userDid, legacyDid, canEdit }: AttachmentsProps) { + const { haptic } = useSettings(); + const fileInputRef = useRef(null); + const [uploadingCount, setUploadingCount] = useState(0); + const [uploadError, setUploadError] = useState(null); + const [deletingId, setDeletingId] = useState | null>(null); + const [failedPreviewIds, setFailedPreviewIds] = useState>({}); + + // Fetch attachment URLs + const attachments = useQuery(api.attachments.getAttachmentUrls, { itemId }); + + // Mutations + const generateUploadUrl = useMutation(api.attachments.generateUploadUrl); + const addAttachment = useMutation(api.attachments.addAttachment); + const removeAttachment = useMutation(api.attachments.removeAttachment); + + const handleUploadClick = () => { + if (!canEdit) return; + haptic("light"); + fileInputRef.current?.click(); + }; + + const uploadSingleFile = async (file: File) => { + // Step 1: Generate upload URL + const uploadUrl = await generateUploadUrl({ + itemId, + userDid, + legacyDid, + }); + + // Step 2: Upload to Convex storage + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type || "application/octet-stream" }, + body: file, + }); + + if (!response.ok) { + throw new Error("Upload failed"); + } + + const { storageId } = await response.json(); + + // Step 3: Add attachment to item + await addAttachment({ + itemId, + storageId, + userDid, + legacyDid, + }); + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const selectedFiles = e.target.files; + if (!selectedFiles || selectedFiles.length === 0) return; + + // Clear previous error + setUploadError(null); + + const files = Array.from(selectedFiles).slice(0, MAX_FILES_PER_UPLOAD); + const rejected: string[] = []; + const validFiles: File[] = []; + + // Validate selected files + for (const file of files) { + if (file.size > MAX_FILE_SIZE) { + rejected.push(`${file.name} (too large)`); + continue; + } + + if (!ALLOWED_TYPES.includes(file.type)) { + rejected.push(`${file.name} (unsupported type)`); + continue; + } + + validFiles.push(file); + } + + if (validFiles.length === 0) { + setUploadError("No valid files selected. Use images, PDFs, or text files under 10MB."); + haptic("error"); + if (fileInputRef.current) fileInputRef.current.value = ""; + return; + } + + if (rejected.length > 0) { + setUploadError(`Skipped ${rejected.length} file(s): ${rejected.slice(0, 2).join(", ")}${rejected.length > 2 ? "..." : ""}`); + } + + setUploadingCount(validFiles.length); + haptic("medium"); + + try { + for (const file of validFiles) { + await uploadSingleFile(file); + setUploadingCount((count) => Math.max(0, count - 1)); + } + haptic("success"); + } catch (err) { + console.error("Failed to upload attachment:", err); + setUploadError("Failed to upload one or more files. Please try again."); + haptic("error"); + } finally { + setUploadingCount(0); + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + const handleDelete = async (storageId: Id<"_storage">) => { + if (!canEdit) return; + + haptic("medium"); + setDeletingId(storageId); + + try { + await removeAttachment({ + itemId, + storageId, + userDid, + legacyDid, + }); + setFailedPreviewIds((prev) => { + if (!prev[String(storageId)]) return prev; + const clone = { ...prev }; + delete clone[String(storageId)]; + return clone; + }); + haptic("success"); + } catch (err) { + console.error("Failed to remove attachment:", err); + haptic("error"); + } finally { + setDeletingId(null); + } + }; + + return ( +
+ {/* Hidden file input */} + + + {/* Attachment grid */} + {attachments && attachments.length > 0 && ( +
+ {attachments.map(({ storageId, url }) => ( +
+ {url ? ( + + {!failedPreviewIds[String(storageId)] ? ( + Attachment { + setFailedPreviewIds((prev) => ({ ...prev, [String(storageId)]: true })); + }} + /> + ) : ( +
+ + + +
+ )} +
+ ) : ( +
+ + + +
+ )} + + {/* Delete button */} + {canEdit && ( + + )} +
+ ))} +
+ )} + + {/* Upload button */} + {canEdit && ( + + )} + + {/* Error message */} + {uploadError && ( +

{uploadError}

+ )} + + {/* Empty state */} + {(!attachments || attachments.length === 0) && !canEdit && ( +

+ No attachments +

+ )} +
+ ); +} + +/** + * Compact attachment preview for list items. + * Shows small thumbnail count. + */ +interface AttachmentPreviewProps { + itemId: Id<"items">; + count?: number; +} + +export function AttachmentPreview({ count }: AttachmentPreviewProps) { + if (!count || count === 0) return null; + + return ( +
+ + + + {count} +
+ ); +} diff --git a/src/components/BatchOperations.tsx b/src/components/BatchOperations.tsx new file mode 100644 index 0000000..1c75fed --- /dev/null +++ b/src/components/BatchOperations.tsx @@ -0,0 +1,214 @@ +/** + * Batch operations toolbar for selecting and modifying multiple items. + */ + +import { useState } from "react"; +import { useMutation } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import type { Id } from "../../convex/_generated/dataModel"; +import { useSettings } from "../hooks/useSettings"; +import { useOffline } from "../hooks/useOffline"; +import { queueMutation } from "../lib/offline"; +import { ConfirmDialog } from "./ConfirmDialog"; + +interface BatchOperationsProps { + selectedIds: Set>; + onClearSelection: () => void; + userDid: string; + legacyDid?: string; +} + +export function BatchOperations({ + selectedIds, + onClearSelection, + userDid, + legacyDid, +}: BatchOperationsProps) { + const { haptic } = useSettings(); + const { isOnline } = useOffline(); + const [isProcessing, setIsProcessing] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const batchCheck = useMutation(api.items.batchCheckItems); + const batchUncheck = useMutation(api.items.batchUncheckItems); + const batchDelete = useMutation(api.items.batchDeleteItems); + + const count = selectedIds.size; + + const handleCheckAll = async () => { + if (isProcessing) return; + haptic("medium"); + setIsProcessing(true); + try { + const payload = { + itemIds: Array.from(selectedIds), + checkedByDid: userDid, + legacyDid, + }; + + if (isOnline) { + await batchCheck(payload); + } else { + await queueMutation({ + type: "batchCheckItems", + payload, + timestamp: Date.now(), + retryCount: 0, + }); + } + + haptic("success"); + onClearSelection(); + } catch (err) { + console.error("Failed to check items:", err); + haptic("error"); + } finally { + setIsProcessing(false); + } + }; + + const handleUncheckAll = async () => { + if (isProcessing) return; + haptic("medium"); + setIsProcessing(true); + try { + const payload = { + itemIds: Array.from(selectedIds), + userDid, + legacyDid, + }; + + if (isOnline) { + await batchUncheck(payload); + } else { + await queueMutation({ + type: "batchUncheckItems", + payload, + timestamp: Date.now(), + retryCount: 0, + }); + } + + haptic("success"); + onClearSelection(); + } catch (err) { + console.error("Failed to uncheck items:", err); + haptic("error"); + } finally { + setIsProcessing(false); + } + }; + + const handleDeleteAll = async () => { + if (isProcessing) return; + haptic("medium"); + setIsProcessing(true); + try { + const payload = { + itemIds: Array.from(selectedIds), + userDid, + legacyDid, + }; + + if (isOnline) { + await batchDelete(payload); + } else { + await queueMutation({ + type: "batchDeleteItems", + payload, + timestamp: Date.now(), + retryCount: 0, + }); + } + + haptic("success"); + onClearSelection(); + } catch (err) { + console.error("Failed to delete items:", err); + haptic("error"); + } finally { + setIsProcessing(false); + setShowDeleteConfirm(false); + } + }; + + if (count === 0) return null; + + return ( + <> +
+ + {count} selected + + +
+ + + + + + + +
+ + +
+ + {showDeleteConfirm && ( + 1 ? "s" : ""}? This cannot be undone.`} + confirmLabel="Delete" + onConfirm={handleDeleteAll} + onCancel={() => setShowDeleteConfirm(false)} + confirmVariant="danger" + /> + )} + + ); +} diff --git a/src/components/CalendarView.tsx b/src/components/CalendarView.tsx new file mode 100644 index 0000000..e83357c --- /dev/null +++ b/src/components/CalendarView.tsx @@ -0,0 +1,313 @@ +/** + * Calendar view for items with due dates. + * Note: Calendar view uses existing items query. + */ + +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import type { Id, Doc } from "../../convex/_generated/dataModel"; +import { useSettings } from "../hooks/useSettings"; + +interface CalendarViewProps { + listId: Id<"lists">; + userDid: string; + onItemClick?: (item: Doc<"items">) => void; +} + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +]; + +export function CalendarView({ listId, userDid, onItemClick }: CalendarViewProps) { + const { haptic } = useSettings(); + const [currentDate, setCurrentDate] = useState(new Date()); + const [showSchedules, setShowSchedules] = useState(true); + const [toggleError, setToggleError] = useState(null); + const [pendingEntryId, setPendingEntryId] = useState(null); + const updateScheduleEntry = useMutation("scheduleEntries:updateScheduleEntry" as any); + + // Use existing items query + const allItems = useQuery(api.items.getListItems, { listId }); + + // Filter items with due dates in current month + const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); + const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59); + + const scheduleEntries = useQuery("scheduleEntries:listForList" as any, { + listId, + userDid, + monthStart: monthStart.getTime(), + monthEnd: monthEnd.getTime(), + }) as Array> | undefined; + + const items = useMemo(() => { + if (!allItems) return []; + return allItems.filter(item => + item.dueDate && + item.dueDate >= monthStart.getTime() && + item.dueDate <= monthEnd.getTime() + ); + }, [allItems, monthStart.getTime(), monthEnd.getTime()]); + + // Group items by date + const itemsByDate = useMemo(() => { + const map = new Map[]>(); + + for (const item of items) { + if (!item.dueDate) continue; + const dateKey = new Date(item.dueDate).toDateString(); + const existing = map.get(dateKey) ?? []; + map.set(dateKey, [...existing, item]); + } + return map; + }, [items]); + + const scheduleByDate = useMemo(() => { + const map = new Map>>(); + if (!scheduleEntries) return map; + for (const entry of scheduleEntries) { + const t = entry.scheduledAt ?? entry.nextRunAt; + if (!t) continue; + const key = new Date(t).toDateString(); + map.set(key, [...(map.get(key) ?? []), entry]); + } + return map; + }, [scheduleEntries]); + + // Generate calendar days + const calendarDays = useMemo(() => { + const days: (Date | null)[] = []; + const firstDay = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); + const lastDay = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0); + + // Add empty cells for days before the first of the month + for (let i = 0; i < firstDay.getDay(); i++) { + days.push(null); + } + + // Add days of the month + for (let d = 1; d <= lastDay.getDate(); d++) { + days.push(new Date(currentDate.getFullYear(), currentDate.getMonth(), d)); + } + + return days; + }, [currentDate]); + + const goToPreviousMonth = () => { + haptic("light"); + setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)); + }; + + const goToNextMonth = () => { + haptic("light"); + setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)); + }; + + const goToToday = () => { + haptic("light"); + setCurrentDate(new Date()); + }; + + const isToday = (date: Date) => { + const today = new Date(); + return date.toDateString() === today.toDateString(); + }; + + const toggleScheduleEnabled = async (entry: Doc<"scheduleEntries">) => { + setToggleError(null); + setPendingEntryId(entry._id); + try { + await updateScheduleEntry({ + entryId: entry._id, + actorDid: userDid, + enabled: !entry.enabled, + }); + } catch (error) { + setToggleError(error instanceof Error ? error.message : "Failed to update schedule entry"); + } finally { + setPendingEntryId(null); + } + }; + + if (!allItems || !scheduleEntries) { + return ( +
+
+
+ {Array.from({ length: 35 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + +
+

+ {MONTHS[currentDate.getMonth()]} {currentDate.getFullYear()} +

+ + +
+ + +
+ + {/* Weekday headers */} +
+ {WEEKDAYS.map((day) => ( +
+ {day} +
+ ))} +
+ + {toggleError && ( +
+ {toggleError} +
+ )} + + {/* Calendar grid */} +
+ {calendarDays.map((date, index) => { + if (!date) { + return
; + } + + const dateItems = itemsByDate.get(date.toDateString()) ?? []; + const dateSchedules = showSchedules ? (scheduleByDate.get(date.toDateString()) ?? []) : []; + const today = isToday(date); + + return ( +
+
+ {date.getDate()} +
+ +
+ {dateItems.slice(0, 2).map((item) => ( + + ))} + {dateSchedules.slice(0, 1).map((entry) => ( +
+ 🕒 {entry.title} + +
+ ))} + {(dateItems.length + dateSchedules.length) > 3 && ( +
+ +{dateItems.length + dateSchedules.length - 3} more +
+ )} +
+
+ ); + })} +
+ + {showSchedules && scheduleEntries.length > 0 && ( +
+
Schedule view
+
+ {scheduleEntries.slice(0, 12).map((entry) => ( +
+
+ {entry.title}{entry.cronExpr ? ` · ${entry.cronExpr}` : ""} +
+ +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/ChangeCategoryDialog.tsx b/src/components/ChangeCategoryDialog.tsx new file mode 100644 index 0000000..f0959aa --- /dev/null +++ b/src/components/ChangeCategoryDialog.tsx @@ -0,0 +1,84 @@ +/** + * Dialog for changing the category of a list. + */ + +import { useState } from "react"; +import { useMutation } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import type { Id } from "../../convex/_generated/dataModel"; +import { useCurrentUser } from "../hooks/useCurrentUser"; +import { CategorySelector } from "./lists/CategorySelector"; + +interface ChangeCategoryDialogProps { + listId: Id<"lists">; + currentCategoryId?: Id<"categories">; + onClose: () => void; +} + +export function ChangeCategoryDialog({ + listId, + currentCategoryId, + onClose, +}: ChangeCategoryDialogProps) { + const { did, legacyDid } = useCurrentUser(); + const updateCategory = useMutation(api.lists.updateListCategory); + const [categoryId, setCategoryId] = useState | undefined>(currentCategoryId); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const handleSave = async () => { + if (!did) return; + setSaving(true); + setError(null); + try { + await updateCategory({ + listId, + categoryId, + userDid: did, + legacyDid: legacyDid ?? undefined, + }); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update category"); + } finally { + setSaving(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +

+ Change Category +

+ + + + {error && ( +

{error}

+ )} + +
+ + +
+
+
+ ); +} diff --git a/src/components/Comments.tsx b/src/components/Comments.tsx new file mode 100644 index 0000000..09b17b3 --- /dev/null +++ b/src/components/Comments.tsx @@ -0,0 +1,230 @@ +/** + * Comments component - Thread discussions on items for shared lists. + * Displays comment thread and allows adding/deleting comments. + */ + +import { useState } from "react"; +import { useQuery, useMutation } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import type { Id } from "../../convex/_generated/dataModel"; +import { useSettings } from "../hooks/useSettings"; + +interface CommentsProps { + itemId: Id<"items">; + userDid: string; + legacyDid?: string; + canEdit: boolean; +} + +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return "just now"; +} + +function truncateDid(did: string): string { + if (did.length <= 20) return did; + return `${did.slice(0, 12)}...${did.slice(-6)}`; +} + +export function Comments({ itemId, userDid, legacyDid, canEdit }: CommentsProps) { + const { haptic } = useSettings(); + const [newComment, setNewComment] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [deletingId, setDeletingId] = useState | null>(null); + + const comments = useQuery(api.comments.getItemComments, { + itemId, + userDid, + legacyDid, + }); + + const addComment = useMutation(api.comments.addComment); + const deleteComment = useMutation(api.comments.deleteComment); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newComment.trim() || isSubmitting) return; + + haptic("light"); + setIsSubmitting(true); + + try { + await addComment({ + itemId, + userDid, + legacyDid, + text: newComment.trim(), + }); + setNewComment(""); + haptic("success"); + } catch (err) { + console.error("Failed to add comment:", err); + haptic("error"); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async (commentId: Id<"comments">) => { + if (deletingId) return; + + haptic("medium"); + setDeletingId(commentId); + + try { + await deleteComment({ + commentId, + userDid, + legacyDid, + }); + haptic("success"); + } catch (err) { + console.error("Failed to delete comment:", err); + haptic("error"); + } finally { + setDeletingId(null); + } + }; + + const canDeleteComment = (commentUserDid: string) => { + // Author can always delete their own comments + if (commentUserDid === userDid || commentUserDid === legacyDid) { + return true; + } + // Editors and owners can delete any comment + return canEdit; + }; + + if (comments === undefined) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Comment thread */} + {comments.length > 0 ? ( +
+ {comments.map((comment) => ( +
+
+
+
+ + {truncateDid(comment.userDid)} + + + {formatRelativeTime(comment.createdAt)} +
+

+ {comment.text} +

+
+ {canDeleteComment(comment.userDid) && ( + + )} +
+
+ ))} +
+ ) : ( +

+ No comments yet +

+ )} + + {/* Add comment form */} +
+ setNewComment(e.target.value)} + placeholder="Add a comment..." + className="flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500" + /> + +
+
+ ); +} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 030dc34..edb857f 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -47,7 +47,7 @@ export function ConfirmDialog({ const confirmButtonClasses = confirmVariant === "danger" ? "bg-red-600 text-white hover:bg-red-700" - : "bg-blue-600 text-white hover:bg-blue-700"; + : "bg-gradient-to-r from-amber-600 to-orange-500 text-white hover:from-amber-500 hover:to-orange-400 transition-all"; return (
diff --git a/src/components/CreateListModal.tsx b/src/components/CreateListModal.tsx index ea1dcb4..14ea095 100644 --- a/src/components/CreateListModal.tsx +++ b/src/components/CreateListModal.tsx @@ -1,5 +1,6 @@ /** - * Modal for creating a new list. + * Panel for creating a new list. + * Uses Panel component for slide-up drawer experience. * Features improved design and dark mode support. */ @@ -9,10 +10,10 @@ import { useNavigate } from "react-router-dom"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; -import { useFocusTrap } from "../hooks/useFocusTrap"; import { useSettings } from "../hooks/useSettings"; import { createListAsset } from "../lib/originals"; import { CategorySelector } from "./lists/CategorySelector"; +import { Panel } from "./ui/Panel"; interface CreateListModalProps { onClose: () => void; @@ -23,7 +24,6 @@ export function CreateListModal({ onClose }: CreateListModalProps) { const navigate = useNavigate(); const { haptic } = useSettings(); const createList = useMutation(api.lists.createList); - const dialogRef = useFocusTrap({ onEscape: onClose }); const [name, setName] = useState(""); const [categoryId, setCategoryId] = useState | undefined>(undefined); @@ -71,30 +71,79 @@ export function CreateListModal({ onClose }: CreateListModalProps) { } }; - return ( -
-
- {/* Header */} -
-
- -

- Create New List -

-
-

- Give your list a name and optionally assign it to a category. + const header = ( + <> +

+ +
+

+ Create New List +

+

+ Give your list a name

+
+ + + ); - {/* Form */} -
+ const footer = ( +
+ + +
+ ); + + return ( + + {/* Form */} + +
@@ -108,56 +157,23 @@ export function CreateListModal({ onClose }: CreateListModalProps) { disabled={isCreating} autoFocus /> +
-
- -
- - {error && ( -
- - - - {error} -
- )} - -
- - + + + {error && ( +
+ + + + {error}
- -
-
+ )} + + ); } diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index ca73fb4..b5d8426 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -39,7 +39,7 @@ export class ErrorBoundary extends Component {
diff --git a/src/components/HeaderActionsMenu.tsx b/src/components/HeaderActionsMenu.tsx new file mode 100644 index 0000000..6e95ec3 --- /dev/null +++ b/src/components/HeaderActionsMenu.tsx @@ -0,0 +1,239 @@ +/** + * HeaderActionsMenu - A dropdown menu for list header actions. + * Consolidates action buttons (Share, Publish, Template, Delete, Keyboard shortcuts) + * into a single menu to reduce header crowding, especially on mobile. + */ + +import { useState, useRef, useEffect } from "react"; + +interface HeaderActionsMenuProps { + canShare: boolean; + canPublish: boolean; + canSaveTemplate: boolean; + canDelete: boolean; + canRename: boolean; + canChangeCategory?: boolean; + isOnline: boolean; + isPublished: boolean; + onShare: () => void; + onNativeShare?: () => void; + onPublish: () => void; + onSaveTemplate: () => void; + onDelete: () => void; + onRename: () => void; + onChangeCategory?: () => void; + onKeyboardShortcuts: () => void; + haptic: (type: 'light' | 'medium' | 'heavy') => void; +} + +export function HeaderActionsMenu({ + canShare, + canPublish, + canSaveTemplate, + canDelete, + canRename, + canChangeCategory, + isOnline, + isPublished, + onShare, + onNativeShare, + onPublish, + onSaveTemplate, + onDelete, + onRename, + onChangeCategory, + onKeyboardShortcuts, + haptic, +}: HeaderActionsMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + const buttonRef = useRef(null); + + // Close menu when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + menuRef.current && + !menuRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + // Close menu on escape key + useEffect(() => { + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + } + }, [isOpen]); + + const handleAction = (action: () => void) => { + haptic('light'); + action(); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
+ {/* Keyboard shortcuts - hidden on mobile via CSS, visible in dropdown on desktop */} + + + {/* Rename */} + {canRename && ( + + )} + + {/* Change category */} + {canChangeCategory && onChangeCategory && ( + + )} + +
+ + {/* Share */} + {canShare && ( + + )} + + {/* Native Share */} + {onNativeShare && ( + + )} + + {/* Publish */} + {canPublish && ( + + )} + + {/* Save as template */} + {canSaveTemplate && ( + + )} + + {/* Delete - only show if user can delete */} + {canDelete && ( + <> +
+ + + )} +
+ )} +
+ ); +} diff --git a/src/components/ItemDetailsModal.tsx b/src/components/ItemDetailsModal.tsx new file mode 100644 index 0000000..b28dc68 --- /dev/null +++ b/src/components/ItemDetailsModal.tsx @@ -0,0 +1,575 @@ +/** + * Panel for viewing and editing item details. + * Uses Panel component for slide-up drawer experience. + * Supports notes, due dates, URLs/links, and recurrence settings. + */ + +import { useState, useEffect, useMemo } from "react"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import type { Doc } from "../../convex/_generated/dataModel"; +import { useSettings } from "../hooks/useSettings"; +import { useOffline } from "../hooks/useOffline"; +import { useCategories } from "../hooks/useCategories"; +import { queueMutation } from "../lib/offline"; +import { AISLES, classifyItem } from "../lib/groceryAisles"; +import type { GroceryAisle } from "../lib/groceryAisles"; +import { TagSelector } from "./TagSelector"; +import { SubItems } from "./SubItems"; +import { Attachments } from "./Attachments"; +import { Comments } from "./Comments"; +import { Panel } from "./ui/Panel"; +import { ItemProvenanceInfo } from "./ProvenanceInfo"; +import { NaturalDateInput } from "./ui/NaturalDateInput"; +import { recordLatencyMs } from "../lib/observability"; + +interface ItemDetailsModalProps { + item: Doc<"items">; + userDid: string; + legacyDid?: string; + canEdit: boolean; + onClose: () => void; +} + +type RecurrenceFrequency = "daily" | "weekly" | "monthly"; +type Priority = "high" | "medium" | "low" | ""; + +const PRIORITY_COLORS = { + high: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-300 dark:border-red-700", + medium: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-700", + low: "bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400 border-amber-300 dark:border-amber-700", +}; + +export function ItemDetailsModal({ + item, + userDid, + legacyDid, + canEdit, + onClose, +}: ItemDetailsModalProps) { + const { haptic } = useSettings(); + const { isOnline } = useOffline(); + const updateItem = useMutation(api.items.updateItem); + + const [name, setName] = useState(item.name); + const [description, setDescription] = useState(item.description ?? ""); + const [url, setUrl] = useState(item.url ?? ""); + const [dueDate, setDueDate] = useState( + item.dueDate ? new Date(item.dueDate).toISOString().split("T")[0] : "" + ); + const [hasRecurrence, setHasRecurrence] = useState(!!item.recurrence); + const [recurrenceFrequency, setRecurrenceFrequency] = useState( + item.recurrence?.frequency ?? "daily" + ); + const [recurrenceInterval, setRecurrenceInterval] = useState( + item.recurrence?.interval ?? 1 + ); + const [recurrenceEndDate, setRecurrenceEndDate] = useState( + item.recurrence?.endDate ? new Date(item.recurrence.endDate).toISOString().split("T")[0] : "" + ); + const [priority, setPriority] = useState(item.priority ?? ""); + const [selectedCategory, setSelectedCategory] = useState(item.groceryAisle ?? ""); + const itemAssigneeDid = (item as Doc<"items"> & { assigneeDid?: string }).assigneeDid; + const [assigneeDid, setAssigneeDid] = useState(itemAssigneeDid ?? ""); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + const startedAt = performance.now(); + requestAnimationFrame(() => { + recordLatencyMs("activity_panel_open_latency_ms", performance.now() - startedAt, { + route: window.location.pathname, + env: import.meta.env.MODE, + }); + }); + }, [item._id]); + + // Load list to get custom aisles + const list = useQuery(api.lists.getList, { listId: item.listId }); + const { categories } = useCategories(); + + const comments = useQuery(api.comments.getItemComments, { + itemId: item._id, + userDid, + legacyDid, + }); + + const participantDids = useMemo(() => { + const dids = new Set([userDid, item.createdByDid]); + if (item.checkedByDid) dids.add(item.checkedByDid); + if (itemAssigneeDid) dids.add(itemAssigneeDid); + (comments ?? []).forEach((comment) => dids.add(comment.userDid)); + return Array.from(dids); + }, [comments, itemAssigneeDid, item.checkedByDid, item.createdByDid, userDid]); + + const participantProfiles = useQuery( + api.users.getUsersByDids, + participantDids.length > 0 ? { dids: participantDids } : "skip" + ); + + // Determine if this is a grocery list (for auto-classification hint) + const isGroceryList = useMemo(() => { + if (!list || !list.categoryId) return false; + const cat = categories.find((c: { _id: string; name: string }) => c._id === list.categoryId); + if (cat) return cat.name.toLowerCase().includes("grocer"); + return list.name?.toLowerCase().includes("grocer") ?? false; + }, [list, categories]); + + // Available categories: built-in aisles + custom aisles from the list + const availableCategories: GroceryAisle[] = useMemo(() => { + const customAisles = (list as any)?.customAisles as GroceryAisle[] | undefined; + const all = customAisles?.length + ? [...AISLES, ...customAisles].sort((a, b) => a.order - b.order) + : [...AISLES]; + return all; + }, [list]); + + // Auto-classified category (what the system would pick) + const autoCategory = useMemo(() => { + return isGroceryList ? classifyItem(item.name) : "other"; + }, [item.name, isGroceryList]); + + // Effective category (user override or auto) + const effectiveCategory = selectedCategory || autoCategory; + + // Reset state when item changes + useEffect(() => { + setName(item.name); + setDescription(item.description ?? ""); + setUrl(item.url ?? ""); + setDueDate(item.dueDate ? new Date(item.dueDate).toISOString().split("T")[0] : ""); + setHasRecurrence(!!item.recurrence); + setRecurrenceFrequency(item.recurrence?.frequency ?? "daily"); + setRecurrenceInterval(item.recurrence?.interval ?? 1); + setRecurrenceEndDate(item.recurrence?.endDate ? new Date(item.recurrence.endDate).toISOString().split("T")[0] : ""); + setPriority(item.priority ?? ""); + setSelectedCategory(item.groceryAisle ?? ""); + setAssigneeDid(itemAssigneeDid ?? ""); + }, [item]); + + const handleSave = async () => { + if (!canEdit) return; + + haptic("medium"); + setIsSaving(true); + + try { + const payload = { + itemId: item._id, + userDid, + legacyDid, + name: name !== item.name ? name : undefined, + description: description || undefined, + dueDate: dueDate ? new Date(dueDate).getTime() : undefined, + url: url || undefined, + recurrence: hasRecurrence + ? { + frequency: recurrenceFrequency, + interval: recurrenceInterval || 1, + ...(recurrenceEndDate ? { endDate: new Date(recurrenceEndDate).getTime() } : {}), + } + : undefined, + priority: priority || undefined, + groceryAisle: selectedCategory || undefined, + assigneeDid: assigneeDid || undefined, + clearDueDate: !dueDate && !!item.dueDate, + clearUrl: !url && !!item.url, + clearRecurrence: !hasRecurrence && !!item.recurrence, + clearPriority: !priority && !!item.priority, + clearAssigneeDid: !assigneeDid && !!itemAssigneeDid, + clearGroceryAisle: !selectedCategory && !!item.groceryAisle, + }; + + if (isOnline) { + await updateItem(payload as any); + } else { + await queueMutation({ + type: "updateItem", + payload, + timestamp: Date.now(), + retryCount: 0, + }); + } + + haptic("success"); + onClose(); + } catch (err) { + console.error("Failed to update item:", err); + haptic("error"); + } finally { + setIsSaving(false); + } + }; + + const header = ( + <> +

+ {canEdit ? "Edit Item" : "Item Details"} +

+ + + ); + + const footer = canEdit ? ( +
+ + +
+ ) : undefined; + + return ( + + {/* Content */} +
+ {/* Name */} +
+ + setName(e.target.value)} + disabled={!canEdit} + className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-amber-500 disabled:opacity-50" + /> +
+ + {/* Description/Notes */} +
+ +