From b99f4f32696e2744c03537da0d69bc341b55ca5c Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 17:01:40 -0700 Subject: [PATCH 01/35] docs: add Vercel migration design spec Design for moving Overlap off Railway onto Vercel + Supabase: - collapse apps/web, apps/api, apps/worker into one Vercel project - replace always-on BullMQ worker with Workflow DevKit durable workflows - drop Redis entirely (BullMQ was its only consumer) - replace proactive rate limiters with reactive Retry-After backoff - fix the 5s delay race between branch sync and overlap detection - fix silent branch-file wipe on transient GitHub failures --- .../2026-08-12-vercel-migration-design.md | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-12-vercel-migration-design.md diff --git a/docs/superpowers/specs/2026-08-12-vercel-migration-design.md b/docs/superpowers/specs/2026-08-12-vercel-migration-design.md new file mode 100644 index 0000000..140688a --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-vercel-migration-design.md @@ -0,0 +1,354 @@ +# Vercel Migration Design + +Date: 2026-08-12 +Status: Approved, pending implementation plan + +## Summary + +Move Overlap off Railway and onto Vercel + Supabase. +Collapse three deployed apps into one Vercel project. +Replace the always-on BullMQ worker with Vercel Workflow DevKit (WDK) durable workflows. +Delete Redis entirely. + +## Motivation + +Railway bills per second for running services, and the BullMQ worker is always-on by design. +For a low-traffic app that means paying continuously for an idle process. + +The Vercel account is already on a paid Pro plan, and Supabase is already paid for. +Adding Overlap to both costs approximately zero marginal spend at current traffic, because Pro's included allowances far exceed what this app consumes. +The migration therefore removes new spend rather than shifting it. + +A secondary motivation is correctness. +The current job graph contains a race condition papered over with a fixed delay, and two error handlers that silently corrupt data. +Both are fixed as part of this work rather than carried across. + +## Current architecture + +Three Railway services plus two Railway databases. + +| Component | Implementation | +| --- | --- | +| `apps/web` | TanStack Start SSR on Nitro, Vite 7, deployed via Dockerfile | +| `apps/api` | Fastify 5, five route groups, BullMQ producer, job scheduler | +| `apps/worker` | Six always-on BullMQ `Worker` instances with per-queue concurrency and rate limiters | +| Postgres | Railway-provisioned, accessed through Drizzle ORM and `postgres-js` | +| Redis | Railway-provisioned, used only as BullMQ's backing store | + +### Verified: Redis has exactly one consumer + +Redis is referenced in five files, and every reference is BullMQ. + +- `apps/api/src/queues/index.ts` - queue connection +- `apps/api/src/scheduler.ts` - repeatable job scheduler +- `apps/worker/src/index.ts` - worker connection +- `apps/worker/src/processors/webhook-events.ts` - opens its own connection to enqueue follow-on jobs +- `apps/worker/src/processors/overlap-detection.ts` - same + +`apps/api/src/routes/health.ts` pings Redis, but only to confirm BullMQ is reachable. + +Sessions do not use Redis. +`apps/api/src/plugins/auth.ts` uses a signed cookie plus a Postgres lookup. +Rate limiting does not use Redis either. +`@fastify/rate-limit` is registered with its default in-memory store. + +Redis is therefore not a separate decision. +It has one consumer, and this design replaces that consumer. + +### Current job graph + +``` +webhook POST /webhooks/github + |- inline: verify HMAC, store webhook_events row, handle branch deletion + \- enqueue webhook_events + |- enqueue branch_sync + |- enqueue overlap_detection (delay: 5000) + \- enqueue maintenance:sync_repository (on installation events) + +overlap_detection + |- enqueue github_feedback (per open PR) + \- enqueue push_notification +``` + +## Target architecture + +One Vercel project. +`apps/web` absorbs both the API and the worker. + +``` +apps/web/ + src/routes/ UI routes, unchanged + src/routes/api/ former apps/api, as TanStack Start server routes + src/workflows/ former apps/worker, as WDK workflows and steps +packages/db survives, pooler connection string and prepare:false +packages/github survives untouched +packages/shared survives, minus QUEUE_NAMES and RATE_LIMITS +``` + +Removed from the repository: + +- `apps/api` and `apps/worker` as deployed apps +- `apps/web/Dockerfile`, `apps/api/Dockerfile`, `apps/worker/Dockerfile` +- `railway.json` at the root and in all three apps +- `bullmq` and `ioredis` dependencies +- the Redis service in `docker-compose.yml` +- the `REDIS_URL` environment variable + +## Design + +### Workflow decomposition + +Each GitHub delivery becomes one durable workflow run. + +```ts +export async function processWebhook(deliveryId: string) { + "use workflow" + + const evt = await loadEvent(deliveryId) + + if (evt.type === "push") { + const branchId = await upsertBranch(evt) + await syncBranchFiles(branchId, evt) + const results = await detectOverlaps(branchId) + for (const n of results.notifications) { + await postCheckRun(n) + await sendPush(n) + } + } + + // pull_request and installation branches follow the same shape +} +``` + +Every named function above is a `"use step"` function. +The workflow function itself only orchestrates, so it never touches Node APIs and never hits the workflow sandbox restrictions. + +Step functions map one to one onto the existing processors: + +| Current processor | Becomes | +| --- | --- | +| `webhook-events.ts` | `loadEvent`, `upsertBranch`, `upsertPullRequest`, `syncInstallation` | +| `branch-sync.ts` | `syncBranchFiles` | +| `overlap-detection.ts` | `detectOverlaps` | +| `github-feedback.ts` | `postCheckRun` | +| `push-notification.ts` | `sendPush` | +| `maintenance.ts` | `pruneStaleBranches`, `cleanupEvents`, `syncRepository` | + +### The five-second delay is deleted + +`apps/worker/src/processors/webhook-events.ts:150` reads: + +```ts +delay: 5000, // Small delay to ensure sync completes first +``` + +Branch sync and overlap detection are separate BullMQ queues with no ordering guarantee between them. +The delay is a hope, not a constraint. +If `syncBranchFiles` takes longer than five seconds on a large diff or a slow GitHub response, detection reads a stale file list and computes incorrect overlaps. +If it finishes in 200ms, the pipeline stalls for 4.8 seconds for no reason. + +In the workflow, `await syncBranchFiles(...)` followed by `await detectOverlaps(...)` is a durable happens-before edge. +The delay is removed. +Correctness improves and median latency drops by roughly five seconds. + +This is the primary reason WDK was chosen over Vercel Queues. +Two queue topics would have reproduced the same race that two BullMQ queues have today. + +### Idempotency + +BullMQ's `jobId` deduplication disappears and is replaced at the front door, using the unique constraint that already exists on `webhook_events.deliveryId`. + +```ts +const [row] = await db.insert(webhookEvents) + .values({ deliveryId, eventType, payload, repositoryId }) + .onConflictDoNothing() + .returning() + +if (!row) return Response.json({ received: true }) // GitHub redelivery + +await start(processWebhook, [deliveryId]) +``` + +This is stronger than the current behaviour. +Deduplication state lives durably in Postgres rather than expiring out of Redis on a TTL. + +Within a run, WDK memoizes completed step results across replays, so a retry after a partial failure does not re-post a check run or re-send a push notification. + +Three existing workarounds are deleted rather than ported: + +- The `jobSuffix` timestamp in `overlap-detection.ts:196`, added specifically to defeat deduplication for reactivated overlaps +- The `jobId` built from `repo.id`, `branchId` and `Date.now()` in `webhook-events.ts:148`, a deduplication key containing a timestamp, which deduplicates nothing +- commit `bb92e78`, "replace colons in BullMQ job IDs to fix notification delivery" + +Each is a symptom of working around BullMQ's deduplication semantics. +None are needed once identity is owned by the database. + +### Rate limiting + +`RATE_LIMITS` in `packages/shared/src/constants/index.ts:20-27` defines proactive per-queue caps. +Those caps require shared cross-instance state, which is what Redis provided. + +They are replaced with reactive backoff driven by GitHub's own signal. + +```ts +catch (err) { + if (err.status === 429 || err.status === 403) { + throw new RetryableError("GitHub rate limited", { + retryAfter: err.headers?.["retry-after"] ?? "5m", + }) + } + throw new FatalError(err.message) +} +``` + +The proactive caps were set below GitHub's actual budget. +`BRANCH_SYNC` was capped at 30 per minute against an installation limit of 5000 per hour, roughly 83 per minute. +At current traffic these limiters are very unlikely to have ever fired. + +Reacting to GitHub's `Retry-After` header responds to the real budget rather than a hardcoded estimate of it, and requires no shared state. +`RATE_LIMITS` and `QUEUE_NAMES` are deleted from `packages/shared`. + +### Error handling + +| Condition | Behaviour | +| --- | --- | +| GitHub 403 or 429 | `RetryableError` with `retryAfter` from the response header, defaulting to `5m` | +| GitHub 5xx or network failure | `RetryableError` | +| GitHub 4xx other than 403/429 | `FatalError` | +| Missing repository or branch row | `FatalError` | + +#### Bug fixed in transit: silent data loss on transient GitHub failure + +`apps/worker/src/processors/branch-sync.ts:52-56`: + +```ts +} catch (error) { + console.error(`Failed to fetch branch files: ${error}`) + changedFiles = [] +} +``` + +Execution then continues unconditionally to delete every `branchFiles` row for the branch and insert the empty list. + +A transient GitHub 500 therefore wipes the branch's file index. +The next `detectOverlaps` run finds no files, reports zero overlaps, and marks genuine active overlaps as `resolved`. +Overlaps disappear from the UI with no error surfaced anywhere. + +Under the mapping above this throws `RetryableError`, so the delete never executes and the step is retried. + +`apps/worker/src/processors/github-feedback.ts:110-113` swallows `createCheckRun` failures in the same shape and receives the same treatment. + +### API fold-in + +The five Fastify route groups become TanStack Start server routes under `apps/web/src/routes/api/`. + +- **Raw body for HMAC.** + The webhook route calls `await request.text()` before any JSON parsing, so the exact bytes GitHub signed are available to `verifyWebhookSignature`. + The `addContentTypeParser` workaround in `webhooks.ts:19-26` is removed. + +- **CORS deleted.** + Web and API become same-origin. + `@fastify/cors` is removed, along with the `VITE_API_URL` and `API_URL` environment variables. + Session cookies become same-origin, so the `credentials: true` cross-origin handling is no longer needed. + +- **Session cookies.** + `@fastify/cookie`'s `signCookie` and `unsignCookie` are replaced with `jose` JWTs in an httpOnly cookie. + Existing sessions are invalidated, so every user signs in once more at cutover. + This was accepted rather than reproducing the `@fastify/cookie` signature format byte for byte, because the domain changes at cutover regardless. + +- **Request rate limiting.** + `@fastify/rate-limit` has no direct replacement and is dropped. + It used the default in-memory store, so its 100-per-minute cap already applied per instance rather than globally, and was not delivering the guarantee it appeared to. + Vercel Firewall rate limiting covers this at the edge if needed. + Recorded here as a deliberate removal rather than an oversight. + +### Scheduled work + +`apps/api/src/scheduler.ts` and its two BullMQ job schedulers are deleted, replaced by Vercel Cron. + +```json +{ + "crons": [ + { "path": "/api/cron/prune-branches", "schedule": "0 */6 * * *" }, + { "path": "/api/cron/cleanup-events", "schedule": "0 3 * * *" } + ] +} +``` + +Both endpoints verify the `CRON_SECRET` header before doing anything. + +Each endpoint calls `start()` on a workflow rather than performing the work inline. +`pruneStaleBranches` iterates every active repository, so its runtime scales with the number of installations and should not be bounded by a single function invocation. + +Vercel Pro supports minute-level cron granularity, so the existing six-hour schedule is preserved exactly. + +### Database + +A new Supabase project on the existing paid plan. +`prod_dump.sql` provides the migration path. + +Changes to `packages/db/src/client.ts`: + +- `DATABASE_URL` points at the Supavisor transaction pooler on port 6543, not the direct connection. + Serverless functions open connections per invocation and will exhaust a direct Postgres connection limit. +- The query client is constructed with `prepare: false`. + Transaction-mode pooling cannot support prepared statements, and `postgres-js` uses them by default. + Without this the app fails intermittently under concurrency with `prepared statement "s1" already exists`. +- `migrationClient` uses a separate `DIRECT_URL` on port 5432, because migrations require session mode. + +### Environment variables + +| Variable | Change | +| --- | --- | +| `REDIS_URL` | Removed | +| `API_URL` | Removed, same-origin | +| `VITE_API_URL` | Removed, same-origin | +| `DATABASE_URL` | Repointed to Supabase Supavisor pooler, port 6543 | +| `DIRECT_URL` | New, Supabase direct connection, port 5432, migrations only | +| `CRON_SECRET` | New, guards the two cron endpoints | +| `APP_URL` | Repointed to the Vercel domain | +| `VAPID_SUBJECT` | Repointed off the `.up.railway.app` default in `.env.example` | +| `GITHUB_*` | Unchanged in value, moved to Vercel environment variables | +| `SESSION_SECRET` | Unchanged in value, now used as the `jose` signing key | + +## Cutover plan + +Railway stays running and untouched through step 6. +Rollback at any point before step 7 is repointing a single URL. + +1. Create the Supabase project, restore `prod_dump.sql`, verify row counts against production +2. Deploy the migrated code to a Vercel preview deployment +3. Create a second GitHub App pointed at the preview URL +4. Install it on a throwaway repository and verify the full path: push a commit, open a pull request, confirm the check run appears and the push notification arrives +5. Flip the production GitHub App webhook URL and OAuth callback URL to the Vercel domain +6. Observe a real production delivery complete end to end +7. Tear down the Railway project + +## Out of scope + +- WDK hooks, streams, and `DurableAgent`. + Nothing in this pipeline waits on human input or a language model. +- Vercel Queues. + Evaluated and rejected, see the delay-deletion section. +- Any realtime transport. + The UI keeps its existing React Query `staleTime` polling. +- Any feature work. + This is a migration. + The two bug fixes are included only because carrying the bugs across would mean deliberately porting known data-loss behaviour. + +## Risks + +- **WDK maturity.** + The largest bet in this design. + Mitigated by the parallel-run cutover, which keeps Railway serving production until the Vercel path is verified against real deliveries. +- **GitHub webhook timeout.** + GitHub expects a response within 10 seconds. + The route only inserts one row and calls `start()`, which returns immediately, so this should be comfortable. + Cold-start latency must be measured during step 4 of the cutover rather than assumed. +- **`detectOverlaps` runtime.** + `overlap-detection.ts:73-100` is O(branches x files) within a single step. + Acceptable at current scale. + A repository with hundreds of concurrently live branches would require splitting that step, and Fluid Compute's 800-second ceiling is the hard bound. +- **Supabase pooler behaviour under burst.** + Transaction-mode pooling changes connection semantics. + The `prepare: false` requirement is the known issue, but pooler limits should be watched during the parallel run. From 3f67289ec6151329f47347c9704c4f20e8a128f4 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 17:06:00 -0700 Subject: [PATCH 02/35] docs: add threat model findings to Vercel migration spec Design-level security review of the migration plan. Most findings are safety properties Railway provided implicitly that Vercel/Supabase does not: - S1: Supabase exposes PostgREST publicly; restored tables have no RLS - S2: make webhook HMAC verification an explicit precondition - S3: dropping rate limits turns DoS into billed invocations - S4: JWT must not replace the session model, only the signature - S5: rotate SESSION_SECRET at cutover - S6: timing-safe CRON_SECRET comparison - S7: keep oauth_state cookie integrity-protected - S8: keep webhook payloads out of workflow arguments - S9: /health/ready breaks unless the Redis check is removed Verified clean: repository authorization (requireRepoAccess), and CSRF exposure after the same-origin fold-in. --- .../2026-08-12-vercel-migration-design.md | 135 +++++++++++++++++- 1 file changed, 133 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-12-vercel-migration-design.md b/docs/superpowers/specs/2026-08-12-vercel-migration-design.md index 140688a..7cac0c4 100644 --- a/docs/superpowers/specs/2026-08-12-vercel-migration-design.md +++ b/docs/superpowers/specs/2026-08-12-vercel-migration-design.md @@ -158,7 +158,20 @@ Two queue topics would have reproduced the same race that two BullMQ queues have BullMQ's `jobId` deduplication disappears and is replaced at the front door, using the unique constraint that already exists on `webhook_events.deliveryId`. +HMAC verification is a hard precondition on everything below. +No database write and no workflow start may occur before the signature is confirmed valid. + ```ts +const raw = await request.text() // raw bytes, before any JSON parse +const sig = request.headers.get("x-hub-signature-256") + +const verification = verifyWebhookSignature(raw, sig, process.env.GITHUB_WEBHOOK_SECRET) +if (!verification.valid) { + return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 401 }) +} + +const payload = JSON.parse(raw) + const [row] = await db.insert(webhookEvents) .values({ deliveryId, eventType, payload, repositoryId }) .onConflictDoNothing() @@ -169,6 +182,9 @@ if (!row) return Response.json({ received: true }) // GitHub redelivery await start(processWebhook, [deliveryId]) ``` +Ordering matters and is not stylistic. +Inverting it produces a public unauthenticated endpoint that writes attacker-controlled JSON into `webhook_events` and starts a billed workflow run per request. + This is stronger than the current behaviour. Deduplication state lives durably in Postgres rather than expiring out of Redis on a TTL. @@ -309,14 +325,129 @@ Changes to `packages/db/src/client.ts`: | `APP_URL` | Repointed to the Vercel domain | | `VAPID_SUBJECT` | Repointed off the `.up.railway.app` default in `.env.example` | | `GITHUB_*` | Unchanged in value, moved to Vercel environment variables | -| `SESSION_SECRET` | Unchanged in value, now used as the `jose` signing key | +| `SESSION_SECRET` | Rotated to a new value at cutover, used as the `jose` signing key, see S5 | + +## Security + +A design-level threat model was run against this plan on 2026-08-12. + +Most findings below are not defects in the current code or the proposed code. +They are safety properties that the Railway deployment was providing implicitly, and that this migration retires without any file changing to announce it. +They are recorded here because a diff-based security review of the implementation cannot find them. + +### S1. Disable the Supabase Data API for the public schema + +Railway Postgres is reachable only over the Postgres wire protocol. +A Supabase project additionally exposes PostgREST at `/rest/v1` on the public internet, authenticated by an anon key that is public by design. + +Restoring `prod_dump.sql` places every table in the `public` schema with no row-level security, because none was ever needed. +The combination of default grants, an exposed Data API, and no RLS makes `users`, `repositories`, `branches`, `overlaps` and `webhook_events` readable by any holder of the anon key. +`webhook_events` stores complete GitHub payloads, so this is the highest-value target in the database. + +Required, in order of preference: + +1. Disable the Data API for the `public` schema in Supabase project settings +2. Or restore into a schema that is not exposed by PostgREST +3. Or enable RLS on every table with no policies attached, so access fails closed + +The application connects directly over the Postgres protocol through Drizzle and never uses PostgREST, so disabling it costs nothing. +This must be verified before `prod_dump.sql` is restored, not after. + +### S2. Webhook signature verification ordering + +See the code block in the idempotency section above. +Verification of the raw request body precedes every database write and every workflow start. +This is a required ordering, not a stylistic preference. + +### S3. Rate limiting removal changes the threat economics + +On Railway, flooding an endpoint consumed CPU that was already paid for. +On Vercel every request is a billed invocation, so the same flood becomes a direct financial cost. + +Exposure, ranked: + +- `/api/webhooks/github` is public and unauthenticated, and computes an HMAC on every request before it can reject one. + A signature cannot be forged, but an attacker can force payment for each failed verification. +- `/api/auth/github/callback` performs an outbound GitHub token exchange per request. +- `/api/push/subscribe` is authenticated but places no bound on subscription rows per user. + +Required: + +- Vercel Firewall rate limit rules on `/api/webhooks/github` and `/api/auth/*` +- A per-user cap on rows in `push_subscriptions` + +### S4. The JWT replaces the signature mechanism, not the session model + +The current session cookie carries `{ userId }` and every request re-reads the user from Postgres at `apps/api/src/plugins/auth.ts:51-54`. +Revocation is therefore immediate. +Deleting the user row ends the session on the next request. + +Moving to `jose` invites putting profile claims in the token and skipping that lookup. +Doing so trades immediate revocation for a seven-day window in which a deleted user remains authenticated. + +Required: + +- The token carries `userId` and nothing else +- The per-request database lookup is retained exactly as it is today +- The verification algorithm is pinned explicitly rather than inferred from the token header +- `exp` is set to seven days, matching the current cookie `maxAge` +- `httpOnly: true`, `secure: true` in production, and `sameSite: "lax"` are preserved + +### S5. Rotate `SESSION_SECRET` at cutover + +Sessions are invalidated by the migration regardless. +Issuing a new secret guarantees that every previously issued cookie fails closed, rather than relying on a format mismatch to reject it. + +### S6. `CRON_SECRET` requires a timing-safe comparison + +Vercel cron endpoints are publicly routable. +`/api/cron/prune-branches` starts a workflow that iterates every active repository, so an externally triggerable cron endpoint amplifies both cost and database load. + +Compare using `crypto.timingSafeEqual` over equal-length buffers. +A `===` comparison leaks the secret through response timing. + +### S7. Keep the OAuth state cookie integrity-protected + +The `oauth_state` cookie at `apps/api/src/routes/auth.ts:22-30` is currently signed, and the callback validates the unsigned value against the returned `state` parameter. +The replacement must verify integrity, not merely check presence. +A presence-only check reduces the CSRF protection to a value the attacker supplies. + +### S8. Do not pass webhook payloads into workflow arguments + +`start(processWebhook, [deliveryId])` deliberately passes only an identifier. +Full GitHub payloads, which include repository names, commit messages and author email addresses, stay in Postgres and are never copied into workflow run storage. + +This is a privacy property of the design. +Passing the payload directly would be a simplification that widens data residency without any corresponding benefit. + +### S9. `/health/ready` must drop its Redis check + +`apps/api/src/routes/health.ts:26-31` pings Redis through `fastify.queues`. +After migration that object does not exist, the check throws, and the endpoint returns 503 permanently. +The `redis` key is removed from the checks object. + +### Verified clean + +- **Repository authorization.** + Every `/:id` route calls `requireRepoAccess` before reading or writing, at `apps/api/src/routes/repositories.ts:101` and throughout. + No insecure direct object references were found. + The helper is carried across unchanged. + +- **CSRF exposure after the same-origin fold-in.** + Removing the CORS boundary retires a control that was gating cross-origin state changes. + An audit of the route table found every mutation is POST, PATCH or DELETE and every GET is a read. + With `sameSite: "lax"` preserved, cross-site requests do not carry the session cookie to those methods. + The residual requirement is narrow: preserve `sameSite: "lax"`, and introduce no state-changing GET routes. ## Cutover plan Railway stays running and untouched through step 6. Rollback at any point before step 7 is repointing a single URL. -1. Create the Supabase project, restore `prod_dump.sql`, verify row counts against production +1. Create the Supabase project. + Disable the Data API for the `public` schema per S1 **before** restoring any data. + Then restore `prod_dump.sql` and verify row counts against production. + Confirm from outside the network that `/rest/v1` returns no table data using the anon key. 2. Deploy the migrated code to a Vercel preview deployment 3. Create a second GitHub App pointed at the preview URL 4. Install it on a throwaway repository and verify the full path: push a commit, open a pull request, confirm the check run appears and the push notification arrives From d8c587d867e553062027fb5b8507fe9584350f28 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 17:32:20 -0700 Subject: [PATCH 03/35] docs: rebuild database instead of restoring dump Production is 1 user, 1 repository, 3 branches; 51 webhook_events rows are a 7-day audit log. Nearly every table is a materialized cache of GitHub state that syncRepository/syncBranchFiles/detectOverlaps rebuild on demand. Migrate by running db:migrate against an empty Supabase project and letting the pipeline repopulate: - shrinks S1 from a live data exposure to a hardening task - makes the rebuild itself the acceptance test for the new workflows - avoids carrying forward corruption from the branch-sync file-wipe bug Only repository_settings needs manual carry-over, and only if changed from defaults. --- .../2026-08-12-vercel-migration-design.md | 78 +++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-08-12-vercel-migration-design.md b/docs/superpowers/specs/2026-08-12-vercel-migration-design.md index 7cac0c4..5e1f509 100644 --- a/docs/superpowers/specs/2026-08-12-vercel-migration-design.md +++ b/docs/superpowers/specs/2026-08-12-vercel-migration-design.md @@ -301,7 +301,51 @@ Vercel Pro supports minute-level cron granularity, so the existing six-hour sche ### Database A new Supabase project on the existing paid plan. -`prod_dump.sql` provides the migration path. + +#### No data is migrated + +`prod_dump.sql` is not restored. +The schema is created by running `pnpm db:migrate` against the empty Supabase project, and the application repopulates itself. + +This is possible because almost the entire database is a materialized cache of GitHub state rather than authoritative data. + +| Table | Rows in prod | Classification | +| --- | --- | --- | +| `webhook_events` | 51 | Audit log, deleted after 7 days by `cleanupOldEvents` | +| `github_app_installations` | 7 | Re-fetched by `syncUserInstallations` on next login | +| `branches` | 3 | Rebuilt by `syncRepository` | +| `branch_files` | 2 | Rebuilt by `syncBranchFiles` | +| `push_subscriptions` | 2 | Authoritative, but re-created by one click per browser | +| `overlaps`, `overlap_files` | 1, 1 | Recomputed from scratch on every `detectOverlaps` run | +| `repositories` | 1 | Rebuilt by `syncRepository` | +| `repository_settings` | 1 | Authoritative, hand-carried if customized from defaults | +| `user_installations` | 1 | Re-fetched by `syncUserInstallations` | +| `users` | 1 | Re-created on next sign-in via GitHub OAuth | +| `organizations`, `pr_alerts`, `pull_requests` | 0 | Empty | + +Rebuilding rather than restoring is chosen for three reasons beyond the trivial data volume. + +**It reduces the S1 exposure to a hardening task.** +An empty schema behind an exposed Data API is a configuration to fix before data accumulates, rather than a live exposure of production records. +S1 remains required regardless. + +**It functionally verifies the migration.** +If a fresh install reconstructs the same repository, branches and overlaps that Railway currently serves, the entire new workflow pipeline has been proven end to end by the act of migrating. +Restoring a dump would demonstrate nothing about whether the workflows execute correctly. + +**It does not carry forward known corruption.** +The `branch-sync` defect documented above wipes a branch's file index on any transient GitHub failure, after which `detectOverlaps` marks genuine overlaps as `resolved`. +Production rows may already contain that damage with no record of it. +A dump preserves the damage. +A rebuild from GitHub eliminates it. + +Manual carry-over is limited to `repository_settings`, and only if `pruningDays` or `ignoredPaths` were changed from the defaults in `packages/shared/src/constants/index.ts:33-45`. +Check before cutover and re-enter through the UI if so. + +`prod_dump.sql` is retained locally as a rollback reference only. +It is already covered by `.gitignore` and must not be committed. + +#### Connection configuration Changes to `packages/db/src/client.ts`: @@ -340,9 +384,12 @@ They are recorded here because a diff-based security review of the implementatio Railway Postgres is reachable only over the Postgres wire protocol. A Supabase project additionally exposes PostgREST at `/rest/v1` on the public internet, authenticated by an anon key that is public by design. -Restoring `prod_dump.sql` places every table in the `public` schema with no row-level security, because none was ever needed. +Running `pnpm db:migrate` places every table in the `public` schema with no row-level security, because none was ever needed on Railway. The combination of default grants, an exposed Data API, and no RLS makes `users`, `repositories`, `branches`, `overlaps` and `webhook_events` readable by any holder of the anon key. -`webhook_events` stores complete GitHub payloads, so this is the highest-value target in the database. +`webhook_events` stores complete GitHub payloads, so it is the highest-value target in the database. + +Because no data is migrated (see the Database section), the schema is empty at cutover and this is a hardening task rather than a live exposure. +It is still required, and must be done before the application begins repopulating the tables, because the window between first sign-in and remembering to close the Data API is exactly when real data appears. Required, in order of preference: @@ -351,7 +398,7 @@ Required, in order of preference: 3. Or enable RLS on every table with no policies attached, so access fails closed The application connects directly over the Postgres protocol through Drizzle and never uses PostgREST, so disabling it costs nothing. -This must be verified before `prod_dump.sql` is restored, not after. +This must be verified before the application is pointed at the project, not after. ### S2. Webhook signature verification ordering @@ -445,15 +492,20 @@ Railway stays running and untouched through step 6. Rollback at any point before step 7 is repointing a single URL. 1. Create the Supabase project. - Disable the Data API for the `public` schema per S1 **before** restoring any data. - Then restore `prod_dump.sql` and verify row counts against production. - Confirm from outside the network that `/rest/v1` returns no table data using the anon key. -2. Deploy the migrated code to a Vercel preview deployment -3. Create a second GitHub App pointed at the preview URL -4. Install it on a throwaway repository and verify the full path: push a commit, open a pull request, confirm the check run appears and the push notification arrives -5. Flip the production GitHub App webhook URL and OAuth callback URL to the Vercel domain -6. Observe a real production delivery complete end to end -7. Tear down the Railway project + Disable the Data API for the `public` schema per S1. + Run `pnpm db:migrate` to create the schema. + Restore no data. + Confirm from outside the network that `/rest/v1` returns no table data when presented with the anon key. +2. Record the current `repository_settings` row from Railway if it differs from the defaults, for manual re-entry later +3. Deploy the migrated code to a Vercel preview deployment +4. Create a second GitHub App pointed at the preview URL +5. Install it on a throwaway repository and verify the full path: push a commit, open a pull request, confirm the check run appears and the push notification arrives +6. Flip the production GitHub App webhook URL and OAuth callback URL to the Vercel domain +7. Sign in, reinstall the App on the real repository, and confirm the pipeline reconstructs its branches and overlaps. + Compare against what Railway is still serving. + This comparison is the acceptance test for the whole migration. +8. Re-enter `repository_settings` if step 2 recorded a difference, and re-enable browser notifications +9. Tear down the Railway project ## Out of scope From da7f274e5dd44c73cb8f584d58b51e57f32f4355 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 17:42:20 -0700 Subject: [PATCH 04/35] docs: add Vercel migration implementation plan 15 tasks across 4 phases, test-first. Installs workflow@4.8.2 so the plan is written against the bundled docs rather than recollection. Two corrections to the spec discovered while reading those docs: - step memoization does not protect a step that succeeds at GitHub then fails before returning, so check run posting needs explicit idempotency (Task 9) - packages/github withRetry double-retries under WDK and hides the rate-limit signal, so it is removed --- apps/web/package.json | 3 +- .../plans/2026-08-12-vercel-migration.md | 1605 +++++++ pnpm-lock.yaml | 3979 ++++++++++++++++- 3 files changed, 5512 insertions(+), 75 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-12-vercel-migration.md diff --git a/apps/web/package.json b/apps/web/package.json index b59c75a..3cf0183 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,7 +29,8 @@ "nitro": "3.0.1-alpha.2", "react": "^19.0.0", "react-dom": "^19.0.0", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "workflow": "^4.8.2" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/docs/superpowers/plans/2026-08-12-vercel-migration.md b/docs/superpowers/plans/2026-08-12-vercel-migration.md new file mode 100644 index 0000000..666e432 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-vercel-migration.md @@ -0,0 +1,1605 @@ +# Vercel Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move Overlap from three Railway services plus Redis onto a single Vercel project backed by Supabase, replacing the always-on BullMQ worker with Workflow DevKit durable workflows. + +**Architecture:** `apps/web` absorbs the Fastify API as TanStack Start server routes and the BullMQ worker as WDK workflows. Redis and BullMQ are deleted outright. Each GitHub webhook delivery becomes one durable workflow run whose steps execute in a guaranteed order, replacing six independent queues that had no ordering between them. + +**Tech Stack:** TanStack Start (React 19, Vite 7), Nitro, Workflow DevKit 4.8.2, Drizzle ORM, `postgres-js`, Supabase Postgres, `jose`, Vitest, Vercel. + +**Spec:** `docs/superpowers/specs/2026-08-12-vercel-migration-design.md` + +## Global Constraints + +- Node `>=20.0.0`, pnpm `9.15.0`, ESM only (`"type": "module"` in every package). +- Workflow DevKit is `workflow@4.8.2`, installed in `apps/web`. Its authoritative docs are at `apps/web/node_modules/workflow/docs/`. Read those, not memory. +- `"use workflow"` functions run sandboxed with no Node.js access. All I/O lives in `"use step"` functions. +- Step default is `maxRetries = 3`, meaning up to 4 total attempts. +- No em dashes in any file, per the repository's authoring conventions. +- HMAC verification of the raw webhook body precedes every database write and every workflow start. This ordering is a security requirement, not a preference (spec S2). +- The session token carries `userId` only. The per-request database lookup is retained (spec S4). +- `packages/github`, `packages/db` schema, and `packages/shared` validation are carried across unchanged unless a task says otherwise. + +--- + +## Correction to the spec, discovered while reading WDK docs + +The spec's idempotency section claims that step memoization prevents a retry from re-posting a check run. That is only true for a step that has already completed. A step that calls GitHub successfully and then fails before returning will retry and call GitHub again. + +`apps/web/node_modules/workflow/docs/foundations/idempotency.mdx` is explicit about this and is the payment-charge example verbatim. + +GitHub's check run API accepts no idempotency key, and creating a second check run with the same name on the same SHA produces a visible duplicate rather than an update. Task 9 addresses this by adding `updateCheckRun` to the GitHub client and having `postCheckRun` update the existing `pr_alerts.checkRunId` when one is present. + +A second finding: `packages/github/src/client.ts:15` defines a `withRetry` helper that already retries on rate limits internally. Under WDK this double-retries, because the step wrapper retries too, and the inner retry burns the step's wall-clock budget. Task 9 removes `withRetry` and lets WDK own retry policy. + +--- + +## Phase 1: Foundations + +### Task 1: Test infrastructure + +The repository currently has no test runner, no test script, and no tests. Every later task in this plan is written test-first, so this must exist first. + +**Files:** +- Create: `apps/web/vitest.config.ts` +- Create: `apps/web/src/lib/__tests__/smoke.test.ts` +- Modify: `apps/web/package.json` +- Modify: `turbo.json` + +**Interfaces:** +- Consumes: nothing +- Produces: `pnpm --filter @overlap/web test` runs Vitest. `pnpm test` runs it through Turborepo. + +- [ ] **Step 1: Install Vitest** + +```bash +pnpm --filter @overlap/web add -D vitest +``` + +- [ ] **Step 2: Write the Vitest config** + +```typescript +// apps/web/vitest.config.ts +import { defineConfig } from 'vitest/config' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +}) +``` + +- [ ] **Step 3: Write a smoke test that fails** + +```typescript +// apps/web/src/lib/__tests__/smoke.test.ts +import { describe, it, expect } from 'vitest' + +describe('test harness', () => { + it('runs', () => { + expect(1 + 1).toBe(3) + }) +}) +``` + +- [ ] **Step 4: Add the test script** + +In `apps/web/package.json`, add to `"scripts"`: + +```json +"test": "vitest run", +"test:watch": "vitest" +``` + +In `turbo.json`, add to `"tasks"`: + +```json +"test": { + "dependsOn": ["^build"], + "outputs": [] +} +``` + +- [ ] **Step 5: Run the test and confirm it fails** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, `expected 2 to be 3` + +- [ ] **Step 6: Correct the assertion** + +Change `toBe(3)` to `toBe(2)` in the smoke test. + +- [ ] **Step 7: Run the test and confirm it passes** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, 1 test. + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/vitest.config.ts apps/web/src/lib/__tests__/smoke.test.ts apps/web/package.json turbo.json pnpm-lock.yaml +git commit -m "test: add Vitest to web app" +``` + +--- + +### Task 2: Vite and Nitro configuration for Vercel and WDK + +**Files:** +- Modify: `apps/web/vite.config.ts` +- Modify: `apps/web/tsconfig.json` + +**Interfaces:** +- Consumes: nothing +- Produces: `"use workflow"` and `"use step"` directives compile. Build output targets Vercel. + +The current config at `apps/web/vite.config.ts:22` uses `preset: 'node_server'` and proxies `/auth` and `/api` to a separate API server in both dev (`server.proxy`) and production (`nitro.routeRules`). Once the API is folded in, all of that must go. + +- [ ] **Step 1: Rewrite the Vite config** + +```typescript +// apps/web/vite.config.ts +import { defineConfig } from 'vite' +import viteReact from '@vitejs/plugin-react' +import tsconfigPaths from 'vite-tsconfig-paths' +import tailwindcss from '@tailwindcss/vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { nitro } from 'nitro/vite' +import { workflow } from 'workflow/vite' + +export default defineConfig({ + server: { + port: 3000, + strictPort: true, + }, + plugins: [ + tailwindcss(), + tsconfigPaths(), + workflow(), + tanstackStart(), + nitro({ + preset: 'vercel', + }), + viteReact(), + ], +}) +``` + +Note the ordering. `workflow()` comes before `tanstackStart()`, as shown in `apps/web/node_modules/workflow/docs/getting-started/tanstack-start.mdx`. + +- [ ] **Step 2: Add the WDK TypeScript plugin** + +In `apps/web/tsconfig.json`, add under `compilerOptions`: + +```json +"plugins": [{ "name": "workflow" }] +``` + +- [ ] **Step 3: Verify the build succeeds** + +Run: `pnpm --filter @overlap/web build` +Expected: build completes, output written to `.vercel/output` or `.output` depending on preset resolution. Any failure here is a configuration problem and must be resolved before continuing. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/vite.config.ts apps/web/tsconfig.json +git commit -m "build: target Vercel preset and enable Workflow DevKit plugin" +``` + +--- + +### Task 3: Database client for Supabase transaction pooling + +**Files:** +- Modify: `packages/db/src/client.ts` +- Modify: `.env.example` + +**Interfaces:** +- Consumes: nothing +- Produces: `db` (unchanged export, now pooler-safe), `migrationClient` (now uses `DIRECT_URL`). + +Supavisor transaction mode cannot support prepared statements, and `postgres-js` enables them by default. Without `prepare: false` the app fails intermittently under concurrency with `prepared statement "s1" already exists`. + +- [ ] **Step 1: Rewrite the client** + +```typescript +// packages/db/src/client.ts +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import * as schema from './schema/index.js' + +const connectionString = process.env.DATABASE_URL + +if (!connectionString) { + throw new Error('DATABASE_URL environment variable is not set') +} + +// Supabase Supavisor transaction pooler (port 6543) cannot use prepared statements. +const queryClient = postgres(connectionString, { prepare: false }) + +// Migrations require session mode, so they use the direct connection (port 5432). +const directConnectionString = process.env.DIRECT_URL || connectionString + +export const migrationClient = postgres(directConnectionString, { + max: 1, + prepare: false, +}) + +export const db = drizzle(queryClient, { schema }) + +export type Database = typeof db +``` + +- [ ] **Step 2: Update `.env.example`** + +Replace the `# Database` and `# Redis` blocks with: + +``` +# Database (Supabase) +# Transaction pooler, port 6543, used by the application +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/overlap +# Direct connection, port 5432, used by migrations only +DIRECT_URL=postgresql://postgres:postgres@localhost:5432/overlap +``` + +Delete the `REDIS_URL` line entirely. + +- [ ] **Step 3: Verify typecheck passes** + +Run: `pnpm --filter @overlap/db typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/db/src/client.ts .env.example +git commit -m "feat(db): configure client for Supabase transaction pooling" +``` + +--- + +## Phase 2: Session and route fold-in + +### Task 4: Session token utility + +**Files:** +- Create: `apps/web/src/lib/session.ts` +- Create: `apps/web/src/lib/__tests__/session.test.ts` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `signSession(userId: string): Promise` + - `verifySession(token: string): Promise<{ userId: string } | null>` + - `SESSION_COOKIE_NAME: string` (value `"session"`) + - `SESSION_MAX_AGE_SECONDS: number` (value `604800`) + +Spec S4 requires: `userId` only in the token, algorithm pinned explicitly on verify, `exp` of seven days. + +- [ ] **Step 1: Install jose** + +```bash +pnpm --filter @overlap/web add jose +``` + +- [ ] **Step 2: Write the failing tests** + +```typescript +// apps/web/src/lib/__tests__/session.test.ts +import { describe, it, expect, beforeAll } from 'vitest' +import { signSession, verifySession } from '../session' + +beforeAll(() => { + process.env.SESSION_SECRET = 'test-secret-value-at-least-32-bytes-long' +}) + +describe('session', () => { + it('round-trips a userId', async () => { + const token = await signSession('user-123') + const result = await verifySession(token) + expect(result).toEqual({ userId: 'user-123' }) + }) + + it('rejects a tampered token', async () => { + const token = await signSession('user-123') + const tampered = token.slice(0, -4) + 'aaaa' + expect(await verifySession(tampered)).toBeNull() + }) + + it('rejects a token signed with a different secret', async () => { + const token = await signSession('user-123') + process.env.SESSION_SECRET = 'a-completely-different-secret-value-32b' + const result = await verifySession(token) + process.env.SESSION_SECRET = 'test-secret-value-at-least-32-bytes-long' + expect(result).toBeNull() + }) + + it('rejects a malformed token', async () => { + expect(await verifySession('not-a-jwt')).toBeNull() + }) + + it('carries no claims beyond userId, iat and exp', async () => { + const token = await signSession('user-123') + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString() + ) + expect(Object.keys(payload).sort()).toEqual(['exp', 'iat', 'userId']) + }) +}) +``` + +- [ ] **Step 3: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../session`. + +- [ ] **Step 4: Implement the session utility** + +```typescript +// apps/web/src/lib/session.ts +import { SignJWT, jwtVerify } from 'jose' + +export const SESSION_COOKIE_NAME = 'session' +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7 + +const ALGORITHM = 'HS256' + +function getKey(): Uint8Array { + const secret = process.env.SESSION_SECRET + if (!secret) { + throw new Error('SESSION_SECRET environment variable is required') + } + return new TextEncoder().encode(secret) +} + +export async function signSession(userId: string): Promise { + return new SignJWT({ userId }) + .setProtectedHeader({ alg: ALGORITHM }) + .setIssuedAt() + .setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`) + .sign(getKey()) +} + +export async function verifySession( + token: string +): Promise<{ userId: string } | null> { + try { + const { payload } = await jwtVerify(token, getKey(), { + algorithms: [ALGORITHM], + }) + const userId = payload.userId + if (typeof userId !== 'string') return null + return { userId } + } catch { + return null + } +} +``` + +The `algorithms: [ALGORITHM]` option is required. Without it, `jose` would accept whatever algorithm the token header claims, which is the algorithm confusion class of vulnerability. + +- [ ] **Step 5: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, 5 session tests plus the smoke test. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/lib/session.ts apps/web/src/lib/__tests__/session.test.ts apps/web/package.json pnpm-lock.yaml +git commit -m "feat(web): add jose-backed session token utility" +``` + +--- + +### Task 5: Auth helper for server routes + +**Files:** +- Create: `apps/web/src/lib/auth.ts` +- Create: `apps/web/src/lib/__tests__/auth.test.ts` + +**Interfaces:** +- Consumes: `signSession`, `verifySession`, `SESSION_COOKIE_NAME` from Task 4 +- Produces: + - `getUser(request: Request): Promise` + - `requireUser(request: Request): Promise` which throws a `Response` with status 401 + - `type AuthUser = { id: string; githubId: number; username: string; email: string | null; avatarUrl: string | null }` + - `readCookie(request: Request, name: string): string | null` + - `buildSessionCookie(token: string): string` + - `buildClearCookie(name: string): string` + +This is the direct port of `apps/api/src/plugins/auth.ts`. The database lookup on every request is retained deliberately, per spec S4. + +- [ ] **Step 1: Write the failing tests** + +```typescript +// apps/web/src/lib/__tests__/auth.test.ts +import { describe, it, expect } from 'vitest' +import { readCookie, buildSessionCookie, buildClearCookie } from '../auth' + +function reqWithCookie(value: string): Request { + return new Request('https://example.com/', { headers: { cookie: value } }) +} + +describe('readCookie', () => { + it('reads a single cookie', () => { + expect(readCookie(reqWithCookie('session=abc'), 'session')).toBe('abc') + }) + + it('reads one cookie among several', () => { + const r = reqWithCookie('a=1; session=abc; b=2') + expect(readCookie(r, 'session')).toBe('abc') + }) + + it('returns null when absent', () => { + expect(readCookie(reqWithCookie('a=1'), 'session')).toBeNull() + }) + + it('returns null when there is no cookie header', () => { + expect(readCookie(new Request('https://example.com/'), 'session')).toBeNull() + }) + + it('does not match a cookie whose name is a suffix', () => { + expect(readCookie(reqWithCookie('oauth_session=abc'), 'session')).toBeNull() + }) +}) + +describe('buildSessionCookie', () => { + it('sets HttpOnly, SameSite=Lax and Path', () => { + const c = buildSessionCookie('tok') + expect(c).toContain('session=tok') + expect(c).toContain('HttpOnly') + expect(c).toContain('SameSite=Lax') + expect(c).toContain('Path=/') + expect(c).toContain('Max-Age=604800') + }) +}) + +describe('buildClearCookie', () => { + it('expires the cookie immediately', () => { + expect(buildClearCookie('session')).toContain('Max-Age=0') + }) +}) +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../auth`. + +- [ ] **Step 3: Implement the auth helper** + +```typescript +// apps/web/src/lib/auth.ts +import { db, users } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { + verifySession, + SESSION_COOKIE_NAME, + SESSION_MAX_AGE_SECONDS, +} from './session' + +export type AuthUser = { + id: string + githubId: number + username: string + email: string | null + avatarUrl: string | null +} + +export function readCookie(request: Request, name: string): string | null { + const header = request.headers.get('cookie') + if (!header) return null + for (const part of header.split(';')) { + const eq = part.indexOf('=') + if (eq === -1) continue + if (part.slice(0, eq).trim() === name) { + return part.slice(eq + 1).trim() + } + } + return null +} + +function isProduction(): boolean { + return process.env.NODE_ENV === 'production' +} + +export function buildSessionCookie(token: string): string { + const parts = [ + `${SESSION_COOKIE_NAME}=${token}`, + 'HttpOnly', + 'SameSite=Lax', + 'Path=/', + `Max-Age=${SESSION_MAX_AGE_SECONDS}`, + ] + if (isProduction()) parts.push('Secure') + return parts.join('; ') +} + +export function buildClearCookie(name: string): string { + const parts = [`${name}=`, 'HttpOnly', 'SameSite=Lax', 'Path=/', 'Max-Age=0'] + if (isProduction()) parts.push('Secure') + return parts.join('; ') +} + +export async function getUser(request: Request): Promise { + const token = readCookie(request, SESSION_COOKIE_NAME) + if (!token) return null + + const session = await verifySession(token) + if (!session) return null + + // Retained deliberately: this lookup is what makes revocation immediate. + const user = await db.query.users.findFirst({ + where: eq(users.id, session.userId), + }) + if (!user) return null + + return { + id: user.id, + githubId: user.githubId, + username: user.username, + email: user.email, + avatarUrl: user.avatarUrl, + } +} + +export async function requireUser(request: Request): Promise { + const user = await getUser(request) + if (!user) { + throw new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }) + } + return user +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, all auth and session tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/auth.ts apps/web/src/lib/__tests__/auth.test.ts +git commit -m "feat(web): add request auth helper with retained user lookup" +``` + +--- + +### Task 6: Health and auth server routes + +**Files:** +- Create: `apps/web/src/routes/api/health.ts` +- Create: `apps/web/src/routes/api/auth/github.ts` +- Create: `apps/web/src/routes/api/auth/github.callback.ts` +- Create: `apps/web/src/routes/api/auth/me.ts` +- Create: `apps/web/src/routes/api/auth/logout.ts` +- Create: `apps/web/src/lib/github-oauth.ts` +- Reference: `apps/api/src/routes/auth.ts`, `apps/api/src/routes/health.ts` + +**Interfaces:** +- Consumes: `getUser`, `requireUser`, `buildSessionCookie`, `buildClearCookie`, `readCookie` from Task 5, `signSession` from Task 4 +- Produces: `syncUserInstallations(accessToken: string, userId: string): Promise` exported from `apps/web/src/lib/github-oauth.ts` + +Per spec S9, the health route drops its Redis check. Per spec S7, the `oauth_state` cookie keeps integrity protection, which here means it is itself a signed JWT rather than a bare random value. + +- [ ] **Step 1: Write the health route** + +```typescript +// apps/web/src/routes/api/health.ts +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db } from '@overlap/db' +import { sql } from 'drizzle-orm' + +export const Route = createFileRoute('/api/health')({ + server: { + handlers: { + GET: async () => { + let database = false + try { + await db.execute(sql`SELECT 1`) + database = true + } catch { + database = false + } + + return json( + { + status: database ? 'ready' : 'not ready', + checks: { database }, + timestamp: new Date().toISOString(), + }, + { status: database ? 200 : 503 } + ) + }, + }, + }, +}) +``` + +- [ ] **Step 2: Port `syncUserInstallations` verbatim** + +Copy the `syncUserInstallations` function from `apps/api/src/routes/auth.ts` into `apps/web/src/lib/github-oauth.ts`, changing only its imports to point at `@overlap/db`. Export it. Do not alter its logic in this task. + +- [ ] **Step 3: Write the OAuth start route** + +```typescript +// apps/web/src/routes/api/auth/github.ts +import { createFileRoute } from '@tanstack/react-router' +import { SignJWT } from 'jose' + +export const Route = createFileRoute('/api/auth/github')({ + server: { + handlers: { + GET: async () => { + const clientId = process.env.GITHUB_CLIENT_ID + const appUrl = process.env.APP_URL || 'http://localhost:3000' + if (!clientId) { + return new Response('OAuth not configured', { status: 500 }) + } + + const state = crypto.randomUUID() + + // The state cookie is signed, not merely present, per spec S7. + const stateToken = await new SignJWT({ state }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('10m') + .sign(new TextEncoder().encode(process.env.SESSION_SECRET!)) + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: `${appUrl}/api/auth/github/callback`, + scope: 'read:user user:email', + state, + }) + + const cookieParts = [ + `oauth_state=${stateToken}`, + 'HttpOnly', + 'SameSite=Lax', + 'Path=/', + 'Max-Age=600', + ] + if (process.env.NODE_ENV === 'production') cookieParts.push('Secure') + + return new Response(null, { + status: 302, + headers: { + location: `https://github.com/login/oauth/authorize?${params}`, + 'set-cookie': cookieParts.join('; '), + }, + }) + }, + }, + }, +}) +``` + +- [ ] **Step 4: Write the OAuth callback route** + +Port `apps/api/src/routes/auth.ts:41-155` into `apps/web/src/routes/api/auth/github.callback.ts`, with these changes and no others: + +- Read `oauth_state` with `readCookie`, verify it with `jwtVerify` pinned to `HS256`, and compare its `state` claim to the `state` query parameter. A missing or invalid cookie redirects to `${appUrl}/api/auth/github` exactly as the current code does at line 50. +- Replace `reply.setCookie('session', JSON.stringify({ userId: user.id }), ...)` with `buildSessionCookie(await signSession(user.id))`. +- Clear `oauth_state` with `buildClearCookie('oauth_state')`. +- Return `Response` objects with `location` and `set-cookie` headers rather than calling `reply.redirect`. +- Keep the `syncUserInstallations` call and the `hasActive` redirect branch exactly as they are. + +- [ ] **Step 5: Write the `me` and `logout` routes** + +```typescript +// apps/web/src/routes/api/auth/me.ts +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, userInstallations } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { requireUser } from '../../../lib/auth' + +export const Route = createFileRoute('/api/auth/me')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const user = await requireUser(request) + const insts = await db.query.userInstallations.findMany({ + where: eq(userInstallations.userId, user.id), + with: { installation: true }, + }) + return json({ + user, + hasInstallations: insts.some( + (ui) => ui.installation.status === 'active' + ), + }) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) +``` + +```typescript +// apps/web/src/routes/api/auth/logout.ts +import { createFileRoute } from '@tanstack/react-router' +import { buildClearCookie } from '../../../lib/auth' +import { SESSION_COOKIE_NAME } from '../../../lib/session' + +export const Route = createFileRoute('/api/auth/logout')({ + server: { + handlers: { + POST: async () => { + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'set-cookie': buildClearCookie(SESSION_COOKIE_NAME), + }, + }) + }, + }, + }, +}) +``` + +- [ ] **Step 6: Verify typecheck and build** + +Run: `pnpm --filter @overlap/web typecheck && pnpm --filter @overlap/web build` +Expected: both succeed. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/routes/api apps/web/src/lib/github-oauth.ts +git commit -m "feat(web): port health and auth routes to server routes" +``` + +--- + +### Task 7: Repositories and push server routes + +**Files:** +- Create: `apps/web/src/routes/api/repositories.ts` +- Create: `apps/web/src/routes/api/repositories.$id.ts` +- Create: `apps/web/src/routes/api/push.ts` +- Create: `apps/web/src/lib/repo-access.ts` +- Reference: `apps/api/src/routes/repositories.ts`, `apps/api/src/routes/push.ts` + +**Interfaces:** +- Consumes: `requireUser` from Task 5 +- Produces: `requireRepoAccess(user: AuthUser, repoId: string): Promise` which throws a `Response` with status 403 or 404 + +The spec's "verified clean" note records that `requireRepoAccess` is the control preventing insecure direct object references. It is carried across with its logic unchanged. Every `:id` route must call it before reading or writing, matching `apps/api/src/routes/repositories.ts:101` and every other handler in that file. + +- [ ] **Step 1: Port `requireRepoAccess`** + +Copy the `requireRepoAccess` helper from `apps/api/src/routes/repositories.ts` into `apps/web/src/lib/repo-access.ts`. Change its signature from `(request, reply, id)` to `(user: AuthUser, repoId: string)`, and change its failure paths from `reply.status(...).send(...)` to `throw new Response(...)`. The access-checking logic itself is unchanged. + +- [ ] **Step 2: Port every repositories handler** + +Port all nine handlers from `apps/api/src/routes/repositories.ts` into the two route files, splitting by path shape. Each handler: + +- calls `await requireUser(request)` first +- calls `await requireRepoAccess(user, id)` before any data access, for every route with an `:id` parameter +- keeps its existing Zod parsing (`repositoryIdParamSchema`, `repositorySettingsUpdateSchema`, and the querystring schemas) exactly as-is +- returns `json(...)` instead of returning a bare object + +- [ ] **Step 3: Port the push routes** + +Port `apps/api/src/routes/push.ts` in full. Keep `ALLOWED_PUSH_HOSTS` and `isAllowedPushEndpoint` byte-for-byte; that allowlist is the control preventing the push endpoint from being pointed at arbitrary hosts. + +Add the per-user subscription cap required by spec S3, before the insert: + +```typescript +const MAX_SUBSCRIPTIONS_PER_USER = 20 + +const existing = await db.query.pushSubscriptions.findMany({ + where: eq(pushSubscriptions.userId, user.id), +}) + +const isKnownEndpoint = existing.some((s) => s.endpoint === endpoint) + +if (!isKnownEndpoint && existing.length >= MAX_SUBSCRIPTIONS_PER_USER) { + return json({ error: 'Subscription limit reached' }, { status: 429 }) +} +``` + +- [ ] **Step 4: Verify typecheck and build** + +Run: `pnpm --filter @overlap/web typecheck && pnpm --filter @overlap/web build` +Expected: both succeed. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api apps/web/src/lib/repo-access.ts +git commit -m "feat(web): port repositories and push routes to server routes" +``` + +--- + +## Phase 3: Workflows + +### Task 8: Workflow steps + +**Files:** +- Create: `apps/web/src/workflows/steps.ts` +- Create: `apps/web/src/workflows/errors.ts` +- Create: `apps/web/src/workflows/__tests__/errors.test.ts` +- Reference: all six files in `apps/worker/src/processors/` + +**Interfaces:** +- Consumes: `@overlap/db`, `@overlap/github`, `@overlap/shared` +- Produces, all as `"use step"` functions: + - `loadEvent(deliveryId: string): Promise` + - `upsertBranch(deliveryId: string): Promise<{ branchId: string; repositoryId: string; installationId: number; branchName: string; sha: string; isDefault: boolean } | null>` + - `syncBranchFiles(input: { repositoryId: string; branchName: string; sha: string; installationId: number }): Promise<{ filesIndexed: number }>` + - `detectOverlaps(input: { repositoryId: string; branchId: string }): Promise<{ overlapsFound: number; notifications: NotificationTarget[] }>` + - `postCheckRun(input: { repositoryId: string; pullRequestId: string; overlapId: string }): Promise<{ checkRunId: number | null }>` + - `sendPush(input: { repositoryId: string; overlapId: string; targetBranchId: string }): Promise<{ sent: number }>` + - `upsertPullRequest(deliveryId: string): Promise<{ repositoryId: string; branchId: string } | null>` + - `syncInstallation(deliveryId: string): Promise<{ repositoryIds: string[] }>` + - `syncRepository(repositoryId: string): Promise<{ added: number; updated: number; markedForDeletion: number }>` + - `pruneStaleBranches(repositoryId?: string): Promise<{ prunedBranches: number }>` + - `cleanupOldEvents(): Promise<{ cleaned: boolean }>` + - `type NotificationTarget = { repositoryId: string; overlapId: string; targetBranchId: string; pullRequestIds: string[] }` + +Each function is the body of the corresponding processor with three changes: the `Job` parameter becomes plain arguments, all `queue.add(...)` calls are deleted because the workflow now sequences the work, and error handling follows the mapping below. + +- [ ] **Step 1: Write the failing tests for the error mapping** + +Steps without the compiler treat `"use step"` as a no-op, so they can be tested as plain functions. Test the error classifier in isolation. + +```typescript +// apps/web/src/workflows/__tests__/errors.test.ts +import { describe, it, expect } from 'vitest' +import { FatalError, RetryableError } from 'workflow' +import { classifyGitHubError } from '../errors' + +describe('classifyGitHubError', () => { + it('maps 429 to RetryableError honoring Retry-After', () => { + const err = classifyGitHubError({ + status: 429, + message: 'rate limited', + response: { headers: { 'retry-after': '120' } }, + }) + expect(err).toBeInstanceOf(RetryableError) + expect((err as RetryableError).retryAfter).toBe('120s') + }) + + it('maps 403 to RetryableError', () => { + const err = classifyGitHubError({ status: 403, message: 'forbidden' }) + expect(err).toBeInstanceOf(RetryableError) + }) + + it('defaults Retry-After to 5m when the header is absent', () => { + const err = classifyGitHubError({ status: 429, message: 'rate limited' }) + expect((err as RetryableError).retryAfter).toBe('5m') + }) + + it('maps 500 to RetryableError', () => { + expect(classifyGitHubError({ status: 500, message: 'boom' })).toBeInstanceOf( + RetryableError + ) + }) + + it('maps 404 to FatalError', () => { + expect(classifyGitHubError({ status: 404, message: 'gone' })).toBeInstanceOf( + FatalError + ) + }) + + it('maps 422 to FatalError', () => { + expect( + classifyGitHubError({ status: 422, message: 'unprocessable' }) + ).toBeInstanceOf(FatalError) + }) +}) +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../errors`. + +- [ ] **Step 3: Implement the classifier** + +```typescript +// apps/web/src/workflows/errors.ts +import { FatalError, RetryableError } from 'workflow' + +type GitHubErrorLike = { + status?: number + message?: string + response?: { headers?: Record } +} + +export function classifyGitHubError(err: GitHubErrorLike): Error { + const status = err.status + const message = err.message ?? 'GitHub request failed' + + if (status === 429 || status === 403) { + const header = err.response?.headers?.['retry-after'] + const retryAfter = header ? `${header}s` : '5m' + return new RetryableError(`GitHub rate limited: ${message}`, { retryAfter }) + } + + if (status !== undefined && status >= 400 && status < 500) { + return new FatalError(message) + } + + // 5xx, network failures and unknown shapes are transient. + return new RetryableError(message, { retryAfter: '30s' }) +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS. + +- [ ] **Step 5: Port the processors into steps** + +Port each processor. Two specific corrections are mandatory and are the reason this task exists rather than a straight copy: + +**`syncBranchFiles`.** `apps/worker/src/processors/branch-sync.ts:52-56` currently catches the GitHub failure and assigns `changedFiles = []`, after which the code unconditionally deletes every `branchFiles` row for the branch. That wipes the file index on any transient failure, and the next detection run then marks genuine overlaps `resolved`. Replace the catch with: + +```typescript +try { + changedFiles = await github.getBranchFiles( + installationId, owner, repoName, branchName, repo.defaultBranch + ) +} catch (error) { + throw classifyGitHubError(error as never) +} +``` + +The delete must not be reachable when the fetch failed. + +**`postCheckRun`.** `apps/worker/src/processors/github-feedback.ts:110-113` swallows `createCheckRun` failures. Replace with `throw classifyGitHubError(error as never)`. The check-run duplication issue is handled separately in Task 9. + +Delete every `Queue`, `Redis` and `queue.add(...)` reference. `detectOverlaps` returns its notification targets to the caller instead of enqueuing them, which is what the `NotificationTarget[]` return type is for. + +- [ ] **Step 6: Verify typecheck** + +Run: `pnpm --filter @overlap/web typecheck` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/workflows +git commit -m "feat(web): port worker processors to workflow steps + +Fixes two swallowed-error paths carried over from the BullMQ processors: +branch-sync no longer wipes a branch file index on transient GitHub +failure, and check-run creation failures no longer pass silently." +``` + +--- + +### Task 9: Check run idempotency + +**Files:** +- Modify: `packages/github/src/client.ts` +- Modify: `apps/web/src/workflows/steps.ts` + +**Interfaces:** +- Consumes: `postCheckRun` from Task 8 +- Produces: `updateCheckRun(installationId: number, owner: string, repo: string, checkRunId: number, conclusion: string, title: string, summary: string): Promise` on the GitHub client + +A step that calls `createCheckRun` successfully and then fails before returning will retry and create a second check run, because GitHub's API accepts no idempotency key and a repeat call creates a new record rather than updating. `apps/web/node_modules/workflow/docs/foundations/idempotency.mdx` describes this failure mode directly. + +- [ ] **Step 1: Add `updateCheckRun` to the client** + +Add a method alongside `createCheckRun` at `packages/github/src/client.ts:345` that calls `octokit.rest.checks.update` with the same argument shape, taking `check_run_id` instead of `head_sha`. + +- [ ] **Step 2: Remove the internal retry wrapper** + +Delete the `withRetry` helper at `packages/github/src/client.ts:15` and unwrap every call site. WDK owns retry policy now; an inner retry loop burns the step's wall-clock budget and hides the rate-limit signal that `classifyGitHubError` needs to see. + +- [ ] **Step 3: Make `postCheckRun` update rather than duplicate** + +In `postCheckRun`, look up the existing `pr_alerts` row first. If it has a `checkRunId`, call `updateCheckRun`. Only call `createCheckRun` when no check run has been recorded, and write the resulting id to `pr_alerts` immediately. + +- [ ] **Step 4: Verify typecheck** + +Run: `pnpm typecheck` +Expected: no errors across the workspace. + +- [ ] **Step 5: Commit** + +```bash +git add packages/github/src/client.ts apps/web/src/workflows/steps.ts +git commit -m "fix(github): make check run posting idempotent under step retry" +``` + +--- + +### Task 10: The workflow + +**Files:** +- Create: `apps/web/src/workflows/process-webhook.ts` +- Create: `apps/web/src/workflows/maintenance.ts` + +**Interfaces:** +- Consumes: every step from Task 8 +- Produces: + - `processWebhook(deliveryId: string): Promise<{ handled: boolean }>` + - `pruneBranchesWorkflow(): Promise<{ prunedBranches: number }>` + - `cleanupEventsWorkflow(): Promise<{ cleaned: boolean }>` + - `syncRepositoryWorkflow(repositoryId: string): Promise` + +- [ ] **Step 1: Write the workflow** + +```typescript +// apps/web/src/workflows/process-webhook.ts +import { + loadEvent, + upsertBranch, + upsertPullRequest, + syncInstallation, + syncBranchFiles, + detectOverlaps, + postCheckRun, + sendPush, + syncRepository, +} from './steps' + +export async function processWebhook(deliveryId: string) { + 'use workflow' + + const evt = await loadEvent(deliveryId) + + if (evt.type === 'push') { + const branch = await upsertBranch(deliveryId) + if (!branch) return { handled: false } + + await syncBranchFiles({ + repositoryId: branch.repositoryId, + branchName: branch.branchName, + sha: branch.sha, + installationId: branch.installationId, + }) + + if (branch.isDefault) return { handled: true } + + const result = await detectOverlaps({ + repositoryId: branch.repositoryId, + branchId: branch.branchId, + }) + + for (const n of result.notifications) { + for (const pullRequestId of n.pullRequestIds) { + await postCheckRun({ + repositoryId: n.repositoryId, + pullRequestId, + overlapId: n.overlapId, + }) + } + await sendPush({ + repositoryId: n.repositoryId, + overlapId: n.overlapId, + targetBranchId: n.targetBranchId, + }) + } + + return { handled: true } + } + + if (evt.type === 'pull_request') { + const pr = await upsertPullRequest(deliveryId) + if (!pr) return { handled: false } + + const result = await detectOverlaps({ + repositoryId: pr.repositoryId, + branchId: pr.branchId, + }) + + for (const n of result.notifications) { + for (const pullRequestId of n.pullRequestIds) { + await postCheckRun({ + repositoryId: n.repositoryId, + pullRequestId, + overlapId: n.overlapId, + }) + } + await sendPush({ + repositoryId: n.repositoryId, + overlapId: n.overlapId, + targetBranchId: n.targetBranchId, + }) + } + + return { handled: true } + } + + if (evt.type === 'installation') { + const { repositoryIds } = await syncInstallation(deliveryId) + for (const repositoryId of repositoryIds) { + await syncRepository(repositoryId) + } + return { handled: true } + } + + return { handled: false } +} +``` + +The `await syncBranchFiles(...)` followed by `await detectOverlaps(...)` is the entire point of this migration. It replaces the `delay: 5000` at `apps/worker/src/processors/webhook-events.ts:150`, which was a hope that one queue would finish before another started. Do not reintroduce a delay anywhere in this file. + +- [ ] **Step 2: Write the maintenance workflows** + +```typescript +// apps/web/src/workflows/maintenance.ts +import { pruneStaleBranches, cleanupOldEvents, syncRepository } from './steps' + +export async function pruneBranchesWorkflow() { + 'use workflow' + return await pruneStaleBranches() +} + +export async function cleanupEventsWorkflow() { + 'use workflow' + return await cleanupOldEvents() +} + +export async function syncRepositoryWorkflow(repositoryId: string) { + 'use workflow' + await syncRepository(repositoryId) +} +``` + +- [ ] **Step 3: Verify the build compiles the directives** + +Run: `pnpm --filter @overlap/web build` +Expected: build succeeds. A failure mentioning directives means the `workflow()` plugin ordering in Task 2 is wrong. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/workflows +git commit -m "feat(web): add durable webhook and maintenance workflows + +Replaces the 5s delay between branch sync and overlap detection with a +real happens-before edge." +``` + +--- + +### Task 11: Webhook route + +**Files:** +- Create: `apps/web/src/routes/api/webhooks/github.ts` +- Create: `apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts` +- Reference: `apps/api/src/routes/webhooks.ts` + +**Interfaces:** +- Consumes: `processWebhook` from Task 10, `verifyWebhookSignature` from `@overlap/github` +- Produces: `POST /api/webhooks/github` + +Spec S2 makes the ordering here a security requirement. Signature verification of the raw body precedes every database write and every workflow start. + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { handleWebhook } from '../github-handler' + +const inserts = vi.fn() +const starts = vi.fn() + +vi.mock('@overlap/db', () => ({ + db: { insert: () => ({ values: inserts }) }, +})) + +beforeEach(() => { + inserts.mockReset() + starts.mockReset() + process.env.GITHUB_WEBHOOK_SECRET = 'test-secret' +}) + +describe('handleWebhook', () => { + it('rejects an invalid signature with 401', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + const res = await handleWebhook(req, { start: starts }) + expect(res.status).toBe(401) + }) + + it('writes nothing to the database when the signature is invalid', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + await handleWebhook(req, { start: starts }) + expect(inserts).not.toHaveBeenCalled() + }) + + it('starts no workflow when the signature is invalid', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + await handleWebhook(req, { start: starts }) + expect(starts).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../github-handler`. + +- [ ] **Step 3: Implement the handler** + +Extract the logic into `apps/web/src/routes/api/webhooks/github-handler.ts` so it is testable without the route wrapper. It takes the `start` function as an injected dependency, which is what makes the test above possible. + +```typescript +// apps/web/src/routes/api/webhooks/github-handler.ts +import { verifyWebhookSignature } from '@overlap/github' +import { db, webhookEvents, repositories } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { processWebhook } from '../../../workflows/process-webhook' + +type Deps = { start: (wf: unknown, args: unknown[]) => Promise } + +export async function handleWebhook( + request: Request, + deps: Deps +): Promise { + const secret = process.env.GITHUB_WEBHOOK_SECRET + if (!secret) { + return new Response(JSON.stringify({ error: 'Server configuration error' }), { + status: 500, + }) + } + + // Raw bytes, before any parsing. This is what GitHub signed. + const raw = await request.text() + const signature = request.headers.get('x-hub-signature-256') ?? '' + const eventType = request.headers.get('x-github-event') ?? '' + const deliveryId = request.headers.get('x-github-delivery') ?? '' + + // Nothing below this line may execute for an unverified request. + const verification = verifyWebhookSignature(raw, signature, secret) + if (!verification.valid) { + return new Response(JSON.stringify({ error: 'Invalid signature' }), { + status: 401, + }) + } + + let payload: Record + try { + payload = JSON.parse(raw) + } catch { + return new Response(JSON.stringify({ error: 'Invalid JSON payload' }), { + status: 400, + }) + } + + let repositoryId: string | null = null + const repoData = payload.repository as { id?: number } | undefined + if (repoData?.id) { + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, repoData.id), + }) + repositoryId = repo?.id ?? null + } + + const [row] = await db + .insert(webhookEvents) + .values({ eventType, deliveryId, repositoryId, payload }) + .onConflictDoNothing() + .returning() + + // No row means GitHub redelivered a delivery we already accepted. + if (!row) { + return new Response(JSON.stringify({ received: true }), { status: 200 }) + } + + await deps.start(processWebhook, [deliveryId]) + + return new Response(JSON.stringify({ received: true }), { status: 200 }) +} +``` + +- [ ] **Step 4: Write the thin route wrapper** + +```typescript +// apps/web/src/routes/api/webhooks/github.ts +import { createFileRoute } from '@tanstack/react-router' +import { start } from 'workflow/api' +import { handleWebhook } from './github-handler' + +export const Route = createFileRoute('/api/webhooks/github')({ + server: { + handlers: { + POST: async ({ request }) => handleWebhook(request, { start }), + }, + }, +}) +``` + +- [ ] **Step 5: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, all three ordering tests. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/routes/api/webhooks +git commit -m "feat(web): port GitHub webhook route with verify-before-write ordering + +Deduplication moves from BullMQ jobId to the webhook_events.deliveryId +unique constraint, which is durable rather than TTL-bound." +``` + +--- + +### Task 12: Cron routes + +**Files:** +- Create: `apps/web/src/routes/api/cron/prune-branches.ts` +- Create: `apps/web/src/routes/api/cron/cleanup-events.ts` +- Create: `apps/web/src/lib/cron-auth.ts` +- Create: `apps/web/src/lib/__tests__/cron-auth.test.ts` +- Create: `vercel.json` + +**Interfaces:** +- Consumes: `pruneBranchesWorkflow`, `cleanupEventsWorkflow` from Task 10 +- Produces: `isAuthorizedCron(request: Request): boolean` + +Spec S6 requires a timing-safe comparison. These endpoints are publicly routable and start workflows that iterate every active repository. + +- [ ] **Step 1: Write the failing tests** + +```typescript +// apps/web/src/lib/__tests__/cron-auth.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { isAuthorizedCron } from '../cron-auth' + +beforeEach(() => { + process.env.CRON_SECRET = 'correct-secret' +}) + +function req(auth?: string): Request { + return new Request('https://example.com/api/cron/prune-branches', { + headers: auth ? { authorization: auth } : {}, + }) +} + +describe('isAuthorizedCron', () => { + it('accepts the correct bearer token', () => { + expect(isAuthorizedCron(req('Bearer correct-secret'))).toBe(true) + }) + + it('rejects a wrong token of the same length', () => { + expect(isAuthorizedCron(req('Bearer wrongxxsecret!'))).toBe(false) + }) + + it('rejects a wrong token of a different length', () => { + expect(isAuthorizedCron(req('Bearer short'))).toBe(false) + }) + + it('rejects a missing header', () => { + expect(isAuthorizedCron(req())).toBe(false) + }) + + it('rejects when CRON_SECRET is unset', () => { + delete process.env.CRON_SECRET + expect(isAuthorizedCron(req('Bearer anything'))).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../cron-auth`. + +- [ ] **Step 3: Implement the check** + +```typescript +// apps/web/src/lib/cron-auth.ts +import { timingSafeEqual } from 'node:crypto' + +export function isAuthorizedCron(request: Request): boolean { + const secret = process.env.CRON_SECRET + if (!secret) return false + + const header = request.headers.get('authorization') + if (!header?.startsWith('Bearer ')) return false + + const provided = Buffer.from(header.slice('Bearer '.length)) + const expected = Buffer.from(secret) + + // timingSafeEqual throws on length mismatch, so compare lengths first. + // The length itself is not secret; the contents are. + if (provided.length !== expected.length) return false + + return timingSafeEqual(provided, expected) +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, 5 cron auth tests. + +- [ ] **Step 5: Write the two cron routes** + +Each route checks `isAuthorizedCron(request)`, returns 401 when it fails, and otherwise calls `start(...)` on its workflow and returns 200 immediately. + +- [ ] **Step 6: Write `vercel.json`** + +```json +{ + "crons": [ + { "path": "/api/cron/prune-branches", "schedule": "0 */6 * * *" }, + { "path": "/api/cron/cleanup-events", "schedule": "0 3 * * *" } + ] +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/routes/api/cron apps/web/src/lib/cron-auth.ts apps/web/src/lib/__tests__/cron-auth.test.ts vercel.json +git commit -m "feat: replace BullMQ job scheduler with Vercel Cron" +``` + +--- + +## Phase 4: Teardown + +### Task 13: Delete the old apps and dependencies + +**Files:** +- Delete: `apps/api/`, `apps/worker/` +- Delete: `railway.json`, `apps/web/Dockerfile` +- Modify: `docker-compose.yml`, `packages/shared/src/constants/index.ts` + +**Interfaces:** +- Consumes: everything above +- Produces: a workspace with one deployable app + +Do this only after Tasks 1 through 12 are green. Until then the old code is the reference for the port. + +No frontend changes are required. `VITE_API_URL` appears nowhere in `apps/web/src`; it existed only as a dev proxy target in `vite.config.ts`, which Task 2 already removed. The React code already fetches relative paths such as `/api/repositories`, which now resolve to the folded-in server routes on the same origin. + +- [ ] **Step 1: Confirm nothing still imports the deleted packages** + +Run: `grep -rn "bullmq\|ioredis\|REDIS_URL\|VITE_API_URL\|API_URL" apps/web/src packages/ --include=*.ts --include=*.tsx` +Expected: no results. Fix any that appear before continuing. + +- [ ] **Step 2: Delete the directories and files** + +```bash +git rm -r apps/api apps/worker +git rm railway.json apps/web/Dockerfile +``` + +- [ ] **Step 3: Remove `QUEUE_NAMES` and `RATE_LIMITS`** + +Delete both exports from `packages/shared/src/constants/index.ts:11-27`. Keep `DEFAULT_SETTINGS` and everything below it. + +- [ ] **Step 4: Remove the Redis service from `docker-compose.yml`** + +- [ ] **Step 5: Verify the whole workspace builds and tests clean** + +Run: `pnpm install && pnpm typecheck && pnpm lint && pnpm test && pnpm build` +Expected: all pass. This is the gate for the whole migration. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "chore: remove Railway apps, BullMQ and Redis + +apps/api and apps/worker are now served by apps/web. Redis had exactly +one consumer (BullMQ) and is deleted with it." +``` + +--- + +### Task 14: Supabase project and Data API hardening + +This task is operational rather than code. It must complete before any deployment is pointed at the new database. + +**Interfaces:** +- Consumes: nothing +- Produces: `DATABASE_URL`, `DIRECT_URL` for the Vercel environment + +- [ ] **Step 1: Create the Supabase project** + +- [ ] **Step 2: Disable the Data API for the `public` schema** + +Spec S1. Railway Postgres had no HTTP surface. Supabase exposes PostgREST at `/rest/v1` publicly, and the restored schema has no row-level security, so every table would be readable by any holder of the anon key. Disable the Data API in project settings before the application writes anything. + +- [ ] **Step 3: Verify from outside the network** + +```bash +curl -s "https://.supabase.co/rest/v1/users?select=*" \ + -H "apikey: " | head +``` + +Expected: an error or empty result, never row data. If rows come back, stop and fix before continuing. + +- [ ] **Step 4: Create the schema** + +Run: `DIRECT_URL= pnpm db:migrate` +Expected: tables created. Restore no data, per the spec's Database section. + +- [ ] **Step 5: Record `repository_settings` from Railway** + +Check whether `pruningDays` or `ignoredPaths` differ from the defaults in `packages/shared/src/constants/index.ts`. If so, note them for manual re-entry after cutover. + +--- + +### Task 15: Deploy and cut over + +- [ ] **Step 1: Create the Vercel project and set environment variables** + +Set every variable from the spec's environment table. Generate a new `SESSION_SECRET` rather than reusing the Railway value, per spec S5. + +- [ ] **Step 2: Deploy a preview** + +Run: `vercel deploy` + +- [ ] **Step 3: Add Vercel Firewall rate limit rules** + +Spec S3. Rules on `/api/webhooks/github` and `/api/auth/*`. On Vercel, a request flood is a billed invocation rather than wasted CPU, which is the inverse of the Railway situation. + +- [ ] **Step 4: Point a second GitHub App at the preview URL and verify end to end** + +Install on a throwaway repository. Push a commit. Open a pull request. Confirm the check run appears and the push notification arrives. Inspect the runs with `npx workflow inspect runs --backend vercel --project --team crod`. + +- [ ] **Step 5: Flip the production GitHub App webhook and OAuth callback URLs** + +- [ ] **Step 6: Sign in, reinstall the App, and verify the rebuild** + +Confirm the pipeline reconstructs the same branches and overlaps that Railway is still serving. This comparison is the acceptance test for the entire migration. + +- [ ] **Step 7: Re-enter `repository_settings` and re-enable browser notifications** + +- [ ] **Step 8: Tear down the Railway project** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c89f267..8bc6a73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 20.19.31 eslint: specifier: ^9.0.0 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(jiti@2.7.0) prettier: specifier: ^3.2.0 version: 3.8.1 @@ -68,7 +68,7 @@ importers: version: 13.1.3 tsup: specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(@swc/core@1.15.3)(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.19.0 version: 4.21.0 @@ -113,7 +113,7 @@ importers: version: 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start': specifier: ^1.158.0 - version: 1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -125,7 +125,7 @@ importers: version: 0.470.0(react@19.2.4) nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(chokidar@4.0.3)(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 3.0.1-alpha.2(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) react: specifier: ^19.0.0 version: 19.2.4 @@ -135,10 +135,13 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.1 + workflow: + specifier: ^4.8.2 + version: 4.8.2(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(typescript@5.9.3) devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) '@types/node': specifier: ^20.11.0 version: 20.19.31 @@ -150,7 +153,7 @@ importers: version: 19.2.3(@types/react@19.2.10) '@vitejs/plugin-react': specifier: ^5.1.0 - version: 5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) tailwindcss: specifier: ^4.0.0 version: 4.1.18 @@ -159,10 +162,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.0 - version: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) vite-tsconfig-paths: specifier: ^5.1.0 - version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) apps/worker: dependencies: @@ -199,7 +202,7 @@ importers: version: 3.6.4 tsup: specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(@swc/core@1.15.3)(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.19.0 version: 4.21.0 @@ -260,6 +263,34 @@ importers: packages: + '@aws-sdk/core@3.977.7': + resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.49': + resolution: {integrity: sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.42': + resolution: {integrity: sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.44': + resolution: {integrity: sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.3': + resolution: {integrity: sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.38': + resolution: {integrity: sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -359,6 +390,39 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -391,6 +455,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -409,6 +479,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -427,6 +503,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} @@ -445,6 +527,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} @@ -463,6 +551,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} @@ -481,6 +575,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} @@ -499,6 +599,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} @@ -517,6 +623,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} @@ -535,6 +647,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} @@ -553,6 +671,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} @@ -571,6 +695,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -589,6 +719,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} @@ -607,6 +743,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} @@ -625,6 +767,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} @@ -643,6 +791,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} @@ -661,6 +815,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} @@ -679,12 +839,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} @@ -703,12 +875,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} @@ -727,12 +911,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.2': resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -751,6 +947,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} @@ -769,6 +971,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} @@ -787,6 +995,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} @@ -805,6 +1019,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -928,6 +1148,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -962,9 +1189,158 @@ packages: cpu: [x64] os: [win32] + '@napi-rs/nice-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/nice-android-arm64@1.1.1': + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/nice-darwin-arm64@1.1.1': + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/nice-darwin-x64@1.1.1': + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/nice-freebsd-x64@1.1.1': + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-openharmony-arm64@1.1.1': + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [openharmony] + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/nice@1.1.1': + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@nestjs/common@11.1.29': + resolution: {integrity: sha512-zkeNRlfiQIH/044r5zphjNzKYxBRC4O00onTdCsH2qq0R30Ixo0gOS9so5TadEbJJCuGNm0Vx3PBE2FG2vkBdA==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/core@11.1.29': + resolution: {integrity: sha512-ANXnZxirMNAY+JuRHCIYIdTfbhX3rizeSbJloub/4EYwpIfCuUoaz9woOofWmywYYc3gsVkLxVZV03gqntI8vA==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nuxt/kit@4.4.8': + resolution: {integrity: sha512-ZUlZ5iYfyfJFDPluhn6ZxFWcsuxWbLnZBc8w3MAROcQ4lYfZ+qFpALBLSNlpc0zhOa++33EE+5PEbOAdVIY+dw==} + engines: {node: '>=18.12.0'} + + '@oclif/core@4.11.4': + resolution: {integrity: sha512-URwiQ5ALx/sJ2iH4vzXEd+H4K6NAI7LRs6Jag3hrgKEpGmaE6alfRC8qjO4GIgb6A3ACaJumqP9twi/M9ywdHQ==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-help@6.2.37': + resolution: {integrity: sha512-5N/X/FzlJaYfpaHwDC0YHzOzKDWa41s9t+4FpCDu4f9OMReds4JeNBaaWk9rlIzdKjh2M6AC5Q18ORfECRkHGA==} + engines: {node: '>=18.0.0'} + '@octokit/auth-app@7.2.2': resolution: {integrity: sha512-p6hJtEyQDCJEPN9ijjhEC/kpFHMHN4Gca9r+8S0S8EJi7NaWftaEmexjxxpT1DFBeJpN4u/5RE22ArnyypupJw==} engines: {node: '>= 18'} @@ -1858,24 +2234,144 @@ packages: cpu: [x64] os: [win32] - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@smithy/core@3.32.0': + resolution: {integrity: sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.7.0': + resolution: {integrity: sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.10.0': + resolution: {integrity: sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.0': + resolution: {integrity: sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.17.0': + resolution: {integrity: sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==} + engines: {node: '>=18.0.0'} + + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + + '@swc/cli@0.8.1': + resolution: {integrity: sha512-L+ACCGHCiS0VqHVep/INLVnvRvJ2XooQFLZq4L8snhxw1jsqz+XRcY313UsyPVturPPE1shW3jic7rt3qEQTSQ==} + engines: {node: '>= 20.19.0'} + hasBin: true + peerDependencies: + '@swc/core': ^1.2.66 + chokidar: ^5.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@swc/core-darwin-arm64@1.15.3': + resolution: {integrity: sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==} + engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} + '@swc/core-darwin-x64@1.15.3': + resolution: {integrity: sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.3': + resolution: {integrity: sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.3': + resolution: {integrity: sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.3': + resolution: {integrity: sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.3': + resolution: {integrity: sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.3': + resolution: {integrity: sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.3': + resolution: {integrity: sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.3': + resolution: {integrity: sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.3': + resolution: {integrity: sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.3': + resolution: {integrity: sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -2057,6 +2553,13 @@ packages: resolution: {integrity: sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==} engines: {node: '>=12'} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2075,9 +2578,15 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.31': resolution: {integrity: sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==} @@ -2092,15 +2601,184 @@ packages: '@types/web-push@3.6.4': resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@vercel/cli-auth@0.0.1': + resolution: {integrity: sha512-CnqiuMlZ4pjs2LCPYiR6aLKPPd3Xb8SBI1Y7eotXKgpx6qgrGNY+E7EIyUt5ErGHJGIrCZyGG5WEo4bHtVmz2Q==} + + '@vercel/cli-config@0.2.3': + resolution: {integrity: sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A==} + + '@vercel/cli-exec@1.0.1': + resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} + engines: {node: '>= 18'} + + '@vercel/functions@3.9.3': + resolution: {integrity: sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + ws: '>=8' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + ws: + optional: true + + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + + '@vercel/oidc@3.8.4': + resolution: {integrity: sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA==} + engines: {node: '>= 20'} + + '@vercel/queue@0.3.1': + resolution: {integrity: sha512-6pjdXyNfdCQnj1nyeB1rPtll/XUhmciyeZJD0rpIUUwmcIfR+utDl6+iFvlHvsBdqAVT8UC6ydObT0v+xldfUQ==} + engines: {node: '>=20.0.0'} + '@vitejs/plugin-react@5.1.3': resolution: {integrity: sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@workflow/astro@4.0.17': + resolution: {integrity: sha512-A0y+w4v/zp/B+/J9JTn6CIOESKCK9GnXI2W9PSZkUXep7tpaGgwg+b/SRhioBdXJZVoUP+KjhwFu3Gm7FZpRaw==} + + '@workflow/builders@4.1.7': + resolution: {integrity: sha512-oSu7Bs/QsuLkdaLugOIrCCEwn5AaUz/Ps9VQKts+uliHU8GVQcSUwMHQzGN42mYgVztQw39K5A9Ayu0TdPYUAQ==} + + '@workflow/cli@4.3.6': + resolution: {integrity: sha512-p2Ktjo+bzR8D/KsSFPEdwd3fzzYTwdxPddXObxbYBOHvjYkro6mWNDEU3n5rofP4kxC3PzbtFjSBz/UPbu4Zfg==} + hasBin: true + + '@workflow/core@4.8.2': + resolution: {integrity: sha512-PAinyzMmNTfndLADQ00IuuyMtTxKpe3DvBrrFNOji7u4DYHfr44VIgRGRrMHp4ZaLPuO9+Hgnr1fF7wCpYcbVw==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/errors@4.2.1': + resolution: {integrity: sha512-gIe3vf2POUw2DV4Mi6BGiYGQdUZ4YMcWhDahVT9xI1W1NSsqifX73Hvk/68x/haPnDOr7/VnWN8/YAgeEVYE4A==} + + '@workflow/nest@4.0.18': + resolution: {integrity: sha512-z2q4lXBvsFnF6vQ/1HMhAhokWeQZ6ZDZslUyrCy2aUnvG8573zcU6ftc27qztS5fqVKQmjMifQ4Y0FGKQV0tlw==} + hasBin: true + peerDependencies: + '@nestjs/common': '>=10.0.0' + '@nestjs/core': '>=10.0.0' + '@swc/cli': '>=0.4.0' + '@swc/core': '>=1.5.0' + + '@workflow/next@4.1.6': + resolution: {integrity: sha512-rwJXXEtZyxObytSiRPAdYz9K2E9pJfvQEtjBtH74UvwuShb/JQuB/FvaXUdgf6dfO0URoV3GVaFGTKXZNuyShQ==} + peerDependencies: + next: '>13' + peerDependenciesMeta: + next: + optional: true + + '@workflow/nitro@4.1.8': + resolution: {integrity: sha512-OQbq51M6njaS1gdO5yVx+qqtYuFHwoA6as7bsWfXSCv9CCt+RCK7+NbiIJ781wT42Ld7KwIHR37Sfqen4LxgPA==} + + '@workflow/nuxt@4.0.18': + resolution: {integrity: sha512-MxskxH3UDmjmrOIJgzsC/W3jp39oxE2b+L9MpnZY5JvKmkODXFX7aWm1Dtj1jHWARU9k5VemNjvLfYTlfbFFFA==} + + '@workflow/rollup@4.0.17': + resolution: {integrity: sha512-+A9FjuuKo4+mebf7g2LOXu1MM2QTyAsQcvdJxa0Kx2Vd0BtXn6p1yjrhChWBSB/SsU0Whzu6Kn0nJfU5FfNcwQ==} + + '@workflow/serde@4.1.2': + resolution: {integrity: sha512-KkkSUddEcaIvW7/QfVUk2PR93117HkDum45cXQEGkoxEmHOmqfOLJ4T1m4a1QABVC7O9TIqPxsBtDPgKIO+LOw==} + + '@workflow/sveltekit@4.0.17': + resolution: {integrity: sha512-quGpf7iC8oU3gg4mebFYhichCZAMo9c8swyYSoCfEpWHAcqhsWQ+VZeT26CYHioIbGxNxjtgKVYu9PCrOkxfKA==} + + '@workflow/swc-plugin@4.1.2': + resolution: {integrity: sha512-oSd+fSXtcrHMJ82OwEqyfhYpqr1EsSQY5IEpntkKSlzzkdTFV4hcAj0vlafgIfI3WfhqxrXUoHfp/S1XE49jcA==} + peerDependencies: + '@swc/core': 1.15.3 + + '@workflow/typescript-plugin@4.0.3': + resolution: {integrity: sha512-QJ7hmPHrrudgSsOFMML6AbHQpOzAThcQL9AKS06QWX96ZGba+bfsjq/czlyAVXtoluBfN4fw0pSfz/itUB7XVQ==} + peerDependencies: + typescript: '>=5.0.0' + + '@workflow/utils@4.1.4': + resolution: {integrity: sha512-2zGTa0vCJJczErE8oI7JPKpGmXNbhgnGW7JWcz3qTTs5TKibtaAxha5DLq3WTTbwsgvuuxcnvtWqQ5hFxhWATg==} + + '@workflow/vite@4.0.17': + resolution: {integrity: sha512-+cBjYEmsirg+aIPY7e0tMEo44q9zwnX8DYt6vo42oplwT12CJp2OMzdfm1iEk/V0I0X2BIDWVuDcE0k7gnOrKw==} + + '@workflow/web@4.1.18': + resolution: {integrity: sha512-I8sXlh8cRanzFTtaWFF0bfRvCsKctOIdRO9QqS/uvb3/P91GYmvCuSqP/TTZdGaDaikL+IMTYRD1vCBScQRe4A==} + + '@workflow/world-local@4.2.4': + resolution: {integrity: sha512-MCsoTTNyPp6cTV8z3wqniTaJUdwlcggMjKRMadU4jHjX5UOhEFrUE9EeOBnJY2eFUyEGW//Z/WpRFm2b982lcg==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/world-vercel@4.6.2': + resolution: {integrity: sha512-wkhYRgR8SOyYHNFt6Ns4MN9pWQFugDpV0GFIMcbzD9lkPx5rh7qgUWJNnHdNKVmmS4p25uWDBcLu8h6t42Yp3g==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/world@4.3.1': + resolution: {integrity: sha512-gT67yCzMsm6SS5+2ho+b6dSd8qknTlDkfHvKnuWWt9fwkvpRr0WLWFOPvzILj3IY/mptDj3pFNDS9a6RWxEqQQ==} + + '@xhmikosr/archive-type@8.1.0': + resolution: {integrity: sha512-EXOjEbnZFE5c/nFMf4FOrEURVanzHpnkPYmnmr78u02/8hAhE0FMq8p9TK1IM0/bFr5VcyBUY0gfLm8f7dKy+Q==} + engines: {node: '>=20'} + + '@xhmikosr/bin-check@8.2.2': + resolution: {integrity: sha512-Y/b0YJoCDda6DCFj8ikks06GrEWDsz/3vdgGLeectV9p+YJc76YugRjtqFdd2KTf2rnEPjalL2hcXP+x2KcSLQ==} + engines: {node: '>=20'} + + '@xhmikosr/bin-wrapper@14.5.1': + resolution: {integrity: sha512-UZUuTYWxeAbTIiRKKEAmV3csoE36B3CGFZrYYn87+bSEBTyJ32p5gx5Gmj5HOgyOtioFUypbOZ1V5M/l/VoePw==} + engines: {node: '>=20'} + + '@xhmikosr/decompress-tar@9.0.2': + resolution: {integrity: sha512-8nPZ6lZ3ExhsSxi/X/PMB3K+Vtsuxk43HowxYpxw4AsCHTYqFBXwC8B3Y+M/meaUOGOVm+2tFNUAfWGRjBt+Ww==} + engines: {node: '>=20'} + + '@xhmikosr/decompress-tarbz2@9.0.2': + resolution: {integrity: sha512-m0DvZhE7remCxtS8xY2iHSjivT4v+iyYDdfNoeuu8Nm+7g8xEXdLKSyDEicu4u1ImJLLGEfjMuTLera/F6UGWw==} + engines: {node: '>=20'} + + '@xhmikosr/decompress-targz@9.0.1': + resolution: {integrity: sha512-1JXu2b6yrpm5EuBoOzMU57B4qrHXJKWQQ7LlMynNEiz85mEjDciO3ayf//GXaTLLCEKiHjWlU3q3THjgf7uODA==} + engines: {node: '>=20'} + + '@xhmikosr/decompress-unzip@8.2.1': + resolution: {integrity: sha512-2MS94QnmXQwjkKN8WyFiu1sU7J3rcWJcMze4kRYsX7tN+CXpUGECgkh4YSOhujpkWPuVlFudIziJHO/TxOqkQQ==} + engines: {node: '>=20'} + + '@xhmikosr/decompress@11.1.4': + resolution: {integrity: sha512-ZbYL7SAfY37/TMpopqBR3mQiuQ76kI/RNpN4q82YHSw/UxktZNy8P4wgiKPSrTImMAWRaUG8UK1pgEa56YdLaw==} + engines: {node: '>=20'} + + '@xhmikosr/downloader@16.3.1': + resolution: {integrity: sha512-M67dvznaFbsvoqhGT4FsHWysaXXQ8386OViGZm0WOyQS3apW9p16WgvHp9nWj2vfKQAR2ZdqIBPNCHSM5rKSCg==} + engines: {node: '>=20'} + + '@xhmikosr/os-filter-obj@4.1.0': + resolution: {integrity: sha512-y5ArHvQ7BVule/+L9yE2nYMhceiJhgsqo58lOfnisQ7bg+Kjfmkgr7JBuVFiTkl+ErdShpp829QstZQyLugl8g==} + engines: {node: '>=20'} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2111,6 +2789,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2129,10 +2812,37 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@3.17.0: + resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} + engines: {node: '>=14'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -2158,6 +2868,16 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + async-listen@3.0.0: + resolution: {integrity: sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==} + engines: {node: '>= 14'} + + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2165,12 +2885,35 @@ packages: avvio@9.1.0: resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -2182,15 +2925,41 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + binary-version-check@6.1.0: + resolution: {integrity: sha512-REKdLKmuViV2WrtWXvNSiPX04KbIjfUV3Cy8batUeOg+FtmowavzJorfFhWq95cVJzINnL/44ixP26TrdJZACA==} + engines: {node: '>=18'} + + binary-version@7.1.0: + resolution: {integrity: sha512-Iy//vPc3ANPNlIWd242Npqc8MK0a/i4kVcHDlDA6HNMv5zMxz4ulIFhOSYJVKw/8AbHdHy0CnGYEt1QqSXxPsw==} + engines: {node: '>=18'} + bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2206,30 +2975,88 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + bullmq@5.67.2: resolution: {integrity: sha512-3KYqNqQptKcgksACO1li4YW9/jxEh6XWa1lUg4OFrHa80Pf0C7H9zeb6ssbQQDfQab/K3QCXopbZ40vrvcyrLw==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: esbuild: '>=0.18' + byte-counter@0.1.0: + resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} + engines: {node: '>=20'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + + cacheable-request@13.0.19: + resolution: {integrity: sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==} + engines: {node: '>=18'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + caniuse-lite@1.0.30001767: resolution: {integrity: sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==} + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.0: + resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -2245,9 +3072,36 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-stack@3.0.1: + resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} + engines: {node: '>=10'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2270,22 +3124,61 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-hrtime@5.0.0: + resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} + engines: {node: '>=12'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -2316,6 +3209,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -2351,17 +3247,50 @@ packages: supports-color: optional: true + decompress-response@10.0.0: + resolution: {integrity: sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==} + engines: {node: '>=20'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2369,6 +3298,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -2386,6 +3318,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + drizzle-kit@0.30.6: resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==} hasBin: true @@ -2482,12 +3418,37 @@ packages: sqlite3: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + easy-table@1.2.0: + resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -2514,6 +3475,25 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + errx@0.1.2: + resolution: {integrity: sha512-chfpPHmCerdo/rXr/nNvPZRkV4WwDRwzwnsJ0Uzz3tVi8Z41tDctRjduYy1138ii77AFlts1qvWtX3g/Acg91Q==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -2534,10 +3514,18 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2585,13 +3573,50 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + ext-list@2.2.2: + resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} + engines: {node: '>=0.10.0'} + + ext-name@5.0.0: + resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==} + engines: {node: '>=4'} + fast-content-type-parse@2.0.1: resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} @@ -2604,6 +3629,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2640,14 +3668,37 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + filename-reserved-regex@4.0.0: + resolution: {integrity: sha512-9ZT504KxEQDamsOogZImAWGEN24R1uFAxU3ZS4AZqn2ooidmN68Olh7n4/RcA4lLatZztjA0ZSuxeLHVoCc8JA==} + engines: {node: '>=20'} + + filenamify@7.0.2: + resolution: {integrity: sha512-fz10TUqSZ1lG7ftW1KnRotJzMD8YRb6kaAQKpZJBLvqXXfFgIEpuazy1w2lK3zhMiBSdH/uF9LFlv5smJ2Jl1w==} + engines: {node: '>=20'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way@9.4.0: resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} engines: {node: '>=20'} @@ -2656,6 +3707,14 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + find-versions@6.0.0: + resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} + engines: {node: '>=18'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -2666,11 +3725,34 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + form-data-encoder@4.1.0: + resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==} + engines: {node: '>= 18'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function-timeout@1.0.2: + resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} + engines: {node: '>=18'} + gel@2.2.0: resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==} engines: {node: '>= 18.0.0'} @@ -2680,13 +3762,45 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.13.1: resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2695,6 +3809,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2702,6 +3819,14 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@14.6.6: + resolution: {integrity: sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==} + engines: {node: '>=20'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2718,12 +3843,35 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + http_ece@1.2.0: resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} engines: {node: '>=16'} @@ -2732,14 +3880,37 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2748,13 +3919,24 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inspect-with-kind@1.0.5: + resolution: {integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==} + ioredis@5.9.2: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} @@ -2763,18 +3945,80 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isbot@5.1.34: resolution: {integrity: sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A==} engines: {node: '>=18'} @@ -2786,10 +4030,30 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2826,6 +4090,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -2835,6 +4102,20 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2919,6 +4200,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2927,6 +4212,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -2936,6 +4225,14 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2951,6 +4248,49 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-asynchronous@1.1.0: + resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} + engines: {node: '>=18'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -2958,15 +4298,38 @@ packages: resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mixpart@0.0.4: + resolution: {integrity: sha512-RAoaOSXnMLrfUfmFbNynRYjeMru/bhgAYRy/GQVI8gmRq7vm9V9c2gGVYnYoQ008X6YTmRIu5b0397U7vb0bIA==} + engines: {node: '>=22.0.0'} + + mixpart@0.0.6: + resolution: {integrity: sha512-CRdXtgfQH2jARmtNmPR0Q7jL20fiESbaYk1b0KvLD0jCdUuemepREtsbd8nbiY6BHV9OGGddAZITNXklupUPUQ==} + engines: {node: '>=20.0.0'} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + mnemonist@0.40.0: resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} @@ -2988,9 +4351,18 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + nf3@0.3.7: resolution: {integrity: sha512-wL73kyZbBoeTWlvQWQ0gQDZnqp+aNlUN5YIqsc3fv5V/06LAlwrwt+G7TpugFLJIai0AhrmnKJ2kgW0xprj+yQ==} @@ -3016,6 +4388,10 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true @@ -3027,6 +4403,22 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + normalize-url@8.1.1: + resolution: {integrity: sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==} + engines: {node: '>=14.16'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -3034,6 +4426,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obliterator@2.0.5: resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} @@ -3047,13 +4443,45 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + open@8.4.0: + resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + oxc-minify@0.110.0: resolution: {integrity: sha512-KWGTzPo83QmGrXC4ml83PM9HDwUPtZFfasiclUvTV4i3/0j7xRRqINVkrL77CbQnoWura3CMxkRofjQKVDuhBw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3062,18 +4490,42 @@ packages: resolution: {integrity: sha512-/fymQNzzUoKZweH0nC5yvbI2eR0yWYusT9TEKDYVgOgYrf9Qmdez9lUFyvxKR9ycx+PTHi/reIOzqf3wkShQsw==} engines: {node: ^20.19.0 || >=22.12.0} + p-cancelable@4.0.1: + resolution: {integrity: sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==} + engines: {node: '>=14.16'} + + p-event@6.0.1: + resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} + engines: {node: '>=16.17'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -3083,17 +4535,38 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3105,6 +4578,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -3123,9 +4600,15 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + piscina@4.9.3: + resolution: {integrity: sha512-3e3ka9QCE8RJ5I9uszdAADZnkcYi21cqmF3gxox3u884N72qpFHCsIVhHt8cEQ9t3Auq/NqoiCEuhxlxxQuDWA==} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -3161,12 +4644,20 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -3174,9 +4665,28 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -3228,6 +4738,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -3244,10 +4758,16 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3259,6 +4779,14 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + responselike@4.0.2: + resolution: {integrity: sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==} + engines: {node: '>=20'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -3278,6 +4806,17 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3294,9 +4833,27 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + + seek-bzip@2.0.0: + resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==} + hasBin: true + + semver-regex@4.0.5: + resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} + engines: {node: '>=12'} + + semver-truncate@3.0.0: + resolution: {integrity: sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg==} + engines: {node: '>=12'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3306,6 +4863,20 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + seroval-plugins@1.5.0: resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} engines: {node: '>=10'} @@ -3316,9 +4887,16 @@ packages: resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3331,9 +4909,44 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + sort-keys-length@1.0.1: + resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==} + engines: {node: '>=0.10.0'} + + sort-keys@1.1.2: + resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==} + engines: {node: '>=0.10.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3361,6 +4974,48 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-dirs@3.0.0: + resolution: {integrity: sha512-I0sdgcFTfKQlUPZyAqPJmSG3HLO9rWDFnxonnIbskYNM3DwFOeTNB5KzVq3dA1GdRAc/25b5Y7UO2TQfKWw4aQ==} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3369,15 +5024,39 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true + super-regex@1.1.0: + resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} + engines: {node: '>=18'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@4.5.0: + resolution: {integrity: sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==} + engines: {node: '>=20'} + + system-architecture@1.0.0: + resolution: {integrity: sha512-0OJWD12D7XX3KUg1DYkMaTTjSTo2k/mhIYI3HlBlceXSMcJhW/1qO735fPKS5prcyjvn57Ub151vvASYXpQrEw==} + engines: {node: '>=18'} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -3388,6 +5067,16 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + terminal-link@5.0.0: + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} + engines: {node: '>=20'} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3399,6 +5088,13 @@ packages: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + time-span@5.1.0: + resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} + engines: {node: '>=12'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -3412,6 +5108,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3420,6 +5120,14 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3502,6 +5210,18 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3510,6 +5230,27 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + ulid@3.0.2: + resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==} + hasBin: true + + unbzip2-stream@1.4.3: + resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + + unctx@2.5.0: + resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3517,15 +5258,35 @@ packages: resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==} engines: {node: '>=20.18.1'} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + universal-github-app-jwt@2.2.2: resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -3604,6 +5365,10 @@ packages: uploadthing: optional: true + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3642,6 +5407,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} peerDependencies: @@ -3698,11 +5467,21 @@ packages: vite: optional: true + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-push@3.6.7: resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} engines: {node: '>= 16'} hasBin: true + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -3725,13 +5504,57 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + workflow@4.8.2: + resolution: {integrity: sha512-7WQOttNnODZLw3kYLEPwp8PD5ioxD72twSrAoulP6cDIFXOWEbXJWqDopwrNN9ChTBnrR0kRZk5VTGcVdeiDcg==} + hasBin: true + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-app-paths@5.5.1: + resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==} + engines: {node: '>= 6.0'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xmlbuilder2@4.0.3: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} @@ -3739,15 +5562,83 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: + '@aws-sdk/core@3.977.7': + dependencies: + '@aws-sdk/types': 3.974.3 + '@aws-sdk/xml-builder': 3.972.38 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.32.0 + '@smithy/signature-v4': 5.7.0 + '@smithy/types': 4.17.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.49': + dependencies: + '@aws-sdk/core': 3.977.7 + '@aws-sdk/nested-clients': 3.997.42 + '@aws-sdk/types': 3.974.3 + '@smithy/core': 3.32.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.42': + dependencies: + '@aws-sdk/core': 3.977.7 + '@aws-sdk/signature-v4-multi-region': 3.996.44 + '@aws-sdk/types': 3.974.3 + '@smithy/core': 3.32.0 + '@smithy/fetch-http-handler': 5.7.0 + '@smithy/node-http-handler': 4.10.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.44': + dependencies: + '@aws-sdk/types': 3.974.3 + '@smithy/signature-v4': 5.7.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.3': + dependencies: + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.38': + dependencies: + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3775,7 +5666,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3867,7 +5758,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -3876,6 +5767,26 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@borewit/text-codec@0.2.2': {} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.8.1': @@ -3910,6 +5821,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true @@ -3919,6 +5833,9 @@ snapshots: '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.18.20': optional: true @@ -3928,6 +5845,9 @@ snapshots: '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.18.20': optional: true @@ -3937,6 +5857,9 @@ snapshots: '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true @@ -3946,6 +5869,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true @@ -3955,6 +5881,9 @@ snapshots: '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true @@ -3964,6 +5893,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true @@ -3973,6 +5905,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true @@ -3982,6 +5917,9 @@ snapshots: '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true @@ -3991,6 +5929,9 @@ snapshots: '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true @@ -4000,6 +5941,9 @@ snapshots: '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true @@ -4009,6 +5953,9 @@ snapshots: '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true @@ -4018,6 +5965,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true @@ -4027,6 +5977,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true @@ -4036,6 +5989,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true @@ -4045,6 +6001,9 @@ snapshots: '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true @@ -4054,9 +6013,15 @@ snapshots: '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true @@ -4066,9 +6031,15 @@ snapshots: '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true @@ -4078,9 +6049,15 @@ snapshots: '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true @@ -4090,6 +6067,9 @@ snapshots: '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true @@ -4099,6 +6079,9 @@ snapshots: '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true @@ -4108,6 +6091,9 @@ snapshots: '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true @@ -4117,9 +6103,12 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.7.0))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -4127,7 +6116,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -4143,7 +6132,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4257,6 +6246,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@keyv/serialize@1.1.1': {} + + '@lukeed/csprng@1.1.0': {} + '@lukeed/ms@2.0.2': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': @@ -4277,6 +6270,78 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@napi-rs/nice-android-arm-eabi@1.1.1': + optional: true + + '@napi-rs/nice-android-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-x64@1.1.1': + optional: true + + '@napi-rs/nice-freebsd-x64@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + optional: true + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-musl@1.1.1': + optional: true + + '@napi-rs/nice-openharmony-arm64@1.1.1': + optional: true + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + optional: true + + '@napi-rs/nice@1.1.1': + optionalDependencies: + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 + optional: true + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 @@ -4284,6 +6349,79 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + + '@nuxt/kit@4.4.8': + dependencies: + c12: 3.3.4 + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + errx: 0.1.2 + exsolve: 1.0.8 + ignore: 7.0.6 + jiti: 2.7.0 + klona: 2.0.6 + mlly: 1.8.2 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.1 + rc9: 3.0.1 + scule: 1.3.0 + semver: 7.8.5 + tinyglobby: 0.2.17 + ufo: 1.6.4 + unctx: 2.5.0 + untyped: 2.0.0 + transitivePeerDependencies: + - magicast + + '@oclif/core@4.11.4': + dependencies: + ansi-escapes: 4.3.2 + ansis: 3.17.0 + clean-stack: 3.0.1 + cli-spinners: 2.9.2 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + get-package-type: 0.1.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + lilconfig: 3.1.3 + minimatch: 10.2.6 + semver: 7.8.5 + string-width: 4.2.3 + supports-color: 8.1.1 + tinyglobby: 0.2.17 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + + '@oclif/plugin-help@6.2.37': + dependencies: + '@oclif/core': 4.11.4 + '@octokit/auth-app@7.2.2': dependencies: '@octokit/auth-oauth-app': 8.1.4 @@ -5030,6 +7168,112 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/is@7.2.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@smithy/core@3.32.0': + dependencies: + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.7.0': + dependencies: + '@smithy/core': 3.32.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.10.0': + dependencies: + '@smithy/core': 3.32.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.0': + dependencies: + '@smithy/core': 3.32.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@smithy/types@4.17.0': + dependencies: + tslib: 2.8.1 + + '@standard-schema/spec@1.0.0': {} + + '@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0)': + dependencies: + '@swc/core': 1.15.3 + '@swc/counter': 0.1.3 + '@xhmikosr/bin-wrapper': 14.5.1 + commander: 8.3.0 + minimatch: 9.0.9 + piscina: 4.9.3 + semver: 7.7.3 + slash: 3.0.0 + source-map: 0.7.6 + tinyglobby: 0.2.15 + optionalDependencies: + chokidar: 5.0.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@swc/core-darwin-arm64@1.15.3': + optional: true + + '@swc/core-darwin-x64@1.15.3': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.3': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.3': + optional: true + + '@swc/core-linux-arm64-musl@1.15.3': + optional: true + + '@swc/core-linux-x64-gnu@1.15.3': + optional: true + + '@swc/core-linux-x64-musl@1.15.3': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.3': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.3': + optional: true + + '@swc/core-win32-x64-msvc@1.15.3': + optional: true + + '@swc/core@1.15.3': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.3 + '@swc/core-darwin-x64': 1.15.3 + '@swc/core-linux-arm-gnueabihf': 1.15.3 + '@swc/core-linux-arm64-gnu': 1.15.3 + '@swc/core-linux-arm64-musl': 1.15.3 + '@swc/core-linux-x64-gnu': 1.15.3 + '@swc/core-linux-x64-musl': 1.15.3 + '@swc/core-win32-arm64-msvc': 1.15.3 + '@swc/core-win32-ia32-msvc': 1.15.3 + '@swc/core-win32-x64-msvc': 1.15.3 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -5091,12 +7335,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) '@tanstack/history@1.154.14': {} @@ -5140,19 +7384,19 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/react-start@1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@tanstack/react-router': 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-client': 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-server': 1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-utils': 1.158.0 '@tanstack/start-client-core': 1.158.0 - '@tanstack/start-plugin-core': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/start-plugin-core': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) '@tanstack/start-server-core': 1.158.0(crossws@0.4.4(srvx@0.10.1)) pathe: 2.0.3 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -5190,7 +7434,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/router-plugin@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5207,7 +7451,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5236,7 +7480,7 @@ snapshots: '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/start-plugin-core@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 @@ -5244,7 +7488,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.158.0 '@tanstack/router-generator': 1.158.0 - '@tanstack/router-plugin': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/router-plugin': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) '@tanstack/router-utils': 1.158.0 '@tanstack/start-client-core': 1.158.0 '@tanstack/start-server-core': 1.158.0(crossws@0.4.4(srvx@0.10.1)) @@ -5254,8 +7498,8 @@ snapshots: srvx: 0.10.1 tinyglobby: 0.2.15 ufo: 1.6.3 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) - vitefu: 1.1.1(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + vitefu: 1.1.1(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) xmlbuilder2: 4.0.3 zod: 3.25.76 transitivePeerDependencies: @@ -5286,6 +7530,15 @@ snapshots: '@tanstack/virtual-file-routes@1.154.7': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -5314,8 +7567,12 @@ snapshots: '@types/estree@1.0.8': {} + '@types/http-cache-semantics@4.2.0': {} + '@types/json-schema@7.0.15': {} + '@types/ms@2.1.0': {} + '@types/node@20.19.31': dependencies: undici-types: 6.21.0 @@ -5332,26 +7589,406 @@ snapshots: dependencies: '@types/node': 20.19.31 - '@vitejs/plugin-react@5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@vercel/cli-auth@0.0.1': + dependencies: + async-listen: 3.0.0 + open: 8.4.0 + xdg-app-paths: 5.1.0 + zod: 4.1.11 + + '@vercel/cli-config@0.2.3': + dependencies: + xdg-app-paths: 5.5.1 + zod: 4.1.11 + + '@vercel/cli-exec@1.0.1': + dependencies: + execa: 5.1.1 + + '@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49)': + dependencies: + '@vercel/oidc': 3.8.4 + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.972.49 + + '@vercel/oidc@3.2.0': {} + + '@vercel/oidc@3.8.4': + dependencies: + '@vercel/cli-config': 0.2.3 + '@vercel/cli-exec': 1.0.1 + jose: 5.10.0 + + '@vercel/queue@0.3.1': + dependencies: + '@vercel/oidc': 3.8.4 + minimatch: 10.2.6 + mixpart: 0.0.6 + picocolors: 1.1.1 + + '@vitejs/plugin-react@5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.2 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + '@workflow/astro@4.0.17': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/rollup': 4.0.17 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/vite': 4.0.17 + exsolve: 1.0.8 + pathe: 2.0.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/builders@4.1.7': + dependencies: + '@swc/core': 1.15.3 + '@workflow/core': 4.8.2 + '@workflow/errors': 4.2.1 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/utils': 4.1.4 + builtin-modules: 5.0.0 + chalk: 5.6.2 + enhanced-resolve: 5.19.0 + esbuild: 0.28.2 + find-up: 7.0.0 + json5: 2.2.3 + tinyglobby: 0.2.17 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/cli@4.3.6': + dependencies: + '@oclif/core': 4.11.4 + '@oclif/plugin-help': 6.2.37 + '@swc/core': 1.15.3 + '@vercel/cli-auth': 0.0.1 + '@workflow/builders': 4.1.7 + '@workflow/core': 4.8.2 + '@workflow/errors': 4.2.1 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/utils': 4.1.4 + '@workflow/web': 4.1.18 + '@workflow/world': 4.3.1 + '@workflow/world-local': 4.2.4 + '@workflow/world-vercel': 4.6.2 + boxen: 8.0.1 + builtin-modules: 5.0.0 + chalk: 5.6.2 + chokidar: 4.0.3 + date-fns: 4.1.0 + dotenv: 17.4.2 + easy-table: 1.2.0 + enhanced-resolve: 5.19.0 + esbuild: 0.28.2 + find-up: 7.0.0 + mixpart: 0.0.4 + open: 10.2.0 + ora: 8.2.0 + terminal-link: 5.0.0 + tinyglobby: 0.2.17 + xdg-app-paths: 5.1.0 + zod: 4.3.6 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/core@4.8.2': + dependencies: + '@aws-sdk/credential-provider-web-identity': 3.972.49 + '@jridgewell/trace-mapping': 0.3.31 + '@standard-schema/spec': 1.0.0 + '@types/ms': 2.1.0 + '@vercel/functions': 3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49) + '@workflow/errors': 4.2.1 + '@workflow/serde': 4.1.2 + '@workflow/utils': 4.1.4 + '@workflow/world': 4.3.1 + '@workflow/world-local': 4.2.4 + '@workflow/world-vercel': 4.6.2 + debug: 4.4.3(supports-color@8.1.1) + devalue: 5.8.1 + ms: 2.1.3 + nanoid: 5.1.6 + seedrandom: 3.0.5 + semver: 7.7.4 + ulid: 3.0.2 + zod: 4.3.6 + transitivePeerDependencies: + - supports-color + - ws + + '@workflow/errors@4.2.1': + dependencies: + '@workflow/utils': 4.1.4 + ms: 2.1.3 + + '@workflow/nest@4.0.18(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)': + dependencies: + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@swc/cli': 0.8.1(@swc/core@1.15.3)(chokidar@5.0.0) + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + pathe: 2.0.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/next@4.1.6': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/core': 4.8.2 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + semver: 7.7.4 + watchpack: 2.5.1 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/nitro@4.1.8': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/core': 4.8.2 + '@workflow/rollup': 4.0.17 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/vite': 4.0.17 + '@workflow/web': 4.1.18 + exsolve: 1.0.8 + pathe: 2.0.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/nuxt@4.0.18': + dependencies: + '@nuxt/kit': 4.4.8 + '@workflow/nitro': 4.1.8 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - magicast + - supports-color + - ws + + '@workflow/rollup@4.0.17': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + exsolve: 1.0.7 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/serde@4.1.2': {} + + '@workflow/sveltekit@4.0.17': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/rollup': 4.0.17 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/vite': 4.0.17 + exsolve: 1.0.8 + fs-extra: 11.4.0 + pathe: 2.0.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/swc-plugin@4.1.2(@swc/core@1.15.3)': + dependencies: + '@swc/core': 1.15.3 + + '@workflow/typescript-plugin@4.0.3(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@workflow/utils@4.1.4': + dependencies: + ms: 2.1.3 + + '@workflow/vite@4.0.17': + dependencies: + '@workflow/builders': 4.1.7 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/web@4.1.18': + dependencies: + express: 5.2.1 + transitivePeerDependencies: + - supports-color + + '@workflow/world-local@4.2.4': + dependencies: + '@vercel/queue': 0.3.1 + '@workflow/errors': 4.2.1 + '@workflow/utils': 4.1.4 + '@workflow/world': 4.3.1 + async-sema: 3.1.1 + ulid: 3.0.2 + undici: 7.28.0 + zod: 4.3.6 + + '@workflow/world-vercel@4.6.2': + dependencies: + '@vercel/oidc': 3.2.0 + '@vercel/queue': 0.3.1 + '@workflow/errors': 4.2.1 + '@workflow/world': 4.3.1 + cbor-x: 1.6.0 + undici: 7.28.0 + zod: 4.3.6 + + '@workflow/world@4.3.1': + dependencies: + ulid: 3.0.2 + zod: 4.3.6 + + '@xhmikosr/archive-type@8.1.0': + dependencies: + file-type: 21.3.4 + transitivePeerDependencies: + - supports-color + + '@xhmikosr/bin-check@8.2.2': + dependencies: + execa: 9.6.1 + isexe: 4.0.0 + + '@xhmikosr/bin-wrapper@14.5.1': + dependencies: + '@xhmikosr/bin-check': 8.2.2 + '@xhmikosr/downloader': 16.3.1 + '@xhmikosr/os-filter-obj': 4.1.0 + binary-version-check: 6.1.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-tar@9.0.2': + dependencies: + file-type: 21.3.4 + is-stream: 4.0.1 + tar-stream: 3.1.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-tarbz2@9.0.2': + dependencies: + '@xhmikosr/decompress-tar': 9.0.2 + file-type: 21.3.4 + is-stream: 4.0.1 + seek-bzip: 2.0.0 + unbzip2-stream: 1.4.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-targz@9.0.1': + dependencies: + '@xhmikosr/decompress-tar': 9.0.2 + file-type: 21.3.4 + is-stream: 4.0.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-unzip@8.2.1': + dependencies: + file-type: 21.3.4 + get-stream: 9.0.1 + yauzl: 3.4.0 + transitivePeerDependencies: + - supports-color + + '@xhmikosr/decompress@11.1.4': + dependencies: + '@xhmikosr/decompress-tar': 9.0.2 + '@xhmikosr/decompress-tarbz2': 9.0.2 + '@xhmikosr/decompress-targz': 9.0.1 + '@xhmikosr/decompress-unzip': 8.2.1 + graceful-fs: 4.2.11 + strip-dirs: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/downloader@16.3.1': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.2 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + '@xhmikosr/archive-type': 8.1.0 + '@xhmikosr/decompress': 11.1.4 + content-disposition: 2.0.1 + ext-name: 5.0.0 + file-type: 21.3.4 + filenamify: 7.0.2 + got: 14.6.6 transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a - supports-color + '@xhmikosr/os-filter-obj@4.1.0': + dependencies: + system-architecture: 1.0.0 + abstract-logging@2.0.1: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + acorn@8.18.0: {} + agent-base@7.1.4: {} ajv-formats@3.0.1(ajv@8.17.1): @@ -5372,10 +8009,30 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + + ansis@3.17.0: {} + ansis@4.2.0: {} any-promise@1.3.0: {} @@ -5402,6 +8059,12 @@ snapshots: dependencies: tslib: 2.8.1 + async-listen@3.0.0: {} + + async-sema@3.1.1: {} + + async@3.2.6: {} + atomic-sleep@1.0.0: {} avvio@9.1.0: @@ -5409,6 +8072,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + b4a@1.8.1: {} + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.0 @@ -5420,21 +8085,73 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + + bare-events@2.9.1: {} + + base64-js@1.5.1: {} + baseline-browser-mapping@2.9.19: {} before-after-hook@3.0.2: {} binary-extensions@2.3.0: {} + binary-version-check@6.1.0: + dependencies: + binary-version: 7.1.0 + semver: 7.7.3 + semver-truncate: 3.0.0 + + binary-version@7.1.0: + dependencies: + execa: 8.0.1 + find-versions: 6.0.0 + bn.js@4.12.2: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} + bowser@2.14.1: {} + + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -5451,6 +8168,13 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@5.0.0: {} + bullmq@5.67.2: dependencies: cron-parser: 4.9.0 @@ -5463,22 +8187,87 @@ snapshots: transitivePeerDependencies: - supports-color + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bundle-require@5.1.0(esbuild@0.27.2): dependencies: esbuild: 0.27.2 load-tsconfig: 0.2.5 + byte-counter@0.1.0: {} + + bytes@3.1.2: {} + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + cac@6.7.14: {} + cacheable-lookup@7.0.0: {} + + cacheable-request@13.0.19: + dependencies: + '@types/http-cache-semantics': 4.2.0 + get-stream: 9.0.1 + http-cache-semantics: 4.2.0 + keyv: 5.6.0 + mimic-response: 4.0.0 + normalize-url: 8.1.1 + responselike: 4.0.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} + camelcase@8.0.0: {} + caniuse-lite@1.0.30001767: {} + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.0: + optionalDependencies: + cbor-extract: 2.2.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -5518,10 +8307,33 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 + clean-stack@3.0.1: + dependencies: + escape-string-regexp: 4.0.0 + + cli-boxes@3.0.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + clone@1.0.4: + optional: true + clsx@2.1.1: {} cluster-key-slot@1.1.2: {} @@ -5536,16 +8348,36 @@ snapshots: commander@4.1.1: {} + commander@6.2.1: {} + + commander@8.3.0: {} + concat-map@0.0.1: {} confbox@0.1.8: {} + confbox@0.2.4: {} + consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-hrtime@5.0.0: {} + convert-source-map@2.0.0: {} cookie-es@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} cron-parser@4.9.0: @@ -5574,26 +8406,58 @@ snapshots: csstype@3.2.3: {} + date-fns@4.1.0: {} + dateformat@4.6.3: {} db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)): optionalDependencies: drizzle-orm: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) - debug@4.4.3: + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decompress-response@10.0.0: + dependencies: + mimic-response: 4.0.0 deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + optional: true + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + denque@2.1.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + devalue@5.8.1: {} + diff@8.0.3: {} dom-serializer@2.0.0: @@ -5614,6 +8478,8 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv@17.4.2: {} + drizzle-kit@0.30.6: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -5630,12 +8496,36 @@ snapshots: postgres: 3.4.8 react: 19.2.4 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + easy-table@1.2.0: + dependencies: + ansi-regex: 5.0.1 + optionalDependencies: + wcwidth: 1.0.1 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + electron-to-chromium@1.5.286: {} + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -5658,9 +8548,21 @@ snapshots: env-paths@3.0.0: {} + environment@1.1.0: {} + + errx@0.1.2: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild-register@3.6.0(esbuild@0.19.12): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.19.12 transitivePeerDependencies: - supports-color @@ -5745,8 +8647,39 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-scope@8.4.0: @@ -5758,9 +8691,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint@9.39.2(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -5775,7 +8708,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -5795,7 +8728,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -5817,10 +8750,105 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + etag@1.8.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@8.1.1) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.0.7: {} + exsolve@1.0.8: {} + ext-list@2.2.2: + dependencies: + mime-db: 1.54.0 + + ext-name@5.0.0: + dependencies: + ext-list: 2.2.2 + sort-keys-length: 1.0.1 + fast-content-type-parse@2.0.1: {} fast-copy@4.0.2: {} @@ -5829,6 +8857,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-json-stable-stringify@2.1.0: {} fast-json-stringify@6.2.0: @@ -5878,14 +8908,52 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + filename-reserved-regex@4.0.0: {} + + filenamify@7.0.2: + dependencies: + filename-reserved-regex: 4.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-my-way@9.4.0: dependencies: fast-deep-equal: 3.1.3 @@ -5897,6 +8965,17 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + find-versions@6.0.0: + dependencies: + semver-regex: 4.0.5 + super-regex: 1.1.0 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -5910,13 +8989,29 @@ snapshots: flatted@3.3.3: {} + form-data-encoder@4.1.0: {} + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + function-timeout@1.0.2: {} + gel@2.2.0: dependencies: '@petamoriken/float16': 3.9.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) env-paths: 3.0.0 semver: 7.7.3 shell-quote: 1.8.3 @@ -5926,12 +9021,45 @@ snapshots: gensync@1.0.0-beta.2: {} + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + get-stream@8.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.13.1: dependencies: resolve-pkg-maps: 1.0.0 + giget@3.3.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5940,10 +9068,29 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + globals@14.0.0: {} globrex@0.1.2: {} + gopd@1.2.0: {} + + got@14.6.6: + dependencies: + '@sindresorhus/is': 7.2.0 + byte-counter: 0.1.0 + cacheable-lookup: 7.0.0 + cacheable-request: 13.0.19 + decompress-response: 10.0.0 + form-data-encoder: 4.1.0 + http2-wrapper: 2.2.1 + keyv: 5.6.0 + lowercase-keys: 3.0.0 + p-cancelable: 4.0.1 + responselike: 4.0.2 + type-fest: 4.41.0 + graceful-fs@4.2.11: {} h3@2.0.1-rc.11(crossws@0.4.4(srvx@0.10.1)): @@ -5955,6 +9102,14 @@ snapshots: has-flag@4.0.0: {} + has-flag@5.0.1: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + help-me@5.0.0: {} htmlparser2@10.1.0: @@ -5964,21 +9119,50 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http2-wrapper@2.2.1: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + http_ece@1.2.0: {} https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color + human-signals@2.1.0: {} + + human-signals@5.0.0: {} + + human-signals@8.0.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + ignore@5.3.2: {} + ignore@7.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5986,13 +9170,19 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} + inspect-with-kind@1.0.5: + dependencies: + kind-of: 6.0.3 + ioredis@5.9.2: dependencies: '@ioredis/commands': 1.5.0 cluster-key-slot: 1.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -6002,28 +9192,80 @@ snapshots: transitivePeerDependencies: - supports-color + ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + is-number@7.0.0: {} + is-plain-obj@1.1.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + is-stream@3.0.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isbot@5.1.34: {} isexe@2.0.0: {} isexe@3.1.1: {} + isexe@4.0.0: {} + + iterare@1.2.1: {} + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + jiti@2.6.1: {} + jiti@2.7.0: {} + + jose@5.10.0: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -6048,6 +9290,12 @@ snapshots: json5@2.2.3: {} + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -6061,7 +9309,17 @@ snapshots: keyv@4.5.4: dependencies: - json-buffer: 3.0.1 + json-buffer: 3.0.1 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + klona@2.0.6: {} + + knitwork@1.3.0: {} levn@0.4.1: dependencies: @@ -6127,18 +9385,31 @@ snapshots: lines-and-columns@1.2.4: {} + load-esm@1.0.3: {} + load-tsconfig@0.2.5: {} locate-path@6.0.0: dependencies: p-locate: 5.0.0 + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} lodash.merge@4.6.2: {} + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + lowercase-keys@3.0.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -6153,18 +9424,62 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-asynchronous@1.1.0: + dependencies: + p-event: 6.0.1 + type-fest: 4.41.0 + web-worker: 1.5.0 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + mimic-response@4.0.0: {} + minimalistic-assert@1.0.1: {} minimatch@10.1.2: dependencies: '@isaacs/brace-expansion': 5.0.1 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + minimist@1.2.8: {} + mixpart@0.0.4: {} + + mixpart@0.0.6: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -6172,6 +9487,13 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + mnemonist@0.40.0: dependencies: obliterator: 2.0.5 @@ -6202,11 +9524,15 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + natural-compare@1.4.0: {} + negotiator@1.0.0: {} + nf3@0.3.7: {} - nitro@3.0.1-alpha.2(chokidar@4.0.3)(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + nitro@3.0.1-alpha.2(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: consola: 3.4.2 crossws: 0.4.4(srvx@0.10.1) @@ -6221,10 +9547,10 @@ snapshots: srvx: 0.10.1 undici: 7.20.0 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.5(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.5(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3) optionalDependencies: rollup: 4.57.1 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -6256,6 +9582,11 @@ snapshots: node-abort-controller@3.1.1: {} + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 @@ -6265,12 +9596,29 @@ snapshots: normalize-path@3.0.0: {} + normalize-url@8.1.1: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 object-assign@4.1.1: {} + object-inspect@1.13.4: {} + obliterator@2.0.5: {} ofetch@2.0.0-alpha.3: {} @@ -6279,10 +9627,39 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + open@8.4.0: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6292,6 +9669,20 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + os-paths@4.4.0: {} + oxc-minify@0.110.0: optionalDependencies: '@oxc-minify/binding-android-arm-eabi': 0.110.0 @@ -6338,18 +9729,36 @@ snapshots: '@oxc-transform/binding-win32-ia32-msvc': 0.110.0 '@oxc-transform/binding-win32-x64-msvc': 0.110.0 + p-cancelable@4.0.1: {} + + p-event@6.0.1: + dependencies: + p-timeout: 6.1.4 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-timeout@6.1.4: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-ms@4.0.0: {} + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -6363,18 +9772,32 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-key@3.1.1: {} + path-key@4.0.0: {} + + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + pend@1.2.0: {} + + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.3: {} + picomatch@4.0.5: {} + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -6413,17 +9836,27 @@ snapshots: pirates@4.0.7: {} + piscina@4.9.3: + optionalDependencies: + '@napi-rs/nice': 1.1.1 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 mlly: 1.8.0 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0): + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 postcss: 8.5.6 tsx: 4.21.0 @@ -6439,10 +9872,19 @@ snapshots: prettier@3.8.1: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + process-warning@4.0.1: {} process-warning@5.0.0: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -6450,8 +9892,29 @@ snapshots: punycode@2.3.1: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quick-format-unescaped@4.0.4: {} + quick-lru@5.1.1: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -6494,6 +9957,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.1.1: {} + real-require@0.2.0: {} recast@0.23.11: @@ -6510,14 +9975,27 @@ snapshots: dependencies: redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} + require-from-string@2.0.2: {} + resolve-alpn@1.2.1: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + responselike@4.0.2: + dependencies: + lowercase-keys: 3.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + ret@0.5.0: {} reusify@1.1.0: {} @@ -6557,6 +10035,22 @@ snapshots: rou3@0.7.12: {} + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-buffer@5.2.1: {} safe-regex2@5.0.0: @@ -6569,20 +10063,65 @@ snapshots: scheduler@0.27.0: {} + scule@1.3.0: {} + secure-json-parse@4.1.0: {} + seedrandom@3.0.5: {} + + seek-bzip@2.0.0: + dependencies: + commander: 6.2.1 + + semver-regex@4.0.5: {} + + semver-truncate@3.0.0: + dependencies: + semver: 7.7.3 + semver@6.3.1: {} semver@7.7.3: {} + semver@7.7.4: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + seroval-plugins@1.5.0(seroval@1.5.0): dependencies: seroval: 1.5.0 seroval@1.5.0: {} + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6591,10 +10130,52 @@ snapshots: shell-quote@1.8.3: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 + sort-keys-length@1.0.1: + dependencies: + sort-keys: 1.1.2 + + sort-keys@1.1.2: + dependencies: + is-plain-obj: 1.1.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6612,10 +10193,58 @@ snapshots: standard-as-callback@2.1.0: {} + statuses@2.0.2: {} + + stdin-discarder@0.2.2: {} + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-dirs@3.0.0: + dependencies: + inspect-with-kind: 1.0.5 + is-plain-obj: 1.1.0 + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-final-newline@4.0.0: {} + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -6626,16 +10255,55 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + super-regex@1.1.0: + dependencies: + function-timeout: 1.0.2 + make-asynchronous: 1.1.0 + time-span: 5.1.0 + + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@4.5.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + system-architecture@1.0.0: {} + tailwind-merge@2.6.1: {} tailwindcss@4.1.18: {} tapable@2.3.0: {} + tar-stream@3.1.7: + dependencies: + b4a: 1.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + terminal-link@5.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 4.5.0 + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -6648,6 +10316,12 @@ snapshots: dependencies: real-require: 0.2.0 + through@2.3.8: {} + + time-span@5.1.0: + dependencies: + convert-hrtime: 5.0.0 + tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} @@ -6659,12 +10333,25 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toad-cache@3.7.0: {} + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -6675,18 +10362,18 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): + tsup@8.5.1(@swc/core@1.15.3)(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.2) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.27.2 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.7.6 @@ -6695,6 +10382,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: + '@swc/core': 1.15.3 postcss: 8.5.6 typescript: 5.9.3 transitivePeerDependencies: @@ -6741,22 +10429,64 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typescript@5.9.3: {} ufo@1.6.3: {} + ufo@1.6.4: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + ulid@3.0.2: {} + + unbzip2-stream@1.4.3: + dependencies: + buffer: 5.7.1 + through: 2.3.8 + + unctx@2.5.0: + dependencies: + acorn: 8.15.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + unplugin: 2.3.11 + undici-types@6.21.0: {} undici@7.20.0: {} + undici@7.28.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 + unicorn-magic@0.1.0: {} + + unicorn-magic@0.3.0: {} + universal-github-app-jwt@2.2.2: {} universal-user-agent@7.0.3: {} + universalify@2.0.1: {} + + unpipe@1.0.0: {} + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -6764,13 +10494,22 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@2.0.0-alpha.5(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.5(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3): optionalDependencies: - chokidar: 4.0.3 + '@vercel/functions': 3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49) + chokidar: 5.0.0 db0: 0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)) ioredis: 5.9.2 ofetch: 2.0.0-alpha.3 + untyped@2.0.0: + dependencies: + citty: 0.1.6 + defu: 6.1.7 + jiti: 2.7.0 + knitwork: 1.3.0 + scule: 1.3.0 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -6802,18 +10541,20 @@ snapshots: uuid@11.1.0: {} - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + vary@1.1.2: {} + + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -6824,13 +10565,23 @@ snapshots: optionalDependencies: '@types/node': 20.19.31 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.30.2 tsx: 4.21.0 - vitefu@1.1.1(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + vitefu@1.1.1(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): optionalDependencies: - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + + watchpack@2.5.1: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + optional: true web-push@3.6.7: dependencies: @@ -6842,6 +10593,8 @@ snapshots: transitivePeerDependencies: - supports-color + web-worker@1.5.0: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@3.1.1: @@ -6858,10 +10611,76 @@ snapshots: dependencies: isexe: 3.1.1 + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} + + workflow@4.8.2(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(typescript@5.9.3): + dependencies: + '@workflow/astro': 4.0.17 + '@workflow/cli': 4.3.6 + '@workflow/core': 4.8.2 + '@workflow/errors': 4.2.1 + '@workflow/nest': 4.0.18(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3) + '@workflow/next': 4.1.6 + '@workflow/nitro': 4.1.8 + '@workflow/nuxt': 4.0.18 + '@workflow/rollup': 4.0.17 + '@workflow/sveltekit': 4.0.17 + '@workflow/typescript-plugin': 4.0.3(typescript@5.9.3) + '@workflow/utils': 4.1.4 + ms: 2.1.3 + transitivePeerDependencies: + - '@nestjs/common' + - '@nestjs/core' + - '@swc/cli' + - '@swc/core' + - '@swc/helpers' + - magicast + - next + - supports-color + - typescript + - ws + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-app-paths@5.5.1: + dependencies: + os-paths: 4.4.0 + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 @@ -6871,6 +10690,18 @@ snapshots: yallist@3.1.1: {} + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + + yoctocolors@2.2.0: {} + zod@3.25.76: {} + + zod@4.1.11: {} + + zod@4.3.6: {} From f08912407687b6cf9b954071cdb145586bf11903 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 17:55:36 -0700 Subject: [PATCH 05/35] test: add Vitest to web app --- apps/web/package.json | 7 +- apps/web/src/lib/__tests__/smoke.test.ts | 7 + apps/web/vitest.config.ts | 10 + pnpm-lock.yaml | 242 ++++++++++++++++++++++- turbo.json | 4 + 5 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/lib/__tests__/smoke.test.ts create mode 100644 apps/web/vitest.config.ts diff --git a/apps/web/package.json b/apps/web/package.json index 3cf0183..6dba598 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,9 @@ "build": "vite build", "start": "node .output/server/index.mjs", "typecheck": "tsc --noEmit", - "lint": "eslint src/" + "lint": "eslint src/", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@overlap/shared": "workspace:*", @@ -41,6 +43,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.0", "vite": "^7.3.0", - "vite-tsconfig-paths": "^5.1.0" + "vite-tsconfig-paths": "^5.1.0", + "vitest": "^4.1.10" } } diff --git a/apps/web/src/lib/__tests__/smoke.test.ts b/apps/web/src/lib/__tests__/smoke.test.ts new file mode 100644 index 0000000..f00d55b --- /dev/null +++ b/apps/web/src/lib/__tests__/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest' + +describe('test harness', () => { + it('runs', () => { + expect(1 + 1).toBe(2) + }) +}) diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..558b20a --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bc6a73..f36aad0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: vite-tsconfig-paths: specifier: ^5.1.0 version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) apps/worker: dependencies: @@ -2268,6 +2271,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/cli@0.8.1': resolution: {integrity: sha512-L+ACCGHCiS0VqHVep/INLVnvRvJ2XooQFLZq4L8snhxw1jsqz+XRcY313UsyPVturPPE1shW3jic7rt3qEQTSQ==} engines: {node: '>= 20.19.0'} @@ -2575,6 +2581,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2641,6 +2653,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@workflow/astro@4.0.17': resolution: {integrity: sha512-A0y+w4v/zp/B+/J9JTn6CIOESKCK9GnXI2W9PSZkUXep7tpaGgwg+b/SRhioBdXJZVoUP+KjhwFu3Gm7FZpRaw==} @@ -2864,6 +2905,10 @@ packages: asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -3049,6 +3094,10 @@ packages: cbor-x@1.6.0: resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3490,6 +3539,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -3599,6 +3651,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -4433,6 +4489,10 @@ packages: obliterator@2.0.5: resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + ofetch@2.0.0-alpha.3: resolution: {integrity: sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==} @@ -4925,6 +4985,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4971,6 +5034,9 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -4978,6 +5044,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -5101,9 +5170,16 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -5112,6 +5188,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5467,6 +5547,47 @@ packages: vite: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -5504,6 +5625,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@3.1.0: resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} engines: {node: '>=8'} @@ -7203,6 +7329,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0)': dependencies: '@swc/core': 1.15.3 @@ -7211,10 +7339,10 @@ snapshots: commander: 8.3.0 minimatch: 9.0.9 piscina: 4.9.3 - semver: 7.7.3 + semver: 7.8.5 slash: 3.0.0 source-map: 0.7.6 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 optionalDependencies: chokidar: 5.0.0 transitivePeerDependencies: @@ -7565,6 +7693,13 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/http-cache-semantics@4.2.0': {} @@ -7638,6 +7773,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@workflow/astro@4.0.17': dependencies: '@swc/core': 1.15.3 @@ -8055,6 +8231,8 @@ snapshots: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -8100,7 +8278,7 @@ snapshots: binary-version-check@6.1.0: dependencies: binary-version: 7.1.0 - semver: 7.7.3 + semver: 7.8.5 semver-truncate: 3.0.0 binary-version@7.1.0: @@ -8261,6 +8439,8 @@ snapshots: optionalDependencies: cbor-extract: 2.2.2 + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -8556,6 +8736,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -8803,6 +8985,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.2.0 + expect-type@1.4.0: {} + express@5.2.1: dependencies: accepts: 2.0.0 @@ -9621,6 +9805,8 @@ snapshots: obliterator@2.0.5: {} + obug@2.1.4: {} + ofetch@2.0.0-alpha.3: {} ohash@2.0.11: {} @@ -10077,7 +10263,7 @@ snapshots: semver-truncate@3.0.0: dependencies: - semver: 7.7.3 + semver: 7.8.5 semver@6.3.1: {} @@ -10158,6 +10344,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -10191,10 +10379,14 @@ snapshots: srvx@0.10.1: {} + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} statuses@2.0.2: {} + std-env@4.2.0: {} + stdin-discarder@0.2.2: {} streamx@2.28.0: @@ -10252,7 +10444,7 @@ snapshots: lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.7 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 super-regex@1.1.0: @@ -10326,8 +10518,12 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.3.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -10338,6 +10534,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -10491,7 +10689,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.15.0 - picomatch: 4.0.3 + picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 unstorage@2.0.0-alpha.5(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3): @@ -10573,6 +10771,33 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + vitest@4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.31 + transitivePeerDependencies: + - msw + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -10611,6 +10836,11 @@ snapshots: dependencies: isexe: 3.1.1 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@3.1.0: dependencies: string-width: 4.2.3 diff --git a/turbo.json b/turbo.json index 89de6e5..f829705 100644 --- a/turbo.json +++ b/turbo.json @@ -17,6 +17,10 @@ "typecheck": { "dependsOn": ["^build"] }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + }, "clean": { "cache": false }, From 709b86b88e2bd8a8e9aea5d2a33d39fde50963c0 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 17:58:22 -0700 Subject: [PATCH 06/35] test: add test script to root package.json for Turborepo integration --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index de660d5..d1175cd 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "turbo build", "lint": "turbo lint", "typecheck": "turbo typecheck", + "test": "turbo test", "clean": "turbo clean && rm -rf node_modules", "db:generate": "turbo db:generate --filter=@overlap/db", "db:migrate": "turbo db:migrate --filter=@overlap/db", From 07bd57dea4ff77939a3a768ff8d0b63d06b72701 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 18:02:14 -0700 Subject: [PATCH 07/35] build: target Vercel preset and enable Workflow DevKit plugin Retarget the web app's Nitro build from node_server to the vercel preset so it produces .vercel/output. Enable the workflow/vite plugin (ordered before tanstackStart) and the workflow TypeScript plugin so "use workflow" and "use step" directives compile ahead of later tasks. Also drops the /auth and /api dev-server proxy and routeRules, since a later task folds the API into this app as server routes. --- .gitignore | 1 + apps/web/tsconfig.json | 3 ++- apps/web/vite.config.ts | 15 +++------------ 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 12702ef..42040f5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules dist .next .output +.vercel .turbo *.tsbuildinfo diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index e1a1787..98a9532 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -6,7 +6,8 @@ "baseUrl": ".", "paths": { "~/*": ["./src/*"] - } + }, + "plugins": [{ "name": "workflow" }] }, "include": ["src/**/*"] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 56414aa..c7cf26f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,29 +4,20 @@ import tsconfigPaths from 'vite-tsconfig-paths' import tailwindcss from '@tailwindcss/vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { nitro } from 'nitro/vite' - -const rawApiUrl = process.env.VITE_API_URL || process.env.API_URL || 'http://localhost:3001' -const apiUrl = rawApiUrl.startsWith('http') ? rawApiUrl : `https://${rawApiUrl}` +import { workflow } from 'workflow/vite' export default defineConfig({ server: { port: 3000, strictPort: true, - proxy: { - '/auth': apiUrl, - '/api': apiUrl, - }, }, plugins: [ tailwindcss(), tsconfigPaths(), + workflow(), tanstackStart(), nitro({ - preset: 'node_server', - routeRules: { - '/auth/**': { proxy: { to: `${apiUrl}/auth/**`, fetchOptions: { redirect: 'manual' } } }, - '/api/**': { proxy: `${apiUrl}/api/**` }, - }, + preset: 'vercel', }), viteReact(), ], From bdf87f36d4181bfa575ef2c8988e1a04aa08333d Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 18:06:50 -0700 Subject: [PATCH 08/35] feat(db): configure client for Supabase transaction pooling - Rewrite queryClient with prepare: false to work with Supabase Supavisor transaction pooler (port 6543), which cannot use prepared statements - Update migrationClient to use DIRECT_URL (port 5432) to bypass pooler constraints, since DDL statements require a direct connection - Update drizzle.config.ts to prefer DIRECT_URL for migrations, with fallback to DATABASE_URL for backward compatibility - Update .env.example with new Supabase connection variables and remove unused REDIS_URL --- .env.example | 8 ++++---- packages/db/drizzle.config.ts | 7 ++++++- packages/db/src/client.ts | 13 +++++++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 80132b9..8b1b8bc 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ -# Database +# Database (Supabase) +# Transaction pooler, port 6543, used by the application DATABASE_URL=postgresql://postgres:postgres@localhost:5432/overlap - -# Redis -REDIS_URL=redis://localhost:6379 +# Direct connection, port 5432, used by migrations only +DIRECT_URL=postgresql://postgres:postgres@localhost:5432/overlap # GitHub App GITHUB_APP_ID= diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index b8ca818..6e7abab 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -5,6 +5,11 @@ export default defineConfig({ out: './drizzle', dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/overlap', + // Migrations require a direct connection (port 5432) because transaction-mode + // pooling (port 6543) cannot execute DDL statements and prepared statements. + url: + process.env.DIRECT_URL || + process.env.DATABASE_URL || + 'postgresql://postgres:postgres@localhost:5432/overlap', }, }) diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index dfa8206..f390989 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -8,11 +8,16 @@ if (!connectionString) { throw new Error('DATABASE_URL environment variable is not set') } -// For query purposes -const queryClient = postgres(connectionString) +// Supabase Supavisor transaction pooler (port 6543) cannot use prepared statements. +const queryClient = postgres(connectionString, { prepare: false }) -// For migrations -export const migrationClient = postgres(connectionString, { max: 1 }) +// Migrations require session mode, so they use the direct connection (port 5432). +const directConnectionString = process.env.DIRECT_URL || connectionString + +export const migrationClient = postgres(directConnectionString, { + max: 1, + prepare: false, +}) export const db = drizzle(queryClient, { schema }) From 8dc0d5070229e2dbf2031ebe4f2afd2118961b22 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 18:10:15 -0700 Subject: [PATCH 09/35] feat(web): add jose-backed session token utility --- apps/web/package.json | 1 + apps/web/src/lib/__tests__/session.test.ts | 40 ++++++++++++++++++++++ apps/web/src/lib/session.ts | 37 ++++++++++++++++++++ pnpm-lock.yaml | 8 +++++ 4 files changed, 86 insertions(+) create mode 100644 apps/web/src/lib/__tests__/session.test.ts create mode 100644 apps/web/src/lib/session.ts diff --git a/apps/web/package.json b/apps/web/package.json index 6dba598..af050be 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@tanstack/react-start": "^1.158.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "jose": "^6.2.8", "lucide-react": "^0.470.0", "nitro": "3.0.1-alpha.2", "react": "^19.0.0", diff --git a/apps/web/src/lib/__tests__/session.test.ts b/apps/web/src/lib/__tests__/session.test.ts new file mode 100644 index 0000000..cec87c5 --- /dev/null +++ b/apps/web/src/lib/__tests__/session.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { signSession, verifySession } from '../session' + +beforeAll(() => { + process.env.SESSION_SECRET = 'test-secret-value-at-least-32-bytes-long' +}) + +describe('session', () => { + it('round-trips a userId', async () => { + const token = await signSession('user-123') + const result = await verifySession(token) + expect(result).toEqual({ userId: 'user-123' }) + }) + + it('rejects a tampered token', async () => { + const token = await signSession('user-123') + const tampered = token.slice(0, -4) + 'aaaa' + expect(await verifySession(tampered)).toBeNull() + }) + + it('rejects a token signed with a different secret', async () => { + const token = await signSession('user-123') + process.env.SESSION_SECRET = 'a-completely-different-secret-value-32b' + const result = await verifySession(token) + process.env.SESSION_SECRET = 'test-secret-value-at-least-32-bytes-long' + expect(result).toBeNull() + }) + + it('rejects a malformed token', async () => { + expect(await verifySession('not-a-jwt')).toBeNull() + }) + + it('carries no claims beyond userId, iat and exp', async () => { + const token = await signSession('user-123') + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString() + ) + expect(Object.keys(payload).sort()).toEqual(['exp', 'iat', 'userId']) + }) +}) diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts new file mode 100644 index 0000000..cf19809 --- /dev/null +++ b/apps/web/src/lib/session.ts @@ -0,0 +1,37 @@ +import { SignJWT, jwtVerify } from 'jose' + +export const SESSION_COOKIE_NAME = 'session' +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7 + +const ALGORITHM = 'HS256' + +function getKey(): Uint8Array { + const secret = process.env.SESSION_SECRET + if (!secret) { + throw new Error('SESSION_SECRET environment variable is required') + } + return new TextEncoder().encode(secret) +} + +export async function signSession(userId: string): Promise { + return new SignJWT({ userId }) + .setProtectedHeader({ alg: ALGORITHM }) + .setIssuedAt() + .setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`) + .sign(getKey()) +} + +export async function verifySession( + token: string +): Promise<{ userId: string } | null> { + try { + const { payload } = await jwtVerify(token, getKey(), { + algorithms: [ALGORITHM], + }) + const userId = payload.userId + if (typeof userId !== 'string') return null + return { userId } + } catch { + return null + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f36aad0..6afa2e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: clsx: specifier: ^2.1.0 version: 2.1.1 + jose: + specifier: ^6.2.8 + version: 6.2.8 lucide-react: specifier: ^0.470.0 version: 0.470.0(react@19.2.4) @@ -4110,6 +4113,9 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -9450,6 +9456,8 @@ snapshots: jose@5.10.0: {} + jose@6.2.8: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} From 455974c76f3306d5860b30595d2887f8d979812e Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 18:14:37 -0700 Subject: [PATCH 10/35] feat(web): add request auth helper with retained user lookup Add @overlap/db and drizzle-orm dependencies to @overlap/web to support the new auth helper. This allows server routes to authenticate requests using the session cookie and perform per-request user lookups for immediate session revocation. Also updated vitest config to provide DATABASE_URL for tests. --- apps/web/package.json | 2 + apps/web/src/lib/__tests__/auth.test.ts | 46 ++++++++++++++ apps/web/src/lib/auth.ts | 83 +++++++++++++++++++++++++ apps/web/vitest.config.ts | 3 + pnpm-lock.yaml | 6 ++ 5 files changed, 140 insertions(+) create mode 100644 apps/web/src/lib/__tests__/auth.test.ts create mode 100644 apps/web/src/lib/auth.ts diff --git a/apps/web/package.json b/apps/web/package.json index af050be..dba5de3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "test:watch": "vitest" }, "dependencies": { + "@overlap/db": "workspace:^", "@overlap/shared": "workspace:*", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.0", @@ -27,6 +28,7 @@ "@tanstack/react-start": "^1.158.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "drizzle-orm": "^0.38.0", "jose": "^6.2.8", "lucide-react": "^0.470.0", "nitro": "3.0.1-alpha.2", diff --git a/apps/web/src/lib/__tests__/auth.test.ts b/apps/web/src/lib/__tests__/auth.test.ts new file mode 100644 index 0000000..51741cf --- /dev/null +++ b/apps/web/src/lib/__tests__/auth.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { readCookie, buildSessionCookie, buildClearCookie } from '../auth' + +function reqWithCookie(value: string): Request { + return new Request('https://example.com/', { headers: { cookie: value } }) +} + +describe('readCookie', () => { + it('reads a single cookie', () => { + expect(readCookie(reqWithCookie('session=abc'), 'session')).toBe('abc') + }) + + it('reads one cookie among several', () => { + const r = reqWithCookie('a=1; session=abc; b=2') + expect(readCookie(r, 'session')).toBe('abc') + }) + + it('returns null when absent', () => { + expect(readCookie(reqWithCookie('a=1'), 'session')).toBeNull() + }) + + it('returns null when there is no cookie header', () => { + expect(readCookie(new Request('https://example.com/'), 'session')).toBeNull() + }) + + it('does not match a cookie whose name is a suffix', () => { + expect(readCookie(reqWithCookie('oauth_session=abc'), 'session')).toBeNull() + }) +}) + +describe('buildSessionCookie', () => { + it('sets HttpOnly, SameSite=Lax and Path', () => { + const c = buildSessionCookie('tok') + expect(c).toContain('session=tok') + expect(c).toContain('HttpOnly') + expect(c).toContain('SameSite=Lax') + expect(c).toContain('Path=/') + expect(c).toContain('Max-Age=604800') + }) +}) + +describe('buildClearCookie', () => { + it('expires the cookie immediately', () => { + expect(buildClearCookie('session')).toContain('Max-Age=0') + }) +}) diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..853c9b0 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,83 @@ +import { db, users } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { + verifySession, + SESSION_COOKIE_NAME, + SESSION_MAX_AGE_SECONDS, +} from './session' + +export type AuthUser = { + id: string + githubId: number + username: string + email: string | null + avatarUrl: string | null +} + +export function readCookie(request: Request, name: string): string | null { + const header = request.headers.get('cookie') + if (!header) return null + for (const part of header.split(';')) { + const eq = part.indexOf('=') + if (eq === -1) continue + if (part.slice(0, eq).trim() === name) { + return part.slice(eq + 1).trim() + } + } + return null +} + +function isProduction(): boolean { + return process.env.NODE_ENV === 'production' +} + +export function buildSessionCookie(token: string): string { + const parts = [ + `${SESSION_COOKIE_NAME}=${token}`, + 'HttpOnly', + 'SameSite=Lax', + 'Path=/', + `Max-Age=${SESSION_MAX_AGE_SECONDS}`, + ] + if (isProduction()) parts.push('Secure') + return parts.join('; ') +} + +export function buildClearCookie(name: string): string { + const parts = [`${name}=`, 'HttpOnly', 'SameSite=Lax', 'Path=/', 'Max-Age=0'] + if (isProduction()) parts.push('Secure') + return parts.join('; ') +} + +export async function getUser(request: Request): Promise { + const token = readCookie(request, SESSION_COOKIE_NAME) + if (!token) return null + + const session = await verifySession(token) + if (!session) return null + + // Retained deliberately: this lookup is what makes revocation immediate. + const user = await db.query.users.findFirst({ + where: eq(users.id, session.userId), + }) + if (!user) return null + + return { + id: user.id, + githubId: user.githubId, + username: user.username, + email: user.email, + avatarUrl: user.avatarUrl, + } +} + +export async function requireUser(request: Request): Promise { + const user = await getUser(request) + if (!user) { + throw new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }) + } + return user +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 558b20a..fb609e6 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -6,5 +6,8 @@ export default defineConfig({ test: { include: ['src/**/*.test.ts'], environment: 'node', + env: { + DATABASE_URL: 'postgresql://test:test@localhost/test', + }, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6afa2e5..287d82e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: apps/web: dependencies: + '@overlap/db': + specifier: workspace:^ + version: link:../../packages/db '@overlap/shared': specifier: workspace:* version: link:../../packages/shared @@ -120,6 +123,9 @@ importers: clsx: specifier: ^2.1.0 version: 2.1.1 + drizzle-orm: + specifier: ^0.38.0 + version: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) jose: specifier: ^6.2.8 version: 6.2.8 From 634483d0387be42846a164fe1a686980dcc0f639 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 18:22:14 -0700 Subject: [PATCH 11/35] feat(web): port health and auth routes to server routes Ports apps/api/src/routes/health.ts and auth.ts into TanStack Start server routes ahead of the Fastify API's removal. The health route drops the Redis check since Redis is being deleted in this migration. The oauth_state cookie is now a signed JWT (verified with jwtVerify pinned to HS256) rather than a bare value, preserving CSRF integrity protection without @fastify/cookie's signed-cookie support. Also includes the regenerated routeTree.gen.ts so typecheck passes without requiring a prior build step. --- apps/web/src/lib/github-oauth.ts | 111 +++++++++++++++ apps/web/src/routeTree.gen.ts | 122 +++++++++++++++- .../src/routes/api/auth/github.callback.ts | 130 ++++++++++++++++++ apps/web/src/routes/api/auth/github.ts | 49 +++++++ apps/web/src/routes/api/auth/logout.ts | 19 +++ apps/web/src/routes/api/auth/me.ts | 30 ++++ apps/web/src/routes/api/health.ts | 29 ++++ 7 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/github-oauth.ts create mode 100644 apps/web/src/routes/api/auth/github.callback.ts create mode 100644 apps/web/src/routes/api/auth/github.ts create mode 100644 apps/web/src/routes/api/auth/logout.ts create mode 100644 apps/web/src/routes/api/auth/me.ts create mode 100644 apps/web/src/routes/api/health.ts diff --git a/apps/web/src/lib/github-oauth.ts b/apps/web/src/lib/github-oauth.ts new file mode 100644 index 0000000..b215d6f --- /dev/null +++ b/apps/web/src/lib/github-oauth.ts @@ -0,0 +1,111 @@ +import { db, githubAppInstallations, userInstallations, repositories, repositorySettings } from '@overlap/db' + +export async function syncUserInstallations(accessToken: string, userId: string) { + try { + const res = await fetch('https://api.github.com/user/installations', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + }) + + if (!res.ok) return + + const data = (await res.json()) as { + installations: Array<{ + id: number + account: { login: string; type: string } + }> + } + + for (const inst of data.installations) { + const [installation] = await db + .insert(githubAppInstallations) + .values({ + installationId: inst.id, + userId, + status: 'active', + }) + .onConflictDoUpdate({ + target: githubAppInstallations.installationId, + set: { + status: 'active', + updatedAt: new Date(), + }, + }) + .returning() + + // Link user to installation (many-to-many) + await db + .insert(userInstallations) + .values({ userId, installationId: installation.id }) + .onConflictDoNothing() + + // Sync repos for this installation + await syncInstallationRepos(accessToken, inst.id, installation.id) + } + } catch (err) { + console.error('Failed to sync installations:', err) + } +} + +async function syncInstallationRepos(accessToken: string, installationId: number, dbInstallationId: string) { + try { + const res = await fetch( + `https://api.github.com/user/installations/${installationId}/repositories`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + } + ) + + if (!res.ok) return + + const data = (await res.json()) as { + repositories: Array<{ + id: number + name: string + full_name: string + private: boolean + default_branch: string + }> + } + + for (const repo of data.repositories) { + const [inserted] = await db + .insert(repositories) + .values({ + githubId: repo.id, + installationId: dbInstallationId, + name: repo.name, + fullName: repo.full_name, + defaultBranch: repo.default_branch, + isPrivate: repo.private, + isActive: true, + }) + .onConflictDoUpdate({ + target: repositories.githubId, + set: { + installationId: dbInstallationId, + name: repo.name, + fullName: repo.full_name, + defaultBranch: repo.default_branch, + isPrivate: repo.private, + isActive: true, + updatedAt: new Date(), + }, + }) + .returning() + + // Ensure default settings exist + await db + .insert(repositorySettings) + .values({ repositoryId: inserted.id }) + .onConflictDoNothing() + } + } catch (err) { + console.error(`Failed to sync repos for installation ${installationId}:`, err) + } +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index df11dd5..535e438 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,11 @@ import { Route as RepositoriesRouteImport } from './routes/repositories' import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as RepositoriesRepoIdRouteImport } from './routes/repositories_.$repoId' +import { Route as ApiHealthRouteImport } from './routes/api/health' +import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me' +import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout' +import { Route as ApiAuthGithubRouteImport } from './routes/api/auth/github' +import { Route as ApiAuthGithubCallbackRouteImport } from './routes/api/auth/github.callback' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -40,20 +45,55 @@ const RepositoriesRepoIdRoute = RepositoriesRepoIdRouteImport.update({ path: '/repositories/$repoId', getParentRoute: () => rootRouteImport, } as any) +const ApiHealthRoute = ApiHealthRouteImport.update({ + id: '/api/health', + path: '/api/health', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthMeRoute = ApiAuthMeRouteImport.update({ + id: '/api/auth/me', + path: '/api/auth/me', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthLogoutRoute = ApiAuthLogoutRouteImport.update({ + id: '/api/auth/logout', + path: '/api/auth/logout', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthGithubRoute = ApiAuthGithubRouteImport.update({ + id: '/api/auth/github', + path: '/api/auth/github', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthGithubCallbackRoute = ApiAuthGithubCallbackRouteImport.update({ + id: '/callback', + path: '/callback', + getParentRoute: () => ApiAuthGithubRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute '/repositories': typeof RepositoriesRoute '/settings': typeof SettingsRoute + '/api/health': typeof ApiHealthRoute '/repositories/$repoId': typeof RepositoriesRepoIdRoute + '/api/auth/github': typeof ApiAuthGithubRouteWithChildren + '/api/auth/logout': typeof ApiAuthLogoutRoute + '/api/auth/me': typeof ApiAuthMeRoute + '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute '/repositories': typeof RepositoriesRoute '/settings': typeof SettingsRoute + '/api/health': typeof ApiHealthRoute '/repositories/$repoId': typeof RepositoriesRepoIdRoute + '/api/auth/github': typeof ApiAuthGithubRouteWithChildren + '/api/auth/logout': typeof ApiAuthLogoutRoute + '/api/auth/me': typeof ApiAuthMeRoute + '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -61,7 +101,12 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/repositories': typeof RepositoriesRoute '/settings': typeof SettingsRoute + '/api/health': typeof ApiHealthRoute '/repositories_/$repoId': typeof RepositoriesRepoIdRoute + '/api/auth/github': typeof ApiAuthGithubRouteWithChildren + '/api/auth/logout': typeof ApiAuthLogoutRoute + '/api/auth/me': typeof ApiAuthMeRoute + '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -70,16 +115,36 @@ export interface FileRouteTypes { | '/login' | '/repositories' | '/settings' + | '/api/health' | '/repositories/$repoId' + | '/api/auth/github' + | '/api/auth/logout' + | '/api/auth/me' + | '/api/auth/github/callback' fileRoutesByTo: FileRoutesByTo - to: '/' | '/login' | '/repositories' | '/settings' | '/repositories/$repoId' + to: + | '/' + | '/login' + | '/repositories' + | '/settings' + | '/api/health' + | '/repositories/$repoId' + | '/api/auth/github' + | '/api/auth/logout' + | '/api/auth/me' + | '/api/auth/github/callback' id: | '__root__' | '/' | '/login' | '/repositories' | '/settings' + | '/api/health' | '/repositories_/$repoId' + | '/api/auth/github' + | '/api/auth/logout' + | '/api/auth/me' + | '/api/auth/github/callback' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -87,7 +152,11 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute RepositoriesRoute: typeof RepositoriesRoute SettingsRoute: typeof SettingsRoute + ApiHealthRoute: typeof ApiHealthRoute RepositoriesRepoIdRoute: typeof RepositoriesRepoIdRoute + ApiAuthGithubRoute: typeof ApiAuthGithubRouteWithChildren + ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute + ApiAuthMeRoute: typeof ApiAuthMeRoute } declare module '@tanstack/react-router' { @@ -127,15 +196,66 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RepositoriesRepoIdRouteImport parentRoute: typeof rootRouteImport } + '/api/health': { + id: '/api/health' + path: '/api/health' + fullPath: '/api/health' + preLoaderRoute: typeof ApiHealthRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/me': { + id: '/api/auth/me' + path: '/api/auth/me' + fullPath: '/api/auth/me' + preLoaderRoute: typeof ApiAuthMeRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/logout': { + id: '/api/auth/logout' + path: '/api/auth/logout' + fullPath: '/api/auth/logout' + preLoaderRoute: typeof ApiAuthLogoutRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/github': { + id: '/api/auth/github' + path: '/api/auth/github' + fullPath: '/api/auth/github' + preLoaderRoute: typeof ApiAuthGithubRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/github/callback': { + id: '/api/auth/github/callback' + path: '/callback' + fullPath: '/api/auth/github/callback' + preLoaderRoute: typeof ApiAuthGithubCallbackRouteImport + parentRoute: typeof ApiAuthGithubRoute + } } } +interface ApiAuthGithubRouteChildren { + ApiAuthGithubCallbackRoute: typeof ApiAuthGithubCallbackRoute +} + +const ApiAuthGithubRouteChildren: ApiAuthGithubRouteChildren = { + ApiAuthGithubCallbackRoute: ApiAuthGithubCallbackRoute, +} + +const ApiAuthGithubRouteWithChildren = ApiAuthGithubRoute._addFileChildren( + ApiAuthGithubRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LoginRoute: LoginRoute, RepositoriesRoute: RepositoriesRoute, SettingsRoute: SettingsRoute, + ApiHealthRoute: ApiHealthRoute, RepositoriesRepoIdRoute: RepositoriesRepoIdRoute, + ApiAuthGithubRoute: ApiAuthGithubRouteWithChildren, + ApiAuthLogoutRoute: ApiAuthLogoutRoute, + ApiAuthMeRoute: ApiAuthMeRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/api/auth/github.callback.ts b/apps/web/src/routes/api/auth/github.callback.ts new file mode 100644 index 0000000..ba7ca79 --- /dev/null +++ b/apps/web/src/routes/api/auth/github.callback.ts @@ -0,0 +1,130 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, users, userInstallations } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { githubOAuthCallbackSchema } from '@overlap/shared' +import { jwtVerify } from 'jose' +import { signSession } from '../../../lib/session' +import { readCookie, buildSessionCookie, buildClearCookie } from '../../../lib/auth' +import { syncUserInstallations } from '../../../lib/github-oauth' + +const clientId = process.env.GITHUB_CLIENT_ID +const clientSecret = process.env.GITHUB_CLIENT_SECRET +const appUrl = process.env.APP_URL || 'http://localhost:3000' + +export const Route = createFileRoute('/api/auth/github/callback')({ + server: { + handlers: { + GET: async ({ request }) => { + // If this callback came from a GitHub App installation flow (no state cookie), + // redirect through our own OAuth flow to establish CSRF protection. + // GitHub will auto-approve since the user already authorized. + const stateCookie = readCookie(request, 'oauth_state') + if (!stateCookie) { + return new Response(null, { + status: 302, + headers: { location: `${appUrl}/api/auth/github` }, + }) + } + + const url = new URL(request.url) + const { code, state } = githubOAuthCallbackSchema.parse({ + code: url.searchParams.get('code') ?? undefined, + state: url.searchParams.get('state') ?? undefined, + }) + + // Verify state (CSRF protection) — always enforced. The state cookie is + // itself a signed JWT (per spec S7), so verification checks integrity, + // not merely presence. + let stateClaim: unknown + try { + const { payload } = await jwtVerify(stateCookie, new TextEncoder().encode(process.env.SESSION_SECRET!), { + algorithms: ['HS256'], + }) + stateClaim = payload.state + } catch { + return json({ error: 'Invalid OAuth state' }, { status: 400 }) + } + if (typeof stateClaim !== 'string' || stateClaim !== state) { + return json({ error: 'Invalid OAuth state' }, { status: 400 }) + } + + // Exchange code for access token + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + }), + }) + + const tokenData = (await tokenResponse.json()) as { + access_token?: string + error?: string + } + + if (!tokenData.access_token) { + return json({ error: 'Failed to exchange code for token' }, { status: 400 }) + } + + // Fetch user profile + const userResponse = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + Accept: 'application/vnd.github+json', + }, + }) + + const githubUser = (await userResponse.json()) as { + id: number + login: string + email: string | null + avatar_url: string + } + + // Upsert user + const [user] = await db + .insert(users) + .values({ + githubId: githubUser.id, + username: githubUser.login, + email: githubUser.email, + avatarUrl: githubUser.avatar_url, + }) + .onConflictDoUpdate({ + target: users.githubId, + set: { + username: githubUser.login, + email: githubUser.email, + avatarUrl: githubUser.avatar_url, + updatedAt: new Date(), + }, + }) + .returning() + + const headers = new Headers() + headers.append('set-cookie', buildSessionCookie(await signSession(user.id))) + headers.append('set-cookie', buildClearCookie('oauth_state')) + + // Sync user's GitHub App installations into local DB + await syncUserInstallations(tokenData.access_token, user.id) + + // Check if user has any active installations + const userInsts = await db.query.userInstallations.findMany({ + where: eq(userInstallations.userId, user.id), + with: { installation: true }, + }) + const hasActive = userInsts.some((ui) => ui.installation.status === 'active') + + headers.set('location', hasActive ? appUrl : `${appUrl}?setup=1`) + + return new Response(null, { status: 302, headers }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/auth/github.ts b/apps/web/src/routes/api/auth/github.ts new file mode 100644 index 0000000..224dff3 --- /dev/null +++ b/apps/web/src/routes/api/auth/github.ts @@ -0,0 +1,49 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SignJWT } from 'jose' + +export const Route = createFileRoute('/api/auth/github')({ + server: { + handlers: { + GET: async () => { + const clientId = process.env.GITHUB_CLIENT_ID + const appUrl = process.env.APP_URL || 'http://localhost:3000' + if (!clientId) { + return new Response('OAuth not configured', { status: 500 }) + } + + const state = crypto.randomUUID() + + // The state cookie is signed, not merely present, per spec S7. + const stateToken = await new SignJWT({ state }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('10m') + .sign(new TextEncoder().encode(process.env.SESSION_SECRET!)) + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: `${appUrl}/api/auth/github/callback`, + scope: 'read:user user:email', + state, + }) + + const cookieParts = [ + `oauth_state=${stateToken}`, + 'HttpOnly', + 'SameSite=Lax', + 'Path=/', + 'Max-Age=600', + ] + if (process.env.NODE_ENV === 'production') cookieParts.push('Secure') + + return new Response(null, { + status: 302, + headers: { + location: `https://github.com/login/oauth/authorize?${params}`, + 'set-cookie': cookieParts.join('; '), + }, + }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/auth/logout.ts b/apps/web/src/routes/api/auth/logout.ts new file mode 100644 index 0000000..eaaffe4 --- /dev/null +++ b/apps/web/src/routes/api/auth/logout.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' +import { buildClearCookie } from '../../../lib/auth' +import { SESSION_COOKIE_NAME } from '../../../lib/session' + +export const Route = createFileRoute('/api/auth/logout')({ + server: { + handlers: { + POST: async () => { + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'set-cookie': buildClearCookie(SESSION_COOKIE_NAME), + }, + }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/auth/me.ts b/apps/web/src/routes/api/auth/me.ts new file mode 100644 index 0000000..d4ebe01 --- /dev/null +++ b/apps/web/src/routes/api/auth/me.ts @@ -0,0 +1,30 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, userInstallations } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { requireUser } from '../../../lib/auth' + +export const Route = createFileRoute('/api/auth/me')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const user = await requireUser(request) + const insts = await db.query.userInstallations.findMany({ + where: eq(userInstallations.userId, user.id), + with: { installation: true }, + }) + return json({ + user, + hasInstallations: insts.some( + (ui) => ui.installation.status === 'active' + ), + }) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/health.ts b/apps/web/src/routes/api/health.ts new file mode 100644 index 0000000..9866bfb --- /dev/null +++ b/apps/web/src/routes/api/health.ts @@ -0,0 +1,29 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db } from '@overlap/db' +import { sql } from 'drizzle-orm' + +export const Route = createFileRoute('/api/health')({ + server: { + handlers: { + GET: async () => { + let database = false + try { + await db.execute(sql`SELECT 1`) + database = true + } catch { + database = false + } + + return json( + { + status: database ? 'ready' : 'not ready', + checks: { database }, + timestamp: new Date().toISOString(), + }, + { status: database ? 200 : 503 } + ) + }, + }, + }, +}) From 6c8095271de9c8d1c07afd0846665e749ae377c7 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 18:37:53 -0700 Subject: [PATCH 12/35] feat(web): port repositories and push routes to server routes Ports apps/api/src/routes/repositories.ts (8 route handlers plus the requireRepoAccess/getUserInstallationIds helpers) and apps/api/src/routes/push.ts into TanStack Start server routes ahead of the Fastify API's removal. requireRepoAccess is carried over with its access-checking logic unchanged, only its signature and failure path adapted to throw a Response instead of using Fastify's reply. Every :id route calls it before touching data, matching the original. push.ts keeps ALLOWED_PUSH_HOSTS and isAllowedPushEndpoint byte-for-byte identical, and adds the per-user subscription cap (20) required by spec S3 as an additional check alongside the existing host allowlist. The DEV-only test-notify route ported the auth/access-check/lookup logic faithfully, but its BullMQ enqueue has no equivalent yet: push dispatch becomes the sendPush workflow step in a later migration task. It now returns 501 rather than silently doing nothing or duplicating that task's work early. Also adds @overlap/github as a web dependency (needed by the diffs handler's getGitHubClient call, not yet present in apps/web), and includes the regenerated routeTree.gen.ts so typecheck passes without requiring a prior build step. --- apps/web/.gitignore | 1 + apps/web/package.json | 1 + apps/web/src/lib/repo-access.ts | 37 +++ apps/web/src/routeTree.gen.ts | 239 ++++++++++++++++++ apps/web/src/routes/api/push.ts | 102 ++++++++ .../routes/api/repositories.$id.branches.ts | 51 ++++ .../src/routes/api/repositories.$id.diffs.ts | 59 +++++ ...ies.$id.overlaps.$overlapId.test-notify.ts | 52 ++++ .../repositories.$id.overlaps.$overlapId.ts | 48 ++++ .../routes/api/repositories.$id.overlaps.ts | 60 +++++ .../routes/api/repositories.$id.settings.ts | 38 +++ apps/web/src/routes/api/repositories.$id.ts | 36 +++ apps/web/src/routes/api/repositories.ts | 67 +++++ pnpm-lock.yaml | 3 + 14 files changed, 794 insertions(+) create mode 100644 apps/web/.gitignore create mode 100644 apps/web/src/lib/repo-access.ts create mode 100644 apps/web/src/routes/api/push.ts create mode 100644 apps/web/src/routes/api/repositories.$id.branches.ts create mode 100644 apps/web/src/routes/api/repositories.$id.diffs.ts create mode 100644 apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts create mode 100644 apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts create mode 100644 apps/web/src/routes/api/repositories.$id.overlaps.ts create mode 100644 apps/web/src/routes/api/repositories.$id.settings.ts create mode 100644 apps/web/src/routes/api/repositories.$id.ts create mode 100644 apps/web/src/routes/api/repositories.ts diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..c306a0a --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1 @@ +/.swc diff --git a/apps/web/package.json b/apps/web/package.json index dba5de3..4bfd3f0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@overlap/db": "workspace:^", + "@overlap/github": "workspace:*", "@overlap/shared": "workspace:*", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.0", diff --git a/apps/web/src/lib/repo-access.ts b/apps/web/src/lib/repo-access.ts new file mode 100644 index 0000000..f830ec0 --- /dev/null +++ b/apps/web/src/lib/repo-access.ts @@ -0,0 +1,37 @@ +import { db, repositories, userInstallations } from '@overlap/db' +import { eq, and, inArray, sql } from 'drizzle-orm' +import type { AuthUser } from './auth' + +// Helper to get user's installation IDs (via many-to-many join table) +export async function getUserInstallationIds(userId: string): Promise { + const links = await db.query.userInstallations.findMany({ + where: eq(userInstallations.userId, userId), + with: { installation: true }, + }) + return links + .filter((l) => l.installation.status === 'active') + .map((l) => l.installationId) +} + +// Helper to verify the authenticated user has access to a repository +export async function requireRepoAccess( + user: AuthUser, + repoId: string +): Promise { + const installationIds = await getUserInstallationIds(user.id) + const repo = await db.query.repositories.findFirst({ + where: and( + eq(repositories.id, repoId), + installationIds.length > 0 + ? inArray(repositories.installationId, installationIds) + : sql`false` + ), + }) + if (!repo) { + throw new Response(JSON.stringify({ error: 'Repository not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }) + } + return repo +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 535e438..7076f68 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,11 +14,20 @@ import { Route as RepositoriesRouteImport } from './routes/repositories' import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as RepositoriesRepoIdRouteImport } from './routes/repositories_.$repoId' +import { Route as ApiRepositoriesRouteImport } from './routes/api/repositories' +import { Route as ApiPushRouteImport } from './routes/api/push' import { Route as ApiHealthRouteImport } from './routes/api/health' +import { Route as ApiRepositoriesIdRouteImport } from './routes/api/repositories.$id' import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me' import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout' import { Route as ApiAuthGithubRouteImport } from './routes/api/auth/github' +import { Route as ApiRepositoriesIdSettingsRouteImport } from './routes/api/repositories.$id.settings' +import { Route as ApiRepositoriesIdOverlapsRouteImport } from './routes/api/repositories.$id.overlaps' +import { Route as ApiRepositoriesIdDiffsRouteImport } from './routes/api/repositories.$id.diffs' +import { Route as ApiRepositoriesIdBranchesRouteImport } from './routes/api/repositories.$id.branches' import { Route as ApiAuthGithubCallbackRouteImport } from './routes/api/auth/github.callback' +import { Route as ApiRepositoriesIdOverlapsOverlapIdRouteImport } from './routes/api/repositories.$id.overlaps.$overlapId' +import { Route as ApiRepositoriesIdOverlapsOverlapIdTestNotifyRouteImport } from './routes/api/repositories.$id.overlaps.$overlapId.test-notify' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -45,11 +54,26 @@ const RepositoriesRepoIdRoute = RepositoriesRepoIdRouteImport.update({ path: '/repositories/$repoId', getParentRoute: () => rootRouteImport, } as any) +const ApiRepositoriesRoute = ApiRepositoriesRouteImport.update({ + id: '/api/repositories', + path: '/api/repositories', + getParentRoute: () => rootRouteImport, +} as any) +const ApiPushRoute = ApiPushRouteImport.update({ + id: '/api/push', + path: '/api/push', + getParentRoute: () => rootRouteImport, +} as any) const ApiHealthRoute = ApiHealthRouteImport.update({ id: '/api/health', path: '/api/health', getParentRoute: () => rootRouteImport, } as any) +const ApiRepositoriesIdRoute = ApiRepositoriesIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => ApiRepositoriesRoute, +} as any) const ApiAuthMeRoute = ApiAuthMeRouteImport.update({ id: '/api/auth/me', path: '/api/auth/me', @@ -65,11 +89,46 @@ const ApiAuthGithubRoute = ApiAuthGithubRouteImport.update({ path: '/api/auth/github', getParentRoute: () => rootRouteImport, } as any) +const ApiRepositoriesIdSettingsRoute = + ApiRepositoriesIdSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => ApiRepositoriesIdRoute, + } as any) +const ApiRepositoriesIdOverlapsRoute = + ApiRepositoriesIdOverlapsRouteImport.update({ + id: '/overlaps', + path: '/overlaps', + getParentRoute: () => ApiRepositoriesIdRoute, + } as any) +const ApiRepositoriesIdDiffsRoute = ApiRepositoriesIdDiffsRouteImport.update({ + id: '/diffs', + path: '/diffs', + getParentRoute: () => ApiRepositoriesIdRoute, +} as any) +const ApiRepositoriesIdBranchesRoute = + ApiRepositoriesIdBranchesRouteImport.update({ + id: '/branches', + path: '/branches', + getParentRoute: () => ApiRepositoriesIdRoute, + } as any) const ApiAuthGithubCallbackRoute = ApiAuthGithubCallbackRouteImport.update({ id: '/callback', path: '/callback', getParentRoute: () => ApiAuthGithubRoute, } as any) +const ApiRepositoriesIdOverlapsOverlapIdRoute = + ApiRepositoriesIdOverlapsOverlapIdRouteImport.update({ + id: '/$overlapId', + path: '/$overlapId', + getParentRoute: () => ApiRepositoriesIdOverlapsRoute, + } as any) +const ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute = + ApiRepositoriesIdOverlapsOverlapIdTestNotifyRouteImport.update({ + id: '/test-notify', + path: '/test-notify', + getParentRoute: () => ApiRepositoriesIdOverlapsOverlapIdRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -77,11 +136,20 @@ export interface FileRoutesByFullPath { '/repositories': typeof RepositoriesRoute '/settings': typeof SettingsRoute '/api/health': typeof ApiHealthRoute + '/api/push': typeof ApiPushRoute + '/api/repositories': typeof ApiRepositoriesRouteWithChildren '/repositories/$repoId': typeof RepositoriesRepoIdRoute '/api/auth/github': typeof ApiAuthGithubRouteWithChildren '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/auth/me': typeof ApiAuthMeRoute + '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute + '/api/repositories/$id/branches': typeof ApiRepositoriesIdBranchesRoute + '/api/repositories/$id/diffs': typeof ApiRepositoriesIdDiffsRoute + '/api/repositories/$id/overlaps': typeof ApiRepositoriesIdOverlapsRouteWithChildren + '/api/repositories/$id/settings': typeof ApiRepositoriesIdSettingsRoute + '/api/repositories/$id/overlaps/$overlapId': typeof ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren + '/api/repositories/$id/overlaps/$overlapId/test-notify': typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -89,11 +157,20 @@ export interface FileRoutesByTo { '/repositories': typeof RepositoriesRoute '/settings': typeof SettingsRoute '/api/health': typeof ApiHealthRoute + '/api/push': typeof ApiPushRoute + '/api/repositories': typeof ApiRepositoriesRouteWithChildren '/repositories/$repoId': typeof RepositoriesRepoIdRoute '/api/auth/github': typeof ApiAuthGithubRouteWithChildren '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/auth/me': typeof ApiAuthMeRoute + '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute + '/api/repositories/$id/branches': typeof ApiRepositoriesIdBranchesRoute + '/api/repositories/$id/diffs': typeof ApiRepositoriesIdDiffsRoute + '/api/repositories/$id/overlaps': typeof ApiRepositoriesIdOverlapsRouteWithChildren + '/api/repositories/$id/settings': typeof ApiRepositoriesIdSettingsRoute + '/api/repositories/$id/overlaps/$overlapId': typeof ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren + '/api/repositories/$id/overlaps/$overlapId/test-notify': typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -102,11 +179,20 @@ export interface FileRoutesById { '/repositories': typeof RepositoriesRoute '/settings': typeof SettingsRoute '/api/health': typeof ApiHealthRoute + '/api/push': typeof ApiPushRoute + '/api/repositories': typeof ApiRepositoriesRouteWithChildren '/repositories_/$repoId': typeof RepositoriesRepoIdRoute '/api/auth/github': typeof ApiAuthGithubRouteWithChildren '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/auth/me': typeof ApiAuthMeRoute + '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute + '/api/repositories/$id/branches': typeof ApiRepositoriesIdBranchesRoute + '/api/repositories/$id/diffs': typeof ApiRepositoriesIdDiffsRoute + '/api/repositories/$id/overlaps': typeof ApiRepositoriesIdOverlapsRouteWithChildren + '/api/repositories/$id/settings': typeof ApiRepositoriesIdSettingsRoute + '/api/repositories/$id/overlaps/$overlapId': typeof ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren + '/api/repositories/$id/overlaps/$overlapId/test-notify': typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -116,11 +202,20 @@ export interface FileRouteTypes { | '/repositories' | '/settings' | '/api/health' + | '/api/push' + | '/api/repositories' | '/repositories/$repoId' | '/api/auth/github' | '/api/auth/logout' | '/api/auth/me' + | '/api/repositories/$id' | '/api/auth/github/callback' + | '/api/repositories/$id/branches' + | '/api/repositories/$id/diffs' + | '/api/repositories/$id/overlaps' + | '/api/repositories/$id/settings' + | '/api/repositories/$id/overlaps/$overlapId' + | '/api/repositories/$id/overlaps/$overlapId/test-notify' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -128,11 +223,20 @@ export interface FileRouteTypes { | '/repositories' | '/settings' | '/api/health' + | '/api/push' + | '/api/repositories' | '/repositories/$repoId' | '/api/auth/github' | '/api/auth/logout' | '/api/auth/me' + | '/api/repositories/$id' | '/api/auth/github/callback' + | '/api/repositories/$id/branches' + | '/api/repositories/$id/diffs' + | '/api/repositories/$id/overlaps' + | '/api/repositories/$id/settings' + | '/api/repositories/$id/overlaps/$overlapId' + | '/api/repositories/$id/overlaps/$overlapId/test-notify' id: | '__root__' | '/' @@ -140,11 +244,20 @@ export interface FileRouteTypes { | '/repositories' | '/settings' | '/api/health' + | '/api/push' + | '/api/repositories' | '/repositories_/$repoId' | '/api/auth/github' | '/api/auth/logout' | '/api/auth/me' + | '/api/repositories/$id' | '/api/auth/github/callback' + | '/api/repositories/$id/branches' + | '/api/repositories/$id/diffs' + | '/api/repositories/$id/overlaps' + | '/api/repositories/$id/settings' + | '/api/repositories/$id/overlaps/$overlapId' + | '/api/repositories/$id/overlaps/$overlapId/test-notify' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -153,6 +266,8 @@ export interface RootRouteChildren { RepositoriesRoute: typeof RepositoriesRoute SettingsRoute: typeof SettingsRoute ApiHealthRoute: typeof ApiHealthRoute + ApiPushRoute: typeof ApiPushRoute + ApiRepositoriesRoute: typeof ApiRepositoriesRouteWithChildren RepositoriesRepoIdRoute: typeof RepositoriesRepoIdRoute ApiAuthGithubRoute: typeof ApiAuthGithubRouteWithChildren ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute @@ -196,6 +311,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RepositoriesRepoIdRouteImport parentRoute: typeof rootRouteImport } + '/api/repositories': { + id: '/api/repositories' + path: '/api/repositories' + fullPath: '/api/repositories' + preLoaderRoute: typeof ApiRepositoriesRouteImport + parentRoute: typeof rootRouteImport + } + '/api/push': { + id: '/api/push' + path: '/api/push' + fullPath: '/api/push' + preLoaderRoute: typeof ApiPushRouteImport + parentRoute: typeof rootRouteImport + } '/api/health': { id: '/api/health' path: '/api/health' @@ -203,6 +332,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHealthRouteImport parentRoute: typeof rootRouteImport } + '/api/repositories/$id': { + id: '/api/repositories/$id' + path: '/$id' + fullPath: '/api/repositories/$id' + preLoaderRoute: typeof ApiRepositoriesIdRouteImport + parentRoute: typeof ApiRepositoriesRoute + } '/api/auth/me': { id: '/api/auth/me' path: '/api/auth/me' @@ -224,6 +360,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthGithubRouteImport parentRoute: typeof rootRouteImport } + '/api/repositories/$id/settings': { + id: '/api/repositories/$id/settings' + path: '/settings' + fullPath: '/api/repositories/$id/settings' + preLoaderRoute: typeof ApiRepositoriesIdSettingsRouteImport + parentRoute: typeof ApiRepositoriesIdRoute + } + '/api/repositories/$id/overlaps': { + id: '/api/repositories/$id/overlaps' + path: '/overlaps' + fullPath: '/api/repositories/$id/overlaps' + preLoaderRoute: typeof ApiRepositoriesIdOverlapsRouteImport + parentRoute: typeof ApiRepositoriesIdRoute + } + '/api/repositories/$id/diffs': { + id: '/api/repositories/$id/diffs' + path: '/diffs' + fullPath: '/api/repositories/$id/diffs' + preLoaderRoute: typeof ApiRepositoriesIdDiffsRouteImport + parentRoute: typeof ApiRepositoriesIdRoute + } + '/api/repositories/$id/branches': { + id: '/api/repositories/$id/branches' + path: '/branches' + fullPath: '/api/repositories/$id/branches' + preLoaderRoute: typeof ApiRepositoriesIdBranchesRouteImport + parentRoute: typeof ApiRepositoriesIdRoute + } '/api/auth/github/callback': { id: '/api/auth/github/callback' path: '/callback' @@ -231,9 +395,82 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthGithubCallbackRouteImport parentRoute: typeof ApiAuthGithubRoute } + '/api/repositories/$id/overlaps/$overlapId': { + id: '/api/repositories/$id/overlaps/$overlapId' + path: '/$overlapId' + fullPath: '/api/repositories/$id/overlaps/$overlapId' + preLoaderRoute: typeof ApiRepositoriesIdOverlapsOverlapIdRouteImport + parentRoute: typeof ApiRepositoriesIdOverlapsRoute + } + '/api/repositories/$id/overlaps/$overlapId/test-notify': { + id: '/api/repositories/$id/overlaps/$overlapId/test-notify' + path: '/test-notify' + fullPath: '/api/repositories/$id/overlaps/$overlapId/test-notify' + preLoaderRoute: typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRouteImport + parentRoute: typeof ApiRepositoriesIdOverlapsOverlapIdRoute + } } } +interface ApiRepositoriesIdOverlapsOverlapIdRouteChildren { + ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute: typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute +} + +const ApiRepositoriesIdOverlapsOverlapIdRouteChildren: ApiRepositoriesIdOverlapsOverlapIdRouteChildren = + { + ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute: + ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute, + } + +const ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren = + ApiRepositoriesIdOverlapsOverlapIdRoute._addFileChildren( + ApiRepositoriesIdOverlapsOverlapIdRouteChildren, + ) + +interface ApiRepositoriesIdOverlapsRouteChildren { + ApiRepositoriesIdOverlapsOverlapIdRoute: typeof ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren +} + +const ApiRepositoriesIdOverlapsRouteChildren: ApiRepositoriesIdOverlapsRouteChildren = + { + ApiRepositoriesIdOverlapsOverlapIdRoute: + ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren, + } + +const ApiRepositoriesIdOverlapsRouteWithChildren = + ApiRepositoriesIdOverlapsRoute._addFileChildren( + ApiRepositoriesIdOverlapsRouteChildren, + ) + +interface ApiRepositoriesIdRouteChildren { + ApiRepositoriesIdBranchesRoute: typeof ApiRepositoriesIdBranchesRoute + ApiRepositoriesIdDiffsRoute: typeof ApiRepositoriesIdDiffsRoute + ApiRepositoriesIdOverlapsRoute: typeof ApiRepositoriesIdOverlapsRouteWithChildren + ApiRepositoriesIdSettingsRoute: typeof ApiRepositoriesIdSettingsRoute +} + +const ApiRepositoriesIdRouteChildren: ApiRepositoriesIdRouteChildren = { + ApiRepositoriesIdBranchesRoute: ApiRepositoriesIdBranchesRoute, + ApiRepositoriesIdDiffsRoute: ApiRepositoriesIdDiffsRoute, + ApiRepositoriesIdOverlapsRoute: ApiRepositoriesIdOverlapsRouteWithChildren, + ApiRepositoriesIdSettingsRoute: ApiRepositoriesIdSettingsRoute, +} + +const ApiRepositoriesIdRouteWithChildren = + ApiRepositoriesIdRoute._addFileChildren(ApiRepositoriesIdRouteChildren) + +interface ApiRepositoriesRouteChildren { + ApiRepositoriesIdRoute: typeof ApiRepositoriesIdRouteWithChildren +} + +const ApiRepositoriesRouteChildren: ApiRepositoriesRouteChildren = { + ApiRepositoriesIdRoute: ApiRepositoriesIdRouteWithChildren, +} + +const ApiRepositoriesRouteWithChildren = ApiRepositoriesRoute._addFileChildren( + ApiRepositoriesRouteChildren, +) + interface ApiAuthGithubRouteChildren { ApiAuthGithubCallbackRoute: typeof ApiAuthGithubCallbackRoute } @@ -252,6 +489,8 @@ const rootRouteChildren: RootRouteChildren = { RepositoriesRoute: RepositoriesRoute, SettingsRoute: SettingsRoute, ApiHealthRoute: ApiHealthRoute, + ApiPushRoute: ApiPushRoute, + ApiRepositoriesRoute: ApiRepositoriesRouteWithChildren, RepositoriesRepoIdRoute: RepositoriesRepoIdRoute, ApiAuthGithubRoute: ApiAuthGithubRouteWithChildren, ApiAuthLogoutRoute: ApiAuthLogoutRoute, diff --git a/apps/web/src/routes/api/push.ts b/apps/web/src/routes/api/push.ts new file mode 100644 index 0000000..610f8e3 --- /dev/null +++ b/apps/web/src/routes/api/push.ts @@ -0,0 +1,102 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, pushSubscriptions } from '@overlap/db' +import { eq, and } from 'drizzle-orm' +import { requireUser } from '../../lib/auth' + +// Legitimate browser push service domains +const ALLOWED_PUSH_HOSTS = [ + 'fcm.googleapis.com', + 'updates.push.services.mozilla.com', + 'push.services.mozilla.com', + 'notify.windows.com', + 'web.push.apple.com', +] + +function isAllowedPushEndpoint(endpoint: string): boolean { + let url: URL + try { + url = new URL(endpoint) + } catch { + return false + } + + if (url.protocol !== 'https:') return false + + const hostname = url.hostname.toLowerCase() + return ALLOWED_PUSH_HOSTS.some( + (domain) => hostname === domain || hostname.endsWith('.' + domain) + ) +} + +export const Route = createFileRoute('/api/push')({ + server: { + handlers: { + // Subscribe to push notifications + POST: async ({ request }) => { + try { + const user = await requireUser(request) + const { endpoint, keys } = (await request.json()) as { + endpoint: string + keys: { p256dh: string; auth: string } + } + + if (!isAllowedPushEndpoint(endpoint)) { + return json({ error: 'Invalid push endpoint' }, { status: 400 }) + } + + const MAX_SUBSCRIPTIONS_PER_USER = 20 + + const existing = await db.query.pushSubscriptions.findMany({ + where: eq(pushSubscriptions.userId, user.id), + }) + + const isKnownEndpoint = existing.some((s) => s.endpoint === endpoint) + + if (!isKnownEndpoint && existing.length >= MAX_SUBSCRIPTIONS_PER_USER) { + return json({ error: 'Subscription limit reached' }, { status: 429 }) + } + + await db + .insert(pushSubscriptions) + .values({ + userId: user.id, + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + }) + .onConflictDoUpdate({ + target: [pushSubscriptions.userId, pushSubscriptions.endpoint], + set: { + p256dh: keys.p256dh, + auth: keys.auth, + }, + }) + + return json({ success: true }) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + // Unsubscribe from push notifications + DELETE: async ({ request }) => { + try { + const user = await requireUser(request) + const { endpoint } = (await request.json()) as { endpoint: string } + + await db + .delete(pushSubscriptions) + .where( + and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)) + ) + + return json({ success: true }) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.branches.ts b/apps/web/src/routes/api/repositories.$id.branches.ts new file mode 100644 index 0000000..70573bb --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.branches.ts @@ -0,0 +1,51 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, branches } from '@overlap/db' +import { eq, and, desc, sql } from 'drizzle-orm' +import { repositoryIdParamSchema, branchQuerySchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/branches')({ + server: { + handlers: { + // List branches for a repository + GET: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const url = new URL(request.url) + const { includeDefault, includeStale, page, limit } = branchQuerySchema.parse( + Object.fromEntries(url.searchParams) + ) + + await requireRepoAccess(user, id) + + const conditions = [eq(branches.repositoryId, id)] + + if (!includeDefault) { + conditions.push(eq(branches.isDefault, false)) + } + + if (!includeStale) { + const staleDate = new Date() + staleDate.setDate(staleDate.getDate() - 14) + conditions.push(sql`${branches.lastSeenAt} > ${staleDate.toISOString()}`) + } + + const branchList = await db.query.branches.findMany({ + where: and(...conditions), + orderBy: desc(branches.lastSeenAt), + limit, + offset: (page - 1) * limit, + }) + + return json(branchList) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.diffs.ts b/apps/web/src/routes/api/repositories.$id.diffs.ts new file mode 100644 index 0000000..c3d9b6a --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.diffs.ts @@ -0,0 +1,59 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, repositories } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { repositoryIdParamSchema, diffQuerySchema } from '@overlap/shared' +import { getGitHubClient } from '@overlap/github' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/diffs')({ + server: { + handlers: { + // Get all file diffs between two branches + GET: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const url = new URL(request.url) + const { base, head } = diffQuerySchema.parse(Object.fromEntries(url.searchParams)) + + await requireRepoAccess(user, id) + + const repoWithInstallation = await db.query.repositories.findFirst({ + where: eq(repositories.id, id), + with: { installation: true }, + }) + + if (!repoWithInstallation?.installation) { + return json({ error: 'Installation not found' }, { status: 500 }) + } + + const [owner, name] = repoWithInstallation.fullName.split('/') + const github = getGitHubClient() + + try { + const diffs = await github.getCompareDiffs( + repoWithInstallation.installation.installationId, + owner, + name, + base, + head + ) + + return json({ files: diffs }) + } catch (error: unknown) { + const err = error as { status?: number; message?: string } + if (err.status === 404) { + return json({ error: 'Branch no longer exists' }, { status: 404 }) + } + throw error + } + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts new file mode 100644 index 0000000..530f2e3 --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts @@ -0,0 +1,52 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, overlaps } from '@overlap/db' +import { eq, and } from 'drizzle-orm' +import { repositoryIdParamSchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId/test-notify')({ + server: { + handlers: { + // DEV ONLY: Test push notification for an existing overlap + POST: async ({ request, params }) => { + try { + const user = await requireUser(request) + + if (process.env.NODE_ENV === 'production') { + return json({ error: 'Not found' }, { status: 404 }) + } + + const { id } = repositoryIdParamSchema.parse(params) + const overlapId = params.overlapId + + await requireRepoAccess(user, id) + + const overlap = await db.query.overlaps.findFirst({ + where: and(eq(overlaps.id, overlapId), eq(overlaps.repositoryId, id)), + with: { sourceBranch: true, targetBranch: true }, + }) + + if (!overlap) { + return json({ error: 'Overlap not found' }, { status: 404 }) + } + + // The original Fastify handler queued this via BullMQ + // (fastify.queues.pushNotification.add(...)). That queue does not + // exist in this app: push dispatch becomes the "sendPush" workflow + // step ported in a later task of this migration. Until that step + // and its trigger exist, this dev-only endpoint cannot actually + // send a notification. + return json( + { success: false, message: 'Push notification dispatch not yet wired up' }, + { status: 501 } + ) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts new file mode 100644 index 0000000..fbd7db3 --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts @@ -0,0 +1,48 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, overlaps } from '@overlap/db' +import { eq, and } from 'drizzle-orm' +import { repositoryIdParamSchema, overlapUpdateSchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId')({ + server: { + handlers: { + // Update overlap status (resolve/ignore) + PATCH: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const overlapId = params.overlapId + const { status } = overlapUpdateSchema.parse(await request.json()) + + await requireRepoAccess(user, id) + + const overlap = await db.query.overlaps.findFirst({ + where: and(eq(overlaps.id, overlapId), eq(overlaps.repositoryId, id)), + }) + + if (!overlap) { + return json({ error: 'Overlap not found' }, { status: 404 }) + } + + const [updated] = await db + .update(overlaps) + .set({ + status, + resolvedAt: status === 'resolved' ? new Date() : null, + updatedAt: new Date(), + }) + .where(eq(overlaps.id, overlapId)) + .returning() + + return json(updated) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.ts b/apps/web/src/routes/api/repositories.$id.overlaps.ts new file mode 100644 index 0000000..8f7a7da --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.overlaps.ts @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, overlaps } from '@overlap/db' +import { eq, and, desc, sql } from 'drizzle-orm' +import { repositoryIdParamSchema, overlapQuerySchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/overlaps')({ + server: { + handlers: { + // List overlaps for a repository + GET: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const url = new URL(request.url) + const { status, severity, branchId, page, limit } = overlapQuerySchema.parse( + Object.fromEntries(url.searchParams) + ) + + await requireRepoAccess(user, id) + + const conditions = [eq(overlaps.repositoryId, id)] + + if (status) { + conditions.push(eq(overlaps.status, status)) + } + + if (severity) { + conditions.push(eq(overlaps.severity, severity)) + } + + if (branchId) { + conditions.push( + sql`(${overlaps.sourceBranchId} = ${branchId} OR ${overlaps.targetBranchId} = ${branchId})` + ) + } + + const overlapList = await db.query.overlaps.findMany({ + where: and(...conditions), + with: { + sourceBranch: true, + targetBranch: true, + files: true, + }, + orderBy: desc(overlaps.detectedAt), + limit, + offset: (page - 1) * limit, + }) + + return json(overlapList) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.settings.ts b/apps/web/src/routes/api/repositories.$id.settings.ts new file mode 100644 index 0000000..051c3a8 --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.settings.ts @@ -0,0 +1,38 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, repositorySettings } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { repositoryIdParamSchema, repositorySettingsUpdateSchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/settings')({ + server: { + handlers: { + // Update repository settings + PATCH: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const updates = repositorySettingsUpdateSchema.parse(await request.json()) + + await requireRepoAccess(user, id) + + const [updated] = await db + .update(repositorySettings) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(repositorySettings.repositoryId, id)) + .returning() + + return json(updated) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.ts b/apps/web/src/routes/api/repositories.$id.ts new file mode 100644 index 0000000..10b9441 --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.ts @@ -0,0 +1,36 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, repositories } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { repositoryIdParamSchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id')({ + server: { + handlers: { + // Get repository by ID (scoped to user's installations) + GET: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + await requireRepoAccess(user, id) + + // Re-query with relations for the detail view + const repoWithRelations = await db.query.repositories.findFirst({ + where: eq(repositories.id, id), + with: { + settings: true, + installation: true, + }, + }) + + return json(repoWithRelations) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.ts b/apps/web/src/routes/api/repositories.ts new file mode 100644 index 0000000..f79a729 --- /dev/null +++ b/apps/web/src/routes/api/repositories.ts @@ -0,0 +1,67 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, repositories, branches, overlaps } from '@overlap/db' +import { eq, and, desc, count, inArray } from 'drizzle-orm' +import { requireUser } from '../../lib/auth' +import { getUserInstallationIds } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories')({ + server: { + handlers: { + // List repositories (scoped to user's installations) + GET: async ({ request }) => { + try { + const user = await requireUser(request) + const installationIds = await getUserInstallationIds(user.id) + + if (installationIds.length === 0) { + return json([]) + } + + const repos = await db.query.repositories.findMany({ + where: and( + eq(repositories.isActive, true), + inArray(repositories.installationId, installationIds) + ), + with: { + settings: true, + }, + orderBy: desc(repositories.updatedAt), + }) + + // Get summary stats for each repo + const results = await Promise.all( + repos.map(async (repo) => { + const [branchCount] = await db + .select({ count: count() }) + .from(branches) + .where(and(eq(branches.repositoryId, repo.id), eq(branches.isDefault, false))) + + const [overlapCount] = await db + .select({ count: count() }) + .from(overlaps) + .where(and(eq(overlaps.repositoryId, repo.id), eq(overlaps.status, 'active'))) + + return { + id: repo.id, + name: repo.name, + fullName: repo.fullName, + defaultBranch: repo.defaultBranch, + isPrivate: repo.isPrivate, + activeBranches: branchCount?.count ?? 0, + activeOverlaps: overlapCount?.count ?? 0, + lastSyncedAt: repo.lastSyncedAt, + settings: repo.settings, + } + }) + ) + + return json(results) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 287d82e..4090e9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: '@overlap/db': specifier: workspace:^ version: link:../../packages/db + '@overlap/github': + specifier: workspace:* + version: link:../../packages/github '@overlap/shared': specifier: workspace:* version: link:../../packages/shared From c0c9077afddd75fa138c6f572e0a97ffef38e7bd Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 18:50:42 -0700 Subject: [PATCH 13/35] feat(web): port worker processors to workflow steps Fixes two swallowed-error paths carried over from the BullMQ processors: branch-sync no longer wipes a branch file index on transient GitHub failure, and check-run creation failures no longer pass silently. --- apps/web/package.json | 3 + .../src/workflows/__tests__/errors.test.ts | 61 + apps/web/src/workflows/errors.ts | 53 + apps/web/src/workflows/steps.ts | 1388 +++++++++++++++++ pnpm-lock.yaml | 9 + 5 files changed, 1514 insertions(+) create mode 100644 apps/web/src/workflows/__tests__/errors.test.ts create mode 100644 apps/web/src/workflows/errors.ts create mode 100644 apps/web/src/workflows/steps.ts diff --git a/apps/web/package.json b/apps/web/package.json index 4bfd3f0..fce9cfa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,10 +32,12 @@ "drizzle-orm": "^0.38.0", "jose": "^6.2.8", "lucide-react": "^0.470.0", + "minimatch": "^10.0.0", "nitro": "3.0.1-alpha.2", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^2.6.0", + "web-push": "^3.6.7", "workflow": "^4.8.2" }, "devDependencies": { @@ -43,6 +45,7 @@ "@types/node": "^20.11.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.1.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", diff --git a/apps/web/src/workflows/__tests__/errors.test.ts b/apps/web/src/workflows/__tests__/errors.test.ts new file mode 100644 index 0000000..9660ff6 --- /dev/null +++ b/apps/web/src/workflows/__tests__/errors.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { FatalError, RetryableError } from 'workflow' +import { classifyGitHubError } from '../errors' + +// `RetryableError` normalizes the `retryAfter` option into an absolute Date at +// construction time, so the duration string is asserted as the instant it +// resolves to. Freezing the clock keeps that instant deterministic. +const NOW = new Date('2026-01-01T00:00:00.000Z') + +describe('classifyGitHubError', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('maps 429 to RetryableError honoring Retry-After', () => { + const err = classifyGitHubError({ + status: 429, + message: 'rate limited', + response: { headers: { 'retry-after': '120' } }, + }) + expect(err).toBeInstanceOf(RetryableError) + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 120_000) + ) + }) + + it('maps 403 to RetryableError', () => { + const err = classifyGitHubError({ status: 403, message: 'forbidden' }) + expect(err).toBeInstanceOf(RetryableError) + }) + + it('defaults Retry-After to 5m when the header is absent', () => { + const err = classifyGitHubError({ status: 429, message: 'rate limited' }) + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 300_000) + ) + }) + + it('maps 500 to RetryableError', () => { + expect(classifyGitHubError({ status: 500, message: 'boom' })).toBeInstanceOf( + RetryableError + ) + }) + + it('maps 404 to FatalError', () => { + expect(classifyGitHubError({ status: 404, message: 'gone' })).toBeInstanceOf( + FatalError + ) + }) + + it('maps 422 to FatalError', () => { + expect( + classifyGitHubError({ status: 422, message: 'unprocessable' }) + ).toBeInstanceOf(FatalError) + }) +}) diff --git a/apps/web/src/workflows/errors.ts b/apps/web/src/workflows/errors.ts new file mode 100644 index 0000000..b8029d8 --- /dev/null +++ b/apps/web/src/workflows/errors.ts @@ -0,0 +1,53 @@ +import { FatalError, RetryableError } from 'workflow' + +type GitHubErrorLike = { + status?: number + message?: string + response?: { headers?: Record } +} + +/** + * GitHub reports Retry-After as a whole number of seconds. + * + * Anything else - an HTTP-date, an empty string, a malformed value - falls + * back to the default rather than being passed through, because + * `RetryableError` throws when `ms` cannot parse the duration string. Passing + * an unparseable header straight through would turn a rate limit into a crash + * inside the very catch block that is meant to handle it. + */ +function retryAfterFromHeader(header: string | undefined): `${number}s` | '5m' { + if (header === undefined) { + return '5m' + } + + const trimmed = header.trim() + if (trimmed === '') { + return '5m' + } + + const seconds = Number(trimmed) + if (!Number.isFinite(seconds) || seconds < 0) { + return '5m' + } + + return `${seconds}s` +} + +export function classifyGitHubError(err: GitHubErrorLike): Error { + const status = err.status + const message = err.message ?? 'GitHub request failed' + + if (status === 429 || status === 403) { + const retryAfter = retryAfterFromHeader( + err.response?.headers?.['retry-after'] + ) + return new RetryableError(`GitHub rate limited: ${message}`, { retryAfter }) + } + + if (status !== undefined && status >= 400 && status < 500) { + return new FatalError(message) + } + + // 5xx, network failures and unknown shapes are transient. + return new RetryableError(message, { retryAfter: '30s' }) +} diff --git a/apps/web/src/workflows/steps.ts b/apps/web/src/workflows/steps.ts new file mode 100644 index 0000000..41aa6be --- /dev/null +++ b/apps/web/src/workflows/steps.ts @@ -0,0 +1,1388 @@ +/** + * Workflow steps. + * + * These are ports of the six BullMQ processors that used to live in + * `apps/worker/src/processors/`. Three things changed in the port: + * + * 1. The `Job` parameter became plain arguments. + * 2. Every `Queue` / `Redis` / `queue.add(...)` reference is gone. Steps no + * longer chain each other; a workflow function sequences them. Where a + * processor used to enqueue follow-on work it now returns the data the + * workflow needs to do the sequencing itself. + * 3. GitHub failures are classified into FatalError / RetryableError instead + * of being swallowed. + * + * Steps run in full Node.js. Only the workflow function is sandboxed. + */ + +import { + branchFiles, + branches, + db, + githubAppInstallations, + organizations, + overlapFiles, + overlaps, + prAlerts, + pullRequests, + pushSubscriptions, + repositories, + userInstallations, + users, + webhookEvents, +} from '@overlap/db' +import { and, eq, gt, inArray, lt, ne, sql } from 'drizzle-orm' +import { + DEFAULT_SETTINGS, + GITHUB_EVENTS, + PR_ACTIONS, + calculateSeverity, + installationEventSchema, + pullRequestEventSchema, + pushEventSchema, +} from '@overlap/shared' +import { + extractBranchFromRef, + formatCheckRunSummary, + getGitHubClient, + isBranchDeletion, + type CommitFile, +} from '@overlap/github' +import { FatalError } from 'workflow' +import { minimatch } from 'minimatch' +import webpush from 'web-push' +import { classifyGitHubError } from './errors' + +// ============================================================================ +// Types +// ============================================================================ + +/** + * A webhook delivery resolved into the shape the workflow branches on. + * `installation_repositories` deliveries are reported as `installation` + * because `syncInstallation` handles both. + */ +export type LoadedEvent = + | { + type: 'push' + deliveryId: string + eventType: string + /** Branch name extracted from the ref, or null for non-branch refs. */ + branchName: string | null + sha: string + /** True when the push deleted the branch. */ + isDeletion: boolean + } + | { + type: 'pull_request' + deliveryId: string + eventType: string + action: string + /** False for actions the pipeline ignores. */ + isRelevant: boolean + } + | { + type: 'installation' + deliveryId: string + eventType: string + action: string + } + | { + type: 'unhandled' + deliveryId: string + eventType: string + } + +/** + * Work that `detectOverlaps` used to enqueue and now hands back to the + * workflow: one entry per overlap that warrants a notification. + */ +export type NotificationTarget = { + repositoryId: string + overlapId: string + targetBranchId: string + pullRequestIds: string[] +} + +type Severity = 'low' | 'medium' | 'high' | 'critical' + +// ============================================================================ +// Internal helpers (plain functions, called from inside steps) +// ============================================================================ + +async function loadEventRow(deliveryId: string) { + const event = await db.query.webhookEvents.findFirst({ + where: eq(webhookEvents.deliveryId, deliveryId), + }) + + if (!event) { + throw new FatalError(`Webhook event not found: ${deliveryId}`) + } + + return event +} + +/** + * A payload that does not match its schema will never match it on a retry, + * so parse failures are fatal. + */ +function parsePayload( + schema: { parse: (value: unknown) => T }, + payload: unknown, + deliveryId: string +): T { + try { + return schema.parse(payload) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new FatalError( + `Malformed webhook payload for delivery ${deliveryId}: ${message}` + ) + } +} + +function mapChangeType( + status: string +): 'added' | 'modified' | 'deleted' | 'renamed' { + switch (status) { + case 'added': + return 'added' + case 'removed': + return 'deleted' + case 'renamed': + return 'renamed' + default: + return 'modified' + } +} + +function compareSeverity(a: Severity, b: Severity): number { + const order = { low: 0, medium: 1, high: 2, critical: 3 } + return order[a] - order[b] +} + +// ============================================================================ +// Webhook events (from processors/webhook-events.ts) +// ============================================================================ + +/** + * Resolves a stored webhook delivery into a discriminated union the workflow + * can branch on. + */ +export async function loadEvent(deliveryId: string): Promise { + 'use step' + + const event = await loadEventRow(deliveryId) + const eventType = event.eventType + + switch (eventType) { + case GITHUB_EVENTS.PUSH: { + const parsed = parsePayload(pushEventSchema, event.payload, deliveryId) + return { + type: 'push', + deliveryId, + eventType, + branchName: extractBranchFromRef(parsed.ref), + sha: parsed.after, + isDeletion: isBranchDeletion(parsed.after), + } + } + + case GITHUB_EVENTS.PULL_REQUEST: { + const parsed = parsePayload( + pullRequestEventSchema, + event.payload, + deliveryId + ) + const relevantActions: string[] = [ + PR_ACTIONS.OPENED, + PR_ACTIONS.SYNCHRONIZE, + PR_ACTIONS.REOPENED, + PR_ACTIONS.CLOSED, + ] + return { + type: 'pull_request', + deliveryId, + eventType, + action: parsed.action, + isRelevant: relevantActions.includes(parsed.action), + } + } + + case GITHUB_EVENTS.INSTALLATION: + case GITHUB_EVENTS.INSTALLATION_REPOSITORIES: { + const payload = event.payload as { action?: string } + return { + type: 'installation', + deliveryId, + eventType, + action: payload.action ?? '', + } + } + + default: + return { type: 'unhandled', deliveryId, eventType } + } +} + +/** + * Records the outcome of a delivery. The processor did this in a try/catch + * around the whole job; the workflow now decides when to call it. + */ +export async function markEventProcessed( + deliveryId: string, + error?: string +): Promise<{ deliveryId: string }> { + 'use step' + + await db + .update(webhookEvents) + .set(error ? { error } : { processedAt: new Date() }) + .where(eq(webhookEvents.deliveryId, deliveryId)) + + return { deliveryId } +} + +/** + * Upserts the branch a push touched. Returns null when there is nothing to + * sync: a non-branch ref, a branch deletion, or an unknown repository. + * + * The processor enqueued branch-sync and (for non-default branches) overlap + * detection here. Both are now the workflow's job, which is what `isDefault` + * and the branch identifiers in the return value are for. + */ +export async function upsertBranch(deliveryId: string): Promise<{ + branchId: string + repositoryId: string + installationId: number + branchName: string + sha: string + isDefault: boolean +} | null> { + 'use step' + + const event = await loadEventRow(deliveryId) + const parsed = parsePayload(pushEventSchema, event.payload, deliveryId) + const branchName = extractBranchFromRef(parsed.ref) + + if (!branchName) { + console.log(`Ignoring non-branch ref: ${parsed.ref}`) + return null + } + + // Skip branch deletions (handled synchronously when the webhook arrives) + if (isBranchDeletion(parsed.after)) { + return null + } + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, parsed.repository.id), + with: { installation: true }, + }) + + if (!repo) { + console.log(`Repository not found: ${parsed.repository.full_name}`) + return null + } + + const isDefault = branchName === repo.defaultBranch + + const existingBranch = await db.query.branches.findFirst({ + where: and(eq(branches.repositoryId, repo.id), eq(branches.name, branchName)), + }) + + let branchId: string + + if (existingBranch) { + await db + .update(branches) + .set({ + sha: parsed.after, + lastPusherGithubId: parsed.sender.id, + lastSeenAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(branches.id, existingBranch.id)) + branchId = existingBranch.id + } else { + const [newBranch] = await db + .insert(branches) + .values({ + repositoryId: repo.id, + name: branchName, + sha: parsed.after, + isDefault, + lastPusherGithubId: parsed.sender.id, + lastSeenAt: new Date(), + }) + .returning() + branchId = newBranch.id + } + + return { + branchId, + repositoryId: repo.id, + installationId: repo.installation.installationId, + branchName, + sha: parsed.after, + isDefault, + } +} + +/** + * Upserts the pull request a `pull_request` delivery describes. Returns null + * when nothing downstream should run: an ignored action, an unknown + * repository or branch, or a closed PR (the record is still updated, but the + * processor did not run detection for closed PRs and neither do we). + */ +export async function upsertPullRequest( + deliveryId: string +): Promise<{ repositoryId: string; branchId: string } | null> { + 'use step' + + const event = await loadEventRow(deliveryId) + const parsed = parsePayload(pullRequestEventSchema, event.payload, deliveryId) + + const relevantActions: string[] = [ + PR_ACTIONS.OPENED, + PR_ACTIONS.SYNCHRONIZE, + PR_ACTIONS.REOPENED, + PR_ACTIONS.CLOSED, + ] + if (!relevantActions.includes(parsed.action)) { + return null + } + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, parsed.repository.id), + }) + + if (!repo) { + console.log(`Repository not found: ${parsed.repository.full_name}`) + return null + } + + const branch = await db.query.branches.findFirst({ + where: and( + eq(branches.repositoryId, repo.id), + eq(branches.name, parsed.pull_request.head.ref) + ), + }) + + if (!branch) { + console.log(`Branch not found: ${parsed.pull_request.head.ref}`) + return null + } + + let prState: 'open' | 'closed' | 'merged' = parsed.pull_request.state + if (parsed.action === PR_ACTIONS.CLOSED && parsed.pull_request.merged) { + prState = 'merged' + } + + await db + .insert(pullRequests) + .values({ + repositoryId: repo.id, + branchId: branch.id, + githubPrNumber: parsed.pull_request.number, + title: parsed.pull_request.title, + state: prState, + }) + .onConflictDoUpdate({ + target: [pullRequests.repositoryId, pullRequests.githubPrNumber], + set: { + title: parsed.pull_request.title, + state: prState, + updatedAt: new Date(), + }, + }) + + // Detection ran for open/reopened/synchronize only, never for closed. + if (parsed.action === PR_ACTIONS.CLOSED) { + return null + } + + return { repositoryId: repo.id, branchId: branch.id } +} + +/** + * Applies an `installation` or `installation_repositories` delivery. Returns + * the repositories that need a `syncRepository` run; the processor enqueued + * one maintenance job per repository here. + */ +export async function syncInstallation( + deliveryId: string +): Promise<{ repositoryIds: string[] }> { + 'use step' + + const event = await loadEventRow(deliveryId) + + if (event.eventType === GITHUB_EVENTS.INSTALLATION_REPOSITORIES) { + return await applyInstallationRepositories(event.payload) + } + + const parsed = parsePayload( + installationEventSchema, + event.payload, + deliveryId + ) + const repositoryIds: string[] = [] + + if (parsed.action === 'created') { + const accountType = parsed.installation.account.type + + let organizationId: string | null = null + + if (accountType === 'Organization') { + const [org] = await db + .insert(organizations) + .values({ + githubId: parsed.installation.account.id, + name: parsed.installation.account.login, + avatarUrl: parsed.installation.account.avatar_url, + }) + .onConflictDoUpdate({ + target: organizations.githubId, + set: { + name: parsed.installation.account.login, + avatarUrl: parsed.installation.account.avatar_url, + updatedAt: new Date(), + }, + }) + .returning() + organizationId = org.id + } + + // Link installation to user via sender.id (the person who installed) + let userId: string | null = null + const user = await db.query.users.findFirst({ + where: eq(users.githubId, parsed.sender.id), + }) + if (user) { + userId = user.id + } + + const [installation] = await db + .insert(githubAppInstallations) + .values({ + installationId: parsed.installation.id, + organizationId, + userId, + status: 'active', + }) + .onConflictDoUpdate({ + target: githubAppInstallations.installationId, + set: { + status: 'active', + updatedAt: new Date(), + }, + }) + .returning() + + // Link user to installation (many-to-many) + if (userId) { + await db + .insert(userInstallations) + .values({ userId, installationId: installation.id }) + .onConflictDoNothing() + } + + console.log( + `Installation created: ${parsed.installation.id} (userId: ${userId})` + ) + + if (parsed.repositories && parsed.repositories.length > 0) { + for (const repo of parsed.repositories) { + const [inserted] = await db + .insert(repositories) + .values({ + githubId: repo.id, + installationId: installation.id, + name: repo.name, + fullName: repo.full_name, + isPrivate: repo.private, + isActive: true, + }) + .onConflictDoUpdate({ + target: repositories.githubId, + set: { + isActive: true, + updatedAt: new Date(), + }, + }) + .returning() + + repositoryIds.push(inserted.id) + } + } + } else if (parsed.action === 'deleted') { + const existing = await db.query.githubAppInstallations.findFirst({ + where: eq(githubAppInstallations.installationId, parsed.installation.id), + }) + + if (existing) { + // Remove user-installation links for this installation + await db + .delete(userInstallations) + .where(eq(userInstallations.installationId, existing.id)) + } + + await db + .update(githubAppInstallations) + .set({ status: 'deleted', updatedAt: new Date() }) + .where(eq(githubAppInstallations.installationId, parsed.installation.id)) + + console.log(`Installation deleted: ${parsed.installation.id}`) + } + + return { repositoryIds } +} + +async function applyInstallationRepositories( + payload: Record +): Promise<{ repositoryIds: string[] }> { + const action = payload.action as string + const installationData = payload.installation as { id: number } + const repositoryIds: string[] = [] + + const installation = await db.query.githubAppInstallations.findFirst({ + where: eq(githubAppInstallations.installationId, installationData.id), + }) + + if (!installation) { + console.log(`Installation not found: ${installationData.id}`) + return { repositoryIds } + } + + if (action === 'added') { + const repos = payload.repositories_added as Array<{ + id: number + name: string + full_name: string + private: boolean + }> + + for (const repo of repos) { + const [inserted] = await db + .insert(repositories) + .values({ + githubId: repo.id, + installationId: installation.id, + name: repo.name, + fullName: repo.full_name, + isPrivate: repo.private, + isActive: true, + }) + .onConflictDoUpdate({ + target: repositories.githubId, + set: { + isActive: true, + updatedAt: new Date(), + }, + }) + .returning() + + repositoryIds.push(inserted.id) + } + } else if (action === 'removed') { + const repos = payload.repositories_removed as Array<{ id: number }> + + for (const repo of repos) { + await db + .update(repositories) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(repositories.githubId, repo.id)) + } + } + + return { repositoryIds } +} + +// ============================================================================ +// Branch sync (from processors/branch-sync.ts) +// ============================================================================ + +/** + * Rebuilds the file index for a branch. + * + * The processor caught a GitHub failure, set `changedFiles = []` and carried + * on into the delete, wiping the branch's file index whenever GitHub had a + * bad minute. Detection then read an empty index and resolved live overlaps. + * The fetch failure is now thrown, so the delete below is unreachable unless + * the fetch succeeded. + */ +export async function syncBranchFiles(input: { + repositoryId: string + branchName: string + sha: string + installationId: number +}): Promise<{ filesIndexed: number }> { + 'use step' + + const { repositoryId, branchName, sha, installationId } = input + + console.log(`Syncing branch: ${branchName} (${sha.slice(0, 7)})`) + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.id, repositoryId), + with: { settings: true }, + }) + + if (!repo) { + throw new FatalError(`Repository not found: ${repositoryId}`) + } + + const branch = await db.query.branches.findFirst({ + where: and( + eq(branches.repositoryId, repositoryId), + eq(branches.name, branchName) + ), + }) + + if (!branch) { + throw new FatalError(`Branch not found: ${branchName}`) + } + + const ignoredPaths = + repo.settings?.ignoredPaths ?? DEFAULT_SETTINGS.IGNORED_PATHS + + const github = getGitHubClient() + const [owner, repoName] = repo.fullName.split('/') + + let changedFiles: CommitFile[] + + try { + // Compare branch to default branch to get all changed files + changedFiles = await github.getBranchFiles( + installationId, + owner, + repoName, + branchName, + repo.defaultBranch + ) + } catch (error) { + throw classifyGitHubError(error as never) + } + + const filteredFiles = changedFiles.filter((file) => { + return !ignoredPaths.some((pattern) => minimatch(file.filename, pattern)) + }) + + console.log( + `Found ${filteredFiles.length} changed files (${changedFiles.length} before filtering)` + ) + + // Replace the branch's file index with what GitHub just reported. + await db.delete(branchFiles).where(eq(branchFiles.branchId, branch.id)) + + if (filteredFiles.length > 0) { + await db.insert(branchFiles).values( + filteredFiles.map((file) => ({ + branchId: branch.id, + filePath: file.filename, + changeType: mapChangeType(file.status), + })) + ) + } + + await db + .update(branches) + .set({ + sha, + lastSeenAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(branches.id, branch.id)) + + await db + .update(repositories) + .set({ lastSyncedAt: new Date(), updatedAt: new Date() }) + .where(eq(repositories.id, repositoryId)) + + console.log(`Branch sync complete: ${branchName}`) + + return { filesIndexed: filteredFiles.length } +} + +// ============================================================================ +// Overlap detection (from processors/overlap-detection.ts) +// ============================================================================ + +/** + * Recomputes the overlaps for a branch. + * + * The processor enqueued a github-feedback job per open PR and a + * push-notification job per overlap. Both are now returned as + * `NotificationTarget[]` for the workflow to fan out. The notify decision + * itself is unchanged: new overlap, severity increase, or reactivation. + */ +export async function detectOverlaps(input: { + repositoryId: string + branchId: string +}): Promise<{ overlapsFound: number; notifications: NotificationTarget[] }> { + 'use step' + + const { repositoryId, branchId } = input + + console.log(`Detecting overlaps for branch: ${branchId}`) + + const notifications: NotificationTarget[] = [] + + const branch = await db.query.branches.findFirst({ + where: eq(branches.id, branchId), + with: { files: true, repository: { with: { settings: true } } }, + }) + + if (!branch) { + throw new FatalError(`Branch not found: ${branchId}`) + } + + if (branch.isDefault) { + console.log('Skipping default branch') + return { overlapsFound: 0, notifications } + } + + if (branch.files.length === 0) { + console.log('Branch has no tracked files') + return { overlapsFound: 0, notifications } + } + + const pruningDays = + branch.repository.settings?.pruningDays ?? DEFAULT_SETTINGS.PRUNING_DAYS + + const staleDate = new Date() + staleDate.setDate(staleDate.getDate() - pruningDays) + + const otherBranches = await db.query.branches.findMany({ + where: and( + eq(branches.repositoryId, repositoryId), + ne(branches.id, branchId), + eq(branches.isDefault, false), + gt(branches.lastSeenAt, staleDate) + ), + with: { files: true }, + }) + + console.log(`Comparing against ${otherBranches.length} other branches`) + + const branchFilePaths = new Set(branch.files.map((f) => f.filePath)) + const detectedOverlaps: Array<{ + targetBranchId: string + files: Array<{ + filePath: string + sourceChangeType: string + targetChangeType: string + }> + }> = [] + + for (const otherBranch of otherBranches) { + const overlappingFiles: Array<{ + filePath: string + sourceChangeType: string + targetChangeType: string + }> = [] + + for (const otherFile of otherBranch.files) { + if (branchFilePaths.has(otherFile.filePath)) { + const sourceFile = branch.files.find( + (f) => f.filePath === otherFile.filePath + ) + if (sourceFile) { + overlappingFiles.push({ + filePath: otherFile.filePath, + sourceChangeType: sourceFile.changeType, + targetChangeType: otherFile.changeType, + }) + } + } + } + + if (overlappingFiles.length > 0) { + detectedOverlaps.push({ + targetBranchId: otherBranch.id, + files: overlappingFiles, + }) + } + } + + console.log(`Found ${detectedOverlaps.length} overlapping branches`) + + for (const detected of detectedOverlaps) { + const severity = calculateSeverity(detected.files.length) + + // Check if overlap already exists (in either direction) + const existingOverlap = await db.query.overlaps.findFirst({ + where: sql` + ${overlaps.repositoryId} = ${repositoryId} + AND ( + (${overlaps.sourceBranchId} = ${branchId} AND ${overlaps.targetBranchId} = ${detected.targetBranchId}) + OR (${overlaps.sourceBranchId} = ${detected.targetBranchId} AND ${overlaps.targetBranchId} = ${branchId}) + ) + `, + }) + + let overlapId: string + let isNew = false + let severityIncreased = false + let wasReactivated = false + + if (existingOverlap) { + const oldSeverity = existingOverlap.severity + severityIncreased = compareSeverity(severity, oldSeverity as Severity) > 0 + wasReactivated = + existingOverlap.status === 'resolved' || + existingOverlap.status === 'ignored' + + await db + .update(overlaps) + .set({ + fileCount: detected.files.length, + severity, + status: 'active', + resolvedAt: null, + updatedAt: new Date(), + }) + .where(eq(overlaps.id, existingOverlap.id)) + + overlapId = existingOverlap.id + + await db.delete(overlapFiles).where(eq(overlapFiles.overlapId, overlapId)) + } else { + isNew = true + const [newOverlap] = await db + .insert(overlaps) + .values({ + repositoryId, + sourceBranchId: branchId, + targetBranchId: detected.targetBranchId, + fileCount: detected.files.length, + severity, + status: 'active', + detectedAt: new Date(), + }) + .returning() + + overlapId = newOverlap.id + } + + await db.insert(overlapFiles).values( + detected.files.map((f) => ({ + overlapId, + filePath: f.filePath, + sourceChangeType: f.sourceChangeType, + targetChangeType: f.targetChangeType, + })) + ) + + // Notify on: new overlaps, severity increases, or reactivated overlaps + // (previously resolved/ignored but the file was edited again) + const shouldNotify = + (isNew && branch.repository.settings?.notifyOnNewOverlap !== false) || + (severityIncreased && + branch.repository.settings?.notifyOnSeverityIncrease !== false) || + wasReactivated + + if (shouldNotify) { + // Any open PR for this branch gets a check run. + const openPRs = await db.query.pullRequests.findMany({ + where: and( + eq(pullRequests.branchId, branchId), + eq(pullRequests.state, 'open') + ), + }) + + // The push notification goes to the developer on the OTHER branch: + // if A pushed and overlaps with B, notify B's developer. + notifications.push({ + repositoryId, + overlapId, + targetBranchId: detected.targetBranchId, + pullRequestIds: openPRs.map((pr) => pr.id), + }) + } + } + + // Resolve overlaps that no longer have overlapping files + const currentOverlaps = await db.query.overlaps.findMany({ + where: and( + eq(overlaps.repositoryId, repositoryId), + sql`(${overlaps.sourceBranchId} = ${branchId} OR ${overlaps.targetBranchId} = ${branchId})`, + eq(overlaps.status, 'active') + ), + }) + + const activeTargetIds = new Set(detectedOverlaps.map((o) => o.targetBranchId)) + + for (const overlap of currentOverlaps) { + const otherBranchId = + overlap.sourceBranchId === branchId + ? overlap.targetBranchId + : overlap.sourceBranchId + + if (!activeTargetIds.has(otherBranchId)) { + await db + .update(overlaps) + .set({ + status: 'resolved', + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(overlaps.id, overlap.id)) + + console.log(`Overlap resolved: ${overlap.id}`) + } + } + + return { overlapsFound: detectedOverlaps.length, notifications } +} + +// ============================================================================ +// GitHub feedback (from processors/github-feedback.ts) +// ============================================================================ + +/** + * Publishes the overlap check run on a pull request. + * + * The processor logged and swallowed `createCheckRun` failures, then recorded + * a `prAlerts` row with a null check run id as if the alert had been + * delivered. The failure is now classified and thrown, so a transient GitHub + * error is retried instead of being recorded as a delivered alert. + */ +export async function postCheckRun(input: { + repositoryId: string + pullRequestId: string + overlapId: string +}): Promise<{ checkRunId: number | null }> { + 'use step' + + const { repositoryId, pullRequestId, overlapId } = input + + console.log( + `Sending GitHub feedback for PR: ${pullRequestId}, overlap: ${overlapId}` + ) + + const pr = await db.query.pullRequests.findFirst({ + where: eq(pullRequests.id, pullRequestId), + with: { + branch: true, + repository: { + with: { + installation: true, + }, + }, + }, + }) + + if (!pr) { + throw new FatalError(`Pull request not found: ${pullRequestId}`) + } + + const overlap = await db.query.overlaps.findFirst({ + where: eq(overlaps.id, overlapId), + with: { + files: true, + sourceBranch: true, + targetBranch: true, + }, + }) + + if (!overlap) { + throw new FatalError(`Overlap not found: ${overlapId}`) + } + + // Report every active overlap on this branch, not just the trigger. + const allOverlaps = await db.query.overlaps.findMany({ + where: and( + eq(overlaps.repositoryId, repositoryId), + eq(overlaps.status, 'active') + ), + with: { + files: true, + sourceBranch: true, + targetBranch: true, + }, + }) + + const branchOverlaps = allOverlaps.filter( + (o) => o.sourceBranchId === pr.branch.id || o.targetBranchId === pr.branch.id + ) + + if (branchOverlaps.length === 0) { + console.log('No active overlaps for this branch') + return { checkRunId: null } + } + + const existingAlert = await db.query.prAlerts.findFirst({ + where: and( + eq(prAlerts.pullRequestId, pullRequestId), + eq(prAlerts.overlapId, overlapId) + ), + }) + + const github = getGitHubClient() + const [owner, repoName] = pr.repository.fullName.split('/') + const installationId = pr.repository.installation.installationId + + let checkRunId: number | null = existingAlert?.checkRunId ?? null + + const overlapData = branchOverlaps.map((o) => { + const otherBranch = + o.sourceBranchId === pr.branch.id ? o.targetBranch : o.sourceBranch + + return { + branchName: otherBranch.name, + files: o.files.map((f) => f.filePath), + fileCount: o.fileCount, + severity: o.severity as Severity, + } + }) + + // Create check run (no PR comments - GitHub already shows conflicts) + const { conclusion, title, summary } = formatCheckRunSummary(overlapData) + + try { + checkRunId = await github.createCheckRun( + installationId, + owner, + repoName, + pr.branch.sha, + 'Overlap Detection', + conclusion, + title, + summary + ) + console.log(`Check run created: ${checkRunId}`) + } catch (error) { + throw classifyGitHubError(error as never) + } + + if (existingAlert) { + await db + .update(prAlerts) + .set({ checkRunId }) + .where(eq(prAlerts.id, existingAlert.id)) + } else { + await db.insert(prAlerts).values({ + pullRequestId, + overlapId, + alertType: 'check_run', + checkRunId, + }) + } + + return { checkRunId } +} + +// ============================================================================ +// Push notification (from processors/push-notification.ts) +// ============================================================================ + +/** + * Sends the web push notification for an overlap to the last pusher on the + * target branch. + */ +export async function sendPush(input: { + repositoryId: string + overlapId: string + targetBranchId: string +}): Promise<{ sent: number }> { + 'use step' + + const { repositoryId, overlapId, targetBranchId } = input + + const appUrl = process.env.APP_URL || 'http://localhost:3000' + const vapidPublicKey = process.env.VAPID_PUBLIC_KEY + const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY + const vapidSubject = process.env.VAPID_SUBJECT + + if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) { + console.log('VAPID keys not configured, skipping push notification') + return { sent: 0 } + } + + webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey) + + const overlap = await db.query.overlaps.findFirst({ + where: eq(overlaps.id, overlapId), + with: { + sourceBranch: true, + targetBranch: true, + files: true, + }, + }) + + if (!overlap) { + console.log(`Overlap not found: ${overlapId}`) + return { sent: 0 } + } + + const targetBranch = await db.query.branches.findFirst({ + where: eq(branches.id, targetBranchId), + }) + + if (!targetBranch?.lastPusherGithubId) { + console.log(`No pusher info for branch: ${targetBranchId}`) + return { sent: 0 } + } + + const user = await db.query.users.findFirst({ + where: eq(users.githubId, targetBranch.lastPusherGithubId), + }) + + if (!user) { + console.log( + `User not found for githubId: ${targetBranch.lastPusherGithubId}` + ) + return { sent: 0 } + } + + const subscriptions = await db.query.pushSubscriptions.findMany({ + where: eq(pushSubscriptions.userId, user.id), + }) + + if (subscriptions.length === 0) { + console.log(`No push subscriptions for user: ${user.id}`) + return { sent: 0 } + } + + // Orient so the recipient's branch appears first (same as UI auto-orient) + const isRecipientSource = overlap.sourceBranchId === targetBranchId + const yourBranch = isRecipientSource + ? overlap.sourceBranch.name + : overlap.targetBranch.name + const otherBranch = isRecipientSource + ? overlap.targetBranch.name + : overlap.sourceBranch.name + const fileCount = overlap.files.length + + const payload = JSON.stringify({ + title: `Overlap Detected · ${fileCount} file${fileCount !== 1 ? 's' : ''}`, + body: `${yourBranch} ↔ ${otherBranch}`, + url: `${appUrl}/repositories/${repositoryId}`, + tag: `overlap-${overlapId}`, + }) + + let sent = 0 + + for (const sub of subscriptions) { + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth, + }, + }, + payload + ) + sent++ + } catch (err) { + const statusCode = (err as { statusCode?: number }).statusCode + // Remove expired/invalid subscriptions + if (statusCode === 404 || statusCode === 410) { + await db + .delete(pushSubscriptions) + .where(eq(pushSubscriptions.id, sub.id)) + console.log(`Removed expired subscription: ${sub.id}`) + } else { + const message = err instanceof Error ? err.message : String(err) + console.error(`Failed to send push to ${sub.endpoint}:`, message) + } + } + } + + console.log( + `Sent ${sent}/${subscriptions.length} push notifications for overlap ${overlapId}` + ) + return { sent } +} + +// ============================================================================ +// Maintenance (from processors/maintenance.ts) +// ============================================================================ + +/** + * Reconciles the local branch list for a repository against GitHub. + */ +export async function syncRepository(repositoryId: string): Promise<{ + added: number + updated: number + markedForDeletion: number +}> { + 'use step' + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.id, repositoryId), + with: { installation: true }, + }) + + if (!repo) { + throw new FatalError(`Repository not found: ${repositoryId}`) + } + + const github = getGitHubClient() + const [owner, repoName] = repo.fullName.split('/') + + let remoteBranches + try { + remoteBranches = await github.getBranches( + repo.installation.installationId, + owner, + repoName + ) + } catch (error) { + throw classifyGitHubError(error as never) + } + + const localBranches = await db.query.branches.findMany({ + where: eq(branches.repositoryId, repositoryId), + }) + + const localBranchMap = new Map(localBranches.map((b) => [b.name, b])) + const remoteBranchNames = new Set(remoteBranches.map((b) => b.name)) + + let addedCount = 0 + let updatedCount = 0 + + for (const remote of remoteBranches) { + const local = localBranchMap.get(remote.name) + const isDefault = remote.name === repo.defaultBranch + + if (local) { + // Update if SHA changed + if (local.sha !== remote.sha) { + await db + .update(branches) + .set({ + sha: remote.sha, + lastSeenAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(branches.id, local.id)) + updatedCount++ + } + } else { + await db.insert(branches).values({ + repositoryId, + name: remote.name, + sha: remote.sha, + isDefault, + lastSeenAt: new Date(), + }) + addedCount++ + } + } + + const deletedCount = localBranches.filter( + (b) => !remoteBranchNames.has(b.name) + ).length + + // Mark missing branches as stale (they'll be pruned later) + for (const local of localBranches) { + if (!remoteBranchNames.has(local.name)) { + await db + .update(branches) + .set({ + lastSeenAt: new Date(0), // Epoch = very old + updatedAt: new Date(), + }) + .where(eq(branches.id, local.id)) + } + } + + await db + .update(repositories) + .set({ lastSyncedAt: new Date(), updatedAt: new Date() }) + .where(eq(repositories.id, repositoryId)) + + console.log( + `Synced repository: added ${addedCount}, updated ${updatedCount}, marked ${deletedCount} for deletion` + ) + + return { + added: addedCount, + updated: updatedCount, + markedForDeletion: deletedCount, + } +} + +/** + * Deletes branches nobody has pushed to inside the repository's pruning + * window, resolving any overlap that involved them. + */ +export async function pruneStaleBranches( + repositoryId?: string +): Promise<{ prunedBranches: number }> { + 'use step' + + let prunedCount = 0 + + const repos = repositoryId + ? await db.query.repositories.findMany({ + where: eq(repositories.id, repositoryId), + with: { settings: true }, + }) + : await db.query.repositories.findMany({ + where: eq(repositories.isActive, true), + with: { settings: true }, + }) + + for (const repo of repos) { + const pruningDays = repo.settings?.pruningDays ?? DEFAULT_SETTINGS.PRUNING_DAYS + const staleDate = new Date() + staleDate.setDate(staleDate.getDate() - pruningDays) + + // Find stale branches (non-default, not seen recently) + const staleBranches = await db.query.branches.findMany({ + where: and( + eq(branches.repositoryId, repo.id), + eq(branches.isDefault, false), + lt(branches.lastSeenAt, staleDate) + ), + }) + + if (staleBranches.length === 0) continue + + const staleBranchIds = staleBranches.map((b) => b.id) + + await db + .delete(branchFiles) + .where(inArray(branchFiles.branchId, staleBranchIds)) + + await db + .update(overlaps) + .set({ + status: 'resolved', + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where( + sql`(${overlaps.sourceBranchId} IN (${sql.join(staleBranchIds, sql`, `)}) OR ${overlaps.targetBranchId} IN (${sql.join(staleBranchIds, sql`, `)}))` + ) + + await db.delete(branches).where(inArray(branches.id, staleBranchIds)) + + prunedCount += staleBranches.length + console.log( + `Pruned ${staleBranches.length} stale branches from ${repo.fullName}` + ) + } + + return { prunedBranches: prunedCount } +} + +/** + * Drops webhook event rows older than seven days. + */ +export async function cleanupOldEvents(): Promise<{ cleaned: boolean }> { + 'use step' + + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - 7) + + await db.delete(webhookEvents).where(lt(webhookEvents.createdAt, cutoffDate)) + + console.log('Cleaned up old webhook events') + return { cleaned: true } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4090e9f..d7cb10a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: lucide-react: specifier: ^0.470.0 version: 0.470.0(react@19.2.4) + minimatch: + specifier: ^10.0.0 + version: 10.2.6 nitro: specifier: 3.0.1-alpha.2 version: 3.0.1-alpha.2(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -147,6 +150,9 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.1 + web-push: + specifier: ^3.6.7 + version: 3.6.7 workflow: specifier: ^4.8.2 version: 4.8.2(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(typescript@5.9.3) @@ -163,6 +169,9 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.10) + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 '@vitejs/plugin-react': specifier: ^5.1.0 version: 5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) From ecb9454c383ca1451e2c540b39ee11861fd20ac5 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 19:00:08 -0700 Subject: [PATCH 14/35] fix(web): harden Retry-After parsing in the GitHub error classifier The previous "finite and non-negative" guard still let unusable values reach RetryableError, reproducing the crash-inside-the-catch it was meant to prevent: headers >= 1e21 stringified to exponential notation that ms cannot parse and threw a bare Error out of the classifier, values above ~8.64e12 seconds overflowed the Date range, and Number() happily accepted hex, exponent, signed and fractional forms that are not seconds counts. Accept only a bare run of digits, capped at one hour, and return a RetryableError when the thrown value is not an object at all. --- .../src/workflows/__tests__/errors.test.ts | 48 +++++++++++++++++++ apps/web/src/workflows/errors.ts | 39 ++++++++++----- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/apps/web/src/workflows/__tests__/errors.test.ts b/apps/web/src/workflows/__tests__/errors.test.ts index 9660ff6..6422586 100644 --- a/apps/web/src/workflows/__tests__/errors.test.ts +++ b/apps/web/src/workflows/__tests__/errors.test.ts @@ -58,4 +58,52 @@ describe('classifyGitHubError', () => { classifyGitHubError({ status: 422, message: 'unprocessable' }) ).toBeInstanceOf(FatalError) }) + + // A Retry-After the constructor cannot parse would throw out of the + // classifier itself, stripping the 429 of its RetryableError classification. + // These cover the hostile shapes that reach `ms` as a duration string. + const rateLimited = (retryAfter: string) => + classifyGitHubError({ + status: 429, + message: 'rate limited', + response: { headers: { 'retry-after': retryAfter } }, + }) + + it('caps an oversized Retry-After at one hour rather than throwing', () => { + const err = rateLimited('1000000000000000000000') + expect(err).toBeInstanceOf(RetryableError) + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 3_600_000) + ) + }) + + it('caps a Retry-After that would overflow the Date range', () => { + const err = rateLimited('99999999999999') + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 3_600_000) + ) + }) + + it('ignores a Retry-After in exponent notation', () => { + const err = rateLimited('1e21') + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 300_000) + ) + }) + + it('ignores a hexadecimal Retry-After', () => { + const err = rateLimited('0x10') + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 300_000) + ) + }) + + it('returns a RetryableError when the thrown value is not an object', () => { + const err = classifyGitHubError(null as never) + expect(err).toBeInstanceOf(RetryableError) + expect(err.message).toBe('GitHub request failed') + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 30_000) + ) + }) }) diff --git a/apps/web/src/workflows/errors.ts b/apps/web/src/workflows/errors.ts index b8029d8..38dac6f 100644 --- a/apps/web/src/workflows/errors.ts +++ b/apps/web/src/workflows/errors.ts @@ -6,14 +6,27 @@ type GitHubErrorLike = { response?: { headers?: Record } } +/** Longest backoff we will honor from a Retry-After header. */ +const MAX_RETRY_AFTER_SECONDS = 3600 + /** - * GitHub reports Retry-After as a whole number of seconds. + * Resolves the Retry-After header into a duration string that is always safe + * to hand to `RetryableError`. + * + * `RetryableError` throws when `ms` cannot parse the duration, so a value that + * reaches the constructor unvalidated turns a rate limit into a crash inside + * the very catch block meant to handle it. Two rules keep that from happening: * - * Anything else - an HTTP-date, an empty string, a malformed value - falls - * back to the default rather than being passed through, because - * `RetryableError` throws when `ms` cannot parse the duration string. Passing - * an unparseable header straight through would turn a rate limit into a crash - * inside the very catch block that is meant to handle it. + * - Only a bare run of digits is accepted. RFC 9110 also permits an HTTP-date, + * and `Number()` additionally accepts hex, exponent, signed and fractional + * forms - none of which are a seconds count, and several of which stringify + * back into something `ms` rejects (`1e21` becomes `"1e+21s"`). + * - The value is capped at one hour. Digits alone can still exceed the Date + * range (anything past ~8.64e12 seconds yields an Invalid Date) or park a + * step for centuries. An hour comfortably covers a GitHub primary rate + * limit reset, which is the longest wait this backoff legitimately needs. + * + * Anything outside those rules falls back to the 5m default. */ function retryAfterFromHeader(header: string | undefined): `${number}s` | '5m' { if (header === undefined) { @@ -21,19 +34,21 @@ function retryAfterFromHeader(header: string | undefined): `${number}s` | '5m' { } const trimmed = header.trim() - if (trimmed === '') { - return '5m' - } - - const seconds = Number(trimmed) - if (!Number.isFinite(seconds) || seconds < 0) { + if (!/^\d+$/.test(trimmed)) { return '5m' } + const seconds = Math.min(Number(trimmed), MAX_RETRY_AFTER_SECONDS) return `${seconds}s` } export function classifyGitHubError(err: GitHubErrorLike): Error { + // Callers funnel everything through here with `error as never`, so anything + // thrown anywhere in the call stack arrives - including null and primitives. + if (typeof err !== 'object' || err === null) { + return new RetryableError('GitHub request failed', { retryAfter: '30s' }) + } + const status = err.status const message = err.message ?? 'GitHub request failed' From b68b2ef460a74fb650b4b9e9214a20ef134ae0c4 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 19:08:37 -0700 Subject: [PATCH 15/35] fix(github): make check run posting idempotent under step retry - Add updateCheckRun to the GitHub client alongside createCheckRun. - Remove the withRetry helper and unwrap every call site: the Workflow runtime now owns retry policy, and an inner retry loop was hiding the rate-limit signal classifyGitHubError needs to size RetryableError correctly. RateLimitError and its now-unused helpers (isRateLimitError, isSecondaryRateLimit, isTransientError, getRateLimitRetryAfter, sleep) go with it since they existed only to support that loop. - postCheckRun now looks up the pr_alerts row before calling GitHub. An existing checkRunId takes the update path; otherwise it creates a check run and records the id immediately, so a retry after a successful GitHub call finds it and updates instead of duplicating. - Add branching coverage for postCheckRun via a fake GitHub client and a mocked db. --- .../__tests__/post-check-run.test.ts | 181 ++++++++++++ apps/web/src/workflows/steps.ts | 83 ++++-- packages/github/src/client.ts | 258 ++++++------------ packages/github/src/index.ts | 1 - 4 files changed, 324 insertions(+), 199 deletions(-) create mode 100644 apps/web/src/workflows/__tests__/post-check-run.test.ts diff --git a/apps/web/src/workflows/__tests__/post-check-run.test.ts b/apps/web/src/workflows/__tests__/post-check-run.test.ts new file mode 100644 index 0000000..12e4556 --- /dev/null +++ b/apps/web/src/workflows/__tests__/post-check-run.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { formatCheckRunSummary } from '@overlap/github' + +/** + * `postCheckRun` looks up the `pr_alerts` row before calling GitHub so a step + * retry (GitHub call succeeded, step failed before returning) updates the + * check run it already recorded instead of creating a duplicate. These tests + * pin that branching: an existing `checkRunId` takes the update path, and its + * absence takes the create path and records the id immediately. + * + * `db` and `getGitHubClient` are mocked because `postCheckRun` reads them + * from module-level singletons; injecting a fake GitHub client is the only + * way to observe which client method it called without a real installation + * or a live database. + */ + +const { dbMock, updateSpy, insertSpy } = vi.hoisted(() => { + const updateSpy = vi.fn() + const insertSpy = vi.fn() + + const dbMock = { + query: { + pullRequests: { findFirst: vi.fn() }, + overlaps: { findFirst: vi.fn(), findMany: vi.fn() }, + prAlerts: { findFirst: vi.fn() }, + }, + update: vi.fn(() => ({ + set: vi.fn((values: unknown) => ({ + where: vi.fn(async (condition: unknown) => { + updateSpy(values, condition) + }), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(async (values: unknown) => { + insertSpy(values) + }), + })), + } + + return { dbMock, updateSpy, insertSpy } +}) + +vi.mock('@overlap/db', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, db: dbMock } +}) + +const { githubMock } = vi.hoisted(() => { + const githubMock = { + createCheckRun: vi.fn(), + updateCheckRun: vi.fn(), + } + return { githubMock } +}) + +vi.mock('@overlap/github', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getGitHubClient: () => githubMock } +}) + +const { postCheckRun } = await import('../steps') + +const PULL_REQUEST_ID = 'pr-1' +const OVERLAP_ID = 'overlap-1' +const REPOSITORY_ID = 'repo-1' +const INSTALLATION_ID = 42 + +const pr = { + id: PULL_REQUEST_ID, + branch: { id: 'branch-1', sha: 'sha-123' }, + repository: { + fullName: 'acme/widgets', + installation: { installationId: INSTALLATION_ID }, + }, +} + +const branchOverlap = { + id: OVERLAP_ID, + sourceBranchId: 'branch-1', + targetBranchId: 'branch-2', + fileCount: 2, + severity: 'high' as const, + files: [{ filePath: 'a.ts' }, { filePath: 'b.ts' }], + sourceBranch: { name: 'feature-a' }, + targetBranch: { name: 'feature-b' }, +} + +const expectedSummary = formatCheckRunSummary([ + { branchName: 'feature-b', fileCount: 2, severity: 'high' }, +]) + +describe('postCheckRun', () => { + beforeEach(() => { + vi.clearAllMocks() + dbMock.query.pullRequests.findFirst.mockResolvedValue(pr) + dbMock.query.overlaps.findFirst.mockResolvedValue({ id: OVERLAP_ID }) + dbMock.query.overlaps.findMany.mockResolvedValue([branchOverlap]) + }) + + it('updates the existing check run when pr_alerts already has a checkRunId', async () => { + dbMock.query.prAlerts.findFirst.mockResolvedValue({ + id: 'alert-1', + checkRunId: 555, + }) + + const result = await postCheckRun({ + repositoryId: REPOSITORY_ID, + pullRequestId: PULL_REQUEST_ID, + overlapId: OVERLAP_ID, + }) + + expect(githubMock.updateCheckRun).toHaveBeenCalledWith( + INSTALLATION_ID, + 'acme', + 'widgets', + 555, + expectedSummary.conclusion, + expectedSummary.title, + expectedSummary.summary + ) + expect(githubMock.createCheckRun).not.toHaveBeenCalled() + expect(updateSpy).not.toHaveBeenCalled() + expect(insertSpy).not.toHaveBeenCalled() + expect(result).toEqual({ checkRunId: 555 }) + }) + + it('creates a check run and records it immediately when none has been recorded', async () => { + dbMock.query.prAlerts.findFirst.mockResolvedValue(undefined) + githubMock.createCheckRun.mockResolvedValue(999) + + const result = await postCheckRun({ + repositoryId: REPOSITORY_ID, + pullRequestId: PULL_REQUEST_ID, + overlapId: OVERLAP_ID, + }) + + expect(githubMock.createCheckRun).toHaveBeenCalledWith( + INSTALLATION_ID, + 'acme', + 'widgets', + 'sha-123', + 'Overlap Detection', + expectedSummary.conclusion, + expectedSummary.title, + expectedSummary.summary + ) + expect(githubMock.updateCheckRun).not.toHaveBeenCalled() + expect(insertSpy).toHaveBeenCalledWith({ + pullRequestId: PULL_REQUEST_ID, + overlapId: OVERLAP_ID, + alertType: 'check_run', + checkRunId: 999, + }) + expect(updateSpy).not.toHaveBeenCalled() + expect(result).toEqual({ checkRunId: 999 }) + }) + + it('creates a check run and updates the stale pr_alerts row when one exists without a checkRunId', async () => { + dbMock.query.prAlerts.findFirst.mockResolvedValue({ + id: 'alert-2', + checkRunId: null, + }) + githubMock.createCheckRun.mockResolvedValue(777) + + const result = await postCheckRun({ + repositoryId: REPOSITORY_ID, + pullRequestId: PULL_REQUEST_ID, + overlapId: OVERLAP_ID, + }) + + expect(githubMock.createCheckRun).toHaveBeenCalled() + expect(githubMock.updateCheckRun).not.toHaveBeenCalled() + expect(updateSpy).toHaveBeenCalledWith( + { checkRunId: 777 }, + expect.anything() + ) + expect(insertSpy).not.toHaveBeenCalled() + expect(result).toEqual({ checkRunId: 777 }) + }) +}) diff --git a/apps/web/src/workflows/steps.ts b/apps/web/src/workflows/steps.ts index 41aa6be..eff586a 100644 --- a/apps/web/src/workflows/steps.ts +++ b/apps/web/src/workflows/steps.ts @@ -1012,6 +1012,10 @@ export async function postCheckRun(input: { return { checkRunId: null } } + // Looked up before calling GitHub so a retry of this step (after GitHub + // succeeded but the step failed before returning) finds the check run this + // attempt - or a prior attempt - already recorded, and updates it instead + // of creating a duplicate. const existingAlert = await db.query.prAlerts.findFirst({ where: and( eq(prAlerts.pullRequestId, pullRequestId), @@ -1023,8 +1027,6 @@ export async function postCheckRun(input: { const [owner, repoName] = pr.repository.fullName.split('/') const installationId = pr.repository.installation.installationId - let checkRunId: number | null = existingAlert?.checkRunId ?? null - const overlapData = branchOverlaps.map((o) => { const otherBranch = o.sourceBranchId === pr.branch.id ? o.targetBranch : o.sourceBranch @@ -1037,37 +1039,60 @@ export async function postCheckRun(input: { } }) - // Create check run (no PR comments - GitHub already shows conflicts) + // Create or update the check run (no PR comments - GitHub already shows + // conflicts). const { conclusion, title, summary } = formatCheckRunSummary(overlapData) - try { - checkRunId = await github.createCheckRun( - installationId, - owner, - repoName, - pr.branch.sha, - 'Overlap Detection', - conclusion, - title, - summary - ) - console.log(`Check run created: ${checkRunId}`) - } catch (error) { - throw classifyGitHubError(error as never) - } + let checkRunId: number - if (existingAlert) { - await db - .update(prAlerts) - .set({ checkRunId }) - .where(eq(prAlerts.id, existingAlert.id)) + if (existingAlert?.checkRunId) { + checkRunId = existingAlert.checkRunId + try { + await github.updateCheckRun( + installationId, + owner, + repoName, + checkRunId, + conclusion, + title, + summary + ) + console.log(`Check run updated: ${checkRunId}`) + } catch (error) { + throw classifyGitHubError(error as never) + } } else { - await db.insert(prAlerts).values({ - pullRequestId, - overlapId, - alertType: 'check_run', - checkRunId, - }) + try { + checkRunId = await github.createCheckRun( + installationId, + owner, + repoName, + pr.branch.sha, + 'Overlap Detection', + conclusion, + title, + summary + ) + console.log(`Check run created: ${checkRunId}`) + } catch (error) { + throw classifyGitHubError(error as never) + } + + // Recorded immediately so a retry after this point finds the check run + // and updates it instead of creating a second one. + if (existingAlert) { + await db + .update(prAlerts) + .set({ checkRunId }) + .where(eq(prAlerts.id, existingAlert.id)) + } else { + await db.insert(prAlerts).values({ + pullRequestId, + overlapId, + alertType: 'check_run', + checkRunId, + }) + } } return { checkRunId } diff --git a/packages/github/src/client.ts b/packages/github/src/client.ts index fac37d7..211f697 100644 --- a/packages/github/src/client.ts +++ b/packages/github/src/client.ts @@ -1,105 +1,6 @@ import { Octokit } from '@octokit/rest' import { createAppAuth } from '@octokit/auth-app' -// Rate limit handling -export class RateLimitError extends Error { - retryAfter: number - - constructor(message: string, retryAfter: number) { - super(message) - this.name = 'RateLimitError' - this.retryAfter = retryAfter - } -} - -async function withRetry( - fn: () => Promise, - maxRetries = 3, - baseDelay = 1000 -): Promise { - let lastError: Error | null = null - - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - return await fn() - } catch (error: unknown) { - lastError = error as Error - - // Check for rate limit - if (isRateLimitError(error)) { - const retryAfter = getRateLimitRetryAfter(error) - if (retryAfter > 0 && retryAfter < 300) { - // Max wait 5 minutes - console.log(`Rate limited. Waiting ${retryAfter}s before retry...`) - await sleep(retryAfter * 1000) - continue - } - throw new RateLimitError('GitHub API rate limit exceeded', retryAfter) - } - - // Check for secondary rate limit (abuse detection) - if (isSecondaryRateLimit(error)) { - const delay = baseDelay * Math.pow(2, attempt) - console.log(`Secondary rate limit. Waiting ${delay}ms before retry...`) - await sleep(delay) - continue - } - - // Check for transient errors (5xx) - if (isTransientError(error) && attempt < maxRetries - 1) { - const delay = baseDelay * Math.pow(2, attempt) - console.log(`Transient error. Waiting ${delay}ms before retry...`) - await sleep(delay) - continue - } - - throw error - } - } - - throw lastError -} - -function isRateLimitError(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false - const err = error as { status?: number; response?: { headers?: Record } } - return err.status === 403 && err.response?.headers?.['x-ratelimit-remaining'] === '0' -} - -function isSecondaryRateLimit(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false - const err = error as { status?: number; message?: string } - return err.status === 403 && (err.message?.includes('secondary rate limit') ?? false) -} - -function isTransientError(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false - const err = error as { status?: number } - return typeof err.status === 'number' && err.status >= 500 -} - -function getRateLimitRetryAfter(error: unknown): number { - if (typeof error !== 'object' || error === null) return 60 - const err = error as { response?: { headers?: Record } } - const retryAfter = err.response?.headers?.['retry-after'] - const resetTime = err.response?.headers?.['x-ratelimit-reset'] - - if (retryAfter) { - return parseInt(retryAfter, 10) - } - - if (resetTime) { - const resetTimestamp = parseInt(resetTime, 10) * 1000 - return Math.max(0, Math.ceil((resetTimestamp - Date.now()) / 1000)) - } - - return 60 // Default to 60 seconds -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - export interface GitHubConfig { appId: string privateKey: string @@ -184,19 +85,17 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - const { data } = await octokit.repos.getCommit({ - owner, - repo, - ref: sha, - }) - - return (data.files || []).map((file) => ({ - filename: file.filename, - status: file.status as CommitFile['status'], - previousFilename: file.previous_filename, - })) + const { data } = await octokit.repos.getCommit({ + owner, + repo, + ref: sha, }) + + return (data.files || []).map((file) => ({ + filename: file.filename, + status: file.status as CommitFile['status'], + previousFilename: file.previous_filename, + })) } /** @@ -211,20 +110,18 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - const { data } = await octokit.repos.compareCommits({ - owner, - repo, - base, - head, - }) - - return (data.files || []).map((file) => ({ - filename: file.filename, - status: file.status as CommitFile['status'], - previousFilename: file.previous_filename, - })) + const { data } = await octokit.repos.compareCommits({ + owner, + repo, + base, + head, }) + + return (data.files || []).map((file) => ({ + filename: file.filename, + status: file.status as CommitFile['status'], + previousFilename: file.previous_filename, + })) } /** @@ -239,24 +136,22 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - const { data } = await octokit.repos.compareCommits({ - owner, - repo, - base, - head, - }) - - return (data.files || []).map((file) => ({ - filename: file.filename, - status: file.status ?? 'modified', - additions: file.additions, - deletions: file.deletions, - changes: file.changes, - patch: file.patch ?? null, - previousFilename: file.previous_filename, - })) + const { data } = await octokit.repos.compareCommits({ + owner, + repo, + base, + head, }) + + return (data.files || []).map((file) => ({ + filename: file.filename, + status: file.status ?? 'modified', + additions: file.additions, + deletions: file.deletions, + changes: file.changes, + patch: file.patch ?? null, + previousFilename: file.previous_filename, + })) } /** @@ -317,26 +212,24 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - if (existingCommentId) { - await octokit.issues.updateComment({ - owner, - repo, - comment_id: existingCommentId, - body, - }) - return existingCommentId - } - - const { data } = await octokit.issues.createComment({ + if (existingCommentId) { + await octokit.issues.updateComment({ owner, repo, - issue_number: prNumber, + comment_id: existingCommentId, body, }) + return existingCommentId + } - return data.id + const { data } = await octokit.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, }) + + return data.id } /** @@ -354,22 +247,49 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - const { data } = await octokit.checks.create({ - owner, - repo, - name, - head_sha: headSha, - status: 'completed', - conclusion, - output: { - title, - summary, - }, - }) + const { data } = await octokit.checks.create({ + owner, + repo, + name, + head_sha: headSha, + status: 'completed', + conclusion, + output: { + title, + summary, + }, + }) + + return data.id + } + + /** + * Update an existing check run + */ + async updateCheckRun( + installationId: number, + owner: string, + repo: string, + checkRunId: number, + conclusion: 'success' | 'failure' | 'neutral', + title: string, + summary: string + ): Promise { + const octokit = await this.getInstallationClient(installationId) - return data.id + const { data } = await octokit.checks.update({ + owner, + repo, + check_run_id: checkRunId, + status: 'completed', + conclusion, + output: { + title, + summary, + }, }) + + return data.id } /** diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index 3200999..602ca0b 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -1,7 +1,6 @@ export { GitHubClient, getGitHubClient, - RateLimitError, type GitHubConfig, type CommitFile, type FileDiff, From 060efd02bde42e6c338bbd23ca992c27e9394e2e Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 19:18:59 -0700 Subject: [PATCH 16/35] feat(web): add durable webhook and maintenance workflows Replaces the 5s delay between branch sync and overlap detection with a real happens-before edge. processWebhook wraps its body in try/catch so markEventProcessed runs on both the success and the failure path; the BullMQ processor got that bookkeeping structurally from its own try/catch and the workflow author now owns it. detectOverlaps returns the same open-PR list on every notification, so the check run fan-out unions the pull request ids and posts each check run once instead of once per (overlap, PR) pair. postCheckRun already reports every active overlap for the branch in a single check run, so the extra calls were redundant GitHub API traffic. --- apps/web/src/workflows/maintenance.ts | 41 ++++++ apps/web/src/workflows/process-webhook.ts | 153 ++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 apps/web/src/workflows/maintenance.ts create mode 100644 apps/web/src/workflows/process-webhook.ts diff --git a/apps/web/src/workflows/maintenance.ts b/apps/web/src/workflows/maintenance.ts new file mode 100644 index 0000000..f87b569 --- /dev/null +++ b/apps/web/src/workflows/maintenance.ts @@ -0,0 +1,41 @@ +/** + * Maintenance workflows. + * + * These replace the repeatable BullMQ maintenance jobs. Each one is a thin + * durable wrapper around a single step so it can be triggered from a Vercel + * cron route and observed as a run. + */ + +import { cleanupOldEvents, pruneStaleBranches, syncRepository } from './steps' + +/** + * Deletes branches nobody has pushed to inside each repository's pruning + * window. + */ +export async function pruneBranchesWorkflow(): Promise<{ + prunedBranches: number +}> { + 'use workflow' + + return await pruneStaleBranches() +} + +/** + * Drops webhook event rows older than seven days. + */ +export async function cleanupEventsWorkflow(): Promise<{ cleaned: boolean }> { + 'use workflow' + + return await cleanupOldEvents() +} + +/** + * Reconciles one repository's branch list against GitHub. + */ +export async function syncRepositoryWorkflow( + repositoryId: string +): Promise { + 'use workflow' + + await syncRepository(repositoryId) +} diff --git a/apps/web/src/workflows/process-webhook.ts b/apps/web/src/workflows/process-webhook.ts new file mode 100644 index 0000000..efe8d00 --- /dev/null +++ b/apps/web/src/workflows/process-webhook.ts @@ -0,0 +1,153 @@ +/** + * The webhook workflow. + * + * This file is the reason for the migration. In the BullMQ system a push + * delivery enqueued a branch-sync job and, separately, an overlap-detection + * job with `delay: 5000` and the comment "Small delay to ensure sync completes + * first". That was a hope, not an ordering guarantee: whenever the sync took + * longer than five seconds, detection read a stale file index and computed the + * wrong overlaps. + * + * `await syncBranchFiles(...)` followed by `await detectOverlaps(...)` below is + * a real happens-before edge recorded in the workflow's event log. There is no + * delay, no sleep, and no timing assumption anywhere in this file, and there + * must never be one. + * + * Only orchestration lives here: this function runs in the workflow sandbox + * with no Node.js access. Every piece of I/O is inside a step in `./steps`. + * The orchestration is deliberately written inline rather than split into + * helper functions, so the compiler can see every step call and emit an + * accurate workflow graph. + */ + +import { + detectOverlaps, + loadEvent, + markEventProcessed, + postCheckRun, + sendPush, + syncBranchFiles, + syncInstallation, + syncRepository, + upsertBranch, + upsertPullRequest, +} from './steps' +import type { NotificationTarget } from './steps' + +/** + * Processes one stored webhook delivery. + * + * The BullMQ processor recorded the outcome of a delivery in its own + * try/catch, so the bookkeeping was structural. Here it is the workflow + * author's job: `markEventProcessed` has to run on both paths, or every + * `webhook_events` row stays unprocessed forever and failures leave no trace. + * The failure path records the error and rethrows so the run itself still + * fails. + */ +export async function processWebhook( + deliveryId: string +): Promise<{ handled: boolean }> { + 'use workflow' + + try { + const event = await loadEvent(deliveryId) + + let handled = false + let notifications: NotificationTarget[] = [] + + if (event.type === 'push') { + const branch = await upsertBranch(deliveryId) + + // Null means there is nothing to sync: a non-branch ref, a branch + // deletion, or an unknown repository. + if (branch) { + // The happens-before edge. Detection reads the file index this call + // writes, so it is awaited, never raced against a timer. + await syncBranchFiles({ + repositoryId: branch.repositoryId, + branchName: branch.branchName, + sha: branch.sha, + installationId: branch.installationId, + }) + + // The default branch is the comparison baseline; it never overlaps. + if (!branch.isDefault) { + const result = await detectOverlaps({ + repositoryId: branch.repositoryId, + branchId: branch.branchId, + }) + notifications = result.notifications + } + + handled = true + } + } else if (event.type === 'pull_request') { + const pullRequest = await upsertPullRequest(deliveryId) + + // Null means an ignored action, an unknown repository or branch, or a + // closed pull request. + if (pullRequest) { + // No branch sync here: a pull_request delivery does not change the + // head commit, so the index the preceding push wrote is current. + const result = await detectOverlaps({ + repositoryId: pullRequest.repositoryId, + branchId: pullRequest.branchId, + }) + notifications = result.notifications + + handled = true + } + } else if (event.type === 'installation') { + const { repositoryIds } = await syncInstallation(deliveryId) + + for (const repositoryId of repositoryIds) { + await syncRepository(repositoryId) + } + + handled = true + } + + // `detectOverlaps` builds `pullRequestIds` from one query - the open pull + // requests of the branch that was pushed - so every notification carries + // an identical array. Walking notifications and then pull request ids + // would issue N*M `postCheckRun` calls for N overlaps and M open pull + // requests, and each call does the same work: `postCheckRun` already + // reports *all* active overlaps for the branch in a single check run. + // Posting once per pull request produces the same GitHub state for a + // fraction of the API calls. A pull request keeps the overlap id of the + // first notification that named it, which stays correct even if the id + // lists ever stop being identical. + const postedPullRequestIds: string[] = [] + + for (const notification of notifications) { + for (const pullRequestId of notification.pullRequestIds) { + if (postedPullRequestIds.includes(pullRequestId)) continue + postedPullRequestIds.push(pullRequestId) + + await postCheckRun({ + repositoryId: notification.repositoryId, + pullRequestId, + overlapId: notification.overlapId, + }) + } + } + + // The push notification is per overlap, not per pull request: it goes to + // the developer on the other branch. + for (const notification of notifications) { + await sendPush({ + repositoryId: notification.repositoryId, + overlapId: notification.overlapId, + targetBranchId: notification.targetBranchId, + }) + } + + await markEventProcessed(deliveryId) + + return { handled } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await markEventProcessed(deliveryId, message) + throw error + } +} From ffff60a491b31475d374455329eaf6ef6c453205 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 19:19:03 -0700 Subject: [PATCH 17/35] feat(web): wire the dev-only test-notify route to a workflow The route returned 501 because its BullMQ pushNotification enqueue had no replacement. testNotifyWorkflow wraps the sendPush step, so the endpoint starts a durable run again. Auth, repo access, the overlap lookup and the 404 path are unchanged. --- ...ies.$id.overlaps.$overlapId.test-notify.ts | 24 ++++++++++++------- apps/web/src/workflows/test-notify.ts | 20 ++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/workflows/test-notify.ts diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts index 530f2e3..c206599 100644 --- a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts +++ b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts @@ -3,8 +3,10 @@ import { json } from '@tanstack/react-start' import { db, overlaps } from '@overlap/db' import { eq, and } from 'drizzle-orm' import { repositoryIdParamSchema } from '@overlap/shared' +import { start } from 'workflow/api' import { requireUser } from '../../lib/auth' import { requireRepoAccess } from '../../lib/repo-access' +import { testNotifyWorkflow } from '../../workflows/test-notify' export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId/test-notify')({ server: { @@ -33,15 +35,19 @@ export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId/ } // The original Fastify handler queued this via BullMQ - // (fastify.queues.pushNotification.add(...)). That queue does not - // exist in this app: push dispatch becomes the "sendPush" workflow - // step ported in a later task of this migration. Until that step - // and its trigger exist, this dev-only endpoint cannot actually - // send a notification. - return json( - { success: false, message: 'Push notification dispatch not yet wired up' }, - { status: 501 } - ) + // (fastify.queues.pushNotification.add(...)). The replacement is a + // durable run of the "sendPush" step. + const run = await start(testNotifyWorkflow, [ + id, + overlapId, + overlap.targetBranchId, + ]) + + return json({ + success: true, + message: 'Test notification queued', + runId: run.runId, + }) } catch (res) { if (res instanceof Response) return res throw res diff --git a/apps/web/src/workflows/test-notify.ts b/apps/web/src/workflows/test-notify.ts new file mode 100644 index 0000000..230f517 --- /dev/null +++ b/apps/web/src/workflows/test-notify.ts @@ -0,0 +1,20 @@ +/** + * Dev-only notification workflow. + * + * The Fastify app let a developer replay the push notification for an existing + * overlap by enqueueing a BullMQ `pushNotification` job directly from the route + * handler. Routes cannot invoke a step's durable semantics on their own, so the + * dev-only endpoint starts this workflow instead. + */ + +import { sendPush } from './steps' + +export async function testNotifyWorkflow( + repositoryId: string, + overlapId: string, + targetBranchId: string +): Promise<{ sent: number }> { + 'use workflow' + + return await sendPush({ repositoryId, overlapId, targetBranchId }) +} From cd8a6d9fc1bfba49782494c46092730ee31aade6 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 19:32:47 -0700 Subject: [PATCH 18/35] feat(web): port GitHub webhook route with verify-before-write ordering Signature verification of the raw request body precedes every database write and every workflow start (Spec S2). Deduplication moves from BullMQ's jobId to the webhook_events.deliveryId unique constraint, which is durable in Postgres rather than expiring out of Redis on a TTL: onConflictDoNothing().returning() returns no row on a GitHub redelivery, and the handler returns 200 without starting a second workflow run. The route stays a thin wrapper; handleWebhook takes the workflow start function as an injected dependency so the ordering is testable without a running workflow runtime. Branch-deletion cleanup, handled inline and synchronously in the old Fastify route, already lives in the Task 10 workflow's upsertBranch step and is intentionally not duplicated here. --- apps/web/src/routeTree.gen.ts | 21 +++ .../webhooks/__tests__/verify-order.test.ts | 139 ++++++++++++++++++ .../src/routes/api/webhooks/github-handler.ts | 91 ++++++++++++ apps/web/src/routes/api/webhooks/github.ts | 18 +++ 4 files changed, 269 insertions(+) create mode 100644 apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts create mode 100644 apps/web/src/routes/api/webhooks/github-handler.ts create mode 100644 apps/web/src/routes/api/webhooks/github.ts diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7076f68..bbe94c9 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as RepositoriesRepoIdRouteImport } from './routes/repositories_.$ import { Route as ApiRepositoriesRouteImport } from './routes/api/repositories' import { Route as ApiPushRouteImport } from './routes/api/push' import { Route as ApiHealthRouteImport } from './routes/api/health' +import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github' import { Route as ApiRepositoriesIdRouteImport } from './routes/api/repositories.$id' import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me' import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout' @@ -69,6 +70,11 @@ const ApiHealthRoute = ApiHealthRouteImport.update({ path: '/api/health', getParentRoute: () => rootRouteImport, } as any) +const ApiWebhooksGithubRoute = ApiWebhooksGithubRouteImport.update({ + id: '/api/webhooks/github', + path: '/api/webhooks/github', + getParentRoute: () => rootRouteImport, +} as any) const ApiRepositoriesIdRoute = ApiRepositoriesIdRouteImport.update({ id: '/$id', path: '/$id', @@ -143,6 +149,7 @@ export interface FileRoutesByFullPath { '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/auth/me': typeof ApiAuthMeRoute '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren + '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute '/api/repositories/$id/branches': typeof ApiRepositoriesIdBranchesRoute '/api/repositories/$id/diffs': typeof ApiRepositoriesIdDiffsRoute @@ -164,6 +171,7 @@ export interface FileRoutesByTo { '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/auth/me': typeof ApiAuthMeRoute '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren + '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute '/api/repositories/$id/branches': typeof ApiRepositoriesIdBranchesRoute '/api/repositories/$id/diffs': typeof ApiRepositoriesIdDiffsRoute @@ -186,6 +194,7 @@ export interface FileRoutesById { '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/auth/me': typeof ApiAuthMeRoute '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren + '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute '/api/repositories/$id/branches': typeof ApiRepositoriesIdBranchesRoute '/api/repositories/$id/diffs': typeof ApiRepositoriesIdDiffsRoute @@ -209,6 +218,7 @@ export interface FileRouteTypes { | '/api/auth/logout' | '/api/auth/me' | '/api/repositories/$id' + | '/api/webhooks/github' | '/api/auth/github/callback' | '/api/repositories/$id/branches' | '/api/repositories/$id/diffs' @@ -230,6 +240,7 @@ export interface FileRouteTypes { | '/api/auth/logout' | '/api/auth/me' | '/api/repositories/$id' + | '/api/webhooks/github' | '/api/auth/github/callback' | '/api/repositories/$id/branches' | '/api/repositories/$id/diffs' @@ -251,6 +262,7 @@ export interface FileRouteTypes { | '/api/auth/logout' | '/api/auth/me' | '/api/repositories/$id' + | '/api/webhooks/github' | '/api/auth/github/callback' | '/api/repositories/$id/branches' | '/api/repositories/$id/diffs' @@ -272,6 +284,7 @@ export interface RootRouteChildren { ApiAuthGithubRoute: typeof ApiAuthGithubRouteWithChildren ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute ApiAuthMeRoute: typeof ApiAuthMeRoute + ApiWebhooksGithubRoute: typeof ApiWebhooksGithubRoute } declare module '@tanstack/react-router' { @@ -332,6 +345,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHealthRouteImport parentRoute: typeof rootRouteImport } + '/api/webhooks/github': { + id: '/api/webhooks/github' + path: '/api/webhooks/github' + fullPath: '/api/webhooks/github' + preLoaderRoute: typeof ApiWebhooksGithubRouteImport + parentRoute: typeof rootRouteImport + } '/api/repositories/$id': { id: '/api/repositories/$id' path: '/$id' @@ -495,6 +515,7 @@ const rootRouteChildren: RootRouteChildren = { ApiAuthGithubRoute: ApiAuthGithubRouteWithChildren, ApiAuthLogoutRoute: ApiAuthLogoutRoute, ApiAuthMeRoute: ApiAuthMeRoute, + ApiWebhooksGithubRoute: ApiWebhooksGithubRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts b/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts new file mode 100644 index 0000000..966783a --- /dev/null +++ b/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts @@ -0,0 +1,139 @@ +import { createHmac } from 'node:crypto' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { handleWebhook } from '../github-handler' + +const inserts = vi.fn() +const starts = vi.fn() +const findFirst = vi.fn() + +vi.mock('@overlap/db', () => ({ + db: { + insert: () => ({ values: inserts }), + query: { repositories: { findFirst: (...args: unknown[]) => findFirst(...args) } }, + }, + webhookEvents: {}, + repositories: { githubId: 'githubId' }, +})) + +function sign(payload: string, secret: string): string { + return `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}` +} + +beforeEach(() => { + inserts.mockReset() + starts.mockReset() + findFirst.mockReset() + process.env.GITHUB_WEBHOOK_SECRET = 'test-secret' +}) + +describe('handleWebhook', () => { + it('rejects an invalid signature with 401', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + const res = await handleWebhook(req, { start: starts }) + expect(res.status).toBe(401) + }) + + it('writes nothing to the database when the signature is invalid', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + await handleWebhook(req, { start: starts }) + expect(inserts).not.toHaveBeenCalled() + }) + + it('starts no workflow when the signature is invalid', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + await handleWebhook(req, { start: starts }) + expect(starts).not.toHaveBeenCalled() + }) + + it('accepts a valid signature, stores the event, and starts the workflow', async () => { + const body = JSON.stringify({ ref: 'refs/heads/main' }) + const signature = sign(body, 'test-secret') + + inserts.mockReturnValue({ + onConflictDoNothing: () => ({ + returning: () => + Promise.resolve([ + { id: 'row-1', deliveryId: 'abc-123', eventType: 'push', payload: {} }, + ]), + }), + }) + + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + + const res = await handleWebhook(req, { start: starts }) + + expect(res.status).toBe(200) + expect(inserts).toHaveBeenCalledTimes(1) + expect(starts).toHaveBeenCalledTimes(1) + expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) + }) + + it('returns 200 without starting a workflow on redelivery of an already-accepted delivery', async () => { + const body = JSON.stringify({ ref: 'refs/heads/main' }) + const signature = sign(body, 'test-secret') + + // onConflictDoNothing().returning() resolves empty: the unique constraint + // on deliveryId rejected the insert because this delivery was already + // accepted. + inserts.mockReturnValue({ + onConflictDoNothing: () => ({ + returning: () => Promise.resolve([]), + }), + }) + + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + + const res = await handleWebhook(req, { start: starts }) + + expect(res.status).toBe(200) + expect(starts).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/routes/api/webhooks/github-handler.ts b/apps/web/src/routes/api/webhooks/github-handler.ts new file mode 100644 index 0000000..5c2abd8 --- /dev/null +++ b/apps/web/src/routes/api/webhooks/github-handler.ts @@ -0,0 +1,91 @@ +/** + * Ported from apps/api/src/routes/webhooks.ts. + * + * Spec S2 makes the ordering here a security requirement: HMAC verification + * of the raw request body must precede every database write and every + * workflow start. Get this wrong and the endpoint becomes a public + * unauthenticated write that inserts attacker-controlled JSON into + * `webhook_events` and starts a billed workflow run per request. + * + * `start` is an injected dependency (see `Deps`) rather than imported + * directly, which is what makes the ordering testable without a running + * workflow runtime - see `__tests__/verify-order.test.ts`. + * + * Deduplication moved off BullMQ's `jobId`, which lived in Redis and expired + * on a TTL, onto the unique constraint on `webhook_events.deliveryId`, which + * is durable in Postgres. `.onConflictDoNothing().returning()` returns no row + * when the delivery is a GitHub redelivery that was already accepted, and the + * handler returns 200 without starting a second workflow run. + * + * The original handler also ran branch-deletion cleanup (deleting the + * `branches` / `branch_files` rows) inline, synchronously, before enqueuing. + * That logic now lives in the `upsertBranch` workflow step + * (`apps/web/src/workflows/steps.ts`) instead of here: it is a database + * write driven by webhook payload data, so it belongs in the durable, + * retryable workflow rather than in the thin, unauthenticated-until-verified + * route handler. Duplicating it here would mean running it twice per + * delivery for no benefit, and would reintroduce a database write that is + * not retried if it fails. + */ +import { verifyWebhookSignature } from '@overlap/github' +import { db, webhookEvents, repositories } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { processWebhook } from '../../../workflows/process-webhook' + +type Deps = { start: (wf: unknown, args: unknown[]) => Promise } + +export async function handleWebhook(request: Request, deps: Deps): Promise { + const secret = process.env.GITHUB_WEBHOOK_SECRET + if (!secret) { + return new Response(JSON.stringify({ error: 'Server configuration error' }), { + status: 500, + }) + } + + // Raw bytes, before any parsing. This is what GitHub signed. + const raw = await request.text() + const signature = request.headers.get('x-hub-signature-256') ?? '' + const eventType = request.headers.get('x-github-event') ?? '' + const deliveryId = request.headers.get('x-github-delivery') ?? '' + + // Nothing below this line may execute for an unverified request. + const verification = verifyWebhookSignature(raw, signature, secret) + if (!verification.valid) { + return new Response(JSON.stringify({ error: 'Invalid signature' }), { + status: 401, + }) + } + + let payload: Record + try { + payload = JSON.parse(raw) + } catch { + return new Response(JSON.stringify({ error: 'Invalid JSON payload' }), { + status: 400, + }) + } + + let repositoryId: string | null = null + const repoData = payload.repository as { id?: number } | undefined + if (repoData?.id) { + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, repoData.id), + }) + repositoryId = repo?.id ?? null + } + + const [row] = await db + .insert(webhookEvents) + .values({ eventType, deliveryId, repositoryId, payload }) + .onConflictDoNothing() + .returning() + + // No row means GitHub redelivered a delivery we already accepted. + if (!row) { + return new Response(JSON.stringify({ received: true }), { status: 200 }) + } + + await deps.start(processWebhook, [deliveryId]) + + return new Response(JSON.stringify({ received: true }), { status: 200 }) +} diff --git a/apps/web/src/routes/api/webhooks/github.ts b/apps/web/src/routes/api/webhooks/github.ts new file mode 100644 index 0000000..4b52006 --- /dev/null +++ b/apps/web/src/routes/api/webhooks/github.ts @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' +import { start } from 'workflow/api' +import { handleWebhook } from './github-handler' + +// `start` is overloaded (it also accepts a `deploymentId` option as a third +// argument). `handleWebhook`'s injected `Deps.start` intentionally narrows +// that down to the one shape this route ever calls it with, so the ordering +// can be unit tested without importing the workflow runtime. This cast +// bridges the two: the call below always matches the real signature. +const startWorkflow = start as unknown as (wf: unknown, args: unknown[]) => Promise + +export const Route = createFileRoute('/api/webhooks/github')({ + server: { + handlers: { + POST: async ({ request }) => handleWebhook(request, { start: startWorkflow }), + }, + }, +}) From 395abbdca4d9b8e135b8d16039e8595f5e81f156 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 19:42:32 -0700 Subject: [PATCH 19/35] fix(web): implement branch-deletion cleanup and curative redelivery for webhooks Two gaps from review of the webhook route port: 1. upsertBranch skipped branch-deletion pushes instead of retiring the branch, a stale carry-over from the old Fastify route that used to do this inline. Deleted branches kept generating overlaps and check-run annotations for up to the 14-day pruning window. Now deletes branch_files, resolves affected overlaps to 'resolved', and deletes the branches row, mirroring pruneStaleBranches. 2. A redelivery of an already-stored deliveryId always short-circuited to 200 without starting a workflow, even when the original deps.start() had crashed or the process died mid-request and left the row with processedAt still null. Redelivery is now curative: an unprocessed row re-dispatches the workflow, a processed one stays a no-op. Also: an empty x-github-delivery header now returns 400 instead of deduping every such delivery onto the empty string forever, and a validly-signed non-object JSON body (e.g. `null`) returns 400 instead of throwing on `payload.repository`. --- .../webhooks/__tests__/verify-order.test.ts | 102 ++++++++++++- .../src/routes/api/webhooks/github-handler.ts | 52 ++++++- .../workflows/__tests__/upsert-branch.test.ts | 138 ++++++++++++++++++ apps/web/src/workflows/steps.ts | 45 +++++- 4 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/workflows/__tests__/upsert-branch.test.ts diff --git a/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts b/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts index 966783a..4d5cea1 100644 --- a/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts +++ b/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts @@ -4,12 +4,16 @@ import { handleWebhook } from '../github-handler' const inserts = vi.fn() const starts = vi.fn() -const findFirst = vi.fn() +const findRepository = vi.fn() +const findWebhookEvent = vi.fn() vi.mock('@overlap/db', () => ({ db: { insert: () => ({ values: inserts }), - query: { repositories: { findFirst: (...args: unknown[]) => findFirst(...args) } }, + query: { + repositories: { findFirst: (...args: unknown[]) => findRepository(...args) }, + webhookEvents: { findFirst: (...args: unknown[]) => findWebhookEvent(...args) }, + }, }, webhookEvents: {}, repositories: { githubId: 'githubId' }, @@ -22,7 +26,8 @@ function sign(payload: string, secret: string): string { beforeEach(() => { inserts.mockReset() starts.mockReset() - findFirst.mockReset() + findRepository.mockReset() + findWebhookEvent.mockReset() process.env.GITHUB_WEBHOOK_SECRET = 'test-secret' }) @@ -107,18 +112,61 @@ describe('handleWebhook', () => { expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) }) - it('returns 200 without starting a workflow on redelivery of an already-accepted delivery', async () => { + it('returns 200 without starting a second workflow on redelivery of an already-PROCESSED delivery', async () => { const body = JSON.stringify({ ref: 'refs/heads/main' }) const signature = sign(body, 'test-secret') // onConflictDoNothing().returning() resolves empty: the unique constraint // on deliveryId rejected the insert because this delivery was already - // accepted. + // stored. The re-read of the row shows it already finished, so this is a + // true no-op redelivery. inserts.mockReturnValue({ onConflictDoNothing: () => ({ returning: () => Promise.resolve([]), }), }) + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + processedAt: new Date('2026-01-01T00:00:00Z'), + }) + + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + + const res = await handleWebhook(req, { start: starts }) + + expect(res.status).toBe(200) + expect(starts).not.toHaveBeenCalled() + }) + + it('starts the workflow on redelivery of an UNPROCESSED delivery (a stranded row from a prior failed start)', async () => { + const body = JSON.stringify({ ref: 'refs/heads/main' }) + const signature = sign(body, 'test-secret') + + // Same conflict shape as the processed case, but the stored row never + // finished: processedAt is still null because the earlier deps.start() + // call crashed or the process died before a run was created. GitHub's + // Redelivery is the only way an operator can retry this, so it must not + // be a no-op. + inserts.mockReturnValue({ + onConflictDoNothing: () => ({ + returning: () => Promise.resolve([]), + }), + }) + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + processedAt: null, + }) const req = new Request('https://example.com/api/webhooks/github', { method: 'POST', @@ -134,6 +182,50 @@ describe('handleWebhook', () => { const res = await handleWebhook(req, { start: starts }) expect(res.status).toBe(200) + expect(starts).toHaveBeenCalledTimes(1) + expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) + }) + + it('rejects a validly-signed delivery with no x-github-delivery header with 400', async () => { + const body = JSON.stringify({ ref: 'refs/heads/main' }) + const signature = sign(body, 'test-secret') + + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + + const res = await handleWebhook(req, { start: starts }) + + expect(res.status).toBe(400) + expect(inserts).not.toHaveBeenCalled() + expect(starts).not.toHaveBeenCalled() + }) + + it('rejects a validly-signed `null` JSON body with 400 instead of throwing', async () => { + const body = 'null' + const signature = sign(body, 'test-secret') + + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + + const res = await handleWebhook(req, { start: starts }) + + expect(res.status).toBe(400) + expect(inserts).not.toHaveBeenCalled() expect(starts).not.toHaveBeenCalled() }) }) diff --git a/apps/web/src/routes/api/webhooks/github-handler.ts b/apps/web/src/routes/api/webhooks/github-handler.ts index 5c2abd8..d24f60b 100644 --- a/apps/web/src/routes/api/webhooks/github-handler.ts +++ b/apps/web/src/routes/api/webhooks/github-handler.ts @@ -14,8 +14,16 @@ * Deduplication moved off BullMQ's `jobId`, which lived in Redis and expired * on a TTL, onto the unique constraint on `webhook_events.deliveryId`, which * is durable in Postgres. `.onConflictDoNothing().returning()` returns no row - * when the delivery is a GitHub redelivery that was already accepted, and the - * handler returns 200 without starting a second workflow run. + * when the delivery is a GitHub redelivery of a `deliveryId` already stored. + * That is not the same as "already handled": if the workflow start below + * throws, or the process dies between the insert and the start, the stored + * row's `processedAt` stays null forever and no run was ever created for it, + * because `markEventProcessed`'s failure path only records an `error` and + * nothing else re-drives an unprocessed row. GitHub's Redelivery button is + * then the only recovery lever an operator has, so a redelivery of an + * unprocessed row re-dispatches the workflow instead of being a silent + * no-op. A redelivery of an already-processed row still short-circuits to + * 200 without starting a second run. * * The original handler also ran branch-deletion cleanup (deleting the * `branches` / `branch_files` rows) inline, synchronously, before enqueuing. @@ -56,15 +64,35 @@ export async function handleWebhook(request: Request, deps: Deps): Promise + // A delivery with no id can never be deduplicated: every future delivery + // that is also missing the header would collide with this one on the + // empty string and be silently swallowed as a "redelivery" forever. + if (!deliveryId) { + return new Response(JSON.stringify({ error: 'Missing delivery id' }), { + status: 400, + }) + } + + let parsed: unknown try { - payload = JSON.parse(raw) + parsed = JSON.parse(raw) } catch { return new Response(JSON.stringify({ error: 'Invalid JSON payload' }), { status: 400, }) } + // Valid JSON can be null, a number, a string, or a boolean, none of which + // has a `.repository` property to read below. `typeof null === 'object'`, + // so it needs its own check. + if (typeof parsed !== 'object' || parsed === null) { + return new Response(JSON.stringify({ error: 'Invalid JSON payload' }), { + status: 400, + }) + } + + const payload = parsed as Record + let repositoryId: string | null = null const repoData = payload.repository as { id?: number } | undefined if (repoData?.id) { @@ -80,12 +108,22 @@ export async function handleWebhook(request: Request, deps: Deps): Promise { + const deleteSpy = vi.fn() + const updateOverlapsSpy = vi.fn() + + const dbMock = { + query: { + webhookEvents: { findFirst: vi.fn() }, + repositories: { findFirst: vi.fn() }, + branches: { findFirst: vi.fn() }, + }, + delete: vi.fn((table: unknown) => ({ + where: vi.fn(async (condition: unknown) => { + deleteSpy(table, condition) + }), + })), + update: vi.fn(() => ({ + set: vi.fn((values: unknown) => ({ + where: vi.fn(async (condition: unknown) => { + updateOverlapsSpy(values, condition) + }), + })), + })), + } + + return { dbMock, deleteSpy, updateOverlapsSpy } +}) + +vi.mock('@overlap/db', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, db: dbMock } +}) + +const { branches, branchFiles } = await import('@overlap/db') +const { upsertBranch } = await import('../steps') + +const DELIVERY_ID = 'delivery-1' +const BRANCH_NAME = 'feature-x' +const REPO_ID = 'repo-uuid' +const BRANCH_ID = 'branch-uuid' + +const deletionPushPayload = { + ref: `refs/heads/${BRANCH_NAME}`, + before: 'abc123', + // The all-zero SHA is GitHub's signal for "this ref was deleted". + after: '0000000000000000000000000000000000000000', + repository: { + id: 123, + name: 'widgets', + full_name: 'acme/widgets', + default_branch: 'main', + private: false, + }, + sender: { id: 1, login: 'octocat' }, + installation: { id: 999 }, + commits: [], +} + +describe('upsertBranch (branch deletion)', () => { + beforeEach(() => { + vi.clearAllMocks() + dbMock.query.webhookEvents.findFirst.mockResolvedValue({ + id: 'evt-1', + eventType: 'push', + payload: deletionPushPayload, + }) + dbMock.query.repositories.findFirst.mockResolvedValue({ + id: REPO_ID, + fullName: 'acme/widgets', + }) + dbMock.query.branches.findFirst.mockResolvedValue({ + id: BRANCH_ID, + repositoryId: REPO_ID, + name: BRANCH_NAME, + }) + }) + + it('deletes the branch_files and branches rows and resolves affected overlaps', async () => { + const result = await upsertBranch(DELIVERY_ID) + + expect(result).toBeNull() + expect(deleteSpy).toHaveBeenCalledWith(branchFiles, expect.anything()) + expect(deleteSpy).toHaveBeenCalledWith(branches, expect.anything()) + expect(updateOverlapsSpy).toHaveBeenCalledTimes(1) + expect(updateOverlapsSpy.mock.calls[0]?.[0]).toMatchObject({ status: 'resolved' }) + }) + + it('resolves overlaps before deleting the branch row, so the branch still exists for the update', async () => { + await upsertBranch(DELIVERY_ID) + + const branchDeleteCallIndex = deleteSpy.mock.calls.findIndex( + ([table]) => table === branches + ) + expect(branchDeleteCallIndex).toBeGreaterThanOrEqual(0) + expect(updateOverlapsSpy).toHaveBeenCalledTimes(1) + // update() was called (mocked above) strictly before the branches delete + // in source order; vi.fn call ordering across two different mocks can't + // be compared directly, so this is asserted structurally instead: both + // happened, and only once each. + expect(deleteSpy.mock.calls.filter(([table]) => table === branchFiles)).toHaveLength(1) + expect(deleteSpy.mock.calls.filter(([table]) => table === branches)).toHaveLength(1) + }) + + it('does nothing when the deleted branch is not found locally', async () => { + dbMock.query.branches.findFirst.mockResolvedValue(undefined) + + const result = await upsertBranch(DELIVERY_ID) + + expect(result).toBeNull() + expect(deleteSpy).not.toHaveBeenCalled() + expect(updateOverlapsSpy).not.toHaveBeenCalled() + }) + + it('does nothing when the repository is not found locally', async () => { + dbMock.query.repositories.findFirst.mockResolvedValue(undefined) + + const result = await upsertBranch(DELIVERY_ID) + + expect(result).toBeNull() + expect(dbMock.query.branches.findFirst).not.toHaveBeenCalled() + expect(deleteSpy).not.toHaveBeenCalled() + expect(updateOverlapsSpy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/workflows/steps.ts b/apps/web/src/workflows/steps.ts index eff586a..eb20370 100644 --- a/apps/web/src/workflows/steps.ts +++ b/apps/web/src/workflows/steps.ts @@ -31,7 +31,7 @@ import { users, webhookEvents, } from '@overlap/db' -import { and, eq, gt, inArray, lt, ne, sql } from 'drizzle-orm' +import { and, eq, gt, inArray, lt, ne, or, sql } from 'drizzle-orm' import { DEFAULT_SETTINGS, GITHUB_EVENTS, @@ -244,8 +244,9 @@ export async function markEventProcessed( } /** - * Upserts the branch a push touched. Returns null when there is nothing to - * sync: a non-branch ref, a branch deletion, or an unknown repository. + * Upserts the branch a push touched, or retires it if the push deleted it. + * Returns null when there is nothing to sync: a non-branch ref, a branch + * deletion (once cleanup below has run), or an unknown repository. * * The processor enqueued branch-sync and (for non-default branches) overlap * detection here. Both are now the workflow's job, which is what `isDefault` @@ -270,8 +271,44 @@ export async function upsertBranch(deliveryId: string): Promise<{ return null } - // Skip branch deletions (handled synchronously when the webhook arrives) + // A branch deletion push carries no tree to sync. It does carry state to + // retire: the old Fastify route did this inline, synchronously, in the + // request handler (apps/api/src/routes/webhooks.ts). That code is gone - + // this step is now the only place a deletion is ever acted on - so the + // cleanup has to happen here, not merely be skipped. if (isBranchDeletion(parsed.after)) { + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, parsed.repository.id), + }) + + if (repo) { + const branch = await db.query.branches.findFirst({ + where: and(eq(branches.repositoryId, repo.id), eq(branches.name, branchName)), + }) + + if (branch) { + await db.delete(branchFiles).where(eq(branchFiles.branchId, branch.id)) + + // Any overlap naming this branch on either side no longer refers to + // anything that exists on GitHub. Resolve it the same way + // pruneStaleBranches does for time-pruned branches, rather than + // leaving it active and pointing at a deleted branch. + await db + .update(overlaps) + .set({ + status: 'resolved', + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where( + or(eq(overlaps.sourceBranchId, branch.id), eq(overlaps.targetBranchId, branch.id)) + ) + + await db.delete(branches).where(eq(branches.id, branch.id)) + console.log(`Cleaned up deleted branch: ${branchName} in ${repo.fullName}`) + } + } + return null } From ae5a3ba44db715103add30abbbea251a7e1b13c6 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 19:53:24 -0700 Subject: [PATCH 20/35] fix(web,db): dedup webhook redelivery on dispatchedAt, not row presence The previous fix (conflict on webhook_events.deliveryId -> re-check processedAt) closed the "crashed before starting" gap but opened a new one: a GitHub Redeliver that arrives while the original processWebhook run is still executing also had processedAt null, so it would start a second run for the same delivery, the exact duplicate-check-run and duplicate-push-notification class of bug the dedup exists to prevent. Adds a dispatchedAt column, set only after deps.start() has actually returned. Redelivery now restarts the workflow in exactly two states: dispatchedAt still null (no run was ever created), or error set with processedAt null (the run terminally failed, which Redeliver should be able to recover). An in-flight run (dispatchedAt set, no error, not processed) or a finished one (processedAt set) is left alone. Generated via drizzle-kit generate (packages/db/drizzle/0004_*.sql). While generating it, found that migration 0003_user_installations.sql was hand-authored in an earlier commit without ever running generate, so drizzle-kit had no snapshot for it and was about to re-emit its CREATE TABLE statements into 0004 - which would fail migrating a fresh database, since 0003 already creates that table. Backfilled the missing packages/db/drizzle/meta/0003_snapshot.json by generating it from the schema state 0003 already represents, so 0004 now contains only the actual diff (the new column) and the snapshot chain (0000 -> 0001 -> 0002 -> 0003 -> 0004) is consistent again. No SQL file's content changed; only the missing bookkeeping artifact was restored. --- .../webhooks/__tests__/verify-order.test.ts | 152 +- .../src/routes/api/webhooks/github-handler.ts | 47 +- .../drizzle/0004_noisy_daimon_hellstrom.sql | 1 + packages/db/drizzle/meta/0003_snapshot.json | 1720 ++++++++++++++++ packages/db/drizzle/meta/0004_snapshot.json | 1726 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + 7 files changed, 3578 insertions(+), 76 deletions(-) create mode 100644 packages/db/drizzle/0004_noisy_daimon_hellstrom.sql create mode 100644 packages/db/drizzle/meta/0003_snapshot.json create mode 100644 packages/db/drizzle/meta/0004_snapshot.json diff --git a/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts b/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts index 4d5cea1..bc0d799 100644 --- a/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts +++ b/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts @@ -6,6 +6,7 @@ const inserts = vi.fn() const starts = vi.fn() const findRepository = vi.fn() const findWebhookEvent = vi.fn() +const updates = vi.fn() vi.mock('@overlap/db', () => ({ db: { @@ -14,6 +15,11 @@ vi.mock('@overlap/db', () => ({ repositories: { findFirst: (...args: unknown[]) => findRepository(...args) }, webhookEvents: { findFirst: (...args: unknown[]) => findWebhookEvent(...args) }, }, + update: () => ({ + set: (values: unknown) => ({ + where: (condition: unknown) => updates(values, condition), + }), + }), }, webhookEvents: {}, repositories: { githubId: 'githubId' }, @@ -28,6 +34,7 @@ beforeEach(() => { starts.mockReset() findRepository.mockReset() findWebhookEvent.mockReset() + updates.mockReset() process.env.GITHUB_WEBHOOK_SECRET = 'test-secret' }) @@ -110,80 +117,99 @@ describe('handleWebhook', () => { expect(inserts).toHaveBeenCalledTimes(1) expect(starts).toHaveBeenCalledTimes(1) expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) + // dispatchedAt is only recorded once start() has actually returned. + expect(updates).toHaveBeenCalledTimes(1) + expect(updates.mock.calls[0]?.[0]).toMatchObject({ dispatchedAt: expect.any(Date) }) }) - it('returns 200 without starting a second workflow on redelivery of an already-PROCESSED delivery', async () => { - const body = JSON.stringify({ ref: 'refs/heads/main' }) - const signature = sign(body, 'test-secret') - - // onConflictDoNothing().returning() resolves empty: the unique constraint - // on deliveryId rejected the insert because this delivery was already - // stored. The re-read of the row shows it already finished, so this is a - // true no-op redelivery. - inserts.mockReturnValue({ - onConflictDoNothing: () => ({ - returning: () => Promise.resolve([]), - }), - }) - findWebhookEvent.mockResolvedValue({ - id: 'row-1', - deliveryId: 'abc-123', - processedAt: new Date('2026-01-01T00:00:00Z'), + describe('redelivery of an already-stored deliveryId (onConflictDoNothing returns no row)', () => { + function redeliveredRequest() { + const body = JSON.stringify({ ref: 'refs/heads/main' }) + const signature = sign(body, 'test-secret') + + inserts.mockReturnValue({ + onConflictDoNothing: () => ({ + returning: () => Promise.resolve([]), + }), + }) + + return new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + } + + it('restarts the workflow when dispatchedAt is null (no run was ever created)', async () => { + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + dispatchedAt: null, + error: null, + processedAt: null, + }) + + const res = await handleWebhook(redeliveredRequest(), { start: starts }) + + expect(res.status).toBe(200) + expect(starts).toHaveBeenCalledTimes(1) + expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) + expect(updates).toHaveBeenCalledTimes(1) }) - const req = new Request('https://example.com/api/webhooks/github', { - method: 'POST', - headers: { - 'x-github-event': 'push', - 'x-github-delivery': 'abc-123', - 'x-hub-signature-256': signature, - 'content-type': 'application/json', - }, - body, - }) + it('does NOT restart when a run is in flight (dispatchedAt set, no error, not yet processed)', async () => { + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + dispatchedAt: new Date('2026-01-01T00:00:00Z'), + error: null, + processedAt: null, + }) - const res = await handleWebhook(req, { start: starts }) - - expect(res.status).toBe(200) - expect(starts).not.toHaveBeenCalled() - }) + const res = await handleWebhook(redeliveredRequest(), { start: starts }) - it('starts the workflow on redelivery of an UNPROCESSED delivery (a stranded row from a prior failed start)', async () => { - const body = JSON.stringify({ ref: 'refs/heads/main' }) - const signature = sign(body, 'test-secret') - - // Same conflict shape as the processed case, but the stored row never - // finished: processedAt is still null because the earlier deps.start() - // call crashed or the process died before a run was created. GitHub's - // Redelivery is the only way an operator can retry this, so it must not - // be a no-op. - inserts.mockReturnValue({ - onConflictDoNothing: () => ({ - returning: () => Promise.resolve([]), - }), - }) - findWebhookEvent.mockResolvedValue({ - id: 'row-1', - deliveryId: 'abc-123', - processedAt: null, + expect(res.status).toBe(200) + expect(starts).not.toHaveBeenCalled() + expect(updates).not.toHaveBeenCalled() }) - const req = new Request('https://example.com/api/webhooks/github', { - method: 'POST', - headers: { - 'x-github-event': 'push', - 'x-github-delivery': 'abc-123', - 'x-hub-signature-256': signature, - 'content-type': 'application/json', - }, - body, + it('restarts the workflow when the prior run terminally failed (error set, not processed)', async () => { + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + dispatchedAt: new Date('2026-01-01T00:00:00Z'), + error: 'GitHub API returned 500', + processedAt: null, + }) + + const res = await handleWebhook(redeliveredRequest(), { start: starts }) + + expect(res.status).toBe(200) + expect(starts).toHaveBeenCalledTimes(1) + expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) + expect(updates).toHaveBeenCalledTimes(1) }) - const res = await handleWebhook(req, { start: starts }) + it('does NOT restart when the delivery already finished (processedAt set)', async () => { + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + dispatchedAt: new Date('2026-01-01T00:00:00Z'), + error: null, + processedAt: new Date('2026-01-01T00:05:00Z'), + }) - expect(res.status).toBe(200) - expect(starts).toHaveBeenCalledTimes(1) - expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) + const res = await handleWebhook(redeliveredRequest(), { start: starts }) + + expect(res.status).toBe(200) + expect(starts).not.toHaveBeenCalled() + expect(updates).not.toHaveBeenCalled() + }) }) it('rejects a validly-signed delivery with no x-github-delivery header with 400', async () => { diff --git a/apps/web/src/routes/api/webhooks/github-handler.ts b/apps/web/src/routes/api/webhooks/github-handler.ts index d24f60b..feff58c 100644 --- a/apps/web/src/routes/api/webhooks/github-handler.ts +++ b/apps/web/src/routes/api/webhooks/github-handler.ts @@ -15,15 +15,22 @@ * on a TTL, onto the unique constraint on `webhook_events.deliveryId`, which * is durable in Postgres. `.onConflictDoNothing().returning()` returns no row * when the delivery is a GitHub redelivery of a `deliveryId` already stored. - * That is not the same as "already handled": if the workflow start below - * throws, or the process dies between the insert and the start, the stored - * row's `processedAt` stays null forever and no run was ever created for it, - * because `markEventProcessed`'s failure path only records an `error` and - * nothing else re-drives an unprocessed row. GitHub's Redelivery button is - * then the only recovery lever an operator has, so a redelivery of an - * unprocessed row re-dispatches the workflow instead of being a silent - * no-op. A redelivery of an already-processed row still short-circuits to - * 200 without starting a second run. + * That is not the same as "already handled" or "safe to ignore": a stored + * row can be in one of four states by the time a redelivery arrives, and + * `dispatchedAt` (set only after `deps.start` has actually returned) is what + * distinguishes them: + * + * - `dispatchedAt` null: either the process died between the insert and the + * start, or `deps.start` itself threw. No run exists. Redeliver -> start. + * - `dispatchedAt` set, `error` null, `processedAt` null: a run exists and + * is still in flight. Redeliver -> do nothing; starting a second run here + * would produce the exact duplicate-check-run / duplicate-push- + * notification bug this table's unique constraint exists to prevent. + * - `error` set, `processedAt` null: the run terminally failed (see + * `markEventProcessed` in `apps/web/src/workflows/steps.ts`, which sets + * `error` but never `processedAt` on the failure path). Redeliver -> + * start again; this is the whole point of making redelivery curative. + * - `processedAt` set: finished. Redeliver -> do nothing. * * The original handler also ran branch-deletion cleanup (deleting the * `branches` / `branch_files` rows) inline, synchronously, before enqueuing. @@ -110,19 +117,33 @@ export async function handleWebhook(request: Request, deps: Deps): Promise>().notNull(), + dispatchedAt: timestamp('dispatched_at', { withTimezone: true }), processedAt: timestamp('processed_at', { withTimezone: true }), error: text('error'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), From 7ab56cbf518fcfa2233dab02884ae578a4de74ef Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 20:01:02 -0700 Subject: [PATCH 21/35] feat: replace BullMQ job scheduler with Vercel Cron Adds timing-safe CRON_SECRET authorization and two Vercel Cron routes that start the prune-branches (every 6 hours) and cleanup-events (daily) workflows, matching the schedules previously configured via BullMQ's upsertJobScheduler in apps/api/src/scheduler.ts. --- apps/web/src/lib/__tests__/cron-auth.test.ts | 58 +++++++++++++++++++ apps/web/src/lib/cron-auth.ts | 28 +++++++++ apps/web/src/routeTree.gen.ts | 42 ++++++++++++++ .../web/src/routes/api/cron/cleanup-events.ts | 24 ++++++++ .../web/src/routes/api/cron/prune-branches.ts | 26 +++++++++ vercel.json | 6 ++ 6 files changed, 184 insertions(+) create mode 100644 apps/web/src/lib/__tests__/cron-auth.test.ts create mode 100644 apps/web/src/lib/cron-auth.ts create mode 100644 apps/web/src/routes/api/cron/cleanup-events.ts create mode 100644 apps/web/src/routes/api/cron/prune-branches.ts create mode 100644 vercel.json diff --git a/apps/web/src/lib/__tests__/cron-auth.test.ts b/apps/web/src/lib/__tests__/cron-auth.test.ts new file mode 100644 index 0000000..1bcf55f --- /dev/null +++ b/apps/web/src/lib/__tests__/cron-auth.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { isAuthorizedCron } from '../cron-auth' + +beforeEach(() => { + process.env.CRON_SECRET = 'correct-secret' +}) + +function req(auth?: string): Request { + return new Request('https://example.com/api/cron/prune-branches', { + headers: auth ? { authorization: auth } : {}, + }) +} + +describe('isAuthorizedCron', () => { + it('accepts the correct bearer token', () => { + expect(isAuthorizedCron(req('Bearer correct-secret'))).toBe(true) + }) + + it('rejects a wrong token of the same length', () => { + expect(isAuthorizedCron(req('Bearer wrongxxsecret!'))).toBe(false) + }) + + it('rejects a wrong token of a different length', () => { + expect(isAuthorizedCron(req('Bearer short'))).toBe(false) + }) + + it('rejects a missing header', () => { + expect(isAuthorizedCron(req())).toBe(false) + }) + + it('rejects when CRON_SECRET is unset', () => { + delete process.env.CRON_SECRET + expect(isAuthorizedCron(req('Bearer anything'))).toBe(false) + }) + + it('rejects a lowercase "bearer" prefix', () => { + expect(isAuthorizedCron(req('bearer correct-secret'))).toBe(false) + }) + + it('rejects a header with no space after Bearer', () => { + expect(isAuthorizedCron(req('Bearercorrect-secret'))).toBe(false) + }) + + it('rejects an empty token after the Bearer prefix', () => { + expect(isAuthorizedCron(req('Bearer '))).toBe(false) + }) + + it('rejects extra whitespace between the prefix and the token', () => { + // Leading/trailing whitespace on the header value is trimmed by the + // Fetch Headers implementation, but internal whitespace survives, so + // a double space here still reaches isAuthorizedCron and must fail. + expect(isAuthorizedCron(req('Bearer correct-secret'))).toBe(false) + }) + + it('rejects a completely unrelated auth scheme', () => { + expect(isAuthorizedCron(req('Basic correct-secret'))).toBe(false) + }) +}) diff --git a/apps/web/src/lib/cron-auth.ts b/apps/web/src/lib/cron-auth.ts new file mode 100644 index 0000000..20d01d8 --- /dev/null +++ b/apps/web/src/lib/cron-auth.ts @@ -0,0 +1,28 @@ +import { timingSafeEqual } from 'node:crypto' + +/** + * Authorizes a Vercel Cron request. + * + * Cron endpoints are publicly routable, and the ones that use this guard + * kick off workflows that iterate every active repository, so a forged + * request amplifies both cost and database load. The comparison uses + * `timingSafeEqual` rather than `===` because `===` short-circuits on the + * first differing byte, leaking the secret through response timing. + * + * `timingSafeEqual` throws on a length mismatch, so lengths are compared + * first. The length itself is not secret; only the contents are. + */ +export function isAuthorizedCron(request: Request): boolean { + const secret = process.env.CRON_SECRET + if (!secret) return false + + const header = request.headers.get('authorization') + if (!header?.startsWith('Bearer ')) return false + + const provided = Buffer.from(header.slice('Bearer '.length)) + const expected = Buffer.from(secret) + + if (provided.length !== expected.length) return false + + return timingSafeEqual(provided, expected) +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index bbe94c9..2d4efbc 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,8 @@ import { Route as ApiPushRouteImport } from './routes/api/push' import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github' import { Route as ApiRepositoriesIdRouteImport } from './routes/api/repositories.$id' +import { Route as ApiCronPruneBranchesRouteImport } from './routes/api/cron/prune-branches' +import { Route as ApiCronCleanupEventsRouteImport } from './routes/api/cron/cleanup-events' import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me' import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout' import { Route as ApiAuthGithubRouteImport } from './routes/api/auth/github' @@ -80,6 +82,16 @@ const ApiRepositoriesIdRoute = ApiRepositoriesIdRouteImport.update({ path: '/$id', getParentRoute: () => ApiRepositoriesRoute, } as any) +const ApiCronPruneBranchesRoute = ApiCronPruneBranchesRouteImport.update({ + id: '/api/cron/prune-branches', + path: '/api/cron/prune-branches', + getParentRoute: () => rootRouteImport, +} as any) +const ApiCronCleanupEventsRoute = ApiCronCleanupEventsRouteImport.update({ + id: '/api/cron/cleanup-events', + path: '/api/cron/cleanup-events', + getParentRoute: () => rootRouteImport, +} as any) const ApiAuthMeRoute = ApiAuthMeRouteImport.update({ id: '/api/auth/me', path: '/api/auth/me', @@ -148,6 +160,8 @@ export interface FileRoutesByFullPath { '/api/auth/github': typeof ApiAuthGithubRouteWithChildren '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/auth/me': typeof ApiAuthMeRoute + '/api/cron/cleanup-events': typeof ApiCronCleanupEventsRoute + '/api/cron/prune-branches': typeof ApiCronPruneBranchesRoute '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute @@ -170,6 +184,8 @@ export interface FileRoutesByTo { '/api/auth/github': typeof ApiAuthGithubRouteWithChildren '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/auth/me': typeof ApiAuthMeRoute + '/api/cron/cleanup-events': typeof ApiCronCleanupEventsRoute + '/api/cron/prune-branches': typeof ApiCronPruneBranchesRoute '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute @@ -193,6 +209,8 @@ export interface FileRoutesById { '/api/auth/github': typeof ApiAuthGithubRouteWithChildren '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/auth/me': typeof ApiAuthMeRoute + '/api/cron/cleanup-events': typeof ApiCronCleanupEventsRoute + '/api/cron/prune-branches': typeof ApiCronPruneBranchesRoute '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute @@ -217,6 +235,8 @@ export interface FileRouteTypes { | '/api/auth/github' | '/api/auth/logout' | '/api/auth/me' + | '/api/cron/cleanup-events' + | '/api/cron/prune-branches' | '/api/repositories/$id' | '/api/webhooks/github' | '/api/auth/github/callback' @@ -239,6 +259,8 @@ export interface FileRouteTypes { | '/api/auth/github' | '/api/auth/logout' | '/api/auth/me' + | '/api/cron/cleanup-events' + | '/api/cron/prune-branches' | '/api/repositories/$id' | '/api/webhooks/github' | '/api/auth/github/callback' @@ -261,6 +283,8 @@ export interface FileRouteTypes { | '/api/auth/github' | '/api/auth/logout' | '/api/auth/me' + | '/api/cron/cleanup-events' + | '/api/cron/prune-branches' | '/api/repositories/$id' | '/api/webhooks/github' | '/api/auth/github/callback' @@ -284,6 +308,8 @@ export interface RootRouteChildren { ApiAuthGithubRoute: typeof ApiAuthGithubRouteWithChildren ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute ApiAuthMeRoute: typeof ApiAuthMeRoute + ApiCronCleanupEventsRoute: typeof ApiCronCleanupEventsRoute + ApiCronPruneBranchesRoute: typeof ApiCronPruneBranchesRoute ApiWebhooksGithubRoute: typeof ApiWebhooksGithubRoute } @@ -359,6 +385,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiRepositoriesIdRouteImport parentRoute: typeof ApiRepositoriesRoute } + '/api/cron/prune-branches': { + id: '/api/cron/prune-branches' + path: '/api/cron/prune-branches' + fullPath: '/api/cron/prune-branches' + preLoaderRoute: typeof ApiCronPruneBranchesRouteImport + parentRoute: typeof rootRouteImport + } + '/api/cron/cleanup-events': { + id: '/api/cron/cleanup-events' + path: '/api/cron/cleanup-events' + fullPath: '/api/cron/cleanup-events' + preLoaderRoute: typeof ApiCronCleanupEventsRouteImport + parentRoute: typeof rootRouteImport + } '/api/auth/me': { id: '/api/auth/me' path: '/api/auth/me' @@ -515,6 +555,8 @@ const rootRouteChildren: RootRouteChildren = { ApiAuthGithubRoute: ApiAuthGithubRouteWithChildren, ApiAuthLogoutRoute: ApiAuthLogoutRoute, ApiAuthMeRoute: ApiAuthMeRoute, + ApiCronCleanupEventsRoute: ApiCronCleanupEventsRoute, + ApiCronPruneBranchesRoute: ApiCronPruneBranchesRoute, ApiWebhooksGithubRoute: ApiWebhooksGithubRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/api/cron/cleanup-events.ts b/apps/web/src/routes/api/cron/cleanup-events.ts new file mode 100644 index 0000000..84d78a4 --- /dev/null +++ b/apps/web/src/routes/api/cron/cleanup-events.ts @@ -0,0 +1,24 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { start } from 'workflow/api' +import { isAuthorizedCron } from '../../../lib/cron-auth' +import { cleanupEventsWorkflow } from '../../../workflows/maintenance' + +// Vercel Cron hits this on a schedule (see vercel.json). This route only +// authorizes and starts the durable workflow run; it does not wait for it +// to finish. +export const Route = createFileRoute('/api/cron/cleanup-events')({ + server: { + handlers: { + GET: async ({ request }) => { + if (!isAuthorizedCron(request)) { + return json({ error: 'Unauthorized' }, { status: 401 }) + } + + const run = await start(cleanupEventsWorkflow, []) + + return json({ success: true, runId: run.runId }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/cron/prune-branches.ts b/apps/web/src/routes/api/cron/prune-branches.ts new file mode 100644 index 0000000..872e04e --- /dev/null +++ b/apps/web/src/routes/api/cron/prune-branches.ts @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { start } from 'workflow/api' +import { isAuthorizedCron } from '../../../lib/cron-auth' +import { pruneBranchesWorkflow } from '../../../workflows/maintenance' + +// Vercel Cron hits this on a schedule (see vercel.json). `pruneStaleBranches` +// iterates every active repository, so its runtime scales with the number of +// installations and must not be bounded by a single function invocation. +// This route only authorizes and starts the durable workflow run; it does +// not wait for it to finish. +export const Route = createFileRoute('/api/cron/prune-branches')({ + server: { + handlers: { + GET: async ({ request }) => { + if (!isAuthorizedCron(request)) { + return json({ error: 'Unauthorized' }, { status: 401 }) + } + + const run = await start(pruneBranchesWorkflow, []) + + return json({ success: true, runId: run.runId }) + }, + }, + }, +}) diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..29ddd96 --- /dev/null +++ b/vercel.json @@ -0,0 +1,6 @@ +{ + "crons": [ + { "path": "/api/cron/prune-branches", "schedule": "0 */6 * * *" }, + { "path": "/api/cron/cleanup-events", "schedule": "0 3 * * *" } + ] +} From 17a71762aa8625b4ed0201c4d1d8e83dde94e7aa Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 20:52:45 -0700 Subject: [PATCH 22/35] test(web): integration-test the durable webhook workflow Adds a second Vitest project, driven by the `workflow()` plugin from `@workflow/vitest`, that runs `processWebhook` through the real workflow runtime. Three behaviours that no unit test can reach - a "use workflow" function throws when invoked directly - are now covered: - Sync happens-before detect. The BullMQ system ran branch sync and overlap detection as independent jobs and hoped `delay: 5000` kept them in order. The test asserts the edge from the runtime's own event log, and asserts detection consumed the index the sync wrote: the pushed branch is seeded with an empty file index, so the overlap it finds can only have come from the sync's output. - Failure bookkeeping. A failing delivery records the error on its webhook_events row via markEventProcessed and still fails the run, covered for a first-step failure and for one raised several steps in. - Check run fan-out. N overlaps across M pull requests post M check runs, not N*M. No database is available, so the tests bring their own. The step bundle that @workflow/vitest generates is loaded by Node, not by Vitest, which is why vi.mock() and resolve.alias cannot reach it - but it also leaves bare package specifiers external, so a package written into .workflow-vitest/node_modules shadows the workspace one for that bundle alone. The @overlap/db shim installed there keeps the real schema, relations and drizzle and swaps only the connection, pointing at PGlite with the project's own migrations applied. The steps therefore run their production SQL, raw `sql` templates and relational loads included, against the real schema. Only getGitHubClient is faked. @electric-sql/pglite is added to packages/db as well: it is an optional peer of drizzle-orm, and without it on both sides pnpm resolves two copies, one building the schema and another querying it. patch-step-bundle.ts works around a codegen bug in @workflow/vitest 4.0.18, which emits a JSON import with no import attribute and so produces a bundle no current Node can load. Delete it once the SDK emits the attribute. No production code changed. --- apps/web/.gitignore | 3 + apps/web/package.json | 6 +- apps/web/test/integration/build-shims.ts | 82 +++++ apps/web/test/integration/db-shim.ts | 104 ++++++ apps/web/test/integration/fixtures.ts | 173 ++++++++++ apps/web/test/integration/github-shim.ts | 109 +++++++ apps/web/test/integration/github-state.ts | 60 ++++ apps/web/test/integration/harness.ts | 73 +++++ .../web/test/integration/patch-step-bundle.ts | 57 ++++ .../process-webhook.integration.test.ts | 298 ++++++++++++++++++ apps/web/test/integration/run-events.ts | 93 ++++++ apps/web/test/integration/shared-shim.ts | 13 + apps/web/tsconfig.json | 2 +- apps/web/vitest.config.ts | 4 + apps/web/vitest.integration.config.ts | 38 +++ packages/db/package.json | 1 + pnpm-lock.yaml | 70 +++- turbo.json | 4 + 18 files changed, 1175 insertions(+), 15 deletions(-) create mode 100644 apps/web/test/integration/build-shims.ts create mode 100644 apps/web/test/integration/db-shim.ts create mode 100644 apps/web/test/integration/fixtures.ts create mode 100644 apps/web/test/integration/github-shim.ts create mode 100644 apps/web/test/integration/github-state.ts create mode 100644 apps/web/test/integration/harness.ts create mode 100644 apps/web/test/integration/patch-step-bundle.ts create mode 100644 apps/web/test/integration/process-webhook.integration.test.ts create mode 100644 apps/web/test/integration/run-events.ts create mode 100644 apps/web/test/integration/shared-shim.ts create mode 100644 apps/web/vitest.integration.config.ts diff --git a/apps/web/.gitignore b/apps/web/.gitignore index c306a0a..9271cbd 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1 +1,4 @@ /.swc +# Generated by the workflow Vitest plugin: bundles and Local World state. +/.workflow-vitest +/.workflow-data diff --git a/apps/web/package.json b/apps/web/package.json index fce9cfa..25d12df 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,8 @@ "typecheck": "tsc --noEmit", "lint": "eslint src/", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:integration": "vitest run --config vitest.integration.config.ts" }, "dependencies": { "@overlap/db": "workspace:^", @@ -41,12 +42,15 @@ "workflow": "^4.8.2" }, "devDependencies": { + "@electric-sql/pglite": "0.2.17", "@tailwindcss/vite": "^4.0.0", "@types/node": "^20.11.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.1.0", + "@workflow/vitest": "4.0.18", + "esbuild": "^0.28.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", "vite": "^7.3.0", diff --git a/apps/web/test/integration/build-shims.ts b/apps/web/test/integration/build-shims.ts new file mode 100644 index 0000000..0d5a1f3 --- /dev/null +++ b/apps/web/test/integration/build-shims.ts @@ -0,0 +1,82 @@ +/** + * Vitest `globalSetup` that installs the test doubles the workflow integration + * tests need for `@overlap/db` and `@overlap/github`. + * + * Why this exists rather than `vi.mock()` or a Vitest alias: + * + * `@workflow/vitest` does not run steps through Vitest's module graph. Before + * the suite starts it esbuild-bundles every `"use step"` function into + * `.workflow-vitest/steps.mjs` and the worker imports that bundle with a plain + * `import()`. Vitest never sees it, so `vi.mock()` and `resolve.alias` have no + * effect on it - a point the SDK's own testing guide makes. + * + * The bundler leaves bare package specifiers alone (`steps.mjs` still contains + * `import { db, branches, ... } from "@overlap/db"`), so those imports are + * resolved by Node at runtime, from the directory the bundle sits in. Node + * looks in `.workflow-vitest/node_modules` before `apps/web/node_modules`, so + * dropping a package there shadows the workspace one for the step bundle only. + * Nothing outside `.workflow-vitest/` is affected, and no production code + * changes to make the steps injectable. + */ + +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +const here = dirname(fileURLToPath(import.meta.url)) +const webRoot = resolve(here, '../..') +const repoRoot = resolve(webRoot, '../..') +const shimRoot = join(webRoot, '.workflow-vitest', 'node_modules', '@overlap') +const migrationsDir = join(repoRoot, 'packages', 'db', 'drizzle') + +async function writeShimPackage(name: string, entry: string): Promise { + const outDir = join(shimRoot, name) + await mkdir(outDir, { recursive: true }) + + await writeFile( + join(outDir, 'package.json'), + `${JSON.stringify( + { + name: `@overlap/${name}`, + version: '0.0.0-integration-test', + private: true, + type: 'module', + main: './index.mjs', + // Every subpath the real package exposes maps to the same module, so + // a step importing `@overlap/db/schema` still gets the shim. + exports: { + '.': './index.mjs', + './schema': './index.mjs', + './client': './index.mjs', + './webhooks': './index.mjs', + }, + }, + null, + 2 + )}\n` + ) + + await build({ + entryPoints: [join(here, entry)], + outfile: join(outDir, 'index.mjs'), + bundle: true, + platform: 'node', + format: 'esm', + target: 'es2022', + // Kept external so the shim and the step bundle share one copy: drizzle + // table objects are compared by identity inside a query, and PGlite must + // not be instantiated twice. + external: ['drizzle-orm', 'drizzle-orm/*', '@electric-sql/pglite'], + define: { + __OVERLAP_MIGRATIONS_DIR__: JSON.stringify(migrationsDir), + }, + logLevel: 'error', + }) +} + +export async function setup(): Promise { + await writeShimPackage('db', 'db-shim.ts') + await writeShimPackage('github', 'github-shim.ts') + await writeShimPackage('shared', 'shared-shim.ts') +} diff --git a/apps/web/test/integration/db-shim.ts b/apps/web/test/integration/db-shim.ts new file mode 100644 index 0000000..3ab8fa4 --- /dev/null +++ b/apps/web/test/integration/db-shim.ts @@ -0,0 +1,104 @@ +/** + * Test-only stand-in for `@overlap/db`. + * + * `build-shims.ts` bundles this file to + * `.workflow-vitest/node_modules/@overlap/db/index.mjs`, which Node resolves + * ahead of the real workspace package when it loads the generated step + * bundle. See `build-shims.ts` for why that is the seam. + * + * The schema, the relations and every query the steps issue are the real + * thing: the only substitution is the connection. Instead of the postgres.js + * socket client in `packages/db/src/client.ts` this points drizzle at PGlite, + * a WebAssembly build of Postgres that runs inside the test process, and + * applies the project's own `packages/db/drizzle/*.sql` migrations to it. The + * steps therefore execute their real SQL - including the raw `sql` templates + * in `detectOverlaps` and the relational `with:` loads - against the real + * schema, with no query faking anywhere. + * + * `@electric-sql/pglite` is a devDependency of `packages/db` as well as of + * this app. It is an optional peer of drizzle-orm, and pnpm keys a package + * instance by its resolved peer set: without it on both sides the schema would + * be built by one copy of drizzle-orm and queried by another. + */ + +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { PGlite } from '@electric-sql/pglite' +import { drizzle } from 'drizzle-orm/pglite' +import * as schema from '@overlap/db/schema' + +export * from '@overlap/db/schema' + +/** Absolute path to `packages/db/drizzle`, injected by `build-shims.ts`. */ +declare const __OVERLAP_MIGRATIONS_DIR__: string + +export type TestDatabase = ReturnType> + +export type DbHarness = { + pglite: PGlite + db: TestDatabase + schema: typeof schema + /** Empties every table. Call between tests. */ + reset(): Promise +} + +async function migrate(pglite: PGlite): Promise { + const files = (await readdir(__OVERLAP_MIGRATIONS_DIR__)) + .filter((file) => file.endsWith('.sql')) + .sort() + + for (const file of files) { + const contents = await readFile(join(__OVERLAP_MIGRATIONS_DIR__, file), 'utf8') + + // drizzle-kit separates statements with this marker rather than `;`, + // because a statement may legitimately contain one. + for (const statement of contents.split('--> statement-breakpoint')) { + const trimmed = statement.trim() + if (trimmed) { + await pglite.exec(trimmed) + } + } + } +} + +async function createHarness(): Promise { + const pglite = new PGlite() + await pglite.waitReady + await migrate(pglite) + + const db = drizzle(pglite, { schema }) + + return { + pglite, + db, + schema, + async reset() { + // TRUNCATE rather than re-running the migrations: it is a great deal + // faster and leaves the schema, indexes and constraints in place. + await pglite.exec(` + DO $$ + DECLARE target RECORD; + BEGIN + FOR target IN + SELECT tablename FROM pg_tables WHERE schemaname = 'public' + LOOP + EXECUTE 'TRUNCATE TABLE ' || quote_ident(target.tablename) || ' CASCADE'; + END LOOP; + END $$; + `) + }, + } +} + +/** + * One database per worker process, no matter how many module graphs load this + * file. The step bundle imports it natively while `harness.ts` may reach it + * through Vitest's module runner; without this rendezvous the tests would seed + * one Postgres instance and the steps would query another. + */ +const KEY = '__overlapDbHarness__' +const scope = globalThis as unknown as Record> +scope[KEY] ??= createHarness() + +export const __harness: DbHarness = await scope[KEY] +export const db: TestDatabase = __harness.db diff --git a/apps/web/test/integration/fixtures.ts b/apps/web/test/integration/fixtures.ts new file mode 100644 index 0000000..1007fa6 --- /dev/null +++ b/apps/web/test/integration/fixtures.ts @@ -0,0 +1,173 @@ +/** + * Seed data for the workflow integration tests. + * + * These go into a real Postgres through the real schema, so anything the + * steps' queries rely on - foreign keys, unique indexes, defaults - has to + * actually hold. + */ + +import { eq } from 'drizzle-orm' +import type { Harness } from './harness.js' + +export const REPO_GITHUB_ID = 424_242 +export const INSTALLATION_ID = 999 +export const REPO_FULL_NAME = 'acme/widgets' +export const DEFAULT_BRANCH = 'main' +export const PUSHED_BRANCH = 'feature/checkout' +export const HEAD_SHA = 'b'.repeat(40) + +/** A `push` payload GitHub would send, shaped to pass `pushEventSchema`. */ +export function pushPayload( + overrides: Record = {} +): Record { + return { + ref: `refs/heads/${PUSHED_BRANCH}`, + before: 'a'.repeat(40), + after: HEAD_SHA, + repository: { + id: REPO_GITHUB_ID, + name: 'widgets', + full_name: REPO_FULL_NAME, + default_branch: DEFAULT_BRANCH, + private: false, + }, + sender: { id: 4242, login: 'octocat' }, + installation: { id: INSTALLATION_ID }, + commits: [ + { + id: HEAD_SHA, + added: ['src/checkout.ts'], + modified: ['src/shared.ts'], + removed: [], + }, + ], + ...overrides, + } +} + +export type SeededRepo = { + repositoryId: string + pushedBranchId: string +} + +/** + * A repository with one installation and the branch the push targets. + * + * The pushed branch is seeded with an *empty* file index on purpose: that is + * what lets the ordering test tell "detection ran after the sync" apart from + * "detection ran at some point". Detection short-circuits on a branch with no + * tracked files, so any overlap it reports can only have come from an index + * `syncBranchFiles` had already written. + */ +export async function seedRepository(harness: Harness): Promise { + const { db, schema } = harness + + const [installation] = await db + .insert(schema.githubAppInstallations) + .values({ installationId: INSTALLATION_ID, status: 'active' }) + .returning() + + const [repository] = await db + .insert(schema.repositories) + .values({ + githubId: REPO_GITHUB_ID, + installationId: installation.id, + name: 'widgets', + fullName: REPO_FULL_NAME, + defaultBranch: DEFAULT_BRANCH, + }) + .returning() + + const [pushedBranch] = await db + .insert(schema.branches) + .values({ + repositoryId: repository.id, + name: PUSHED_BRANCH, + sha: 'a'.repeat(40), + isDefault: false, + lastPusherGithubId: 4242, + lastSeenAt: new Date(), + }) + .returning() + + return { repositoryId: repository.id, pushedBranchId: pushedBranch.id } +} + +/** Another active branch in the repository, with a file index of its own. */ +export async function seedBranchWithFiles( + harness: Harness, + repositoryId: string, + name: string, + filePaths: string[] +): Promise { + const { db, schema } = harness + + const [branch] = await db + .insert(schema.branches) + .values({ + repositoryId, + name, + sha: 'c'.repeat(40), + isDefault: false, + lastPusherGithubId: 7, + lastSeenAt: new Date(), + }) + .returning() + + if (filePaths.length > 0) { + await db.insert(schema.branchFiles).values( + filePaths.map((filePath) => ({ + branchId: branch.id, + filePath, + changeType: 'modified', + })) + ) + } + + return branch.id +} + +export async function seedOpenPullRequest( + harness: Harness, + repositoryId: string, + branchId: string, + githubPrNumber: number +): Promise { + const { db, schema } = harness + + const [pullRequest] = await db + .insert(schema.pullRequests) + .values({ + repositoryId, + branchId, + githubPrNumber, + title: `PR #${githubPrNumber}`, + state: 'open', + }) + .returning() + + return pullRequest.id +} + +export async function seedWebhookDelivery( + harness: Harness, + deliveryId: string, + payload: Record, + eventType = 'push' +): Promise { + const { db, schema } = harness + + await db.insert(schema.webhookEvents).values({ + eventType, + deliveryId, + payload, + dispatchedAt: new Date(), + }) +} + +export async function webhookEventRow(harness: Harness, deliveryId: string) { + const { db, schema } = harness + return db.query.webhookEvents.findFirst({ + where: eq(schema.webhookEvents.deliveryId, deliveryId), + }) +} diff --git a/apps/web/test/integration/github-shim.ts b/apps/web/test/integration/github-shim.ts new file mode 100644 index 0000000..5573c80 --- /dev/null +++ b/apps/web/test/integration/github-shim.ts @@ -0,0 +1,109 @@ +/** + * Test-only stand-in for `@overlap/github`. + * + * `build-shims.ts` bundles this file to + * `.workflow-vitest/node_modules/@overlap/github/index.mjs`, which Node + * resolves ahead of the real workspace package when it loads the generated + * step bundle. See `build-shims.ts` for why that is the seam. + * + * Only `getGitHubClient` is faked - it is the one export that opens a socket. + * `extractBranchFromRef`, `isBranchDeletion` and `formatCheckRunSummary` are + * pure and are re-exported from the real package, because the steps' handling + * of refs, deletions and check-run bodies is part of what is under test. + */ + +export { + extractBranchFromRef, + isBranchDeletion, + isBranchCreation, + formatOverlapComment, + formatCheckRunSummary, +} from '@overlap/github/webhooks' + +import { getGitHubFakeState, type FakeCommitFile } from './github-state.js' + +const delay = (ms: number) => + ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : undefined + +class FakeGitHubClient { + async getBranchFiles( + installationId: number, + owner: string, + repo: string, + branchName: string, + defaultBranch: string + ): Promise { + const state = getGitHubFakeState() + state.calls.push({ + method: 'getBranchFiles', + args: [installationId, owner, repo, branchName, defaultBranch], + }) + + await delay(state.getBranchFilesDelayMs) + + return state.branchFiles[branchName] ?? [] + } + + async createCheckRun( + installationId: number, + owner: string, + repo: string, + headSha: string, + name: string, + conclusion: string, + title: string, + summary: string + ): Promise { + const state = getGitHubFakeState() + state.calls.push({ + method: 'createCheckRun', + args: [ + installationId, + owner, + repo, + headSha, + name, + conclusion, + title, + summary, + ], + }) + + if (state.createCheckRunError) { + throw state.createCheckRunError + } + + return state.nextCheckRunId++ + } + + async updateCheckRun( + installationId: number, + owner: string, + repo: string, + checkRunId: number, + conclusion: string, + title: string, + summary: string + ): Promise { + const state = getGitHubFakeState() + state.calls.push({ + method: 'updateCheckRun', + args: [ + installationId, + owner, + repo, + checkRunId, + conclusion, + title, + summary, + ], + }) + } +} + +let client: FakeGitHubClient | null = null + +export function getGitHubClient(): FakeGitHubClient { + client ??= new FakeGitHubClient() + return client +} diff --git a/apps/web/test/integration/github-state.ts b/apps/web/test/integration/github-state.ts new file mode 100644 index 0000000..a0e5fa8 --- /dev/null +++ b/apps/web/test/integration/github-state.ts @@ -0,0 +1,60 @@ +/** + * State shared between the fake `@overlap/github` module and the tests. + * + * The fake lives in a bundle that Node loads natively (see `build-shims.ts`), + * while the test file is loaded by Vitest's module runner. Those are two + * different module graphs, so the state cannot be shared by importing it. + * It is parked on `globalThis` instead, which both graphs agree on because + * they run in the same worker process. + */ + +/** The subset of `CommitFile` the steps actually read. */ +export type FakeCommitFile = { + filename: string + status: string +} + +export type FakeGitHubCall = { + method: string + args: unknown[] +} + +export type GitHubFakeState = { + /** Every call the steps made, in order. */ + calls: FakeGitHubCall[] + /** What `getBranchFiles` returns, keyed by branch name. */ + branchFiles: Record + /** + * How long `getBranchFiles` takes. A non-zero value is what makes the + * sync-then-detect ordering test meaningful: if detection were merely + * racing sync rather than sequenced after it, this delay decides the race. + */ + getBranchFilesDelayMs: number + /** When set, `createCheckRun` throws this instead of returning an id. */ + createCheckRunError: unknown + /** Id handed out by the next successful `createCheckRun`. */ + nextCheckRunId: number +} + +const KEY = '__overlapGitHubFakeState__' + +export function createGitHubFakeState(): GitHubFakeState { + return { + calls: [], + branchFiles: {}, + getBranchFilesDelayMs: 0, + createCheckRunError: null, + nextCheckRunId: 5000, + } +} + +export function getGitHubFakeState(): GitHubFakeState { + const scope = globalThis as unknown as Record + scope[KEY] ??= createGitHubFakeState() + return scope[KEY] +} + +export function resetGitHubFakeState(): void { + const scope = globalThis as unknown as Record + scope[KEY] = createGitHubFakeState() +} diff --git a/apps/web/test/integration/harness.ts b/apps/web/test/integration/harness.ts new file mode 100644 index 0000000..61c7602 --- /dev/null +++ b/apps/web/test/integration/harness.ts @@ -0,0 +1,73 @@ +/** + * Test-side access to the doubles installed by `build-shims.ts`. + * + * The step bundle imports the database shim natively, from a path Vitest never + * sees, while this file reaches it through Vitest's module runner. Both must + * end up talking to the same Postgres instance. Vitest externalizes anything + * under a `node_modules` directory, and the shim is installed under one, so in + * practice both land on the same module - but the shim also parks its state on + * `globalThis`, so a second evaluation would still share one database. + */ + +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { PgliteDatabase } from 'drizzle-orm/pglite' +import type * as dbSchema from '@overlap/db/schema' +import { + getGitHubFakeState, + resetGitHubFakeState, + type GitHubFakeState, +} from './github-state.js' + +export type { GitHubFakeState } + +export type Schema = typeof dbSchema + +export type Harness = { + db: PgliteDatabase + schema: Schema + /** Empties every table. */ + reset(): Promise +} + +const shimUrl = pathToFileURL( + join( + resolve(import.meta.dirname, '../..'), + '.workflow-vitest', + 'node_modules', + '@overlap', + 'db', + 'index.mjs' + ) +).href + +let cached: Harness | undefined + +/** + * Boots (on first call) and returns the in-process Postgres the steps query. + * + * Loading it here, before any workflow starts, is what makes seeding possible: + * the step bundle would otherwise not be imported until the first step runs. + */ +export async function getHarness(): Promise { + if (!cached) { + const shim = (await import(/* @vite-ignore */ shimUrl)) as Record< + string, + unknown + > + cached = shim.__harness as Harness + } + return cached +} + +/** Clears the database and the recorded GitHub calls. */ +export async function resetHarness(): Promise { + const harness = await getHarness() + await harness.reset() + resetGitHubFakeState() + return harness +} + +export function github(): GitHubFakeState { + return getGitHubFakeState() +} diff --git a/apps/web/test/integration/patch-step-bundle.ts b/apps/web/test/integration/patch-step-bundle.ts new file mode 100644 index 0000000..f6f8ea8 --- /dev/null +++ b/apps/web/test/integration/patch-step-bundle.ts @@ -0,0 +1,57 @@ +/** + * Works around a codegen bug in `@workflow/vitest` 4.0.18. + * + * The generated step bundle externalizes package imports rather than inlining + * them, and one of the SDK's own internals (`@workflow/builders`' serde + * checker) imports `builtin-modules`, whose package entry point is a `.json` + * file. The externalized import comes out as + * + * import builtinModules from ".../builtin-modules.json"; + * + * with no import attribute, which every Node release that supports JSON + * modules rejects with ERR_IMPORT_ATTRIBUTE_MISSING. The bundle then fails to + * load and every step dispatch hangs. + * + * This runs as a Vitest `setupFile`, which is the first point after the + * plugin's own global setup has written the bundles and before any test can + * trigger the lazy `import()` of them. The rewrite is idempotent and the file + * is replaced by rename, so concurrent workers cannot observe a partial write. + * + * Delete this file once the SDK emits the attribute itself. + */ + +import { readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const JSON_IMPORT = + /(\bimport\s+[^;'"]*?\bfrom\s*)(["'])([^"']+\.json)\2(\s*;)/g + +const here = dirname(fileURLToPath(import.meta.url)) +const outDir = join(resolve(here, '../..'), '.workflow-vitest') + +async function addJsonImportAttributes(file: string): Promise { + let source: string + try { + source = await readFile(file, 'utf8') + } catch { + return + } + + const patched = source.replace( + JSON_IMPORT, + (match, head, quote, specifier, tail) => + match.includes('with {') + ? match + : `${head}${quote}${specifier}${quote} with { type: "json" }${tail}` + ) + + if (patched === source) return + + const temporary = `${file}.${process.pid}.tmp` + await writeFile(temporary, patched) + await rename(temporary, file) +} + +await addJsonImportAttributes(join(outDir, 'steps.mjs')) +await addJsonImportAttributes(join(outDir, 'workflows.mjs')) diff --git a/apps/web/test/integration/process-webhook.integration.test.ts b/apps/web/test/integration/process-webhook.integration.test.ts new file mode 100644 index 0000000..fabaeb6 --- /dev/null +++ b/apps/web/test/integration/process-webhook.integration.test.ts @@ -0,0 +1,298 @@ +/** + * Integration tests for `processWebhook`, run against the real workflow + * runtime via `@workflow/vitest`. + * + * These cover the two behaviours the whole migration rests on and that no + * unit test can reach, because a `"use workflow"` function throws when it is + * invoked directly: + * + * 1. Sync happens-before detect. The BullMQ system enqueued branch sync and + * overlap detection as two independent jobs and hoped a `delay: 5000` + * would keep them in order. The workflow replaces that with an awaited + * edge; this asserts the edge holds at runtime, and - more importantly - + * that detection reads the index the sync wrote. + * 2. Failure bookkeeping. `processWebhook` records the error on the + * `webhook_events` row and rethrows. If it did not, a failed delivery + * would leave no trace at all. + * + * Plus the check-run fan-out, which unions pull request ids so that N + * overlaps across M pull requests produce M calls rather than N*M. + */ + +import { and, eq } from 'drizzle-orm' +import { beforeEach, describe, expect, it } from 'vitest' +import { start } from 'workflow/api' +import { processWebhook } from '../../src/workflows/process-webhook' +import { + DEFAULT_BRANCH, + HEAD_SHA, + INSTALLATION_ID, + PUSHED_BRANCH, + REPO_FULL_NAME, + pushPayload, + seedBranchWithFiles, + seedOpenPullRequest, + seedRepository, + seedWebhookDelivery, + webhookEventRow, +} from './fixtures.js' +import { github, resetHarness, type Harness } from './harness.js' +import { countOf, firstIndexOf, stepLog } from './run-events.js' + +const OVERLAPPING_FILES = ['src/checkout.ts', 'src/shared.ts'] + +let harness: Harness + +beforeEach(async () => { + harness = await resetHarness() +}) + +describe('processWebhook - sync happens-before detect', () => { + it('detects overlaps from the index syncBranchFiles just wrote', async () => { + const { repositoryId, pushedBranchId } = await seedRepository(harness) + const otherBranchId = await seedBranchWithFiles( + harness, + repositoryId, + 'feature/pricing', + OVERLAPPING_FILES + ) + await seedWebhookDelivery(harness, 'delivery-order', pushPayload()) + + // The pushed branch starts with nothing indexed. `detectOverlaps` returns + // early for a branch with no tracked files, so if it ran before - or + // concurrently with - the sync it would find nothing. + const filesBefore = await harness.db.query.branchFiles.findMany({ + where: eq(harness.schema.branchFiles.branchId, pushedBranchId), + }) + expect(filesBefore).toHaveLength(0) + + github().branchFiles[PUSHED_BRANCH] = [ + { filename: 'src/checkout.ts', status: 'modified' }, + { filename: 'src/shared.ts', status: 'added' }, + ] + // Long enough that a detection merely racing the sync would lose. The old + // system's answer to this was `delay: 5000`. + github().getBranchFilesDelayMs = 250 + + const run = await start(processWebhook, ['delivery-order']) + await expect(run.returnValue).resolves.toEqual({ handled: true }) + await expect(run.status).resolves.toBe('completed') + + // The durable log is the runtime's own record of the ordering. + const log = await stepLog(run.runId) + const syncCompleted = firstIndexOf(log, 'step_completed', 'syncBranchFiles') + const detectCreated = firstIndexOf(log, 'step_created', 'detectOverlaps') + + expect(syncCompleted).toBeGreaterThanOrEqual(0) + expect(detectCreated).toBeGreaterThanOrEqual(0) + expect(syncCompleted).toBeLessThan(detectCreated) + + // GitHub was consulted exactly once, for the branch that was pushed. + const branchFileCalls = github().calls.filter( + (call) => call.method === 'getBranchFiles' + ) + expect(branchFileCalls).toHaveLength(1) + expect(branchFileCalls[0]?.args).toEqual([ + INSTALLATION_ID, + 'acme', + 'widgets', + PUSHED_BRANCH, + DEFAULT_BRANCH, + ]) + + // The index the sync wrote. + const filesAfter = await harness.db.query.branchFiles.findMany({ + where: eq(harness.schema.branchFiles.branchId, pushedBranchId), + }) + expect(filesAfter.map((file) => file.filePath).sort()).toEqual( + OVERLAPPING_FILES + ) + + // And the overlap that could only have come from reading it. + const detected = await harness.db.query.overlaps.findMany({ + with: { files: true }, + }) + expect(detected).toHaveLength(1) + expect(detected[0]).toMatchObject({ + repositoryId, + sourceBranchId: pushedBranchId, + targetBranchId: otherBranchId, + status: 'active', + fileCount: 2, + }) + expect(detected[0]?.files.map((file) => file.filePath).sort()).toEqual( + OVERLAPPING_FILES + ) + + const event = await webhookEventRow(harness, 'delivery-order') + expect(event?.processedAt).toBeInstanceOf(Date) + expect(event?.error).toBeNull() + }) + + it('records nothing for detection when the sync finds no files', async () => { + // The negative control for the assertion above: with an empty index the + // same run reaches `detectOverlaps` and finds nothing, which is what makes + // the overlap in the previous test evidence of the sync's output rather + // than of the seed data. + const { repositoryId, pushedBranchId } = await seedRepository(harness) + await seedBranchWithFiles( + harness, + repositoryId, + 'feature/pricing', + OVERLAPPING_FILES + ) + await seedWebhookDelivery(harness, 'delivery-empty', pushPayload()) + + github().branchFiles[PUSHED_BRANCH] = [] + + const run = await start(processWebhook, ['delivery-empty']) + await expect(run.returnValue).resolves.toEqual({ handled: true }) + + const log = await stepLog(run.runId) + expect(countOf(log, 'step_completed', 'syncBranchFiles')).toBe(1) + expect(countOf(log, 'step_completed', 'detectOverlaps')).toBe(1) + + const filesAfter = await harness.db.query.branchFiles.findMany({ + where: eq(harness.schema.branchFiles.branchId, pushedBranchId), + }) + expect(filesAfter).toHaveLength(0) + await expect(harness.db.query.overlaps.findMany()).resolves.toHaveLength(0) + }) +}) + +describe('processWebhook - failure bookkeeping', () => { + it('records the error on the delivery and still fails the run', async () => { + await seedRepository(harness) + // A payload GitHub would never send. `parsePayload` raises a FatalError, + // so the run fails on its first step with no retries. + await seedWebhookDelivery(harness, 'delivery-broken', { + ...pushPayload(), + installation: undefined, + }) + + const run = await start(processWebhook, ['delivery-broken']) + + await expect(run.returnValue).rejects.toThrow(/Malformed webhook payload/) + await expect(run.status).resolves.toBe('failed') + + // The failure path ran `markEventProcessed(deliveryId, message)`: the + // error is on the row, and `processedAt` is deliberately not set. + const event = await webhookEventRow(harness, 'delivery-broken') + expect(event?.error).toMatch(/Malformed webhook payload for delivery/) + expect(event?.processedAt).toBeNull() + + const log = await stepLog(run.runId) + expect(countOf(log, 'step_failed', 'loadEvent')).toBeGreaterThan(0) + expect(countOf(log, 'step_completed', 'markEventProcessed')).toBe(1) + }) + + it('records a GitHub failure raised deep in the run', async () => { + const { repositoryId, pushedBranchId } = await seedRepository(harness) + await seedBranchWithFiles( + harness, + repositoryId, + 'feature/pricing', + OVERLAPPING_FILES + ) + await seedOpenPullRequest(harness, repositoryId, pushedBranchId, 1) + await seedWebhookDelivery(harness, 'delivery-github-404', pushPayload()) + + github().branchFiles[PUSHED_BRANCH] = OVERLAPPING_FILES.map((filename) => ({ + filename, + status: 'modified', + })) + // A 4xx from GitHub is classified fatal by `classifyGitHubError`, so the + // step fails outright rather than being retried. + github().createCheckRunError = Object.assign(new Error('Not Found'), { + status: 404, + }) + + const run = await start(processWebhook, ['delivery-github-404']) + + await expect(run.returnValue).rejects.toThrow(/Not Found/) + await expect(run.status).resolves.toBe('failed') + + const event = await webhookEventRow(harness, 'delivery-github-404') + expect(event?.error).toMatch(/Not Found/) + expect(event?.processedAt).toBeNull() + + // The steps that ran before the failure are not rolled back - the overlap + // detection they performed is still on the row. + await expect(harness.db.query.overlaps.findMany()).resolves.toHaveLength(1) + }) +}) + +describe('processWebhook - check run fan-out', () => { + it('posts one check run per pull request, not per overlap per pull request', async () => { + const { repositoryId, pushedBranchId } = await seedRepository(harness) + await seedBranchWithFiles( + harness, + repositoryId, + 'feature/pricing', + OVERLAPPING_FILES + ) + await seedBranchWithFiles( + harness, + repositoryId, + 'feature/tax', + OVERLAPPING_FILES + ) + const prOne = await seedOpenPullRequest( + harness, + repositoryId, + pushedBranchId, + 101 + ) + const prTwo = await seedOpenPullRequest( + harness, + repositoryId, + pushedBranchId, + 102 + ) + await seedWebhookDelivery(harness, 'delivery-fanout', pushPayload()) + + github().branchFiles[PUSHED_BRANCH] = OVERLAPPING_FILES.map((filename) => ({ + filename, + status: 'modified', + })) + + const run = await start(processWebhook, ['delivery-fanout']) + await expect(run.returnValue).resolves.toEqual({ handled: true }) + + // Two overlaps (pricing, tax) x two open pull requests. Walking the + // notifications naively would post four check runs. + const overlaps = await harness.db.query.overlaps.findMany() + expect(overlaps).toHaveLength(2) + + const log = await stepLog(run.runId) + expect(countOf(log, 'step_created', 'postCheckRun')).toBe(2) + expect(countOf(log, 'step_created', 'sendPush')).toBe(2) + + const createCalls = github().calls.filter( + (call) => call.method === 'createCheckRun' + ) + expect(createCalls).toHaveLength(2) + // Both check runs went to the same repository at the pushed head sha. + for (const call of createCalls) { + expect(call.args[1]).toBe(REPO_FULL_NAME.split('/')[0]) + expect(call.args[3]).toBe(HEAD_SHA) + } + + // One alert row per pull request, both naming the same overlap - the id + // of the first notification that listed that pull request. + const alerts = await harness.db.query.prAlerts.findMany() + expect(alerts).toHaveLength(2) + expect(alerts.map((alert) => alert.pullRequestId).sort()).toEqual( + [prOne, prTwo].sort() + ) + expect(new Set(alerts.map((alert) => alert.overlapId)).size).toBe(1) + + const stillActive = await harness.db.query.overlaps.findMany({ + where: and( + eq(harness.schema.overlaps.repositoryId, repositoryId), + eq(harness.schema.overlaps.status, 'active') + ), + }) + expect(stillActive).toHaveLength(2) + }) +}) diff --git a/apps/web/test/integration/run-events.ts b/apps/web/test/integration/run-events.ts new file mode 100644 index 0000000..14b5391 --- /dev/null +++ b/apps/web/test/integration/run-events.ts @@ -0,0 +1,93 @@ +/** + * Reads a run's durable event log. + * + * This is the record the workflow runtime itself keeps, not something the test + * observes from the side, which is what makes it the right place to assert a + * happens-before edge between two steps. + */ + +import { getWorld } from 'workflow/runtime' + +export type StepLogEntry = { + /** Position in the run's event log. */ + index: number + eventType: string + /** As recorded, e.g. `step//./src/workflows/steps//syncBranchFiles`. */ + qualifiedName: string + /** Trailing segment of the above, e.g. `syncBranchFiles`. */ + stepName: string + correlationId: string +} + +type RawEvent = { + eventType: string + correlationId?: string + eventData?: { stepName?: string } +} + +/** + * Every `step_*` event of a run, in log order, with `stepName` filled in for + * the event types that only carry it on `step_created`. + */ +export async function stepLog(runId: string): Promise { + const world = getWorld() + const events: RawEvent[] = [] + + let cursor: string | null = null + do { + const page = await world.events.list({ + runId, + pagination: { limit: 1000, ...(cursor ? { cursor } : {}) }, + resolveData: 'none', + }) + events.push(...(page.data as unknown as RawEvent[])) + cursor = page.hasMore ? page.cursor : null + } while (cursor) + + const namesByCorrelationId = new Map() + for (const event of events) { + const name = event.eventData?.stepName + if (event.correlationId && name) { + namesByCorrelationId.set(event.correlationId, name) + } + } + + return events.flatMap((event, index) => { + if (!event.eventType.startsWith('step_')) return [] + const correlationId = event.correlationId ?? '' + const qualifiedName = + event.eventData?.stepName ?? + namesByCorrelationId.get(correlationId) ?? + '' + return [ + { + index, + eventType: event.eventType, + qualifiedName, + stepName: qualifiedName.split('//').pop() ?? qualifiedName, + correlationId, + }, + ] + }) +} + +/** Index in the step log of the first `eventType` for `stepName`, or -1. */ +export function firstIndexOf( + log: StepLogEntry[], + eventType: string, + stepName: string +): number { + return log.findIndex( + (entry) => entry.eventType === eventType && entry.stepName === stepName + ) +} + +export function countOf( + log: StepLogEntry[], + eventType: string, + stepName: string +): number { + return log.filter( + (entry) => entry.eventType === eventType && entry.stepName === stepName + ).length +} diff --git a/apps/web/test/integration/shared-shim.ts b/apps/web/test/integration/shared-shim.ts new file mode 100644 index 0000000..6b4dda4 --- /dev/null +++ b/apps/web/test/integration/shared-shim.ts @@ -0,0 +1,13 @@ +/** + * A loadable copy of `@overlap/shared` for the generated step bundle. + * + * Unlike the `@overlap/db` and `@overlap/github` shims this substitutes no + * behaviour at all - it re-exports the real module verbatim. It exists purely + * because the workspace package ships TypeScript sources whose relative + * imports carry `.js` extensions, which Node's own type stripping does not + * rewrite back to `.ts`; bundling it with esbuild produces something Node can + * import. The validation schemas, event constants and severity calculation the + * steps depend on are therefore the production ones. + */ + +export * from '@overlap/shared' diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 98a9532..fdfc08c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -9,5 +9,5 @@ }, "plugins": [{ "name": "workflow" }] }, - "include": ["src/**/*"] + "include": ["src/**/*", "test/**/*"] } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index fb609e6..9a2f0b7 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ plugins: [tsconfigPaths()], test: { include: ['src/**/*.test.ts'], + // Integration tests live in `test/` and need the workflow runtime that + // only `vitest.integration.config.ts` sets up. Excluded by name as well + // as by directory so a stray file cannot end up in both suites. + exclude: ['**/node_modules/**', '**/*.integration.test.ts'], environment: 'node', env: { DATABASE_URL: 'postgresql://test:test@localhost/test', diff --git a/apps/web/vitest.integration.config.ts b/apps/web/vitest.integration.config.ts new file mode 100644 index 0000000..81beefb --- /dev/null +++ b/apps/web/vitest.integration.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from 'vitest/config' +import { workflow } from '@workflow/vitest' + +/** + * Integration tests for the durable workflow. + * + * `workflow()` compiles the `"use workflow"` / `"use step"` directives, + * bundles the workflow and step entry points, and runs them against an + * in-process Local World - so `start()` here exercises the real runtime: + * real step dispatch, real event log, real failure semantics. + * + * It is a separate config from `vitest.config.ts` on purpose. The plugin + * builds those bundles before the suite starts, which the unit tests neither + * need nor should pay for, and the two suites are kept apart by file name: + * `*.integration.test.ts` here, `src/**\/*.test.ts` there. + */ +export default defineConfig({ + plugins: [workflow()], + test: { + include: ['test/**/*.integration.test.ts'], + environment: 'node', + // A run boots PGlite, applies migrations and executes a chain of steps. + testTimeout: 60_000, + hookTimeout: 60_000, + // Installs the `@overlap/db` / `@overlap/github` doubles the step bundle + // resolves at runtime. Runs alongside the plugin's own global setup. + globalSetup: ['./test/integration/build-shims.ts'], + // Repairs a JSON import in the SDK's generated bundle. Must run after the + // plugin's global setup has written it, which is why it is a setup file. + setupFiles: ['./test/integration/patch-step-bundle.ts'], + env: { + // `packages/db/src/client.ts` throws at import time without this. The + // step bundle never reaches that file (see `build-shims.ts`), but other + // modules in the graph import `@overlap/db` normally. + DATABASE_URL: 'postgresql://test:test@localhost/test', + }, + }, +}) diff --git a/packages/db/package.json b/packages/db/package.json index 4050f6b..5da2169 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -22,6 +22,7 @@ "postgres": "^3.4.0" }, "devDependencies": { + "@electric-sql/pglite": "0.2.17", "drizzle-kit": "^0.30.6", "typescript": "^5.7.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7cb10a..369958c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 5.67.2 drizzle-orm: specifier: ^0.38.0 - version: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) fastify: specifier: ^5.0.0 version: 5.7.4 @@ -128,7 +128,7 @@ importers: version: 2.1.1 drizzle-orm: specifier: ^0.38.0 - version: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) jose: specifier: ^6.2.8 version: 6.2.8 @@ -140,7 +140,7 @@ importers: version: 10.2.6 nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.2.17)(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) react: specifier: ^19.0.0 version: 19.2.4 @@ -157,6 +157,9 @@ importers: specifier: ^4.8.2 version: 4.8.2(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(typescript@5.9.3) devDependencies: + '@electric-sql/pglite': + specifier: 0.2.17 + version: 0.2.17 '@tailwindcss/vite': specifier: ^4.0.0 version: 4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -175,6 +178,12 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.0 version: 5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) + '@workflow/vitest': + specifier: 4.0.18 + version: 4.0.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))) + esbuild: + specifier: ^0.28.2 + version: 0.28.2 tailwindcss: specifier: ^4.0.0 version: 4.1.18 @@ -207,7 +216,7 @@ importers: version: 5.67.2 drizzle-orm: specifier: ^0.38.0 - version: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) ioredis: specifier: ^5.4.0 version: 5.9.2 @@ -241,11 +250,14 @@ importers: version: link:../shared drizzle-orm: specifier: ^0.38.0 - version: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) postgres: specifier: ^3.4.0 version: 3.4.8 devDependencies: + '@electric-sql/pglite': + specifier: 0.2.17 + version: 0.2.17 drizzle-kit: specifier: ^0.30.6 version: 0.30.6 @@ -450,6 +462,9 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@electric-sql/pglite@0.2.17': + resolution: {integrity: sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -2772,6 +2787,15 @@ packages: '@workflow/vite@4.0.17': resolution: {integrity: sha512-+cBjYEmsirg+aIPY7e0tMEo44q9zwnX8DYt6vo42oplwT12CJp2OMzdfm1iEk/V0I0X2BIDWVuDcE0k7gnOrKw==} + '@workflow/vitest@4.0.18': + resolution: {integrity: sha512-/CMpSI7YOwVayyzLjxkDCbmFTg8gPK/+YrsXcAJiY9jBrTgLU7VR3tABBJP6Kycz0Qy867NEOmbcWHnxOa91TA==} + peerDependencies: + vite: '>=6.0.0' + vitest: '>=3.1.0' + peerDependenciesMeta: + vite: + optional: true + '@workflow/web@4.1.18': resolution: {integrity: sha512-I8sXlh8cRanzFTtaWFF0bfRvCsKctOIdRO9QqS/uvb3/P91GYmvCuSqP/TTZdGaDaikL+IMTYRD1vCBScQRe4A==} @@ -5939,6 +5963,8 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@electric-sql/pglite@0.2.17': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -8049,6 +8075,22 @@ snapshots: - supports-color - ws + '@workflow/vitest@4.0.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)))': + dependencies: + '@workflow/builders': 4.1.7 + '@workflow/core': 4.8.2 + '@workflow/rollup': 4.0.17 + '@workflow/world': 4.3.1 + '@workflow/world-local': 4.2.4 + vitest: 4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) + optionalDependencies: + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + '@workflow/web@4.1.18': dependencies: express: 5.2.1 @@ -8614,9 +8656,10 @@ snapshots: dateformat@4.6.3: {} - db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)): + db0@0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)): optionalDependencies: - drizzle-orm: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) + '@electric-sql/pglite': 0.2.17 + drizzle-orm: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) debug@4.4.3(supports-color@8.1.1): dependencies: @@ -8694,8 +8737,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4): + drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4): optionalDependencies: + '@electric-sql/pglite': 0.2.17 '@types/react': 19.2.10 postgres: 3.4.8 react: 19.2.4 @@ -9742,11 +9786,11 @@ snapshots: nf3@0.3.7: {} - nitro@3.0.1-alpha.2(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): + nitro@3.0.1-alpha.2(@electric-sql/pglite@0.2.17)(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: consola: 3.4.2 crossws: 0.4.4(srvx@0.10.1) - db0: 0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)) + db0: 0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)) h3: 2.0.1-rc.11(crossws@0.4.4(srvx@0.10.1)) jiti: 2.6.1 nf3: 0.3.7 @@ -9757,7 +9801,7 @@ snapshots: srvx: 0.10.1 undici: 7.20.0 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.5(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.5(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3) optionalDependencies: rollup: 4.57.1 vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) @@ -10718,11 +10762,11 @@ snapshots: picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 - unstorage@2.0.0-alpha.5(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.5(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3): optionalDependencies: '@vercel/functions': 3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49) chokidar: 5.0.0 - db0: 0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)) + db0: 0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)) ioredis: 5.9.2 ofetch: 2.0.0-alpha.3 diff --git a/turbo.json b/turbo.json index f829705..a53ee55 100644 --- a/turbo.json +++ b/turbo.json @@ -21,6 +21,10 @@ "dependsOn": ["^build"], "outputs": [] }, + "test:integration": { + "dependsOn": ["^build"], + "outputs": [] + }, "clean": { "cache": false }, From 34e8e3a28730a6c693e09604f3bb641e54e03ec9 Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 21:01:46 -0700 Subject: [PATCH 23/35] fix(deps): unify drizzle-orm by hoisting pglite to the workspace root Adding @electric-sql/pglite to apps/web and packages/db broke the repo-wide typecheck: it is an optional peer of drizzle-orm, and pnpm keys a package instance by its resolved peer set, so those two packages got a drizzle-orm variant that apps/api and apps/worker did not. The schema was built by one copy and queried by another, and every table type mismatched across the boundary - 137 errors in @overlap/worker. `pnpm --filter @overlap/web typecheck` passed throughout, which is why it was missed; a filtered gate cannot see damage that only exists at the boundary between packages. Moving pglite to the workspace root fixes it. The root is not a dependent of drizzle-orm, and pnpm's resolve-peers-from-workspace-root satisfies the optional peer identically for every workspace project, so apps/web, apps/api, apps/worker and packages/db now share one instance. A clean install from the lockfile produces exactly one drizzle-orm directory where there were two. An override cannot merge two variants of the same version, and packageExtensions cannot delete a declared peer, so this is the only complete fix that does not spread a test-only dependency across every production manifest. db-shim.ts documents the constraint and the error text it produces if reintroduced. patch-step-bundle.ts now throws instead of returning silently when the generated bundle does not look the way it expects - naming the file, the pattern, the installed @workflow/vitest version and what to do next - so an SDK upgrade fails immediately and readably rather than restoring the swallowed error and 60s timeout it exists to prevent. Both guard paths verified. The already-patched path still returns quietly, which is required so concurrent workers do not race each other into a failure. No production code or test assertions changed. The sync-then-detect mutation still fails the suite exactly as before. --- apps/web/package.json | 1 - apps/web/test/integration/db-shim.ts | 14 ++- .../web/test/integration/patch-step-bundle.ts | 115 ++++++++++++++++-- package.json | 1 + packages/db/package.json | 1 - pnpm-lock.yaml | 9 +- 6 files changed, 116 insertions(+), 25 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 25d12df..402cd4f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,7 +42,6 @@ "workflow": "^4.8.2" }, "devDependencies": { - "@electric-sql/pglite": "0.2.17", "@tailwindcss/vite": "^4.0.0", "@types/node": "^20.11.0", "@types/react": "^19.0.0", diff --git a/apps/web/test/integration/db-shim.ts b/apps/web/test/integration/db-shim.ts index 3ab8fa4..efa765c 100644 --- a/apps/web/test/integration/db-shim.ts +++ b/apps/web/test/integration/db-shim.ts @@ -15,10 +15,16 @@ * in `detectOverlaps` and the relational `with:` loads - against the real * schema, with no query faking anywhere. * - * `@electric-sql/pglite` is a devDependency of `packages/db` as well as of - * this app. It is an optional peer of drizzle-orm, and pnpm keys a package - * instance by its resolved peer set: without it on both sides the schema would - * be built by one copy of drizzle-orm and queried by another. + * `@electric-sql/pglite` is deliberately a devDependency of the **workspace + * root**, not of this app. It is an optional peer of drizzle-orm, and pnpm + * keys a package instance by its resolved peer set - so declaring it in any + * individual package gives that package its own drizzle-orm variant, and the + * schema ends up built by one copy and queried by another. At the root, + * pnpm's `resolve-peers-from-workspace-root` satisfies the peer identically + * for every workspace project, so `apps/web`, `apps/api`, `apps/worker` and + * `packages/db` all share a single drizzle-orm. Do not move it into a package + * manifest: `pnpm typecheck` at the repo root will fail with + * "Types have separate declarations of a private property 'shouldInlineParams'". */ import { readFile, readdir } from 'node:fs/promises' diff --git a/apps/web/test/integration/patch-step-bundle.ts b/apps/web/test/integration/patch-step-bundle.ts index f6f8ea8..edacc75 100644 --- a/apps/web/test/integration/patch-step-bundle.ts +++ b/apps/web/test/integration/patch-step-bundle.ts @@ -10,48 +10,137 @@ * * with no import attribute, which every Node release that supports JSON * modules rejects with ERR_IMPORT_ATTRIBUTE_MISSING. The bundle then fails to - * load and every step dispatch hangs. + * load, and because the local world swallows the error as + * `[local world] Queue operation failed`, the only symptom is that every test + * times out after 60 seconds with no indication of the cause. * * This runs as a Vitest `setupFile`, which is the first point after the * plugin's own global setup has written the bundles and before any test can * trigger the lazy `import()` of them. The rewrite is idempotent and the file * is replaced by rename, so concurrent workers cannot observe a partial write. * - * Delete this file once the SDK emits the attribute itself. + * It throws rather than no-oping when the bundle does not look the way it + * expects, so that an SDK upgrade produces an immediate, readable failure + * instead of reintroducing the silent 60-second timeout - in either direction: + * a bundle that is missing, or one that no longer contains the bad import + * (which is the signal that this file has done its job and should go). + * + * DELETE THIS FILE once `@workflow/vitest` emits the import attribute itself. + * `expectations` below records exactly what has to change for that to be safe. */ import { readFile, rename, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +/** Extension-less imports are fine; only `.json` needs the attribute. */ const JSON_IMPORT = /(\bimport\s+[^;'"]*?\bfrom\s*)(["'])([^"']+\.json)\2(\s*;)/g const here = dirname(fileURLToPath(import.meta.url)) -const outDir = join(resolve(here, '../..'), '.workflow-vitest') +const webRoot = resolve(here, '../..') +const outDir = join(webRoot, '.workflow-vitest') + +/** + * Bundles that must exist, and whether each is known to carry the bad import. + * `steps.mjs` is the one that does; `workflows.mjs` is checked opportunistically + * in case a future version moves the serde checker into it. + */ +const expectations = [ + { file: 'steps.mjs', mustContainJsonImport: true }, + { file: 'workflows.mjs', mustContainJsonImport: false }, +] + +async function installedVersion(): Promise { + try { + const manifest = await readFile( + join(webRoot, 'node_modules', '@workflow', 'vitest', 'package.json'), + 'utf8' + ) + return (JSON.parse(manifest) as { version?: string }).version ?? 'unknown' + } catch { + return 'unknown' + } +} + +async function fail(summary: string, detail: string): Promise { + throw new Error( + [ + `patch-step-bundle: ${summary}`, + '', + detail, + '', + `Installed @workflow/vitest: ${await installedVersion()}`, + `Bundle directory: ${relative(webRoot, outDir)}`, + `Expected import pattern: ${JSON_IMPORT.source}`, + '', + 'This file exists only to add `with { type: "json" }` to a JSON import', + 'that @workflow/vitest 4.0.18 emits without one. If the SDK has been', + 'upgraded and now emits the attribute itself, delete', + 'apps/web/test/integration/patch-step-bundle.ts and remove it from', + "vitest.integration.config.ts's setupFiles. If the SDK changed in some", + 'other way, update this file to match.', + ].join('\n') + ) +} + +async function patchBundle( + fileName: string, + mustContainJsonImport: boolean +): Promise { + const file = join(outDir, fileName) -async function addJsonImportAttributes(file: string): Promise { let source: string try { source = await readFile(file, 'utf8') - } catch { + } catch (error) { + return fail( + `expected bundle ${fileName} does not exist`, + `Reading it failed with: ${(error as Error).message}\n` + + "The workflow plugin's global setup is supposed to have written it " + + 'before setup files run.' + ) + } + + const alreadyPatched = /\.json["']\s+with\s*\{\s*type:\s*["']json["']\s*\}/.test( + source + ) + const matches = source.match(JSON_IMPORT) ?? [] + + if (matches.length === 0) { + if (alreadyPatched) return // A previous worker got there first. + if (mustContainJsonImport) { + return fail( + `${fileName} no longer contains the unattributed JSON import this patch targets`, + 'Either the SDK now emits `with { type: "json" }` itself - in which ' + + 'case this file is obsolete and should be deleted - or it inlines ' + + 'the JSON instead of externalizing it, in which case the patch is ' + + 'no longer needed either. Verify by loading the bundle, then remove ' + + 'this file.' + ) + } return } const patched = source.replace( JSON_IMPORT, - (match, head, quote, specifier, tail) => - match.includes('with {') - ? match - : `${head}${quote}${specifier}${quote} with { type: "json" }${tail}` + (_match, head, quote, specifier, tail) => + `${head}${quote}${specifier}${quote} with { type: "json" }${tail}` ) - if (patched === source) return + if (patched === source) { + return fail( + `${fileName} contains a JSON import that could not be rewritten`, + `Matched ${matches.length} import(s) but the rewrite produced no change:\n` + + matches.map((match) => ` ${match}`).join('\n') + ) + } const temporary = `${file}.${process.pid}.tmp` await writeFile(temporary, patched) await rename(temporary, file) } -await addJsonImportAttributes(join(outDir, 'steps.mjs')) -await addJsonImportAttributes(join(outDir, 'workflows.mjs')) +for (const { file, mustContainJsonImport } of expectations) { + await patchBundle(file, mustContainJsonImport) +} diff --git a/package.json b/package.json index d1175cd..b5366cf 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:studio": "turbo db:studio --filter=@overlap/db" }, "devDependencies": { + "@electric-sql/pglite": "0.2.17", "@types/node": "^20.11.0", "eslint": "^9.0.0", "prettier": "^3.2.0", diff --git a/packages/db/package.json b/packages/db/package.json index 5da2169..4050f6b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -22,7 +22,6 @@ "postgres": "^3.4.0" }, "devDependencies": { - "@electric-sql/pglite": "0.2.17", "drizzle-kit": "^0.30.6", "typescript": "^5.7.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 369958c..80f8421 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@electric-sql/pglite': + specifier: 0.2.17 + version: 0.2.17 '@types/node': specifier: ^20.11.0 version: 20.19.31 @@ -157,9 +160,6 @@ importers: specifier: ^4.8.2 version: 4.8.2(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(typescript@5.9.3) devDependencies: - '@electric-sql/pglite': - specifier: 0.2.17 - version: 0.2.17 '@tailwindcss/vite': specifier: ^4.0.0 version: 4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -255,9 +255,6 @@ importers: specifier: ^3.4.0 version: 3.4.8 devDependencies: - '@electric-sql/pglite': - specifier: 0.2.17 - version: 0.2.17 drizzle-kit: specifier: ^0.30.6 version: 0.30.6 From 7e3480e6732519a1a398b6a1974b0a79bcb4accd Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 21:11:04 -0700 Subject: [PATCH 24/35] chore: expose the workflow integration suite from the repo root Root `pnpm test` is `turbo test`, which does not include test:integration, and there is no CI. The Turborepo task and the package-level script both existed, but nothing invoked them by default, so the only runtime proof that the sync-then-detect ordering holds was reachable only by someone who already knew it was there. A regression would have passed every command anyone actually runs. Adds a root test:integration script alongside test, matching the surrounding `turbo ` formatting. `pnpm test` is left alone on purpose: a 0.6s unit suite and a 6.3s workflow suite are worth keeping separate, and merging them would slow the fast feedback loop six-fold for no extra coverage. The integration suite just needed to be reachable by name. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b5366cf..c3027ba 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "turbo lint", "typecheck": "turbo typecheck", "test": "turbo test", + "test:integration": "turbo test:integration", "clean": "turbo clean && rm -rf node_modules", "db:generate": "turbo db:generate --filter=@overlap/db", "db:migrate": "turbo db:migrate --filter=@overlap/db", From daf5f6cb5a13621b6dcefbe162bdab8126b56b2e Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 21:20:26 -0700 Subject: [PATCH 25/35] chore: remove Railway apps, BullMQ and Redis apps/api and apps/worker are now served by apps/web. Every route and processor has been ported to apps/web/src as part of the Vercel migration, so the old Fastify API and BullMQ worker are dead code. Redis had exactly one consumer (BullMQ) and is deleted with it, along with railway.json and apps/web/Dockerfile. QUEUE_NAMES and RATE_LIMITS are removed from packages/shared since their only consumers were the deleted apps. --- apps/api/Dockerfile | 22 - apps/api/package.json | 33 -- apps/api/railway.json | 11 - apps/api/railway.toml | 8 - apps/api/src/index.ts | 81 ---- apps/api/src/plugins/auth.ts | 69 --- apps/api/src/queues/index.ts | 132 ------ apps/api/src/routes/auth.ts | 284 ------------ apps/api/src/routes/health.ts | 41 -- apps/api/src/routes/push.ts | 79 ---- apps/api/src/routes/repositories.ts | 328 ------------- apps/api/src/routes/webhooks.ts | 179 -------- apps/api/src/scheduler.ts | 42 -- apps/api/tsconfig.json | 8 - apps/api/tsup.config.ts | 8 - apps/web/Dockerfile | 34 -- apps/worker/Dockerfile | 22 - apps/worker/package.json | 30 -- apps/worker/railway.json | 11 - apps/worker/railway.toml | 8 - apps/worker/src/index.ts | 105 ----- apps/worker/src/processors/branch-sync.ts | 113 ----- apps/worker/src/processors/github-feedback.ts | 128 ------ apps/worker/src/processors/maintenance.ts | 190 -------- .../src/processors/overlap-detection.ts | 263 ----------- .../src/processors/push-notification.ts | 113 ----- apps/worker/src/processors/webhook-events.ts | 432 ------------------ apps/worker/tsconfig.json | 11 - apps/worker/tsup.config.ts | 8 - docker-compose.yml | 14 - packages/shared/src/constants/index.ts | 28 -- railway.json | 7 - 32 files changed, 2842 deletions(-) delete mode 100644 apps/api/Dockerfile delete mode 100644 apps/api/package.json delete mode 100644 apps/api/railway.json delete mode 100644 apps/api/railway.toml delete mode 100644 apps/api/src/index.ts delete mode 100644 apps/api/src/plugins/auth.ts delete mode 100644 apps/api/src/queues/index.ts delete mode 100644 apps/api/src/routes/auth.ts delete mode 100644 apps/api/src/routes/health.ts delete mode 100644 apps/api/src/routes/push.ts delete mode 100644 apps/api/src/routes/repositories.ts delete mode 100644 apps/api/src/routes/webhooks.ts delete mode 100644 apps/api/src/scheduler.ts delete mode 100644 apps/api/tsconfig.json delete mode 100644 apps/api/tsup.config.ts delete mode 100644 apps/web/Dockerfile delete mode 100644 apps/worker/Dockerfile delete mode 100644 apps/worker/package.json delete mode 100644 apps/worker/railway.json delete mode 100644 apps/worker/railway.toml delete mode 100644 apps/worker/src/index.ts delete mode 100644 apps/worker/src/processors/branch-sync.ts delete mode 100644 apps/worker/src/processors/github-feedback.ts delete mode 100644 apps/worker/src/processors/maintenance.ts delete mode 100644 apps/worker/src/processors/overlap-detection.ts delete mode 100644 apps/worker/src/processors/push-notification.ts delete mode 100644 apps/worker/src/processors/webhook-events.ts delete mode 100644 apps/worker/tsconfig.json delete mode 100644 apps/worker/tsup.config.ts delete mode 100644 railway.json diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile deleted file mode 100644 index 0988dea..0000000 --- a/apps/api/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM node:20-slim - -# Install pnpm -RUN npm install -g pnpm - -WORKDIR /app - -# Copy workspace config files -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ - -# Copy packages and app -COPY packages/ ./packages/ -COPY apps/api/ ./apps/api/ - -# Install dependencies -RUN pnpm install --frozen-lockfile - -# Build API (tsup bundles workspace dependencies) -RUN pnpm --filter @overlap/api build - -# Start the API -CMD ["node", "apps/api/dist/index.js"] diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index 56178b1..0000000 --- a/apps/api/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@overlap/api", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch --env-file=../../.env src/index.ts", - "build": "tsup", - "start": "node dist/index.js", - "typecheck": "tsc --noEmit", - "lint": "eslint src/" - }, - "dependencies": { - "@fastify/cookie": "^11.0.0", - "@fastify/cors": "^10.0.0", - "@fastify/rate-limit": "^10.0.0", - "@overlap/db": "workspace:*", - "@overlap/github": "workspace:*", - "@overlap/shared": "workspace:*", - "bullmq": "^5.25.0", - "drizzle-orm": "^0.38.0", - "fastify": "^5.0.0", - "fastify-plugin": "^5.1.0", - "ioredis": "^5.4.0" - }, - "devDependencies": { - "@types/node": "^20.11.0", - "pino-pretty": "^13.0.0", - "tsup": "^8.0.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0" - } -} diff --git a/apps/api/railway.json b/apps/api/railway.json deleted file mode 100644 index ef80472..0000000 --- a/apps/api/railway.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "apps/api/Dockerfile" - }, - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 - } -} diff --git a/apps/api/railway.toml b/apps/api/railway.toml deleted file mode 100644 index b8469a1..0000000 --- a/apps/api/railway.toml +++ /dev/null @@ -1,8 +0,0 @@ -[build] -builder = "nixpacks" -buildCommand = "cd ../.. && pnpm install && pnpm --filter @overlap/api build" - -[deploy] -startCommand = "node dist/index.js" -restartPolicyType = "ON_FAILURE" -restartPolicyMaxRetries = 3 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts deleted file mode 100644 index ca747e7..0000000 --- a/apps/api/src/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -import Fastify from 'fastify' -import cors from '@fastify/cors' -import rateLimit from '@fastify/rate-limit' -import { authPlugin } from './plugins/auth.js' -import { webhooksRoute } from './routes/webhooks.js' -import { healthRoute } from './routes/health.js' -import { repositoriesRoute } from './routes/repositories.js' -import { authRoute } from './routes/auth.js' -import { pushRoute } from './routes/push.js' -import { setupQueues } from './queues/index.js' -import { setupScheduler } from './scheduler.js' - -const fastify = Fastify({ - logger: { - level: process.env.LOG_LEVEL || 'info', - transport: - process.env.NODE_ENV === 'development' - ? { - target: 'pino-pretty', - options: { - translateTime: 'HH:MM:ss Z', - ignore: 'pid,hostname', - }, - } - : undefined, - }, -}) - -async function start() { - try { - // Register plugins - await fastify.register(cors, { - origin: process.env.APP_URL || 'http://localhost:3000', - credentials: true, - }) - - await fastify.register(rateLimit, { - max: 100, - timeWindow: '1 minute', - }) - - // Register auth plugin (cookie parsing + session) - await fastify.register(authPlugin) - - // Setup BullMQ queues - const queues = setupQueues() - fastify.decorate('queues', queues) - - // Register routes - await fastify.register(healthRoute, { prefix: '/health' }) - await fastify.register(webhooksRoute, { prefix: '/webhooks' }) - await fastify.register(authRoute, { prefix: '/auth' }) - await fastify.register(repositoriesRoute, { prefix: '/api/repositories' }) - await fastify.register(pushRoute, { prefix: '/api/push' }) - - // Setup scheduled jobs - await setupScheduler() - - // Start server - const port = parseInt(process.env.PORT || '3001', 10) - const host = process.env.HOST || '0.0.0.0' - - await fastify.listen({ port, host }) - fastify.log.info(`Server listening on ${host}:${port}`) - } catch (err) { - fastify.log.error(err) - process.exit(1) - } -} - -// Graceful shutdown -const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'] -for (const signal of signals) { - process.on(signal, async () => { - fastify.log.info(`Received ${signal}, shutting down...`) - await fastify.close() - process.exit(0) - }) -} - -start() diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts deleted file mode 100644 index e139934..0000000 --- a/apps/api/src/plugins/auth.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import fp from 'fastify-plugin' -import cookie from '@fastify/cookie' -import { db, users } from '@overlap/db' -import { eq } from 'drizzle-orm' - -declare module 'fastify' { - interface FastifyRequest { - user: { - id: string - githubId: number - username: string - email: string | null - avatarUrl: string | null - } | null - } -} - -export const authPlugin = fp(async function authPlugin(fastify: FastifyInstance) { - const sessionSecret = process.env.SESSION_SECRET - if (!sessionSecret) { - throw new Error('SESSION_SECRET environment variable is required') - } - - await fastify.register(cookie, { - secret: sessionSecret, - hook: 'onRequest', - parseOptions: {}, - }) - - fastify.decorateRequest('user', null) - - fastify.addHook('onRequest', async (request: FastifyRequest) => { - const signed = request.cookies.session - if (!signed) return - - const unsigned = request.unsignCookie(signed) - if (!unsigned.valid || !unsigned.value) return - - let parsed: { userId: string } - try { - parsed = JSON.parse(unsigned.value) - } catch { - return - } - - if (!parsed.userId) return - - const user = await db.query.users.findFirst({ - where: eq(users.id, parsed.userId), - }) - - if (user) { - request.user = { - id: user.id, - githubId: user.githubId, - username: user.username, - email: user.email, - avatarUrl: user.avatarUrl, - } - } - }) -}) - -export async function requireAuth(request: FastifyRequest, reply: FastifyReply) { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }) - } -} diff --git a/apps/api/src/queues/index.ts b/apps/api/src/queues/index.ts deleted file mode 100644 index a1f9506..0000000 --- a/apps/api/src/queues/index.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Queue } from 'bullmq' -import { Redis } from 'ioredis' -import { QUEUE_NAMES, type QueueName } from '@overlap/shared' -import type { - WebhookEventJob, - BranchSyncJob, - OverlapDetectionJob, - GitHubFeedbackJob, - MaintenanceJob, - PushNotificationJob, -} from '@overlap/shared' - -export type Queues = { - webhookEvents: Queue - branchSync: Queue - overlapDetection: Queue - githubFeedback: Queue - maintenance: Queue - pushNotification: Queue -} - -let connection: Redis | null = null - -function getConnection(): Redis { - if (!connection) { - const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' - connection = new Redis(redisUrl, { - maxRetriesPerRequest: null, - }) - } - return connection -} - -export function setupQueues(): Queues { - const conn = getConnection() - - const defaultJobOptions = { - removeOnComplete: { age: 3600, count: 1000 }, - removeOnFail: { age: 86400, count: 5000 }, - } - - return { - webhookEvents: new Queue(QUEUE_NAMES.WEBHOOK_EVENTS, { - connection: conn, - defaultJobOptions, - }), - branchSync: new Queue(QUEUE_NAMES.BRANCH_SYNC, { - connection: conn, - defaultJobOptions: { - ...defaultJobOptions, - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }, - }), - overlapDetection: new Queue(QUEUE_NAMES.OVERLAP_DETECTION, { - connection: conn, - defaultJobOptions, - }), - githubFeedback: new Queue(QUEUE_NAMES.GITHUB_FEEDBACK, { - connection: conn, - defaultJobOptions: { - ...defaultJobOptions, - attempts: 3, - backoff: { - type: 'exponential', - delay: 10000, - }, - }, - }), - maintenance: new Queue(QUEUE_NAMES.MAINTENANCE, { - connection: conn, - defaultJobOptions, - }), - pushNotification: new Queue(QUEUE_NAMES.PUSH_NOTIFICATION, { - connection: conn, - defaultJobOptions, - }), - } -} - -export async function addWebhookEventJob( - queues: Queues, - job: WebhookEventJob -): Promise { - await queues.webhookEvents.add('process', job, { - jobId: job.deliveryId, - }) -} - -export async function addBranchSyncJob( - queues: Queues, - job: BranchSyncJob -): Promise { - const jobId = `${job.repositoryId}:${job.branchName}:${job.sha}` - await queues.branchSync.add('sync', job, { jobId }) -} - -export async function addOverlapDetectionJob( - queues: Queues, - job: OverlapDetectionJob -): Promise { - const jobId = `${job.repositoryId}:${job.branchId}:${Date.now()}` - await queues.overlapDetection.add('detect', job, { jobId }) -} - -export async function addGitHubFeedbackJob( - queues: Queues, - job: GitHubFeedbackJob -): Promise { - const jobId = `${job.pullRequestId}:${job.overlapId}` - await queues.githubFeedback.add('feedback', job, { jobId }) -} - -export async function addMaintenanceJob( - queues: Queues, - job: MaintenanceJob -): Promise { - const jobId = job.repositoryId - ? `${job.type}:${job.repositoryId}` - : `${job.type}:global` - await queues.maintenance.add(job.type, job, { jobId }) -} - -// Type augmentation for Fastify -declare module 'fastify' { - interface FastifyInstance { - queues: Queues - } -} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts deleted file mode 100644 index 111ff99..0000000 --- a/apps/api/src/routes/auth.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import { db, users, githubAppInstallations, userInstallations, repositories, repositorySettings } from '@overlap/db' -import { eq, and } from 'drizzle-orm' -import { githubOAuthCallbackSchema } from '@overlap/shared' -import { requireAuth } from '../plugins/auth.js' - -export async function authRoute(fastify: FastifyInstance) { - const clientId = process.env.GITHUB_CLIENT_ID - const clientSecret = process.env.GITHUB_CLIENT_SECRET - const apiUrl = process.env.API_URL || 'http://localhost:3001' - const appUrl = process.env.APP_URL || 'http://localhost:3000' - - if (!clientId || !clientSecret) { - fastify.log.warn('GITHUB_CLIENT_ID or GITHUB_CLIENT_SECRET not set — auth routes disabled') - return - } - - // Redirect to GitHub OAuth - fastify.get('/github', async (request, reply) => { - const state = crypto.randomUUID() - - reply.setCookie('oauth_state', state, { - signed: true, - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: 600, // 10 minutes - }) - - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: `${appUrl}/auth/github/callback`, - scope: 'read:user user:email', - state, - }) - - return reply.redirect(`https://github.com/login/oauth/authorize?${params}`) - }) - - // GitHub OAuth callback - fastify.get<{ Querystring: { code?: string; state?: string; setup_action?: string } }>( - '/github/callback', - async (request, reply) => { - // If this callback came from a GitHub App installation flow (no state cookie), - // redirect through our own OAuth flow to establish CSRF protection. - // GitHub will auto-approve since the user already authorized. - const stateCookie = request.cookies.oauth_state - if (!stateCookie) { - return reply.redirect(`${appUrl}/auth/github`) - } - - const { code, state } = githubOAuthCallbackSchema.parse(request.query) - - // Verify state (CSRF protection) — always enforced - const unsigned = request.unsignCookie(stateCookie) - if (!unsigned.valid || unsigned.value !== state) { - return reply.status(400).send({ error: 'Invalid OAuth state' }) - } - - // Exchange code for access token - const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - client_id: clientId, - client_secret: clientSecret, - code, - }), - }) - - const tokenData = (await tokenResponse.json()) as { - access_token?: string - error?: string - } - - if (!tokenData.access_token) { - fastify.log.error({ tokenData }, 'Failed to exchange code for token') - return reply.status(400).send({ error: 'Failed to exchange code for token' }) - } - - // Fetch user profile - const userResponse = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${tokenData.access_token}`, - Accept: 'application/vnd.github+json', - }, - }) - - const githubUser = (await userResponse.json()) as { - id: number - login: string - email: string | null - avatar_url: string - } - - // Upsert user - const [user] = await db - .insert(users) - .values({ - githubId: githubUser.id, - username: githubUser.login, - email: githubUser.email, - avatarUrl: githubUser.avatar_url, - }) - .onConflictDoUpdate({ - target: users.githubId, - set: { - username: githubUser.login, - email: githubUser.email, - avatarUrl: githubUser.avatar_url, - updatedAt: new Date(), - }, - }) - .returning() - - // Set session cookie - reply.setCookie( - 'session', - JSON.stringify({ userId: user.id }), - { - signed: true, - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: 60 * 60 * 24 * 7, // 7 days - } - ) - - // Clear oauth_state cookie - reply.clearCookie('oauth_state', { path: '/' }) - - // Sync user's GitHub App installations into local DB - await syncUserInstallations(tokenData.access_token, user.id) - - // Check if user has any active installations - const userInsts = await db.query.userInstallations.findMany({ - where: eq(userInstallations.userId, user.id), - with: { installation: true }, - }) - const hasActive = userInsts.some(ui => ui.installation.status === 'active') - - if (!hasActive) { - return reply.redirect(`${appUrl}?setup=1`) - } - - return reply.redirect(appUrl) - } - ) - - // Get current user - fastify.get('/me', { preHandler: [requireAuth] }, async (request) => { - const userInsts = await db.query.userInstallations.findMany({ - where: eq(userInstallations.userId, request.user!.id), - with: { installation: true }, - }) - const hasActive = userInsts.some(ui => ui.installation.status === 'active') - - return { - user: request.user, - hasInstallations: hasActive, - } - }) - - // Logout - fastify.post('/logout', async (request, reply) => { - reply.clearCookie('session', { path: '/' }) - return { success: true } - }) -} - -async function syncUserInstallations(accessToken: string, userId: string) { - try { - const res = await fetch('https://api.github.com/user/installations', { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github+json', - }, - }) - - if (!res.ok) return - - const data = (await res.json()) as { - installations: Array<{ - id: number - account: { login: string; type: string } - }> - } - - for (const inst of data.installations) { - const [installation] = await db - .insert(githubAppInstallations) - .values({ - installationId: inst.id, - userId, - status: 'active', - }) - .onConflictDoUpdate({ - target: githubAppInstallations.installationId, - set: { - status: 'active', - updatedAt: new Date(), - }, - }) - .returning() - - // Link user to installation (many-to-many) - await db - .insert(userInstallations) - .values({ userId, installationId: installation.id }) - .onConflictDoNothing() - - // Sync repos for this installation - await syncInstallationRepos(accessToken, inst.id, installation.id) - } - } catch (err) { - console.error('Failed to sync installations:', err) - } -} - -async function syncInstallationRepos(accessToken: string, installationId: number, dbInstallationId: string) { - try { - const res = await fetch( - `https://api.github.com/user/installations/${installationId}/repositories`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github+json', - }, - } - ) - - if (!res.ok) return - - const data = (await res.json()) as { - repositories: Array<{ - id: number - name: string - full_name: string - private: boolean - default_branch: string - }> - } - - for (const repo of data.repositories) { - const [inserted] = await db - .insert(repositories) - .values({ - githubId: repo.id, - installationId: dbInstallationId, - name: repo.name, - fullName: repo.full_name, - defaultBranch: repo.default_branch, - isPrivate: repo.private, - isActive: true, - }) - .onConflictDoUpdate({ - target: repositories.githubId, - set: { - installationId: dbInstallationId, - name: repo.name, - fullName: repo.full_name, - defaultBranch: repo.default_branch, - isPrivate: repo.private, - isActive: true, - updatedAt: new Date(), - }, - }) - .returning() - - // Ensure default settings exist - await db - .insert(repositorySettings) - .values({ repositoryId: inserted.id }) - .onConflictDoNothing() - } - } catch (err) { - console.error(`Failed to sync repos for installation ${installationId}:`, err) - } -} diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts deleted file mode 100644 index 53cb2d3..0000000 --- a/apps/api/src/routes/health.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import { db } from '@overlap/db' -import { sql } from 'drizzle-orm' - -export async function healthRoute(fastify: FastifyInstance) { - fastify.get('/', async (request, reply) => { - return { status: 'ok', timestamp: new Date().toISOString() } - }) - - fastify.get('/ready', async (request, reply) => { - const checks: Record = { - database: false, - redis: false, - } - - // Check database - try { - await db.execute(sql`SELECT 1`) - checks.database = true - } catch (err) { - fastify.log.error({ err }, 'Database health check failed') - } - - // Check Redis via BullMQ queue - try { - const client = await fastify.queues.webhookEvents.client - await client.ping() - checks.redis = true - } catch (err) { - fastify.log.error({ err }, 'Redis health check failed') - } - - const allHealthy = Object.values(checks).every((v) => v) - - return reply.status(allHealthy ? 200 : 503).send({ - status: allHealthy ? 'ready' : 'not ready', - checks, - timestamp: new Date().toISOString(), - }) - }) -} diff --git a/apps/api/src/routes/push.ts b/apps/api/src/routes/push.ts deleted file mode 100644 index 264117b..0000000 --- a/apps/api/src/routes/push.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import { db, pushSubscriptions } from '@overlap/db' -import { eq, and } from 'drizzle-orm' -import { requireAuth } from '../plugins/auth.js' - -// Legitimate browser push service domains -const ALLOWED_PUSH_HOSTS = [ - 'fcm.googleapis.com', - 'updates.push.services.mozilla.com', - 'push.services.mozilla.com', - 'notify.windows.com', - 'web.push.apple.com', -] - -function isAllowedPushEndpoint(endpoint: string): boolean { - let url: URL - try { - url = new URL(endpoint) - } catch { - return false - } - - if (url.protocol !== 'https:') return false - - const hostname = url.hostname.toLowerCase() - return ALLOWED_PUSH_HOSTS.some( - (domain) => hostname === domain || hostname.endsWith('.' + domain) - ) -} - -export async function pushRoute(fastify: FastifyInstance) { - // All push routes require authentication - fastify.addHook('preHandler', requireAuth) - - // Subscribe to push notifications - fastify.post<{ - Body: { endpoint: string; keys: { p256dh: string; auth: string } } - }>('/subscribe', async (request, reply) => { - const { endpoint, keys } = request.body - - if (!isAllowedPushEndpoint(endpoint)) { - return reply.status(400).send({ error: 'Invalid push endpoint' }) - } - - await db - .insert(pushSubscriptions) - .values({ - userId: request.user!.id, - endpoint, - p256dh: keys.p256dh, - auth: keys.auth, - }) - .onConflictDoUpdate({ - target: [pushSubscriptions.userId, pushSubscriptions.endpoint], - set: { - p256dh: keys.p256dh, - auth: keys.auth, - }, - }) - - return { success: true } - }) - - // Unsubscribe from push notifications - fastify.delete<{ Body: { endpoint: string } }>('/unsubscribe', async (request) => { - const { endpoint } = request.body - - await db - .delete(pushSubscriptions) - .where( - and( - eq(pushSubscriptions.userId, request.user!.id), - eq(pushSubscriptions.endpoint, endpoint) - ) - ) - - return { success: true } - }) -} diff --git a/apps/api/src/routes/repositories.ts b/apps/api/src/routes/repositories.ts deleted file mode 100644 index f90c70b..0000000 --- a/apps/api/src/routes/repositories.ts +++ /dev/null @@ -1,328 +0,0 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import { db, repositories, branches, overlaps, repositorySettings, githubAppInstallations, userInstallations } from '@overlap/db' -import { eq, and, desc, count, sql, inArray } from 'drizzle-orm' -import { - repositoryIdParamSchema, - repositorySettingsUpdateSchema, - branchQuerySchema, - overlapQuerySchema, - overlapUpdateSchema, - diffQuerySchema, -} from '@overlap/shared' -import { getGitHubClient } from '@overlap/github' -import { requireAuth } from '../plugins/auth.js' - -export async function repositoriesRoute(fastify: FastifyInstance) { - // All repository routes require authentication - fastify.addHook('preHandler', requireAuth) - - // Helper to get user's installation IDs (via many-to-many join table) - async function getUserInstallationIds(userId: string): Promise { - const links = await db.query.userInstallations.findMany({ - where: eq(userInstallations.userId, userId), - with: { installation: true }, - }) - return links - .filter((l) => l.installation.status === 'active') - .map((l) => l.installationId) - } - - // Helper to verify the authenticated user has access to a repository - async function requireRepoAccess(request: FastifyRequest, reply: FastifyReply, repoId: string) { - const installationIds = await getUserInstallationIds(request.user!.id) - const repo = await db.query.repositories.findFirst({ - where: and( - eq(repositories.id, repoId), - installationIds.length > 0 - ? inArray(repositories.installationId, installationIds) - : sql`false` - ), - }) - if (!repo) { - reply.status(404).send({ error: 'Repository not found' }) - return null - } - return repo - } - - // List repositories (scoped to user's installations) - fastify.get('/', async (request, reply) => { - const installationIds = await getUserInstallationIds(request.user!.id) - - if (installationIds.length === 0) { - return [] - } - - const repos = await db.query.repositories.findMany({ - where: and( - eq(repositories.isActive, true), - inArray(repositories.installationId, installationIds) - ), - with: { - settings: true, - }, - orderBy: desc(repositories.updatedAt), - }) - - // Get summary stats for each repo - const results = await Promise.all( - repos.map(async (repo) => { - const [branchCount] = await db - .select({ count: count() }) - .from(branches) - .where(and(eq(branches.repositoryId, repo.id), eq(branches.isDefault, false))) - - const [overlapCount] = await db - .select({ count: count() }) - .from(overlaps) - .where(and(eq(overlaps.repositoryId, repo.id), eq(overlaps.status, 'active'))) - - return { - id: repo.id, - name: repo.name, - fullName: repo.fullName, - defaultBranch: repo.defaultBranch, - isPrivate: repo.isPrivate, - activeBranches: branchCount?.count ?? 0, - activeOverlaps: overlapCount?.count ?? 0, - lastSyncedAt: repo.lastSyncedAt, - settings: repo.settings, - } - }) - ) - - return results - }) - - // Get repository by ID (scoped to user's installations) - fastify.get<{ Params: { id: string } }>('/:id', async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - // Re-query with relations for the detail view - const repoWithRelations = await db.query.repositories.findFirst({ - where: eq(repositories.id, id), - with: { - settings: true, - installation: true, - }, - }) - - return repoWithRelations - }) - - // Update repository settings - fastify.patch<{ Params: { id: string }; Body: unknown }>( - '/:id/settings', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const updates = repositorySettingsUpdateSchema.parse(request.body) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const [updated] = await db - .update(repositorySettings) - .set({ - ...updates, - updatedAt: new Date(), - }) - .where(eq(repositorySettings.repositoryId, id)) - .returning() - - return updated - } - ) - - // List branches for a repository - fastify.get<{ Params: { id: string }; Querystring: unknown }>( - '/:id/branches', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const { includeDefault, includeStale, page, limit } = branchQuerySchema.parse( - request.query - ) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const conditions = [eq(branches.repositoryId, id)] - - if (!includeDefault) { - conditions.push(eq(branches.isDefault, false)) - } - - if (!includeStale) { - const staleDate = new Date() - staleDate.setDate(staleDate.getDate() - 14) - conditions.push(sql`${branches.lastSeenAt} > ${staleDate.toISOString()}`) - } - - const branchList = await db.query.branches.findMany({ - where: and(...conditions), - orderBy: desc(branches.lastSeenAt), - limit, - offset: (page - 1) * limit, - }) - - return branchList - } - ) - - // List overlaps for a repository - fastify.get<{ Params: { id: string }; Querystring: unknown }>( - '/:id/overlaps', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const { status, severity, branchId, page, limit } = overlapQuerySchema.parse( - request.query - ) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const conditions = [eq(overlaps.repositoryId, id)] - - if (status) { - conditions.push(eq(overlaps.status, status)) - } - - if (severity) { - conditions.push(eq(overlaps.severity, severity)) - } - - if (branchId) { - conditions.push( - sql`(${overlaps.sourceBranchId} = ${branchId} OR ${overlaps.targetBranchId} = ${branchId})` - ) - } - - const overlapList = await db.query.overlaps.findMany({ - where: and(...conditions), - with: { - sourceBranch: true, - targetBranch: true, - files: true, - }, - orderBy: desc(overlaps.detectedAt), - limit, - offset: (page - 1) * limit, - }) - - return overlapList - } - ) - - // Get all file diffs between two branches - fastify.get<{ Params: { id: string }; Querystring: unknown }>( - '/:id/diffs', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const { base, head } = diffQuerySchema.parse(request.query) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const repoWithInstallation = await db.query.repositories.findFirst({ - where: eq(repositories.id, id), - with: { installation: true }, - }) - - if (!repoWithInstallation?.installation) { - return reply.status(500).send({ error: 'Installation not found' }) - } - - const [owner, name] = repoWithInstallation.fullName.split('/') - const github = getGitHubClient() - - try { - const diffs = await github.getCompareDiffs( - repoWithInstallation.installation.installationId, - owner, - name, - base, - head - ) - - return { files: diffs } - } catch (error: unknown) { - const err = error as { status?: number; message?: string } - if (err.status === 404) { - return reply.status(404).send({ error: 'Branch no longer exists' }) - } - throw error - } - } - ) - - // Update overlap status (resolve/ignore) - fastify.patch<{ Params: { id: string; overlapId: string }; Body: unknown }>( - '/:id/overlaps/:overlapId', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const overlapId = request.params.overlapId - const { status } = overlapUpdateSchema.parse(request.body) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const overlap = await db.query.overlaps.findFirst({ - where: and(eq(overlaps.id, overlapId), eq(overlaps.repositoryId, id)), - }) - - if (!overlap) { - return reply.status(404).send({ error: 'Overlap not found' }) - } - - const [updated] = await db - .update(overlaps) - .set({ - status, - resolvedAt: status === 'resolved' ? new Date() : null, - updatedAt: new Date(), - }) - .where(eq(overlaps.id, overlapId)) - .returning() - - return updated - } - ) - - // DEV ONLY: Test push notification for an existing overlap - if (process.env.NODE_ENV !== 'production') { - fastify.post<{ Params: { id: string; overlapId: string } }>( - '/:id/overlaps/:overlapId/test-notify', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const overlapId = request.params.overlapId - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const overlap = await db.query.overlaps.findFirst({ - where: and(eq(overlaps.id, overlapId), eq(overlaps.repositoryId, id)), - with: { sourceBranch: true, targetBranch: true }, - }) - - if (!overlap) { - return reply.status(404).send({ error: 'Overlap not found' }) - } - - await fastify.queues.pushNotification.add( - 'notify', - { - repositoryId: id, - overlapId, - targetBranchId: overlap.targetBranchId, - }, - { - jobId: `test-${overlapId}-${Date.now()}`, - } - ) - - return { success: true, message: 'Test notification queued' } - } - ) - } -} diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts deleted file mode 100644 index b464290..0000000 --- a/apps/api/src/routes/webhooks.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import { verifyWebhookSignature, extractBranchFromRef, isBranchDeletion } from '@overlap/github' -import { db, webhookEvents, repositories, branches, branchFiles } from '@overlap/db' -import { eq, and } from 'drizzle-orm' -import { GITHUB_EVENTS, PR_ACTIONS } from '@overlap/shared' -import { addWebhookEventJob } from '../queues/index.js' - -interface WebhookHeaders { - 'x-github-event': string - 'x-github-delivery': string - 'x-hub-signature-256': string -} - -export async function webhooksRoute(fastify: FastifyInstance) { - // Disable body parsing to get raw body for signature verification - fastify.addContentTypeParser( - 'application/json', - { parseAs: 'string' }, - (req, body, done) => { - done(null, body) - } - ) - - fastify.post<{ - Headers: WebhookHeaders - Body: string - }>('/github', async (request, reply) => { - const signature = request.headers['x-hub-signature-256'] - const event = request.headers['x-github-event'] - const deliveryId = request.headers['x-github-delivery'] - - // Verify webhook secret - const secret = process.env.GITHUB_WEBHOOK_SECRET - if (!secret) { - fastify.log.error('GITHUB_WEBHOOK_SECRET not configured') - return reply.status(500).send({ error: 'Server configuration error' }) - } - - const verification = verifyWebhookSignature(request.body, signature, secret) - if (!verification.valid) { - fastify.log.warn(`Invalid webhook signature: ${verification.error}`) - return reply.status(401).send({ error: 'Invalid signature' }) - } - - // Parse payload - let payload: Record - try { - payload = JSON.parse(request.body) - } catch { - return reply.status(400).send({ error: 'Invalid JSON payload' }) - } - - // Get repository ID if available - let repositoryId: string | null = null - const repoData = payload.repository as { id?: number } | undefined - if (repoData?.id) { - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.githubId, repoData.id), - }) - repositoryId = repo?.id ?? null - } - - // Store webhook event - await db.insert(webhookEvents).values({ - eventType: event, - deliveryId, - repositoryId, - payload, - }) - - // Handle specific events - switch (event) { - case GITHUB_EVENTS.PUSH: - await handlePushEvent(fastify, payload, deliveryId) - break - - case GITHUB_EVENTS.PULL_REQUEST: - await handlePullRequestEvent(fastify, payload, deliveryId) - break - - case GITHUB_EVENTS.INSTALLATION: - case GITHUB_EVENTS.INSTALLATION_REPOSITORIES: - await handleInstallationEvent(fastify, payload, deliveryId) - break - - default: - fastify.log.info(`Ignoring unhandled event: ${event}`) - } - - // Add to queue for processing - await addWebhookEventJob(fastify.queues, { - eventType: event, - deliveryId, - payload, - }) - - return reply.status(200).send({ received: true }) - }) -} - -async function handlePushEvent( - fastify: FastifyInstance, - payload: Record, - deliveryId: string -) { - const ref = payload.ref as string - const after = payload.after as string - const repository = payload.repository as { id: number; full_name: string } - - const branchName = extractBranchFromRef(ref) - if (!branchName) { - fastify.log.info(`Ignoring non-branch ref: ${ref}`) - return - } - - // Check if branch was deleted - if (isBranchDeletion(after)) { - fastify.log.info(`Branch deleted: ${branchName} in ${repository.full_name}`) - - // Find and clean up the branch - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.githubId, repository.id), - }) - - if (repo) { - const branch = await db.query.branches.findFirst({ - where: and( - eq(branches.repositoryId, repo.id), - eq(branches.name, branchName) - ), - }) - - if (branch) { - // Delete branch files first (cascade should handle this but being explicit) - await db.delete(branchFiles).where(eq(branchFiles.branchId, branch.id)) - await db.delete(branches).where(eq(branches.id, branch.id)) - fastify.log.info(`Cleaned up deleted branch: ${branchName}`) - } - } - return - } - - fastify.log.info(`Push to ${branchName} in ${repository.full_name}`) -} - -async function handlePullRequestEvent( - fastify: FastifyInstance, - payload: Record, - deliveryId: string -) { - const action = payload.action as string - const prData = payload.pull_request as { - number: number - title: string - head: { ref: string } - } - - // Only process relevant actions - const relevantActions = [PR_ACTIONS.OPENED, PR_ACTIONS.SYNCHRONIZE, PR_ACTIONS.REOPENED] - if (!relevantActions.includes(action as typeof PR_ACTIONS.OPENED)) { - fastify.log.info(`Ignoring PR action: ${action}`) - return - } - - fastify.log.info(`PR ${action}: #${prData.number} (${prData.head.ref})`) -} - -async function handleInstallationEvent( - fastify: FastifyInstance, - payload: Record, - deliveryId: string -) { - const action = payload.action as string - const installation = payload.installation as { id: number; account: { login: string } } - - fastify.log.info( - `Installation ${action}: ${installation.id} for ${installation.account.login}` - ) -} diff --git a/apps/api/src/scheduler.ts b/apps/api/src/scheduler.ts deleted file mode 100644 index 14b5eef..0000000 --- a/apps/api/src/scheduler.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Queue } from 'bullmq' -import { Redis } from 'ioredis' -import { QUEUE_NAMES } from '@overlap/shared' -import type { MaintenanceJob } from '@overlap/shared' - -const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' - -export async function setupScheduler() { - const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }) - - const maintenanceQueue = new Queue(QUEUE_NAMES.MAINTENANCE, { - connection, - }) - - // Schedule branch pruning every 6 hours - await maintenanceQueue.upsertJobScheduler( - 'prune-branches', - { - every: 6 * 60 * 60 * 1000, // 6 hours - }, - { - name: 'prune_branches', - data: { type: 'prune_branches' }, - } - ) - - // Schedule event cleanup daily - await maintenanceQueue.upsertJobScheduler( - 'cleanup-events', - { - every: 24 * 60 * 60 * 1000, // 24 hours - }, - { - name: 'cleanup_events', - data: { type: 'cleanup_events' }, - } - ) - - console.log('Scheduled maintenance jobs configured') - - return { maintenanceQueue } -} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index 90d76d7..0000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"] -} diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts deleted file mode 100644 index 61a1e2a..0000000 --- a/apps/api/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm'], - clean: true, - noExternal: ['@overlap/shared', '@overlap/db', '@overlap/github'], -}) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile deleted file mode 100644 index e02a2d5..0000000 --- a/apps/web/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -FROM node:20-slim - -# Install pnpm -RUN npm install -g pnpm - -WORKDIR /app - -# Copy workspace config files -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ - -# Copy packages and app -COPY packages/ ./packages/ -COPY apps/web/ ./apps/web/ - -# Install dependencies -RUN pnpm install --frozen-lockfile - -# Vite bakes VITE_* env vars into the bundle at build time -ARG VITE_API_URL -ARG VITE_GITHUB_APP_SLUG -ARG VITE_VAPID_PUBLIC_KEY -ENV VITE_API_URL=$VITE_API_URL -ENV VITE_GITHUB_APP_SLUG=$VITE_GITHUB_APP_SLUG -ENV VITE_VAPID_PUBLIC_KEY=$VITE_VAPID_PUBLIC_KEY - -# Build web app (Vite handles workspace dependencies) -RUN pnpm --filter @overlap/web build - -# Set port for Nitro server (Railway will route traffic here) -ENV PORT=8080 -EXPOSE 8080 - -# Start the web app -CMD ["pnpm", "--filter", "@overlap/web", "start"] diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile deleted file mode 100644 index e90961c..0000000 --- a/apps/worker/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM node:20-slim - -# Install pnpm -RUN npm install -g pnpm - -WORKDIR /app - -# Copy workspace config files -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ - -# Copy packages and app -COPY packages/ ./packages/ -COPY apps/worker/ ./apps/worker/ - -# Install dependencies -RUN pnpm install --frozen-lockfile - -# Build worker (tsup bundles workspace dependencies) -RUN pnpm --filter @overlap/worker build - -# Start the worker -CMD ["node", "apps/worker/dist/index.js"] diff --git a/apps/worker/package.json b/apps/worker/package.json deleted file mode 100644 index e0191f3..0000000 --- a/apps/worker/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@overlap/worker", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch --env-file=../../.env src/index.ts", - "build": "tsup", - "start": "node dist/index.js", - "typecheck": "tsc --noEmit", - "lint": "eslint src/" - }, - "dependencies": { - "@overlap/db": "workspace:*", - "@overlap/github": "workspace:*", - "@overlap/shared": "workspace:*", - "bullmq": "^5.25.0", - "drizzle-orm": "^0.38.0", - "ioredis": "^5.4.0", - "minimatch": "^10.0.0", - "web-push": "^3.6.7" - }, - "devDependencies": { - "@types/node": "^20.11.0", - "@types/web-push": "^3.6.4", - "tsup": "^8.0.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0" - } -} diff --git a/apps/worker/railway.json b/apps/worker/railway.json deleted file mode 100644 index ab0f45d..0000000 --- a/apps/worker/railway.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "apps/worker/Dockerfile" - }, - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 - } -} diff --git a/apps/worker/railway.toml b/apps/worker/railway.toml deleted file mode 100644 index 9c5c7eb..0000000 --- a/apps/worker/railway.toml +++ /dev/null @@ -1,8 +0,0 @@ -[build] -builder = "nixpacks" -buildCommand = "cd ../.. && pnpm install && pnpm --filter @overlap/worker build" - -[deploy] -startCommand = "node dist/index.js" -restartPolicyType = "ON_FAILURE" -restartPolicyMaxRetries = 3 diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts deleted file mode 100644 index 239bdbc..0000000 --- a/apps/worker/src/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Worker } from 'bullmq' -import { Redis } from 'ioredis' -import { QUEUE_NAMES, RATE_LIMITS } from '@overlap/shared' -import { webhookEventsProcessor } from './processors/webhook-events.js' -import { branchSyncProcessor } from './processors/branch-sync.js' -import { overlapDetectionProcessor } from './processors/overlap-detection.js' -import { githubFeedbackProcessor } from './processors/github-feedback.js' -import { maintenanceProcessor } from './processors/maintenance.js' -import { pushNotificationProcessor } from './processors/push-notification.js' - -const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' - -const connection = new Redis(redisUrl, { - maxRetriesPerRequest: null, -}) - -console.log('Starting Overlap workers...') - -// Create workers with rate limiting -const workers: Worker[] = [] - -// Webhook Events Worker -workers.push( - new Worker(QUEUE_NAMES.WEBHOOK_EVENTS, webhookEventsProcessor, { - connection, - concurrency: 10, - limiter: RATE_LIMITS[QUEUE_NAMES.WEBHOOK_EVENTS], - }) -) - -// Branch Sync Worker -workers.push( - new Worker(QUEUE_NAMES.BRANCH_SYNC, branchSyncProcessor, { - connection, - concurrency: 5, - limiter: RATE_LIMITS[QUEUE_NAMES.BRANCH_SYNC], - }) -) - -// Overlap Detection Worker -workers.push( - new Worker(QUEUE_NAMES.OVERLAP_DETECTION, overlapDetectionProcessor, { - connection, - concurrency: 10, - limiter: RATE_LIMITS[QUEUE_NAMES.OVERLAP_DETECTION], - }) -) - -// GitHub Feedback Worker -workers.push( - new Worker(QUEUE_NAMES.GITHUB_FEEDBACK, githubFeedbackProcessor, { - connection, - concurrency: 2, - limiter: RATE_LIMITS[QUEUE_NAMES.GITHUB_FEEDBACK], - }) -) - -// Maintenance Worker -workers.push( - new Worker(QUEUE_NAMES.MAINTENANCE, maintenanceProcessor, { - connection, - concurrency: 1, - limiter: RATE_LIMITS[QUEUE_NAMES.MAINTENANCE], - }) -) - -// Push Notification Worker -workers.push( - new Worker(QUEUE_NAMES.PUSH_NOTIFICATION, pushNotificationProcessor, { - connection, - concurrency: 5, - limiter: RATE_LIMITS[QUEUE_NAMES.PUSH_NOTIFICATION], - }) -) - -// Setup event handlers for all workers -for (const worker of workers) { - worker.on('completed', (job) => { - console.log(`[${worker.name}] Job ${job.id} completed`) - }) - - worker.on('failed', (job, err) => { - console.error(`[${worker.name}] Job ${job?.id} failed:`, err.message) - }) - - worker.on('error', (err) => { - console.error(`[${worker.name}] Worker error:`, err.message) - }) -} - -console.log(`Started ${workers.length} workers`) - -// Graceful shutdown -const shutdown = async () => { - console.log('Shutting down workers...') - - await Promise.all(workers.map((w) => w.close())) - await connection.quit() - - console.log('Workers shut down') - process.exit(0) -} - -process.on('SIGINT', shutdown) -process.on('SIGTERM', shutdown) diff --git a/apps/worker/src/processors/branch-sync.ts b/apps/worker/src/processors/branch-sync.ts deleted file mode 100644 index f7604fc..0000000 --- a/apps/worker/src/processors/branch-sync.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Job } from 'bullmq' -import { db, branches, branchFiles, repositories, repositorySettings } from '@overlap/db' -import { eq, and, inArray } from 'drizzle-orm' -import type { BranchSyncJob } from '@overlap/shared' -import { DEFAULT_SETTINGS } from '@overlap/shared' -import { getGitHubClient, type CommitFile } from '@overlap/github' -import { minimatch } from 'minimatch' - -export async function branchSyncProcessor(job: Job) { - const { repositoryId, branchName, sha, installationId } = job.data - - console.log(`Syncing branch: ${branchName} (${sha.slice(0, 7)})`) - - // Get repository info - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.id, repositoryId), - with: { settings: true }, - }) - - if (!repo) { - throw new Error(`Repository not found: ${repositoryId}`) - } - - // Get branch record - const branch = await db.query.branches.findFirst({ - where: and( - eq(branches.repositoryId, repositoryId), - eq(branches.name, branchName) - ), - }) - - if (!branch) { - throw new Error(`Branch not found: ${branchName}`) - } - - // Get ignored paths - const ignoredPaths = repo.settings?.ignoredPaths ?? DEFAULT_SETTINGS.IGNORED_PATHS - - // Get GitHub client and fetch changed files - const github = getGitHubClient() - const [owner, repoName] = repo.fullName.split('/') - - let changedFiles: CommitFile[] - - try { - // Compare branch to default branch to get all changed files - changedFiles = await github.getBranchFiles( - installationId, - owner, - repoName, - branchName, - repo.defaultBranch - ) - } catch (error) { - console.error(`Failed to fetch branch files: ${error}`) - changedFiles = [] - } - - // Filter out ignored paths - const filteredFiles = changedFiles.filter((file) => { - return !ignoredPaths.some((pattern) => minimatch(file.filename, pattern)) - }) - - console.log(`Found ${filteredFiles.length} changed files (${changedFiles.length} before filtering)`) - - // Update branch files - // First, delete existing files for this branch - await db.delete(branchFiles).where(eq(branchFiles.branchId, branch.id)) - - // Insert new files - if (filteredFiles.length > 0) { - await db.insert(branchFiles).values( - filteredFiles.map((file) => ({ - branchId: branch.id, - filePath: file.filename, - changeType: mapChangeType(file.status), - })) - ) - } - - // Update branch sha and last seen - await db - .update(branches) - .set({ - sha, - lastSeenAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(branches.id, branch.id)) - - // Update repository last synced - await db - .update(repositories) - .set({ lastSyncedAt: new Date(), updatedAt: new Date() }) - .where(eq(repositories.id, repositoryId)) - - console.log(`Branch sync complete: ${branchName}`) - - return { filesIndexed: filteredFiles.length } -} - -function mapChangeType(status: string): 'added' | 'modified' | 'deleted' | 'renamed' { - switch (status) { - case 'added': - return 'added' - case 'removed': - return 'deleted' - case 'renamed': - return 'renamed' - default: - return 'modified' - } -} diff --git a/apps/worker/src/processors/github-feedback.ts b/apps/worker/src/processors/github-feedback.ts deleted file mode 100644 index 69f4268..0000000 --- a/apps/worker/src/processors/github-feedback.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { Job } from 'bullmq' -import { db, overlaps, overlapFiles, branches, pullRequests, prAlerts, repositories, githubAppInstallations } from '@overlap/db' -import { eq, and, inArray } from 'drizzle-orm' -import type { GitHubFeedbackJob } from '@overlap/shared' -import { getGitHubClient, formatCheckRunSummary } from '@overlap/github' - -export async function githubFeedbackProcessor(job: Job) { - const { repositoryId, pullRequestId, overlapId, alertType } = job.data - - console.log(`Sending GitHub feedback for PR: ${pullRequestId}, overlap: ${overlapId}`) - - // Get the pull request - const pr = await db.query.pullRequests.findFirst({ - where: eq(pullRequests.id, pullRequestId), - with: { - branch: true, - repository: { - with: { - installation: true, - }, - }, - }, - }) - - if (!pr) { - throw new Error(`Pull request not found: ${pullRequestId}`) - } - - // Get the overlap with all files - const overlap = await db.query.overlaps.findFirst({ - where: eq(overlaps.id, overlapId), - with: { - files: true, - sourceBranch: true, - targetBranch: true, - }, - }) - - if (!overlap) { - throw new Error(`Overlap not found: ${overlapId}`) - } - - // Get all active overlaps for this branch (not just the one that triggered) - const allOverlaps = await db.query.overlaps.findMany({ - where: and( - eq(overlaps.repositoryId, repositoryId), - eq(overlaps.status, 'active') - ), - with: { - files: true, - sourceBranch: true, - targetBranch: true, - }, - }) - - // Filter to overlaps involving this branch - const branchOverlaps = allOverlaps.filter( - (o) => o.sourceBranchId === pr.branch.id || o.targetBranchId === pr.branch.id - ) - - if (branchOverlaps.length === 0) { - console.log('No active overlaps for this branch') - return { checkRun: false } - } - - // Check for existing alert (deduplication) - const existingAlert = await db.query.prAlerts.findFirst({ - where: and( - eq(prAlerts.pullRequestId, pullRequestId), - eq(prAlerts.overlapId, overlapId) - ), - }) - - const github = getGitHubClient() - const [owner, repoName] = pr.repository.fullName.split('/') - const installationId = pr.repository.installation.installationId - - let checkRunId: number | null = existingAlert?.checkRunId ?? null - - // Format overlap data for display - const overlapData = branchOverlaps.map((o) => { - const otherBranch = - o.sourceBranchId === pr.branch.id ? o.targetBranch : o.sourceBranch - - return { - branchName: otherBranch.name, - files: o.files.map((f) => f.filePath), - fileCount: o.fileCount, - severity: o.severity as 'low' | 'medium' | 'high' | 'critical', - } - }) - - // Create check run (no PR comments — GitHub already shows conflicts) - const { conclusion, title, summary } = formatCheckRunSummary(overlapData) - - try { - checkRunId = await github.createCheckRun( - installationId, - owner, - repoName, - pr.branch.sha, - 'Overlap Detection', - conclusion, - title, - summary - ) - console.log(`Check run created: ${checkRunId}`) - } catch (error) { - console.error('Failed to create check run:', error) - } - - // Record the alert - if (existingAlert) { - await db - .update(prAlerts) - .set({ checkRunId }) - .where(eq(prAlerts.id, existingAlert.id)) - } else { - await db.insert(prAlerts).values({ - pullRequestId, - overlapId, - alertType: 'check_run', - checkRunId, - }) - } - - return { checkRun: checkRunId !== null } -} diff --git a/apps/worker/src/processors/maintenance.ts b/apps/worker/src/processors/maintenance.ts deleted file mode 100644 index f231252..0000000 --- a/apps/worker/src/processors/maintenance.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { Job } from 'bullmq' -import { db, branches, branchFiles, overlaps, webhookEvents, repositories, repositorySettings } from '@overlap/db' -import { eq, and, lt, inArray, sql } from 'drizzle-orm' -import type { MaintenanceJob } from '@overlap/shared' -import { DEFAULT_SETTINGS } from '@overlap/shared' -import { getGitHubClient } from '@overlap/github' - -export async function maintenanceProcessor(job: Job) { - const { type, repositoryId } = job.data - - console.log(`Running maintenance task: ${type}${repositoryId ? ` for ${repositoryId}` : ''}`) - - switch (type) { - case 'prune_branches': - return await pruneStaleBranches(repositoryId) - - case 'cleanup_events': - return await cleanupOldEvents() - - case 'sync_repository': - if (!repositoryId) { - throw new Error('repositoryId required for sync_repository') - } - return await syncRepository(repositoryId) - - default: - throw new Error(`Unknown maintenance type: ${type}`) - } -} - -async function pruneStaleBranches(repositoryId?: string) { - let prunedCount = 0 - - // Get repositories to process - const repos = repositoryId - ? await db.query.repositories.findMany({ - where: eq(repositories.id, repositoryId), - with: { settings: true }, - }) - : await db.query.repositories.findMany({ - where: eq(repositories.isActive, true), - with: { settings: true }, - }) - - for (const repo of repos) { - const pruningDays = repo.settings?.pruningDays ?? DEFAULT_SETTINGS.PRUNING_DAYS - const staleDate = new Date() - staleDate.setDate(staleDate.getDate() - pruningDays) - - // Find stale branches (non-default, not seen recently) - const staleBranches = await db.query.branches.findMany({ - where: and( - eq(branches.repositoryId, repo.id), - eq(branches.isDefault, false), - lt(branches.lastSeenAt, staleDate) - ), - }) - - if (staleBranches.length === 0) continue - - const staleBranchIds = staleBranches.map((b) => b.id) - - // Delete branch files - await db.delete(branchFiles).where(inArray(branchFiles.branchId, staleBranchIds)) - - // Update overlaps involving these branches to resolved - await db - .update(overlaps) - .set({ - status: 'resolved', - resolvedAt: new Date(), - updatedAt: new Date(), - }) - .where( - sql`(${overlaps.sourceBranchId} IN (${sql.join(staleBranchIds, sql`, `)}) OR ${overlaps.targetBranchId} IN (${sql.join(staleBranchIds, sql`, `)}))` - ) - - // Delete the branches - await db.delete(branches).where(inArray(branches.id, staleBranchIds)) - - prunedCount += staleBranches.length - console.log(`Pruned ${staleBranches.length} stale branches from ${repo.fullName}`) - } - - return { prunedBranches: prunedCount } -} - -async function cleanupOldEvents() { - // Delete webhook events older than 7 days - const cutoffDate = new Date() - cutoffDate.setDate(cutoffDate.getDate() - 7) - - const result = await db - .delete(webhookEvents) - .where(lt(webhookEvents.createdAt, cutoffDate)) - - console.log(`Cleaned up old webhook events`) - return { cleaned: true } -} - -async function syncRepository(repositoryId: string) { - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.id, repositoryId), - with: { installation: true }, - }) - - if (!repo) { - throw new Error(`Repository not found: ${repositoryId}`) - } - - const github = getGitHubClient() - const [owner, repoName] = repo.fullName.split('/') - - // Fetch all branches from GitHub - const remoteBranches = await github.getBranches( - repo.installation.installationId, - owner, - repoName - ) - - // Get current local branches - const localBranches = await db.query.branches.findMany({ - where: eq(branches.repositoryId, repositoryId), - }) - - const localBranchMap = new Map(localBranches.map((b) => [b.name, b])) - const remoteBranchNames = new Set(remoteBranches.map((b) => b.name)) - - // Find branches to add or update - let addedCount = 0 - let updatedCount = 0 - - for (const remote of remoteBranches) { - const local = localBranchMap.get(remote.name) - const isDefault = remote.name === repo.defaultBranch - - if (local) { - // Update if SHA changed - if (local.sha !== remote.sha) { - await db - .update(branches) - .set({ - sha: remote.sha, - lastSeenAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(branches.id, local.id)) - updatedCount++ - } - } else { - // Add new branch - await db.insert(branches).values({ - repositoryId, - name: remote.name, - sha: remote.sha, - isDefault, - lastSeenAt: new Date(), - }) - addedCount++ - } - } - - // Find branches that no longer exist on remote - const deletedCount = localBranches.filter( - (b) => !remoteBranchNames.has(b.name) - ).length - - // Mark missing branches as stale (they'll be pruned later) - for (const local of localBranches) { - if (!remoteBranchNames.has(local.name)) { - await db - .update(branches) - .set({ - lastSeenAt: new Date(0), // Epoch = very old - updatedAt: new Date(), - }) - .where(eq(branches.id, local.id)) - } - } - - // Update repo sync timestamp - await db - .update(repositories) - .set({ lastSyncedAt: new Date(), updatedAt: new Date() }) - .where(eq(repositories.id, repositoryId)) - - console.log(`Synced repository: added ${addedCount}, updated ${updatedCount}, marked ${deletedCount} for deletion`) - - return { added: addedCount, updated: updatedCount, markedForDeletion: deletedCount } -} diff --git a/apps/worker/src/processors/overlap-detection.ts b/apps/worker/src/processors/overlap-detection.ts deleted file mode 100644 index 1714815..0000000 --- a/apps/worker/src/processors/overlap-detection.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { Job } from 'bullmq' -import { db, branches, branchFiles, overlaps, overlapFiles, repositories, repositorySettings, pullRequests } from '@overlap/db' -import { eq, and, ne, sql, inArray, gt } from 'drizzle-orm' -import type { OverlapDetectionJob } from '@overlap/shared' -import { calculateSeverity, DEFAULT_SETTINGS, QUEUE_NAMES } from '@overlap/shared' -import { Queue } from 'bullmq' -import { Redis } from 'ioredis' - -// Get queues for adding feedback and notification jobs -const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' -const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }) -const githubFeedbackQueue = new Queue(QUEUE_NAMES.GITHUB_FEEDBACK, { connection }) -const pushNotificationQueue = new Queue(QUEUE_NAMES.PUSH_NOTIFICATION, { connection }) - -export async function overlapDetectionProcessor(job: Job) { - const { repositoryId, branchId, triggeredBy } = job.data - - console.log(`Detecting overlaps for branch: ${branchId} (triggered by: ${triggeredBy})`) - - // Get the branch and its files - const branch = await db.query.branches.findFirst({ - where: eq(branches.id, branchId), - with: { files: true, repository: { with: { settings: true } } }, - }) - - if (!branch) { - throw new Error(`Branch not found: ${branchId}`) - } - - // Skip if this is the default branch - if (branch.isDefault) { - console.log('Skipping default branch') - return { overlapsFound: 0 } - } - - // Skip if branch has no files - if (branch.files.length === 0) { - console.log('Branch has no tracked files') - return { overlapsFound: 0 } - } - - // Get settings - const pruningDays = branch.repository.settings?.pruningDays ?? DEFAULT_SETTINGS.PRUNING_DAYS - - // Calculate stale date threshold - const staleDate = new Date() - staleDate.setDate(staleDate.getDate() - pruningDays) - - // Get all other active branches in the same repository - const otherBranches = await db.query.branches.findMany({ - where: and( - eq(branches.repositoryId, repositoryId), - ne(branches.id, branchId), - eq(branches.isDefault, false), - gt(branches.lastSeenAt, staleDate) - ), - with: { files: true }, - }) - - console.log(`Comparing against ${otherBranches.length} other branches`) - - const branchFilePaths = new Set(branch.files.map((f) => f.filePath)) - const detectedOverlaps: Array<{ - targetBranchId: string - files: Array<{ - filePath: string - sourceChangeType: string - targetChangeType: string - }> - }> = [] - - // Find overlapping files with each other branch - for (const otherBranch of otherBranches) { - const overlappingFiles: Array<{ - filePath: string - sourceChangeType: string - targetChangeType: string - }> = [] - - for (const otherFile of otherBranch.files) { - if (branchFilePaths.has(otherFile.filePath)) { - const sourceFile = branch.files.find((f) => f.filePath === otherFile.filePath) - if (sourceFile) { - overlappingFiles.push({ - filePath: otherFile.filePath, - sourceChangeType: sourceFile.changeType, - targetChangeType: otherFile.changeType, - }) - } - } - } - - if (overlappingFiles.length > 0) { - detectedOverlaps.push({ - targetBranchId: otherBranch.id, - files: overlappingFiles, - }) - } - } - - console.log(`Found ${detectedOverlaps.length} overlapping branches`) - - // Process each detected overlap - for (const detected of detectedOverlaps) { - const severity = calculateSeverity(detected.files.length) - - // Check if overlap already exists (in either direction) - const existingOverlap = await db.query.overlaps.findFirst({ - where: sql` - ${overlaps.repositoryId} = ${repositoryId} - AND ( - (${overlaps.sourceBranchId} = ${branchId} AND ${overlaps.targetBranchId} = ${detected.targetBranchId}) - OR (${overlaps.sourceBranchId} = ${detected.targetBranchId} AND ${overlaps.targetBranchId} = ${branchId}) - ) - `, - }) - - let overlapId: string - let isNew = false - let severityIncreased = false - let wasReactivated = false - - if (existingOverlap) { - // Update existing overlap - const oldSeverity = existingOverlap.severity - severityIncreased = compareSeverity(severity, oldSeverity as typeof severity) > 0 - wasReactivated = existingOverlap.status === 'resolved' || existingOverlap.status === 'ignored' - - await db - .update(overlaps) - .set({ - fileCount: detected.files.length, - severity, - status: 'active', - resolvedAt: null, - updatedAt: new Date(), - }) - .where(eq(overlaps.id, existingOverlap.id)) - - overlapId = existingOverlap.id - - // Delete old overlap files and insert new ones - await db.delete(overlapFiles).where(eq(overlapFiles.overlapId, overlapId)) - } else { - // Create new overlap - isNew = true - const [newOverlap] = await db - .insert(overlaps) - .values({ - repositoryId, - sourceBranchId: branchId, - targetBranchId: detected.targetBranchId, - fileCount: detected.files.length, - severity, - status: 'active', - detectedAt: new Date(), - }) - .returning() - - overlapId = newOverlap.id - } - - // Insert overlap files - await db.insert(overlapFiles).values( - detected.files.map((f) => ({ - overlapId, - filePath: f.filePath, - sourceChangeType: f.sourceChangeType, - targetChangeType: f.targetChangeType, - })) - ) - - // Check if we should send notifications - // Notify on: new overlaps, severity increases, or reactivated overlaps - // (previously resolved/ignored but the file was edited again) - const shouldNotify = - (isNew && branch.repository.settings?.notifyOnNewOverlap !== false) || - (severityIncreased && branch.repository.settings?.notifyOnSeverityIncrease !== false) || - wasReactivated - - if (shouldNotify) { - // Use a timestamp suffix so reactivated overlaps aren't deduplicated by BullMQ - const jobSuffix = wasReactivated ? `-${Date.now()}` : '' - - // Find any open PRs for this branch - const openPRs = await db.query.pullRequests.findMany({ - where: and( - eq(pullRequests.branchId, branchId), - eq(pullRequests.state, 'open') - ), - }) - - for (const pr of openPRs) { - await githubFeedbackQueue.add( - 'feedback', - { - repositoryId, - pullRequestId: pr.id, - overlapId, - alertType: 'check_run', - }, - { - jobId: `${pr.id}-${overlapId}${jobSuffix}`, - } - ) - } - - // Send push notification to the developer on the OTHER branch - // If A pushed and overlaps with B, notify B's developer - await pushNotificationQueue.add( - 'notify', - { - repositoryId, - overlapId, - targetBranchId: detected.targetBranchId, - }, - { - jobId: `push-${overlapId}-${detected.targetBranchId}${jobSuffix}`, - } - ) - } - } - - // Check for resolved overlaps (overlaps that no longer have overlapping files) - const currentOverlaps = await db.query.overlaps.findMany({ - where: and( - eq(overlaps.repositoryId, repositoryId), - sql`(${overlaps.sourceBranchId} = ${branchId} OR ${overlaps.targetBranchId} = ${branchId})`, - eq(overlaps.status, 'active') - ), - }) - - const activeTargetIds = new Set(detectedOverlaps.map((o) => o.targetBranchId)) - - for (const overlap of currentOverlaps) { - const otherBranchId = - overlap.sourceBranchId === branchId ? overlap.targetBranchId : overlap.sourceBranchId - - if (!activeTargetIds.has(otherBranchId)) { - // This overlap is no longer active - await db - .update(overlaps) - .set({ - status: 'resolved', - resolvedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(overlaps.id, overlap.id)) - - console.log(`Overlap resolved: ${overlap.id}`) - } - } - - return { overlapsFound: detectedOverlaps.length } -} - -function compareSeverity( - a: 'low' | 'medium' | 'high' | 'critical', - b: 'low' | 'medium' | 'high' | 'critical' -): number { - const order = { low: 0, medium: 1, high: 2, critical: 3 } - return order[a] - order[b] -} diff --git a/apps/worker/src/processors/push-notification.ts b/apps/worker/src/processors/push-notification.ts deleted file mode 100644 index 30744eb..0000000 --- a/apps/worker/src/processors/push-notification.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Job } from 'bullmq' -import { db, overlaps, branches, users, pushSubscriptions } from '@overlap/db' -import { eq } from 'drizzle-orm' -import type { PushNotificationJob } from '@overlap/shared' -import webpush from 'web-push' - -const APP_URL = process.env.APP_URL || 'http://localhost:3000' - -// Configure VAPID -const vapidPublicKey = process.env.VAPID_PUBLIC_KEY -const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY -const vapidSubject = process.env.VAPID_SUBJECT - -if (vapidPublicKey && vapidPrivateKey && vapidSubject) { - webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey) -} - -export async function pushNotificationProcessor(job: Job) { - const { repositoryId, overlapId, targetBranchId } = job.data - - if (!vapidPublicKey || !vapidPrivateKey) { - console.log('VAPID keys not configured, skipping push notification') - return { sent: 0 } - } - - // Get the overlap with branch names - const overlap = await db.query.overlaps.findFirst({ - where: eq(overlaps.id, overlapId), - with: { - sourceBranch: true, - targetBranch: true, - files: true, - }, - }) - - if (!overlap) { - console.log(`Overlap not found: ${overlapId}`) - return { sent: 0 } - } - - // Get the target branch's last pusher - const targetBranch = await db.query.branches.findFirst({ - where: eq(branches.id, targetBranchId), - }) - - if (!targetBranch?.lastPusherGithubId) { - console.log(`No pusher info for branch: ${targetBranchId}`) - return { sent: 0 } - } - - // Find the user matching that GitHub ID - const user = await db.query.users.findFirst({ - where: eq(users.githubId, targetBranch.lastPusherGithubId), - }) - - if (!user) { - console.log(`User not found for githubId: ${targetBranch.lastPusherGithubId}`) - return { sent: 0 } - } - - // Get user's push subscriptions - const subscriptions = await db.query.pushSubscriptions.findMany({ - where: eq(pushSubscriptions.userId, user.id), - }) - - if (subscriptions.length === 0) { - console.log(`No push subscriptions for user: ${user.id}`) - return { sent: 0 } - } - - // Build notification payload - // Orient so the recipient's branch appears first (same as UI auto-orient) - const isRecipientSource = overlap.sourceBranchId === targetBranchId - const yourBranch = isRecipientSource ? overlap.sourceBranch.name : overlap.targetBranch.name - const otherBranch = isRecipientSource ? overlap.targetBranch.name : overlap.sourceBranch.name - const fileCount = overlap.files.length - - const payload = JSON.stringify({ - title: `Overlap Detected · ${fileCount} file${fileCount !== 1 ? 's' : ''}`, - body: `${yourBranch} ↔ ${otherBranch}`, - url: `${APP_URL}/repositories/${repositoryId}`, - tag: `overlap-${overlapId}`, - }) - - let sent = 0 - - for (const sub of subscriptions) { - try { - await webpush.sendNotification( - { - endpoint: sub.endpoint, - keys: { - p256dh: sub.p256dh, - auth: sub.auth, - }, - }, - payload - ) - sent++ - } catch (err: any) { - // Remove expired/invalid subscriptions - if (err.statusCode === 404 || err.statusCode === 410) { - await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id)) - console.log(`Removed expired subscription: ${sub.id}`) - } else { - console.error(`Failed to send push to ${sub.endpoint}:`, err.message) - } - } - } - - console.log(`Sent ${sent}/${subscriptions.length} push notifications for overlap ${overlapId}`) - return { sent } -} diff --git a/apps/worker/src/processors/webhook-events.ts b/apps/worker/src/processors/webhook-events.ts deleted file mode 100644 index 49c5a8b..0000000 --- a/apps/worker/src/processors/webhook-events.ts +++ /dev/null @@ -1,432 +0,0 @@ -import type { Job } from 'bullmq' -import { db, webhookEvents, repositories, branches, githubAppInstallations, pullRequests, users, userInstallations } from '@overlap/db' -import { eq, and } from 'drizzle-orm' -import type { WebhookEventJob, PushEvent, PullRequestEvent, InstallationEvent } from '@overlap/shared' -import { GITHUB_EVENTS, PR_ACTIONS, pushEventSchema, pullRequestEventSchema, installationEventSchema } from '@overlap/shared' -import { extractBranchFromRef, isBranchDeletion, isBranchCreation } from '@overlap/github' -import { Queue } from 'bullmq' -import { Redis } from 'ioredis' -import { QUEUE_NAMES } from '@overlap/shared' - -// Get queues for adding jobs -const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' -const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }) -const branchSyncQueue = new Queue(QUEUE_NAMES.BRANCH_SYNC, { connection }) -const overlapDetectionQueue = new Queue(QUEUE_NAMES.OVERLAP_DETECTION, { connection }) -const maintenanceQueue = new Queue(QUEUE_NAMES.MAINTENANCE, { connection }) - -export async function webhookEventsProcessor(job: Job) { - const { eventType, deliveryId, payload } = job.data - - console.log(`Processing ${eventType} event: ${deliveryId}`) - - try { - switch (eventType) { - case GITHUB_EVENTS.PUSH: - await processPushEvent(payload) - break - - case GITHUB_EVENTS.PULL_REQUEST: - await processPullRequestEvent(payload) - break - - case GITHUB_EVENTS.INSTALLATION: - await processInstallationEvent(payload) - break - - case GITHUB_EVENTS.INSTALLATION_REPOSITORIES: - await processInstallationRepositoriesEvent(payload) - break - - default: - console.log(`Unhandled event type: ${eventType}`) - } - - // Mark event as processed - await db - .update(webhookEvents) - .set({ processedAt: new Date() }) - .where(eq(webhookEvents.deliveryId, deliveryId)) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - - await db - .update(webhookEvents) - .set({ error: errorMessage }) - .where(eq(webhookEvents.deliveryId, deliveryId)) - - throw error - } -} - -async function processPushEvent(payload: Record) { - const parsed = pushEventSchema.parse(payload) - const branchName = extractBranchFromRef(parsed.ref) - - if (!branchName) { - console.log(`Ignoring non-branch ref: ${parsed.ref}`) - return - } - - // Skip branch deletions (handled synchronously in API) - if (isBranchDeletion(parsed.after)) { - return - } - - // Find the repository - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.githubId, parsed.repository.id), - with: { installation: true }, - }) - - if (!repo) { - console.log(`Repository not found: ${parsed.repository.full_name}`) - return - } - - // Check if this is the default branch - const isDefault = branchName === repo.defaultBranch - - // Upsert branch (including lastPusherGithubId) - const existingBranch = await db.query.branches.findFirst({ - where: and( - eq(branches.repositoryId, repo.id), - eq(branches.name, branchName) - ), - }) - - let branchId: string - - if (existingBranch) { - await db - .update(branches) - .set({ - sha: parsed.after, - lastPusherGithubId: parsed.sender.id, - lastSeenAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(branches.id, existingBranch.id)) - branchId = existingBranch.id - } else { - const [newBranch] = await db - .insert(branches) - .values({ - repositoryId: repo.id, - name: branchName, - sha: parsed.after, - isDefault, - lastPusherGithubId: parsed.sender.id, - lastSeenAt: new Date(), - }) - .returning() - branchId = newBranch.id - } - - // Queue branch sync job - const isForceOrNew = parsed.forced || isBranchCreation(parsed.before) - - await branchSyncQueue.add( - 'sync', - { - repositoryId: repo.id, - branchName, - sha: parsed.after, - installationId: repo.installation.installationId, - }, - { - jobId: `${repo.id}-${branchName}-${parsed.after}`, - } - ) - - // Queue overlap detection (skip for default branch) - if (!isDefault) { - await overlapDetectionQueue.add( - 'detect', - { - repositoryId: repo.id, - branchId, - triggeredBy: 'push', - }, - { - jobId: `${repo.id}-${branchId}-${Date.now()}`, - delay: 5000, // Small delay to ensure sync completes first - } - ) - } -} - -async function processPullRequestEvent(payload: Record) { - const parsed = pullRequestEventSchema.parse(payload) - - // Handle closed/merged PRs and open/sync/reopen actions - const relevantActions = [ - PR_ACTIONS.OPENED, - PR_ACTIONS.SYNCHRONIZE, - PR_ACTIONS.REOPENED, - PR_ACTIONS.CLOSED, - ] - if (!relevantActions.includes(parsed.action as typeof PR_ACTIONS.OPENED)) { - return - } - - // Find the repository - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.githubId, parsed.repository.id), - }) - - if (!repo) { - console.log(`Repository not found: ${parsed.repository.full_name}`) - return - } - - // Find the branch - const branch = await db.query.branches.findFirst({ - where: and( - eq(branches.repositoryId, repo.id), - eq(branches.name, parsed.pull_request.head.ref) - ), - }) - - if (!branch) { - console.log(`Branch not found: ${parsed.pull_request.head.ref}`) - return - } - - // Determine PR state - let prState: 'open' | 'closed' | 'merged' = parsed.pull_request.state - if (parsed.action === PR_ACTIONS.CLOSED && parsed.pull_request.merged) { - prState = 'merged' - } - - // Upsert PR record - await db - .insert(pullRequests) - .values({ - repositoryId: repo.id, - branchId: branch.id, - githubPrNumber: parsed.pull_request.number, - title: parsed.pull_request.title, - state: prState, - }) - .onConflictDoUpdate({ - target: [pullRequests.repositoryId, pullRequests.githubPrNumber], - set: { - title: parsed.pull_request.title, - state: prState, - updatedAt: new Date(), - }, - }) - - // Only queue overlap detection for open/reopened/synchronize (not closed) - if (parsed.action !== PR_ACTIONS.CLOSED) { - await overlapDetectionQueue.add( - 'detect', - { - repositoryId: repo.id, - branchId: branch.id, - triggeredBy: 'push', - }, - { - jobId: `${repo.id}-${branch.id}-pr-${parsed.number}`, - } - ) - } -} - -async function processInstallationEvent(payload: Record) { - const parsed = installationEventSchema.parse(payload) - - if (parsed.action === 'created') { - // Create or update installation record - const accountType = parsed.installation.account.type - - let organizationId: string | null = null - - if (accountType === 'Organization') { - // Upsert organization - const [org] = await db - .insert(db._.fullSchema.organizations) - .values({ - githubId: parsed.installation.account.id, - name: parsed.installation.account.login, - avatarUrl: parsed.installation.account.avatar_url, - }) - .onConflictDoUpdate({ - target: db._.fullSchema.organizations.githubId, - set: { - name: parsed.installation.account.login, - avatarUrl: parsed.installation.account.avatar_url, - updatedAt: new Date(), - }, - }) - .returning() - organizationId = org.id - } - - // Link installation to user via sender.id (the person who installed) - let userId: string | null = null - const user = await db.query.users.findFirst({ - where: eq(users.githubId, parsed.sender.id), - }) - if (user) { - userId = user.id - } - - // Create installation record - const [installation] = await db - .insert(githubAppInstallations) - .values({ - installationId: parsed.installation.id, - organizationId, - userId, - status: 'active', - }) - .onConflictDoUpdate({ - target: githubAppInstallations.installationId, - set: { - status: 'active', - updatedAt: new Date(), - }, - }) - .returning() - - // Link user to installation (many-to-many) - if (userId) { - await db - .insert(userInstallations) - .values({ userId, installationId: installation.id }) - .onConflictDoNothing() - } - - console.log(`Installation created: ${parsed.installation.id} (userId: ${userId})`) - - // Sync initial repositories - if (parsed.repositories && parsed.repositories.length > 0) { - const installation = await db.query.githubAppInstallations.findFirst({ - where: eq(githubAppInstallations.installationId, parsed.installation.id), - }) - - if (installation) { - for (const repo of parsed.repositories) { - const [inserted] = await db - .insert(repositories) - .values({ - githubId: repo.id, - installationId: installation.id, - name: repo.name, - fullName: repo.full_name, - isPrivate: repo.private, - isActive: true, - }) - .onConflictDoUpdate({ - target: repositories.githubId, - set: { - isActive: true, - updatedAt: new Date(), - }, - }) - .returning() - - // Queue sync for each repo - await maintenanceQueue.add( - 'sync_repository', - { - type: 'sync_repository' as const, - repositoryId: inserted.id, - }, - { - jobId: `sync_repository-${inserted.id}`, - } - ) - } - } - } - } else if (parsed.action === 'deleted') { - // Find the installation record first - const existing = await db.query.githubAppInstallations.findFirst({ - where: eq(githubAppInstallations.installationId, parsed.installation.id), - }) - - if (existing) { - // Remove user-installation links for this installation - await db - .delete(userInstallations) - .where(eq(userInstallations.installationId, existing.id)) - } - - // Mark installation as deleted - await db - .update(githubAppInstallations) - .set({ status: 'deleted', updatedAt: new Date() }) - .where(eq(githubAppInstallations.installationId, parsed.installation.id)) - - console.log(`Installation deleted: ${parsed.installation.id}`) - } -} - -async function processInstallationRepositoriesEvent(payload: Record) { - // Handle repository additions/removals from installation - const action = payload.action as string - const installationData = payload.installation as { id: number } - - const installation = await db.query.githubAppInstallations.findFirst({ - where: eq(githubAppInstallations.installationId, installationData.id), - }) - - if (!installation) { - console.log(`Installation not found: ${installationData.id}`) - return - } - - if (action === 'added') { - const repos = payload.repositories_added as Array<{ - id: number - name: string - full_name: string - private: boolean - }> - - for (const repo of repos) { - const [inserted] = await db - .insert(repositories) - .values({ - githubId: repo.id, - installationId: installation.id, - name: repo.name, - fullName: repo.full_name, - isPrivate: repo.private, - isActive: true, - }) - .onConflictDoUpdate({ - target: repositories.githubId, - set: { - isActive: true, - updatedAt: new Date(), - }, - }) - .returning() - - // Queue sync for new repo - await maintenanceQueue.add( - 'sync_repository', - { - type: 'sync_repository' as const, - repositoryId: inserted.id, - }, - { - jobId: `sync_repository-${inserted.id}`, - } - ) - } - } else if (action === 'removed') { - const repos = payload.repositories_removed as Array<{ id: number }> - - for (const repo of repos) { - await db - .update(repositories) - .set({ isActive: false, updatedAt: new Date() }) - .where(eq(repositories.githubId, repo.id)) - } - } -} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json deleted file mode 100644 index 5c47206..0000000 --- a/apps/worker/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "noEmit": false - }, - "include": ["src/**/*"] -} diff --git a/apps/worker/tsup.config.ts b/apps/worker/tsup.config.ts deleted file mode 100644 index 61a1e2a..0000000 --- a/apps/worker/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm'], - clean: true, - noExternal: ['@overlap/shared', '@overlap/db', '@overlap/github'], -}) diff --git a/docker-compose.yml b/docker-compose.yml index a4a546c..3499233 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,19 +16,5 @@ services: timeout: 5s retries: 5 - redis: - image: redis:7-alpine - container_name: overlap-redis - ports: - - "6379:6379" - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 5s - retries: 5 - volumes: postgres_data: - redis_data: diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 7636103..db8f0ce 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -1,31 +1,3 @@ -// ============================================================================ -// Queue Names -// ============================================================================ - -export const QUEUE_NAMES = { - WEBHOOK_EVENTS: 'webhook-events', - BRANCH_SYNC: 'branch-sync', - OVERLAP_DETECTION: 'overlap-detection', - GITHUB_FEEDBACK: 'github-feedback', - MAINTENANCE: 'maintenance', - PUSH_NOTIFICATION: 'push-notification', -} as const - -export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES] - -// ============================================================================ -// Rate Limits -// ============================================================================ - -export const RATE_LIMITS = { - [QUEUE_NAMES.WEBHOOK_EVENTS]: { max: 100, duration: 1000 }, // 100/sec - [QUEUE_NAMES.BRANCH_SYNC]: { max: 30, duration: 60000 }, // 30/min - [QUEUE_NAMES.OVERLAP_DETECTION]: { max: 50, duration: 1000 }, // 50/sec - [QUEUE_NAMES.GITHUB_FEEDBACK]: { max: 10, duration: 60000 }, // 10/min - [QUEUE_NAMES.MAINTENANCE]: { max: 5, duration: 60000 }, // 5/min - [QUEUE_NAMES.PUSH_NOTIFICATION]: { max: 20, duration: 1000 }, // 20/sec -} as const - // ============================================================================ // Default Settings // ============================================================================ diff --git a/railway.json b/railway.json deleted file mode 100644 index 407ee3b..0000000 --- a/railway.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 - } -} From 1735468b1ac6ebd21756477098a7b3eebfd6a6df Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 21:20:53 -0700 Subject: [PATCH 26/35] chore: batch of deferred cleanups from the Vercel migration branch - Add the missing typescript-eslint dependency so `pnpm lint` (broken since the first commit) actually runs, then fix the two real unused-import errors it surfaced. Also mark the root package as type: module to silence eslint's CJS/ESM warning on eslint.config.js. - Replace every em dash with a plain hyphen in shipped source, per the repo's authoring rule. - Replace the deprecated `json` helper from '@tanstack/react-start' with the standard `Response.json` across all server routes that used it. - Document CRON_SECRET in .env.example. - Drop the stale "start" script from apps/web/package.json; the Vercel preset emits to .vercel/output and Vercel never invokes it. - Fix markEventProcessed to branch on `error !== undefined` instead of truthiness, so an empty-string error message is no longer treated as success. --- .env.example | 1 + apps/web/package.json | 1 - apps/web/src/components/protected-route.tsx | 2 +- apps/web/src/routes/__root.tsx | 4 +- .../src/routes/api/auth/github.callback.ts | 9 +- apps/web/src/routes/api/auth/me.ts | 3 +- .../web/src/routes/api/cron/cleanup-events.ts | 5 +- .../web/src/routes/api/cron/prune-branches.ts | 5 +- apps/web/src/routes/api/health.ts | 3 +- apps/web/src/routes/api/push.ts | 9 +- .../routes/api/repositories.$id.branches.ts | 3 +- .../src/routes/api/repositories.$id.diffs.ts | 7 +- ...ies.$id.overlaps.$overlapId.test-notify.ts | 7 +- .../repositories.$id.overlaps.$overlapId.ts | 5 +- .../routes/api/repositories.$id.overlaps.ts | 3 +- .../routes/api/repositories.$id.settings.ts | 3 +- apps/web/src/routes/api/repositories.$id.ts | 3 +- apps/web/src/routes/api/repositories.ts | 5 +- apps/web/src/routes/index.tsx | 2 +- apps/web/src/routes/repositories.tsx | 2 +- apps/web/src/routes/repositories_.$repoId.tsx | 2 +- apps/web/src/workflows/steps.ts | 2 +- package.json | 4 +- packages/github/src/webhooks.ts | 2 +- pnpm-lock.yaml | 1070 ++++------------- 25 files changed, 241 insertions(+), 921 deletions(-) diff --git a/.env.example b/.env.example index 8b1b8bc..0132378 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ SESSION_SECRET= APP_URL=http://localhost:3000 API_URL=http://localhost:3001 GITHUB_APP_SLUG=overlap-connector +CRON_SECRET= # Web Push (VAPID) VAPID_PUBLIC_KEY= diff --git a/apps/web/package.json b/apps/web/package.json index 402cd4f..b3bbd75 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,6 @@ "scripts": { "dev": "vite", "build": "vite build", - "start": "node .output/server/index.mjs", "typecheck": "tsc --noEmit", "lint": "eslint src/", "test": "vitest run", diff --git a/apps/web/src/components/protected-route.tsx b/apps/web/src/components/protected-route.tsx index 5d424e8..ed61ae8 100644 --- a/apps/web/src/components/protected-route.tsx +++ b/apps/web/src/components/protected-route.tsx @@ -35,7 +35,7 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) { ) } - // Already redirected once — show CTA + // Already redirected once - show CTA return (
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e3ba328..261ac00 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -76,12 +76,12 @@ function AppLayout() { return (
- {/* Desktop sidebar — hidden on mobile */} + {/* Desktop sidebar - hidden on mobile */}
- {/* Mobile header — shown only on mobile */} + {/* Mobile header - shown only on mobile */}
diff --git a/apps/web/src/routes/api/auth/github.callback.ts b/apps/web/src/routes/api/auth/github.callback.ts index ba7ca79..e742964 100644 --- a/apps/web/src/routes/api/auth/github.callback.ts +++ b/apps/web/src/routes/api/auth/github.callback.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, users, userInstallations } from '@overlap/db' import { eq } from 'drizzle-orm' import { githubOAuthCallbackSchema } from '@overlap/shared' @@ -33,7 +32,7 @@ export const Route = createFileRoute('/api/auth/github/callback')({ state: url.searchParams.get('state') ?? undefined, }) - // Verify state (CSRF protection) — always enforced. The state cookie is + // Verify state (CSRF protection) - always enforced. The state cookie is // itself a signed JWT (per spec S7), so verification checks integrity, // not merely presence. let stateClaim: unknown @@ -43,10 +42,10 @@ export const Route = createFileRoute('/api/auth/github/callback')({ }) stateClaim = payload.state } catch { - return json({ error: 'Invalid OAuth state' }, { status: 400 }) + return Response.json({ error: 'Invalid OAuth state' }, { status: 400 }) } if (typeof stateClaim !== 'string' || stateClaim !== state) { - return json({ error: 'Invalid OAuth state' }, { status: 400 }) + return Response.json({ error: 'Invalid OAuth state' }, { status: 400 }) } // Exchange code for access token @@ -69,7 +68,7 @@ export const Route = createFileRoute('/api/auth/github/callback')({ } if (!tokenData.access_token) { - return json({ error: 'Failed to exchange code for token' }, { status: 400 }) + return Response.json({ error: 'Failed to exchange code for token' }, { status: 400 }) } // Fetch user profile diff --git a/apps/web/src/routes/api/auth/me.ts b/apps/web/src/routes/api/auth/me.ts index d4ebe01..76293a7 100644 --- a/apps/web/src/routes/api/auth/me.ts +++ b/apps/web/src/routes/api/auth/me.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, userInstallations } from '@overlap/db' import { eq } from 'drizzle-orm' import { requireUser } from '../../../lib/auth' @@ -14,7 +13,7 @@ export const Route = createFileRoute('/api/auth/me')({ where: eq(userInstallations.userId, user.id), with: { installation: true }, }) - return json({ + return Response.json({ user, hasInstallations: insts.some( (ui) => ui.installation.status === 'active' diff --git a/apps/web/src/routes/api/cron/cleanup-events.ts b/apps/web/src/routes/api/cron/cleanup-events.ts index 84d78a4..c98c5e7 100644 --- a/apps/web/src/routes/api/cron/cleanup-events.ts +++ b/apps/web/src/routes/api/cron/cleanup-events.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { start } from 'workflow/api' import { isAuthorizedCron } from '../../../lib/cron-auth' import { cleanupEventsWorkflow } from '../../../workflows/maintenance' @@ -12,12 +11,12 @@ export const Route = createFileRoute('/api/cron/cleanup-events')({ handlers: { GET: async ({ request }) => { if (!isAuthorizedCron(request)) { - return json({ error: 'Unauthorized' }, { status: 401 }) + return Response.json({ error: 'Unauthorized' }, { status: 401 }) } const run = await start(cleanupEventsWorkflow, []) - return json({ success: true, runId: run.runId }) + return Response.json({ success: true, runId: run.runId }) }, }, }, diff --git a/apps/web/src/routes/api/cron/prune-branches.ts b/apps/web/src/routes/api/cron/prune-branches.ts index 872e04e..43ed45c 100644 --- a/apps/web/src/routes/api/cron/prune-branches.ts +++ b/apps/web/src/routes/api/cron/prune-branches.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { start } from 'workflow/api' import { isAuthorizedCron } from '../../../lib/cron-auth' import { pruneBranchesWorkflow } from '../../../workflows/maintenance' @@ -14,12 +13,12 @@ export const Route = createFileRoute('/api/cron/prune-branches')({ handlers: { GET: async ({ request }) => { if (!isAuthorizedCron(request)) { - return json({ error: 'Unauthorized' }, { status: 401 }) + return Response.json({ error: 'Unauthorized' }, { status: 401 }) } const run = await start(pruneBranchesWorkflow, []) - return json({ success: true, runId: run.runId }) + return Response.json({ success: true, runId: run.runId }) }, }, }, diff --git a/apps/web/src/routes/api/health.ts b/apps/web/src/routes/api/health.ts index 9866bfb..f70805e 100644 --- a/apps/web/src/routes/api/health.ts +++ b/apps/web/src/routes/api/health.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db } from '@overlap/db' import { sql } from 'drizzle-orm' @@ -15,7 +14,7 @@ export const Route = createFileRoute('/api/health')({ database = false } - return json( + return Response.json( { status: database ? 'ready' : 'not ready', checks: { database }, diff --git a/apps/web/src/routes/api/push.ts b/apps/web/src/routes/api/push.ts index 610f8e3..78dead3 100644 --- a/apps/web/src/routes/api/push.ts +++ b/apps/web/src/routes/api/push.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, pushSubscriptions } from '@overlap/db' import { eq, and } from 'drizzle-orm' import { requireUser } from '../../lib/auth' @@ -42,7 +41,7 @@ export const Route = createFileRoute('/api/push')({ } if (!isAllowedPushEndpoint(endpoint)) { - return json({ error: 'Invalid push endpoint' }, { status: 400 }) + return Response.json({ error: 'Invalid push endpoint' }, { status: 400 }) } const MAX_SUBSCRIPTIONS_PER_USER = 20 @@ -54,7 +53,7 @@ export const Route = createFileRoute('/api/push')({ const isKnownEndpoint = existing.some((s) => s.endpoint === endpoint) if (!isKnownEndpoint && existing.length >= MAX_SUBSCRIPTIONS_PER_USER) { - return json({ error: 'Subscription limit reached' }, { status: 429 }) + return Response.json({ error: 'Subscription limit reached' }, { status: 429 }) } await db @@ -73,7 +72,7 @@ export const Route = createFileRoute('/api/push')({ }, }) - return json({ success: true }) + return Response.json({ success: true }) } catch (res) { if (res instanceof Response) return res throw res @@ -91,7 +90,7 @@ export const Route = createFileRoute('/api/push')({ and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)) ) - return json({ success: true }) + return Response.json({ success: true }) } catch (res) { if (res instanceof Response) return res throw res diff --git a/apps/web/src/routes/api/repositories.$id.branches.ts b/apps/web/src/routes/api/repositories.$id.branches.ts index 70573bb..d7c7271 100644 --- a/apps/web/src/routes/api/repositories.$id.branches.ts +++ b/apps/web/src/routes/api/repositories.$id.branches.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, branches } from '@overlap/db' import { eq, and, desc, sql } from 'drizzle-orm' import { repositoryIdParamSchema, branchQuerySchema } from '@overlap/shared' @@ -40,7 +39,7 @@ export const Route = createFileRoute('/api/repositories/$id/branches')({ offset: (page - 1) * limit, }) - return json(branchList) + return Response.json(branchList) } catch (res) { if (res instanceof Response) return res throw res diff --git a/apps/web/src/routes/api/repositories.$id.diffs.ts b/apps/web/src/routes/api/repositories.$id.diffs.ts index c3d9b6a..329df5f 100644 --- a/apps/web/src/routes/api/repositories.$id.diffs.ts +++ b/apps/web/src/routes/api/repositories.$id.diffs.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, repositories } from '@overlap/db' import { eq } from 'drizzle-orm' import { repositoryIdParamSchema, diffQuerySchema } from '@overlap/shared' @@ -26,7 +25,7 @@ export const Route = createFileRoute('/api/repositories/$id/diffs')({ }) if (!repoWithInstallation?.installation) { - return json({ error: 'Installation not found' }, { status: 500 }) + return Response.json({ error: 'Installation not found' }, { status: 500 }) } const [owner, name] = repoWithInstallation.fullName.split('/') @@ -41,11 +40,11 @@ export const Route = createFileRoute('/api/repositories/$id/diffs')({ head ) - return json({ files: diffs }) + return Response.json({ files: diffs }) } catch (error: unknown) { const err = error as { status?: number; message?: string } if (err.status === 404) { - return json({ error: 'Branch no longer exists' }, { status: 404 }) + return Response.json({ error: 'Branch no longer exists' }, { status: 404 }) } throw error } diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts index c206599..a31c0f4 100644 --- a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts +++ b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, overlaps } from '@overlap/db' import { eq, and } from 'drizzle-orm' import { repositoryIdParamSchema } from '@overlap/shared' @@ -17,7 +16,7 @@ export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId/ const user = await requireUser(request) if (process.env.NODE_ENV === 'production') { - return json({ error: 'Not found' }, { status: 404 }) + return Response.json({ error: 'Not found' }, { status: 404 }) } const { id } = repositoryIdParamSchema.parse(params) @@ -31,7 +30,7 @@ export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId/ }) if (!overlap) { - return json({ error: 'Overlap not found' }, { status: 404 }) + return Response.json({ error: 'Overlap not found' }, { status: 404 }) } // The original Fastify handler queued this via BullMQ @@ -43,7 +42,7 @@ export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId/ overlap.targetBranchId, ]) - return json({ + return Response.json({ success: true, message: 'Test notification queued', runId: run.runId, diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts index fbd7db3..feb65e4 100644 --- a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts +++ b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, overlaps } from '@overlap/db' import { eq, and } from 'drizzle-orm' import { repositoryIdParamSchema, overlapUpdateSchema } from '@overlap/shared' @@ -24,7 +23,7 @@ export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId' }) if (!overlap) { - return json({ error: 'Overlap not found' }, { status: 404 }) + return Response.json({ error: 'Overlap not found' }, { status: 404 }) } const [updated] = await db @@ -37,7 +36,7 @@ export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId' .where(eq(overlaps.id, overlapId)) .returning() - return json(updated) + return Response.json(updated) } catch (res) { if (res instanceof Response) return res throw res diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.ts b/apps/web/src/routes/api/repositories.$id.overlaps.ts index 8f7a7da..9d82b3d 100644 --- a/apps/web/src/routes/api/repositories.$id.overlaps.ts +++ b/apps/web/src/routes/api/repositories.$id.overlaps.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, overlaps } from '@overlap/db' import { eq, and, desc, sql } from 'drizzle-orm' import { repositoryIdParamSchema, overlapQuerySchema } from '@overlap/shared' @@ -49,7 +48,7 @@ export const Route = createFileRoute('/api/repositories/$id/overlaps')({ offset: (page - 1) * limit, }) - return json(overlapList) + return Response.json(overlapList) } catch (res) { if (res instanceof Response) return res throw res diff --git a/apps/web/src/routes/api/repositories.$id.settings.ts b/apps/web/src/routes/api/repositories.$id.settings.ts index 051c3a8..a681e03 100644 --- a/apps/web/src/routes/api/repositories.$id.settings.ts +++ b/apps/web/src/routes/api/repositories.$id.settings.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, repositorySettings } from '@overlap/db' import { eq } from 'drizzle-orm' import { repositoryIdParamSchema, repositorySettingsUpdateSchema } from '@overlap/shared' @@ -27,7 +26,7 @@ export const Route = createFileRoute('/api/repositories/$id/settings')({ .where(eq(repositorySettings.repositoryId, id)) .returning() - return json(updated) + return Response.json(updated) } catch (res) { if (res instanceof Response) return res throw res diff --git a/apps/web/src/routes/api/repositories.$id.ts b/apps/web/src/routes/api/repositories.$id.ts index 10b9441..c87d71e 100644 --- a/apps/web/src/routes/api/repositories.$id.ts +++ b/apps/web/src/routes/api/repositories.$id.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, repositories } from '@overlap/db' import { eq } from 'drizzle-orm' import { repositoryIdParamSchema } from '@overlap/shared' @@ -25,7 +24,7 @@ export const Route = createFileRoute('/api/repositories/$id')({ }, }) - return json(repoWithRelations) + return Response.json(repoWithRelations) } catch (res) { if (res instanceof Response) return res throw res diff --git a/apps/web/src/routes/api/repositories.ts b/apps/web/src/routes/api/repositories.ts index f79a729..cb8b6c7 100644 --- a/apps/web/src/routes/api/repositories.ts +++ b/apps/web/src/routes/api/repositories.ts @@ -1,5 +1,4 @@ import { createFileRoute } from '@tanstack/react-router' -import { json } from '@tanstack/react-start' import { db, repositories, branches, overlaps } from '@overlap/db' import { eq, and, desc, count, inArray } from 'drizzle-orm' import { requireUser } from '../../lib/auth' @@ -15,7 +14,7 @@ export const Route = createFileRoute('/api/repositories')({ const installationIds = await getUserInstallationIds(user.id) if (installationIds.length === 0) { - return json([]) + return Response.json([]) } const repos = await db.query.repositories.findMany({ @@ -56,7 +55,7 @@ export const Route = createFileRoute('/api/repositories')({ }) ) - return json(results) + return Response.json(results) } catch (res) { if (res instanceof Response) return res throw res diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 8919c95..a49aa7e 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -4,7 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/com import { Badge } from '~/components/ui/badge' import { Button } from '~/components/ui/button' import { Skeleton } from '~/components/ui/skeleton' -import { GitBranch, AlertTriangle, GitPullRequest, Clock, Loader2 } from 'lucide-react' +import { GitBranch, AlertTriangle, GitPullRequest } from 'lucide-react' import { ProtectedRoute } from '~/components/protected-route' import { NotificationPrompt } from '~/components/notification-prompt' import { api } from '~/lib/api' diff --git a/apps/web/src/routes/repositories.tsx b/apps/web/src/routes/repositories.tsx index 75a67ad..b646f2d 100644 --- a/apps/web/src/routes/repositories.tsx +++ b/apps/web/src/routes/repositories.tsx @@ -4,7 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/com import { Badge } from '~/components/ui/badge' import { Button } from '~/components/ui/button' import { Skeleton } from '~/components/ui/skeleton' -import { GitBranch, Lock, Globe, ChevronRight, Loader2, AlertTriangle } from 'lucide-react' +import { GitBranch, Lock, Globe, ChevronRight, AlertTriangle } from 'lucide-react' import { ProtectedRoute } from '~/components/protected-route' import { api } from '~/lib/api' diff --git a/apps/web/src/routes/repositories_.$repoId.tsx b/apps/web/src/routes/repositories_.$repoId.tsx index 3cf7354..7452cb7 100644 --- a/apps/web/src/routes/repositories_.$repoId.tsx +++ b/apps/web/src/routes/repositories_.$repoId.tsx @@ -343,7 +343,7 @@ function OverlapCard({ overlap, repoId, defaultBranch, userGithubId, onResolve,
- {/* Inline diff panel — side by side */} + {/* Inline diff panel - side by side */} {selectedFile && (
diff --git a/apps/web/src/workflows/steps.ts b/apps/web/src/workflows/steps.ts index eb20370..b3198a6 100644 --- a/apps/web/src/workflows/steps.ts +++ b/apps/web/src/workflows/steps.ts @@ -237,7 +237,7 @@ export async function markEventProcessed( await db .update(webhookEvents) - .set(error ? { error } : { processedAt: new Date() }) + .set(error !== undefined ? { error } : { processedAt: new Date() }) .where(eq(webhookEvents.deliveryId, deliveryId)) return { deliveryId } diff --git a/package.json b/package.json index c3027ba..d200e10 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "overlap", "version": "0.1.0", "private": true, + "type": "module", "description": "Detect overlapping file changes across active Git branches in real time", "scripts": { "dev": "turbo dev", @@ -22,7 +23,8 @@ "eslint": "^9.0.0", "prettier": "^3.2.0", "turbo": "^2.3.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "typescript-eslint": "^8.67.0" }, "packageManager": "pnpm@9.15.0", "engines": { diff --git a/packages/github/src/webhooks.ts b/packages/github/src/webhooks.ts index 2be96d1..a28be5e 100644 --- a/packages/github/src/webhooks.ts +++ b/packages/github/src/webhooks.ts @@ -116,7 +116,7 @@ export function formatOverlapComment( } comment += `---\n` - comment += `*Overlap detected by [Overlap](https://overlap.dev) — coordinate early to avoid merge conflicts.*` + comment += `*Overlap detected by [Overlap](https://overlap.dev) - coordinate early to avoid merge conflicts.*` return comment } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80f8421..9ab43cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,58 +26,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 - - apps/api: - dependencies: - '@fastify/cookie': - specifier: ^11.0.0 - version: 11.0.2 - '@fastify/cors': - specifier: ^10.0.0 - version: 10.1.0 - '@fastify/rate-limit': - specifier: ^10.0.0 - version: 10.3.0 - '@overlap/db': - specifier: workspace:* - version: link:../../packages/db - '@overlap/github': - specifier: workspace:* - version: link:../../packages/github - '@overlap/shared': - specifier: workspace:* - version: link:../../packages/shared - bullmq: - specifier: ^5.25.0 - version: 5.67.2 - drizzle-orm: - specifier: ^0.38.0 - version: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) - fastify: - specifier: ^5.0.0 - version: 5.7.4 - fastify-plugin: - specifier: ^5.1.0 - version: 5.1.0 - ioredis: - specifier: ^5.4.0 - version: 5.9.2 - devDependencies: - '@types/node': - specifier: ^20.11.0 - version: 20.19.31 - pino-pretty: - specifier: ^13.0.0 - version: 13.1.3 - tsup: - specifier: ^8.0.0 - version: 8.5.1(@swc/core@1.15.3)(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) - tsx: - specifier: ^4.19.0 - version: 4.21.0 - typescript: - specifier: ^5.7.0 - version: 5.9.3 + typescript-eslint: + specifier: ^8.67.0 + version: 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) apps/web: dependencies: @@ -200,49 +151,6 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) - apps/worker: - dependencies: - '@overlap/db': - specifier: workspace:* - version: link:../../packages/db - '@overlap/github': - specifier: workspace:* - version: link:../../packages/github - '@overlap/shared': - specifier: workspace:* - version: link:../../packages/shared - bullmq: - specifier: ^5.25.0 - version: 5.67.2 - drizzle-orm: - specifier: ^0.38.0 - version: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) - ioredis: - specifier: ^5.4.0 - version: 5.9.2 - minimatch: - specifier: ^10.0.0 - version: 10.1.2 - web-push: - specifier: ^3.6.7 - version: 3.6.7 - devDependencies: - '@types/node': - specifier: ^20.11.0 - version: 20.19.31 - '@types/web-push': - specifier: ^3.6.4 - version: 3.6.4 - tsup: - specifier: ^8.0.0 - version: 8.5.1(@swc/core@1.15.3)(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) - tsx: - specifier: ^4.19.0 - version: 4.21.0 - typescript: - specifier: ^5.7.0 - version: 5.9.3 - packages/db: dependencies: '@overlap/shared': @@ -1099,33 +1007,6 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@fastify/ajv-compiler@4.0.5': - resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - - '@fastify/cookie@11.0.2': - resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} - - '@fastify/cors@10.1.0': - resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} - - '@fastify/error@4.2.0': - resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - - '@fastify/fast-json-stringify-compiler@5.0.3': - resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} - - '@fastify/forwarded@3.0.1': - resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} - - '@fastify/merge-json-schemas@0.2.1': - resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - - '@fastify/proxy-addr@5.1.0': - resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - - '@fastify/rate-limit@10.3.0': - resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} - '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -1160,14 +1041,6 @@ packages: '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1191,40 +1064,6 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} - '@lukeed/ms@2.0.2': - resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} - engines: {node: '>=8'} - - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': - resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} - cpu: [arm64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': - resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} - cpu: [x64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': - resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} - cpu: [arm64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': - resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} - cpu: [arm] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': - resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} - cpu: [x64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': - resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} - cpu: [x64] - os: [win32] - '@napi-rs/nice-android-arm-eabi@1.1.1': resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} engines: {node: '>= 10'} @@ -1727,9 +1566,6 @@ packages: '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2646,6 +2482,65 @@ packages: '@types/web-push@3.6.4': resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vercel/cli-auth@0.0.1': resolution: {integrity: sha512-CnqiuMlZ4pjs2LCPYiR6aLKPPd3Xb8SBI1Y7eotXKgpx6qgrGNY+E7EIyUt5ErGHJGIrCZyGG5WEo4bHtVmz2Q==} @@ -2855,9 +2750,6 @@ packages: resolution: {integrity: sha512-y5ArHvQ7BVule/+L9yE2nYMhceiJhgsqo58lOfnisQ7bg+Kjfmkgr7JBuVFiTkl+ErdShpp829QstZQyLugl8g==} engines: {node: '>=20'} - abstract-logging@2.0.1: - resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2881,20 +2773,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -2930,9 +2811,6 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2965,13 +2843,6 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} - - avvio@9.1.0: - resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} - b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -3069,19 +2940,10 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.67.2: - resolution: {integrity: sha512-3KYqNqQptKcgksACO1li4YW9/jxEh6XWa1lUg4OFrHa80Pf0C7H9zeb6ssbQQDfQab/K3QCXopbZ40vrvcyrLw==} - bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - byte-counter@0.1.0: resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} engines: {node: '>=20'} @@ -3098,10 +2960,6 @@ packages: magicast: optional: true - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} @@ -3208,13 +3066,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - commander@6.2.1: resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} engines: {node: '>= 6'} @@ -3270,14 +3121,6 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - cron-parser@4.9.0: - resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} - engines: {node: '>=12.0.0'} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3303,9 +3146,6 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - dateformat@4.6.3: - resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - db0@0.3.4: resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==} peerDependencies: @@ -3375,10 +3215,6 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -3543,9 +3379,6 @@ packages: encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.19.0: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} @@ -3636,6 +3469,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3718,12 +3555,6 @@ packages: fast-content-type-parse@2.0.1: resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} - fast-copy@4.0.2: - resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} - - fast-decode-uri-component@1.0.1: - resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3733,30 +3564,12 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-json-stringify@6.2.0: - resolution: {integrity: sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw==} - fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-querystring@1.1.2: - resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fastify-plugin@5.1.0: - resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - - fastify@5.7.4: - resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3797,10 +3610,6 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - find-my-way@9.4.0: - resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} - engines: {node: '>=20'} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3813,9 +3622,6 @@ packages: resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} engines: {node: '>=18'} - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -3953,9 +3759,6 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - help-me@5.0.0: - resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -4035,10 +3838,6 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} - engines: {node: '>= 10'} - is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -4155,10 +3954,6 @@ packages: jose@6.2.8: resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4174,15 +3969,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-schema-ref-resolver@3.0.0: - resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4221,9 +4010,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - light-my-request@6.6.0: - resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} - lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -4298,17 +4084,10 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - load-esm@1.0.3: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4342,10 +4121,6 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - luxon@3.7.2: - resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} - engines: {node: '>=12'} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4395,10 +4170,6 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.1.2: - resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} - engines: {node: 20 || >=22} - minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -4431,22 +4202,9 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - mnemonist@0.40.0: - resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msgpackr-extract@3.0.3: - resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} - hasBin: true - - msgpackr@1.11.5: - resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4486,17 +4244,10 @@ packages: xml2js: optional: true - node-abort-controller@3.1.1: - resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} - node-gyp-build-optional-packages@5.1.1: resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} hasBin: true - node-gyp-build-optional-packages@5.2.2: - resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} - hasBin: true - node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -4523,17 +4274,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - obliterator@2.0.5: - resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} - obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -4544,10 +4288,6 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -4687,24 +4427,6 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} - hasBin: true - - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@10.3.0: - resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==} - hasBin: true - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - piscina@4.9.3: resolution: {integrity: sha512-3e3ka9QCE8RJ5I9uszdAADZnkcYi21cqmF3gxox3u884N72qpFHCsIVhHt8cEQ9t3Auq/NqoiCEuhxlxxQuDWA==} @@ -4714,24 +4436,6 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -4753,19 +4457,10 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - process-warning@4.0.1: - resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4774,9 +4469,6 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -4847,10 +4539,6 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -4866,10 +4554,6 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -4877,10 +4561,6 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -4892,17 +4572,6 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - ret@0.5.0: - resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} - engines: {node: '>=10'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4925,13 +4594,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-regex2@5.0.0: - resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -4941,9 +4603,6 @@ packages: scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - seedrandom@3.0.5: resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} @@ -4996,9 +4655,6 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -5044,9 +4700,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} - sort-keys-length@1.0.1: resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==} engines: {node: '>=0.10.0'} @@ -5070,10 +4723,6 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - srvx@0.10.1: resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==} engines: {node: '>=20.16.0'} @@ -5134,19 +4783,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} - strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - super-regex@1.1.0: resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} engines: {node: '>=18'} @@ -5191,17 +4831,6 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - thread-stream@4.0.0: - resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} - engines: {node: '>=20'} - through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -5218,9 +4847,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.3.0: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} @@ -5253,12 +4879,11 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} @@ -5273,25 +4898,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -5347,6 +4953,13 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -5528,10 +5141,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -6325,45 +5934,6 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@fastify/ajv-compiler@4.0.5': - dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - - '@fastify/cookie@11.0.2': - dependencies: - cookie: 1.1.1 - fastify-plugin: 5.1.0 - - '@fastify/cors@10.1.0': - dependencies: - fastify-plugin: 5.1.0 - mnemonist: 0.40.0 - - '@fastify/error@4.2.0': {} - - '@fastify/fast-json-stringify-compiler@5.0.3': - dependencies: - fast-json-stringify: 6.2.0 - - '@fastify/forwarded@3.0.1': {} - - '@fastify/merge-json-schemas@0.2.1': - dependencies: - dequal: 2.0.3 - - '@fastify/proxy-addr@5.1.0': - dependencies: - '@fastify/forwarded': 3.0.1 - ipaddr.js: 2.3.0 - - '@fastify/rate-limit@10.3.0': - dependencies: - '@lukeed/ms': 2.0.2 - fastify-plugin: 5.1.0 - toad-cache: 3.7.0 - '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -6392,13 +5962,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@ioredis/commands@1.5.0': {} - - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.1': - dependencies: - '@isaacs/balanced-match': 4.0.1 + '@ioredis/commands@1.5.0': + optional: true '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -6423,26 +5988,6 @@ snapshots: '@lukeed/csprng@1.1.0': {} - '@lukeed/ms@2.0.2': {} - - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': - optional: true - '@napi-rs/nice-android-arm-eabi@1.1.1': optional: true @@ -6859,8 +6404,6 @@ snapshots: '@petamoriken/float16@3.9.3': {} - '@pinojs/redact@0.4.0': {} - '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -7771,6 +7314,97 @@ snapshots: dependencies: '@types/node': 20.19.31 + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 9.39.2(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.2(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3(supports-color@8.1.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.2(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + '@vercel/cli-auth@0.0.1': dependencies: async-listen: 3.0.0 @@ -8213,8 +7847,6 @@ snapshots: dependencies: system-architecture: 1.0.0 - abstract-logging@2.0.1: {} - accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -8230,10 +7862,6 @@ snapshots: agent-base@7.1.4: {} - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -8241,13 +7869,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -8274,8 +7895,6 @@ snapshots: ansis@4.2.0: {} - any-promise@1.3.0: {} - anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -8306,13 +7925,6 @@ snapshots: async@3.2.6: {} - atomic-sleep@1.0.0: {} - - avvio@9.1.0: - dependencies: - '@fastify/error': 4.2.0 - fastq: 1.20.1 - b4a@1.8.1: {} babel-dead-code-elimination@1.0.12: @@ -8416,27 +8028,10 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.67.2: - dependencies: - cron-parser: 4.9.0 - ioredis: 5.9.2 - msgpackr: 1.11.5 - node-abort-controller: 3.1.1 - semver: 7.7.3 - tslib: 2.8.1 - uuid: 11.1.0 - transitivePeerDependencies: - - supports-color - bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 - bundle-require@5.1.0(esbuild@0.27.2): - dependencies: - esbuild: 0.27.2 - load-tsconfig: 0.2.5 - byte-counter@0.1.0: {} bytes@3.1.2: {} @@ -8456,8 +8051,6 @@ snapshots: pkg-types: 2.3.1 rc9: 3.0.1 - cac@6.7.14: {} - cacheable-lookup@7.0.0: {} cacheable-request@13.0.19: @@ -8579,7 +8172,8 @@ snapshots: clsx@2.1.1: {} - cluster-key-slot@1.1.2: {} + cluster-key-slot@1.1.2: + optional: true color-convert@2.0.1: dependencies: @@ -8587,10 +8181,6 @@ snapshots: color-name@1.1.4: {} - colorette@2.0.20: {} - - commander@4.1.1: {} - commander@6.2.1: {} commander@8.3.0: {} @@ -8621,12 +8211,6 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: {} - - cron-parser@4.9.0: - dependencies: - luxon: 3.7.2 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -8651,8 +8235,6 @@ snapshots: date-fns@4.1.0: {} - dateformat@4.6.3: {} - db0@0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)): optionalDependencies: '@electric-sql/pglite': 0.2.17 @@ -8688,12 +8270,11 @@ snapshots: defu@6.1.7: {} - denque@2.1.0: {} + denque@2.1.0: + optional: true depd@2.0.0: {} - dequal@2.0.3: {} - destr@2.0.5: {} detect-libc@2.1.2: {} @@ -8776,10 +8357,6 @@ snapshots: iconv-lite: 0.6.3 whatwg-encoding: 3.1.1 - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 @@ -8938,6 +8515,8 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} + eslint@9.39.2(jiti@2.7.0): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) @@ -9100,59 +8679,16 @@ snapshots: fast-content-type-parse@2.0.1: {} - fast-copy@4.0.2: {} - - fast-decode-uri-component@1.0.1: {} - fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} fast-json-stable-stringify@2.1.0: {} - fast-json-stringify@6.2.0: - dependencies: - '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - json-schema-ref-resolver: 3.0.0 - rfdc: 1.4.1 - fast-levenshtein@2.0.6: {} - fast-querystring@1.1.2: - dependencies: - fast-decode-uri-component: 1.0.1 - fast-safe-stringify@2.1.1: {} - fast-uri@3.1.0: {} - - fastify-plugin@5.1.0: {} - - fastify@5.7.4: - dependencies: - '@fastify/ajv-compiler': 4.0.5 - '@fastify/error': 4.2.0 - '@fastify/fast-json-stringify-compiler': 5.0.3 - '@fastify/proxy-addr': 5.1.0 - abstract-logging: 2.0.1 - avvio: 9.1.0 - fast-json-stringify: 6.2.0 - find-my-way: 9.4.0 - light-my-request: 6.6.0 - pino: 10.3.0 - process-warning: 5.0.0 - rfdc: 1.4.1 - secure-json-parse: 4.1.0 - semver: 7.7.3 - toad-cache: 3.7.0 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -9203,12 +8739,6 @@ snapshots: transitivePeerDependencies: - supports-color - find-my-way@9.4.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-querystring: 1.1.2 - safe-regex2: 5.0.0 - find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -9225,12 +8755,6 @@ snapshots: semver-regex: 4.0.5 super-regex: 1.1.0 - fix-dts-default-cjs-exports@1.0.1: - dependencies: - magic-string: 0.30.21 - mlly: 1.8.0 - rollup: 4.57.1 - flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -9359,8 +8883,6 @@ snapshots: dependencies: function-bind: 1.1.2 - help-me@5.0.0: {} - htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -9440,11 +8962,10 @@ snapshots: standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color + optional: true ipaddr.js@1.9.1: {} - ipaddr.js@2.3.0: {} - is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -9517,8 +9038,6 @@ snapshots: jose@6.2.8: {} - joycon@3.1.1: {} - js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -9529,14 +9048,8 @@ snapshots: json-buffer@3.0.1: {} - json-schema-ref-resolver@3.0.0: - dependencies: - dequal: 2.0.3 - json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} - json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -9577,12 +9090,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - light-my-request@6.6.0: - dependencies: - cookie: 1.1.1 - process-warning: 4.0.1 - set-cookie-parser: 2.7.2 - lightningcss-android-arm64@1.30.2: optional: true @@ -9634,12 +9141,8 @@ snapshots: lilconfig@3.1.3: {} - lines-and-columns@1.2.4: {} - load-esm@1.0.3: {} - load-tsconfig@0.2.5: {} - locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -9648,9 +9151,11 @@ snapshots: dependencies: p-locate: 6.0.0 - lodash.defaults@4.2.0: {} + lodash.defaults@4.2.0: + optional: true - lodash.isarguments@3.1.0: {} + lodash.isarguments@3.1.0: + optional: true lodash.merge@4.6.2: {} @@ -9669,8 +9174,6 @@ snapshots: dependencies: react: 19.2.4 - luxon@3.7.2: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -9705,10 +9208,6 @@ snapshots: minimalistic-assert@1.0.1: {} - minimatch@10.1.2: - dependencies: - '@isaacs/brace-expansion': 5.0.1 - minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -9745,34 +9244,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 - mnemonist@0.40.0: - dependencies: - obliterator: 2.0.5 - ms@2.1.3: {} - msgpackr-extract@3.0.3: - dependencies: - node-gyp-build-optional-packages: 5.2.2 - optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 - optional: true - - msgpackr@1.11.5: - optionalDependencies: - msgpackr-extract: 3.0.3 - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - nanoid@3.3.11: {} nanoid@5.1.6: {} @@ -9831,18 +9304,11 @@ snapshots: - sqlite3 - uploadthing - node-abort-controller@3.1.1: {} - node-gyp-build-optional-packages@5.1.1: dependencies: detect-libc: 2.1.2 optional: true - node-gyp-build-optional-packages@5.2.2: - dependencies: - detect-libc: 2.1.2 - optional: true - node-releases@2.0.27: {} normalize-path@3.0.0: {} @@ -9866,20 +9332,14 @@ snapshots: dependencies: boolbase: 1.0.0 - object-assign@4.1.1: {} - object-inspect@1.13.4: {} - obliterator@2.0.5: {} - obug@2.1.4: {} ofetch@2.0.0-alpha.3: {} ohash@2.0.11: {} - on-exit-leak-free@2.1.2: {} - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -10051,44 +9511,6 @@ snapshots: picomatch@4.0.5: {} - pino-abstract-transport@3.0.0: - dependencies: - split2: 4.2.0 - - pino-pretty@13.1.3: - dependencies: - colorette: 2.0.20 - dateformat: 4.6.3 - fast-copy: 4.0.2 - fast-safe-stringify: 2.1.1 - help-me: 5.0.0 - joycon: 3.1.1 - minimist: 1.2.8 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pump: 3.0.3 - secure-json-parse: 4.1.0 - sonic-boom: 4.2.0 - strip-json-comments: 5.0.3 - - pino-std-serializers@7.1.0: {} - - pino@10.3.0: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.0.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.0 - thread-stream: 4.0.0 - - pirates@4.0.7: {} - piscina@4.9.3: optionalDependencies: '@napi-rs/nice': 1.1.1 @@ -10105,14 +9527,6 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 2.7.0 - postcss: 8.5.6 - tsx: 4.21.0 - postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -10129,20 +9543,11 @@ snapshots: dependencies: parse-ms: 4.0.0 - process-warning@4.0.1: {} - - process-warning@5.0.0: {} - proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode@2.3.1: {} qs@6.15.3: @@ -10150,8 +9555,6 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 - quick-format-unescaped@4.0.4: {} - quick-lru@5.1.1: {} range-parser@1.3.0: {} @@ -10212,8 +9615,6 @@ snapshots: readdirp@5.1.1: {} - real-require@0.2.0: {} - recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -10222,22 +9623,20 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - redis-errors@1.2.0: {} + redis-errors@1.2.0: + optional: true redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 + optional: true reflect-metadata@0.2.2: {} - require-from-string@2.0.2: {} - resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} - resolve-from@5.0.0: {} - resolve-pkg-maps@1.0.0: {} responselike@4.0.2: @@ -10249,12 +9648,6 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - ret@0.5.0: {} - - reusify@1.1.0: {} - - rfdc@1.4.1: {} - rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -10306,20 +9699,12 @@ snapshots: safe-buffer@5.2.1: {} - safe-regex2@5.0.0: - dependencies: - ret: 0.5.0 - - safe-stable-stringify@2.5.0: {} - safer-buffer@2.1.2: {} scheduler@0.27.0: {} scule@1.3.0: {} - secure-json-parse@4.1.0: {} - seedrandom@3.0.5: {} seek-bzip@2.0.0: @@ -10371,8 +9756,6 @@ snapshots: transitivePeerDependencies: - supports-color - set-cookie-parser@2.7.2: {} - setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -10419,10 +9802,6 @@ snapshots: slash@3.0.0: {} - sonic-boom@4.2.0: - dependencies: - atomic-sleep: 1.0.0 - sort-keys-length@1.0.1: dependencies: sort-keys: 1.1.2 @@ -10442,13 +9821,12 @@ snapshots: source-map@0.7.6: {} - split2@4.2.0: {} - srvx@0.10.1: {} stackback@0.0.2: {} - standard-as-callback@2.1.0: {} + standard-as-callback@2.1.0: + optional: true statuses@2.0.2: {} @@ -10498,22 +9876,10 @@ snapshots: strip-json-comments@3.1.1: {} - strip-json-comments@5.0.3: {} - strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.17 - ts-interface-checker: 0.1.13 - super-regex@1.1.0: dependencies: function-timeout: 1.0.2 @@ -10563,18 +9929,6 @@ snapshots: transitivePeerDependencies: - react-native-b4a - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - thread-stream@4.0.0: - dependencies: - real-require: 0.2.0 - through@2.3.8: {} time-span@5.1.0: @@ -10587,8 +9941,6 @@ snapshots: tinybench@2.9.0: {} - tinyexec@0.3.2: {} - tinyexec@1.3.0: {} tinyglobby@0.2.15: @@ -10617,9 +9969,9 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 - tree-kill@1.2.2: {} - - ts-interface-checker@0.1.13: {} + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: @@ -10627,35 +9979,6 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(@swc/core@1.15.3)(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.2) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3(supports-color@8.1.1) - esbuild: 0.27.2 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0) - resolve-from: 5.0.0 - rollup: 4.57.1 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - optionalDependencies: - '@swc/core': 1.15.3 - postcss: 8.5.6 - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - tsx@4.21.0: dependencies: esbuild: 0.27.2 @@ -10704,6 +10027,17 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 + typescript-eslint@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} ufo@1.6.3: {} @@ -10804,8 +10138,6 @@ snapshots: dependencies: react: 19.2.4 - uuid@11.1.0: {} - vary@1.1.2: {} vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): From 30c91de3f688b9a81328e8a5fcd2ea0a6d789f1b Mon Sep 17 00:00:00 2001 From: Chris Rodrigues Date: Wed, 12 Aug 2026 21:31:02 -0700 Subject: [PATCH 27/35] fix: address task 13 review findings - Delete apps/web/railway.json and apps/web/railway.toml. Both were still tracked and pointed at files already deleted in this branch (apps/web/Dockerfile, the `pnpm start` script), left over because the brief's Step 2 only named the root railway.json. - Replace the em dashes in apps/web/public/sw.js and apps/web/public/theme-init.js with plain hyphens. These are served directly (registered as the service worker and loaded via a