From 943869b9123e1d65dc5dbaebaf37e3990faa5a64 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:08:12 +0700 Subject: [PATCH 01/20] docs: design DOS ID SSO bridge --- .../2026-08-14-crove-dos-id-sso-design.md | 192 ++++++++++++++++++ reports/crove-dos-id-sso-design.html | 110 ++++++++++ 2 files changed, 302 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md create mode 100644 reports/crove-dos-id-sso-design.html diff --git a/docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md b/docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md new file mode 100644 index 0000000000..de7b9c33a5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md @@ -0,0 +1,192 @@ +# Crove DOS ID SSO Design + +## Status + +Approved architecture direction: deploy a standalone OAuth compatibility bridge and keep the upstream Postiz image unchanged. + +## Goal + +Allow Crove users to sign in through DOS ID, backed by the existing Supabase OAuth 2.1 provider, without forking or rebuilding Postiz. + +## Constraints + +- Keep the upstream Postiz image and its Generic OAuth environment contract unchanged. +- Supabase OAuth 2.1 requires Authorization Code with PKCE. +- Postiz Generic OAuth does not currently send PKCE parameters. +- Preserve the existing Crove user, organization, integrations, and content. +- Prevent duplicate Postiz users during the identity transition. +- Disable local email registration only after DOS ID login is verified. +- Never log client secrets, authorization codes, access tokens, refresh tokens, or user claims. + +## Selected Architecture + +Deploy a Cloudflare Worker named `crove-sso` at `https://sso.crove.com` with a Durable Object binding named `OAUTH_STATE`. + +Postiz treats the bridge as its Generic OAuth provider. The bridge acts as an OAuth 2.1 confidential client of Supabase DOS ID and adds the missing PKCE behavior. + +### Components + +1. **Postiz downstream client** + - Authorization endpoint: `https://sso.crove.com/authorize` + - Token endpoint: `https://sso.crove.com/token` + - UserInfo endpoint: `https://sso.crove.com/userinfo` + - Redirect URI: `https://crove.com/settings` + - Uses a bridge-specific client ID and secret. + +2. **Cloudflare OAuth bridge** + - Validates every downstream OAuth parameter against exact allowlists. + - Generates an S256 PKCE verifier and challenge. + - Preserves the downstream state without exposing it to the upstream provider. + - Exchanges the Supabase authorization code server-side. + - Issues a short-lived, single-use bridge authorization code. + - Proxies UserInfo requests to Supabase. + +3. **Supabase DOS ID upstream provider** + - Authorization endpoint: `https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/authorize` + - Token endpoint: `https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/token` + - UserInfo endpoint: `https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/userinfo` + - Crove callback: `https://sso.crove.com/callback` + +4. **Durable Object state store** + - Stores authorization transactions for at most 10 minutes. + - Stores bridge authorization codes for at most 60 seconds. + - Atomically consumes state and authorization codes. + - Provides strong consistency and replay protection. + +## Endpoint Behavior + +### `GET /authorize` + +1. Require `response_type=code`. +2. Require the exact bridge client ID. +3. Require `redirect_uri=https://crove.com/settings`. +4. Restrict scope to `openid profile email`. +5. Generate a cryptographically random upstream state and PKCE verifier. +6. Persist the transaction with a 10-minute expiry. +7. Redirect to Supabase with `code_challenge_method=S256`. + +### `GET /callback` + +1. Require a valid, unexpired upstream state. +2. Consume the transaction atomically. +3. Exchange the Supabase code using the stored PKCE verifier and confidential client secret. +4. Generate a random one-time bridge code. +5. Store the upstream token response behind that bridge code for at most 60 seconds. +6. Redirect to the original Postiz redirect URI with the bridge code and original Postiz state. + +### `POST /token` + +1. Require `application/x-www-form-urlencoded`. +2. Require `grant_type=authorization_code`. +3. Authenticate the exact bridge client ID and secret using constant-time comparison. +4. Require the exact Postiz redirect URI. +5. Atomically consume the bridge code. +6. Return the upstream token response once. + +### `GET /userinfo` + +1. Require a Bearer access token. +2. Proxy the request to the Supabase UserInfo endpoint. +3. Return only the upstream response body and safe headers. +4. Never log the token or claims. + +### `GET /health` + +Return a static status document without checking secrets or external dependencies. + +## Security Invariants + +- Exact client ID, redirect URI, response type, grant type, and scope validation. +- S256 PKCE only. +- Random values use Web Crypto with at least 256 bits of entropy. +- State and bridge codes are single-use and expire quickly. +- OAuth errors never echo secrets or upstream response bodies that may contain tokens. +- Responses containing tokens use `Cache-Control: no-store` and `Pragma: no-cache`. +- Authorization and callback responses set a restrictive referrer policy. +- No permissive CORS headers are required. +- Structured logs contain request IDs, endpoint names, safe error codes, and latency only. + +## Secret Ownership + +### GCP Secret Manager + +- Existing Supabase client ID: `CROVE_POSTIZ_OAUTH_CLIENT_ID` +- Existing rotated Supabase client secret: `CROVE_POSTIZ_OAUTH_CLIENT_SECRET` +- New Postiz-to-bridge client ID: `CROVE_SSO_BRIDGE_CLIENT_ID` +- New Postiz-to-bridge client secret: `CROVE_SSO_BRIDGE_CLIENT_SECRET` + +### Cloudflare Worker Secrets + +- `UPSTREAM_CLIENT_ID` +- `UPSTREAM_CLIENT_SECRET` +- `DOWNSTREAM_CLIENT_ID` +- `DOWNSTREAM_CLIENT_SECRET` + +No secret values are committed to Git or stored in Wrangler configuration. + +## Identity Migration + +Before enabling the DOS ID button publicly: + +1. Complete a controlled DOS ID authorization and verify the returned email is exactly `joy@dos.ai`. +2. Capture the verified DOS ID `sub` without logging the access token. +3. Back up the exact existing Postiz user row and related organization membership identifiers. +4. Assert there is exactly one existing `LOCAL` user for `joy@dos.ai` and no conflicting `GENERIC` identity. +5. Update only that row to `providerName=GENERIC` and `providerId=` inside a database transaction. +6. Verify the same user ID, organization, integrations, and content counts remain unchanged. + +If any precondition fails, stop without mutating the identity. + +## Deployment + +- Worker name: `crove-sso` +- Custom domain: `sso.crove.com` +- Compatibility date: `2026-08-14` +- Compatibility flag: `nodejs_compat` +- Observability: enabled with structured logs +- Durable Object migration tag: `v1` +- Durable Object class: `OAuthStateStore` + +The custom domain is managed by Wrangler. No separate DNS record is required. + +## Test Strategy + +### Unit and integration tests + +- Reject unknown client IDs. +- Reject non-allowlisted redirect URIs. +- Reject unsupported response and grant types. +- Generate a correct S256 PKCE challenge. +- Preserve downstream state through the callback. +- Reject missing, expired, and replayed state. +- Reject missing, expired, and replayed bridge codes. +- Reject invalid downstream client secrets. +- Return upstream token responses only once. +- Proxy UserInfo without logging Bearer tokens. +- Add no-store headers to sensitive responses. + +Tests follow red-green-refactor with Vitest and the Cloudflare Workers test pool. + +### Deployment verification + +1. Verify `/health` on the deployed custom domain. +2. Verify invalid OAuth requests fail closed. +3. Verify the full DOS ID login flow with Playwright. +4. Verify logout and login again. +5. Verify the original Postiz user ID, organization, channels, and content remain intact. +6. Set `DISABLE_REGISTRATION=true`. +7. Verify local email registration is unavailable while DOS ID login remains available. + +## Rollback + +1. Clear `POSTIZ_GENERIC_OAUTH` in Postiz production and recreate the container. +2. Confirm the DOS ID button is absent and the existing authenticated session remains unaffected. +3. Keep the Worker deployed but unreachable from Postiz for investigation, or remove its custom domain if required. +4. Restore the exact backed-up user identity fields only if the identity migration had completed and DOS ID login cannot be recovered. + +## Out of Scope + +- Modifying or rebuilding Postiz source code. +- Replacing Supabase DOS ID. +- Adding schema or DDL to the shared production Supabase project. +- Generalizing the bridge for arbitrary third-party clients. diff --git a/reports/crove-dos-id-sso-design.html b/reports/crove-dos-id-sso-design.html new file mode 100644 index 0000000000..5e8f0be48b --- /dev/null +++ b/reports/crove-dos-id-sso-design.html @@ -0,0 +1,110 @@ + + + + + + + Thiết kế SSO DOS ID cho Crove + + + +
+
+ Thiết kế đã chọn +

SSO DOS ID cho Crove

+

Giữ nguyên image Postiz upstream, bổ sung bridge OAuth 2.1 có PKCE tại sso.crove.com.

+
+ +
+ Quyết định: Không fork, không patch và không build lại Postiz. Một Cloudflare Worker nhỏ sẽ thêm PKCE giữa Postiz Generic OAuth và Supabase DOS ID. +
+ +

Vì sao cần bridge?

+
+
Postiz

Hỗ trợ Generic OAuth qua env nhưng không gửi code_challenge.

+
Supabase DOS ID

OAuth 2.1 bắt buộc Authorization Code với PKCE S256.

+
Bridge

Bổ sung PKCE và vẫn giữ nguyên contract OAuth mà Postiz đang dùng.

+
+ +

Luồng xác thực

+
+
Crove / PostizGeneric OAuth client
+
+
sso.crove.comPKCE bridge + Durable Object
+
+
DOS IDSupabase OAuth 2.1
+
+
    +
  1. Postiz gọi /authorize trên bridge.
  2. +
  3. Bridge kiểm tra chính xác client, callback và scope, sau đó tạo PKCE S256.
  4. +
  5. DOS ID xác thực người dùng và callback về bridge.
  6. +
  7. Bridge đổi code bằng PKCE, phát hành một code ngắn hạn chỉ dùng một lần cho Postiz.
  8. +
  9. Postiz đổi code tại /token và đọc danh tính tại /userinfo.
  10. +
+ +

Biên bảo mật

+
+
Callback allowlist tuyệt đối, không có open redirect.
+
State sống tối đa 10 phút và chỉ dùng một lần.
+
Bridge code sống tối đa 60 giây và chỉ dùng một lần.
+
Durable Object cung cấp lưu trữ nhất quán mạnh để chống replay.
+
Token response dùng Cache-Control: no-store.
+
Log không chứa secret, code, token hay claim người dùng.
+
+ +

Giữ nguyên account hiện tại

+

Trước khi bật nút DOS ID công khai, hệ thống sẽ xác minh DOS ID trả về đúng email joy@dos.ai và lấy sub đã xác thực. Sau đó:

+
    +
  1. Backup chính xác user hiện tại cùng membership của organization.
  2. +
  3. Xác nhận chỉ có một user LOCAL và chưa có identity GENERIC xung đột.
  4. +
  5. Đổi credential trên đúng user đó trong transaction.
  6. +
  7. Đối chiếu user ID, organization, channels và content trước và sau.
  8. +
+

Fail-closed: Nếu bất kỳ điều kiện nào không đúng, không sửa dữ liệu.

+ +

Triển khai và rollback

+
+
Deploy

Worker crove-sso, custom domain sso.crove.com, Durable Object OAuthStateStore, observability bật.

+
Secrets

Supabase upstream credentials và bridge downstream credentials tách biệt, không commit vào Git.

+
Rollback

Xóa giá trị POSTIZ_GENERIC_OAUTH và recreate container để ẩn DOS ID ngay, không ảnh hưởng session hiện tại.

+
+ +

Điều kiện hoàn thành

+ + +
Nguồn kỹ thuật chi tiết: docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md
+
+ + From e75182a373ab9107f6f32f65ca527c4970998388 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:21:46 +0700 Subject: [PATCH 02/20] docs: plan Crove DOS ID SSO rollout --- .../plans/2026-08-14-crove-dos-id-sso.md | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-14-crove-dos-id-sso.md diff --git a/docs/superpowers/plans/2026-08-14-crove-dos-id-sso.md b/docs/superpowers/plans/2026-08-14-crove-dos-id-sso.md new file mode 100644 index 0000000000..f8ab31294e --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-crove-dos-id-sso.md @@ -0,0 +1,325 @@ +# Crove DOS ID SSO 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:** Deploy a PKCE compatibility bridge at `sso.crove.com` so the upstream Postiz image can use Supabase DOS ID for SSO without creating a duplicate Crove user. + +**Architecture:** A Cloudflare Worker exposes the OAuth endpoints expected by Postiz and acts as a confidential OAuth 2.1 client of Supabase. A Durable Object provides strongly consistent, expiring, single-use state and authorization-code storage. Production activation happens only after the verified DOS ID subject is bound to the existing Postiz user with exact database preconditions. + +**Tech Stack:** TypeScript 5.5.4, Cloudflare Workers, Durable Objects, Wrangler 4.123.0, Vitest 4.1.10, `@cloudflare/vitest-pool-workers` 0.21.3, Supabase OAuth 2.1, GCP Secret Manager, Docker Compose, PostgreSQL. + +## Global Constraints + +- Keep the upstream Postiz image and Generic OAuth environment contract unchanged. +- Use `https://sso.crove.com` as the bridge origin and `https://crove.com/settings` as the only downstream redirect URI. +- Use S256 PKCE and exact allowlists for client ID, redirect URI, response type, grant type, and scope. +- State expires after 600 seconds; bridge authorization codes expire after 60 seconds; both are single-use. +- Never log client secrets, authorization codes, access tokens, refresh tokens, or user claims. +- Never commit secret values to Git or Wrangler configuration. +- Do not apply schema or DDL to the shared production Supabase project. +- Preserve the existing Postiz user ID, organization membership, 15 integrations, and 10 posts. +- Enable `DISABLE_REGISTRATION=true` only after SSO login is verified. +- Use PowerShell 7 for local shell commands. + +--- + +### Task 1: Worker package and OAuth request validation + +**Files:** +- Create: `apps/crove-sso/package.json` +- Create: `apps/crove-sso/tsconfig.json` +- Create: `apps/crove-sso/wrangler.jsonc` +- Create: `apps/crove-sso/vitest.config.ts` +- Create: `apps/crove-sso/src/env.ts` +- Create: `apps/crove-sso/src/oauth.ts` +- Create: `apps/crove-sso/test/oauth.test.ts` +- Modify: `pnpm-workspace.yaml` +- Modify: `pnpm-lock.yaml` + +**Interfaces:** +- Produces: `parseAuthorizeRequest(request: Request, env: Env): AuthorizeRequest` +- Produces: `parseTokenRequest(request: Request, env: Env): Promise` +- Produces: `createPkce(): Promise<{ verifier: string; challenge: string }>` +- Produces: `oauthError(error: string, description: string, status?: number): Response` + +- [ ] **Step 1: Add the package manifest and test configuration** + +Create a workspace package with scripts `test`, `typecheck`, `cf-typegen`, and `deploy`. Pin Wrangler 4.123.0, Vitest 4.1.10, the Workers test pool 0.21.3, and TypeScript 5.5.4. Configure `cloudflareTest()` to load `wrangler.jsonc`. + +- [ ] **Step 2: Write failing authorization-validation tests** + +Add tests that call `parseAuthorizeRequest()` and prove it rejects an unknown client ID, a callback other than `https://crove.com/settings`, a response type other than `code`, and a scope outside `openid profile email`. + +- [ ] **Step 3: Run the targeted tests and verify RED** + +Run: `pnpm.cmd --filter @crove/sso test -- test/oauth.test.ts` + +Expected: FAIL because `src/oauth.ts` does not exist. + +- [ ] **Step 4: Implement minimal authorization validation** + +Define `Env` with all Worker secrets and the Durable Object binding. Parse query parameters with `URL`, compare exact strings, and return safe OAuth errors without echoing values. + +- [ ] **Step 5: Add and verify token-validation tests** + +Test form content type, `grant_type=authorization_code`, exact downstream client ID and redirect URI, missing code, and invalid client secret. Use a fixed-length byte comparison implemented with Web Crypto-compatible primitives. + +- [ ] **Step 6: Add and verify the PKCE test** + +Generate a verifier from 32 random bytes, compute SHA-256, encode with base64url without padding, and assert the challenge matches an independent digest of the returned verifier. + +- [ ] **Step 7: Run tests and typecheck** + +Run: + +```powershell +pnpm.cmd --filter @crove/sso test +pnpm.cmd --filter @crove/sso typecheck +``` + +Expected: all tests pass and TypeScript exits 0. + +- [ ] **Step 8: Commit** + +```powershell +git add pnpm-workspace.yaml pnpm-lock.yaml apps/crove-sso +git commit -m "feat: validate Crove SSO OAuth requests" +``` + +### Task 2: Durable Object state and single-use code storage + +**Files:** +- Create: `apps/crove-sso/src/state-store.ts` +- Create: `apps/crove-sso/test/state-store.test.ts` +- Modify: `apps/crove-sso/src/env.ts` +- Modify: `apps/crove-sso/wrangler.jsonc` + +**Interfaces:** +- Consumes: `Env.OAUTH_STATE: DurableObjectNamespace` +- Produces: `OAuthStateStore.fetch()` private operations for `put`, `take`, and `delete-expired` +- Produces: `putState(env, key, value, ttlSeconds): Promise` +- Produces: `takeState(env, key): Promise` + +- [ ] **Step 1: Write failing Durable Object tests** + +Test that a stored value can be taken exactly once, a replay returns null, and an expired value returns null. Use the Workers Vitest integration with a real Durable Object binding. + +- [ ] **Step 2: Run the targeted tests and verify RED** + +Run: `pnpm.cmd --filter @crove/sso test -- test/state-store.test.ts` + +Expected: FAIL because `OAuthStateStore` is missing. + +- [ ] **Step 3: Implement the strongly consistent store** + +Use Durable Object SQLite storage with a single table created in `blockConcurrencyWhile()`. Store JSON, absolute expiry milliseconds, and delete on atomic take. Return 404 for missing and expired keys. + +- [ ] **Step 4: Add eviction and concurrency coverage** + +Use `evictDurableObject()` to prove persisted state survives eviction. Start two parallel `takeState()` calls and assert exactly one receives the value. + +- [ ] **Step 5: Run tests and typecheck** + +Run: + +```powershell +pnpm.cmd --filter @crove/sso test +pnpm.cmd --filter @crove/sso typecheck +``` + +Expected: all tests pass and TypeScript exits 0. + +- [ ] **Step 6: Commit** + +```powershell +git add apps/crove-sso +git commit -m "feat: add single-use OAuth state store" +``` + +### Task 3: End-to-end bridge endpoints + +**Files:** +- Create: `apps/crove-sso/src/index.ts` +- Create: `apps/crove-sso/test/worker.test.ts` +- Modify: `apps/crove-sso/src/oauth.ts` +- Modify: `apps/crove-sso/src/state-store.ts` +- Modify: `apps/crove-sso/wrangler.jsonc` + +**Interfaces:** +- Consumes: the validation, PKCE, and state-store interfaces from Tasks 1 and 2 +- Produces: Worker routes `GET /health`, `GET /authorize`, `GET /callback`, `POST /token`, and `GET /userinfo` + +- [ ] **Step 1: Write failing `/health` and `/authorize` integration tests** + +Call `exports.default.fetch()` in the Workers runtime. Assert `/health` returns a static JSON response. Assert `/authorize` redirects to Supabase with exact client ID, callback, scope, random state, `code_challenge_method=S256`, and no downstream state exposed upstream. + +- [ ] **Step 2: Run the targeted tests and verify RED** + +Run: `pnpm.cmd --filter @crove/sso test -- test/worker.test.ts` + +Expected: FAIL because `src/index.ts` does not exist. + +- [ ] **Step 3: Implement `/health` and `/authorize`** + +Route by exact method and pathname. Persist the downstream request, generated verifier, and downstream state for 600 seconds before redirecting to Supabase. + +- [ ] **Step 4: Write failing `/callback` tests** + +Mock only the upstream Supabase token endpoint. Prove callback consumes state, posts the stored PKCE verifier with `client_secret_post`, creates a one-time bridge code, preserves the original downstream state, adds `Referrer-Policy: no-referrer`, and rejects missing or replayed upstream state. + +- [ ] **Step 5: Implement `/callback`** + +Exchange the upstream code, retain the token response only behind a 60-second random bridge code, and redirect to `https://crove.com/settings`. On upstream error, return a safe `temporarily_unavailable` response without including the upstream body. + +- [ ] **Step 6: Write failing `/token` tests** + +Prove exact client authentication, exact redirect URI, single-use bridge code consumption, replay rejection, and `Cache-Control: no-store` plus `Pragma: no-cache` on every token response. + +- [ ] **Step 7: Implement `/token`** + +Consume the bridge code atomically and return the stored upstream JSON response once. Never refresh or transform upstream tokens. + +- [ ] **Step 8: Write failing `/userinfo` tests** + +Mock only the Supabase UserInfo endpoint. Prove the bridge requires a Bearer token, forwards it without logging, preserves safe status and JSON body, and adds `Cache-Control: no-store`. + +- [ ] **Step 9: Implement `/userinfo` and safe structured logging** + +Log only request ID, endpoint, HTTP status, safe error code, and duration. Do not log URLs with query strings, request bodies, authorization headers, token bodies, or claims. + +- [ ] **Step 10: Run the full quality gate** + +Run: + +```powershell +pnpm.cmd --filter @crove/sso test +pnpm.cmd --filter @crove/sso typecheck +pnpm.cmd --filter @crove/sso cf-typegen +git diff --check +``` + +Expected: all tests pass, type generation and typecheck exit 0, and no whitespace errors exist. + +- [ ] **Step 11: Commit** + +```powershell +git add apps/crove-sso pnpm-lock.yaml +git commit -m "feat: bridge Postiz OAuth to DOS ID" +``` + +### Task 4: Cloudflare deployment and Supabase callback activation + +**Files:** +- Modify: `apps/crove-sso/wrangler.jsonc` +- Modify: `D:\Projects\Crove\.env` locally without committing secret values +- Modify externally: GCP Secret Manager, Cloudflare Worker, Supabase OAuth client, and `/opt/crove/.env` + +**Interfaces:** +- Consumes: GCP secrets `CROVE_POSTIZ_OAUTH_CLIENT_ID` and `CROVE_POSTIZ_OAUTH_CLIENT_SECRET` +- Produces: GCP secrets `CROVE_SSO_BRIDGE_CLIENT_ID` and `CROVE_SSO_BRIDGE_CLIENT_SECRET` +- Produces: deployed origin `https://sso.crove.com` + +- [ ] **Step 1: Generate and store downstream credentials** + +Generate a public client ID and a 256-bit client secret with Web Crypto. Add them to GCP Secret Manager over stdin without printing values. + +- [ ] **Step 2: Configure Cloudflare secrets** + +Copy the upstream and downstream credentials from GCP Secret Manager directly into Worker secrets named `UPSTREAM_CLIENT_ID`, `UPSTREAM_CLIENT_SECRET`, `DOWNSTREAM_CLIENT_ID`, and `DOWNSTREAM_CLIENT_SECRET` without writing plaintext temp files. + +- [ ] **Step 3: Deploy Worker and custom domain** + +Deploy Worker `crove-sso` with compatibility date `2026-08-14`, `nodejs_compat`, Durable Object migration `v1`, observability enabled, and custom domain `sso.crove.com`. + +- [ ] **Step 4: Verify deployed fail-closed behavior** + +Verify `/health` returns 200. Verify unknown client, wrong callback, wrong response type, missing code, and unauthenticated token requests fail with the documented OAuth errors. Confirm no secret values occur in recent Worker logs. + +- [ ] **Step 5: Update Supabase OAuth client** + +Using the official Supabase OAuth Admin API with the service-role key from GCP Secret Manager, update Crove client `18790ccb-4d71-48cd-ad24-aee5f3ced3da` to redirect only to `https://sso.crove.com/callback`. Read the client back and assert the exact callback, name, grant types, and authentication method. + +- [ ] **Step 6: Configure Postiz production endpoints but keep SSO hidden** + +Create a root-owned 0600 backup of `/opt/crove/.env`. Set the Postiz OAuth URLs to bridge endpoints and its client credentials to the downstream bridge credentials. Keep `POSTIZ_GENERIC_OAUTH` empty until identity binding finishes. + +- [ ] **Step 7: Commit deployment configuration** + +```powershell +git add apps/crove-sso/wrangler.jsonc +git commit -m "chore: configure Crove SSO deployment" +``` + +### Task 5: Verified DOS ID identity binding + +**Files:** +- Create externally: root-owned PostgreSQL backup under `/opt/crove/backups/` +- Modify externally: one exact `User` row in the Postiz production database + +**Interfaces:** +- Consumes: verified DOS ID `sub` and email from Supabase UserInfo +- Produces: existing user `c5c577f6-4aef-491a-8f6a-6b975f8b9678` bound to provider `GENERIC` + +- [ ] **Step 1: Obtain a verified upstream identity** + +Run the bridge authorization flow in Chrome. Confirm the visible DOS ID account is `joy@dos.ai`. Complete consent, exchange the returned bridge code through the bridge token endpoint, call UserInfo, and retain only `{sub,email}` in memory. Do not log or persist the access token. + +- [ ] **Step 2: Re-run exact database preconditions** + +Assert in one read-only query: one user for `joy@dos.ai`, provider `LOCAL`, empty provider ID, zero `GENERIC` conflicts, one membership to organization `314a2673-b9c3-49d6-b916-6836513381c0`, 15 integrations, and 10 posts. + +- [ ] **Step 3: Create a minimal identity backup** + +Export the exact user row and membership rows with `pg_dump --data-only --column-inserts` to a root-owned 0600 file. Verify its checksum and nonzero size without printing password or provider tokens. + +- [ ] **Step 4: Bind the identity transactionally** + +Inside one transaction, lock the exact user row, repeat every precondition, update only `providerName='GENERIC'`, `providerId=`, and `updatedAt=now()`, then require exactly one affected row. Roll back automatically if any assertion fails. + +- [ ] **Step 5: Verify preservation** + +Assert the same user ID, email, membership, organization ID, integration count, and post count. Assert there is one `GENERIC` identity for the verified subject and no remaining `LOCAL` identity for that email. + +### Task 6: Enable SSO, disable local registration, and verify production + +**Files:** +- Modify externally: `/opt/crove/.env` + +**Interfaces:** +- Produces: visible DOS ID SSO on `https://crove.com/auth` +- Produces: local registration disabled while Generic OAuth remains available + +- [ ] **Step 1: Activate DOS ID and recreate Postiz** + +Set `POSTIZ_GENERIC_OAUTH=true` and `DISABLE_REGISTRATION=true` in `/opt/crove/.env`, recreate only the Postiz application container, and wait for its health check to pass. + +- [ ] **Step 2: Verify unauthenticated UI with Playwright** + +Assert the auth page shows `DOS ID`, does not offer local registration, and clicking DOS ID redirects through `sso.crove.com` to the Supabase DOS ID consent flow. + +- [ ] **Step 3: Verify positive login** + +Complete DOS ID login and assert Crove opens the existing organization. Verify the UI exposes the previously connected channels and existing posts rather than onboarding a new organization. + +- [ ] **Step 4: Verify logout and login again** + +Log out, repeat DOS ID login, and assert the same Postiz user ID and organization are used. + +- [ ] **Step 5: Verify negative registration behavior** + +Call `/auth/can-register` and assert local registration is false. Attempt a local email registration request and assert it fails with `Registration is disabled`. Confirm DOS ID login still succeeds afterward. + +- [ ] **Step 6: Inspect health and logs** + +Assert the Postiz container is healthy, `https://crove.com` returns a successful response, Worker logs contain no secrets or tokens, and there are no OAuth error spikes from the verified run. + +- [ ] **Step 7: Run completion audit** + +Re-read the approved design and this plan. Map every goal and invariant to fresh test, runtime, database, and browser evidence. Do not declare completion if any item lacks direct evidence. + +- [ ] **Step 8: Commit final operational documentation changes if any** + +```powershell +git add apps/crove-sso docs/superpowers +git commit -m "docs: record Crove DOS ID SSO rollout" +``` From 153328d38f9e65ac9f8da9a071b9cc276c1ce461 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:33:02 +0700 Subject: [PATCH 03/20] feat: validate Crove SSO OAuth requests --- apps/crove-sso/package.json | 18 + apps/crove-sso/src/env.ts | 13 + apps/crove-sso/src/index.ts | 5 + apps/crove-sso/src/oauth.ts | 118 ++++ apps/crove-sso/test/oauth.test.ts | 112 +++ apps/crove-sso/tsconfig.json | 13 + apps/crove-sso/vitest.config.ts | 13 + apps/crove-sso/wrangler.jsonc | 18 + pnpm-lock.yaml | 1085 ++++++++++++++++++++++++++++- 9 files changed, 1383 insertions(+), 12 deletions(-) create mode 100644 apps/crove-sso/package.json create mode 100644 apps/crove-sso/src/env.ts create mode 100644 apps/crove-sso/src/index.ts create mode 100644 apps/crove-sso/src/oauth.ts create mode 100644 apps/crove-sso/test/oauth.test.ts create mode 100644 apps/crove-sso/tsconfig.json create mode 100644 apps/crove-sso/vitest.config.ts create mode 100644 apps/crove-sso/wrangler.jsonc diff --git a/apps/crove-sso/package.json b/apps/crove-sso/package.json new file mode 100644 index 0000000000..b8ea03910a --- /dev/null +++ b/apps/crove-sso/package.json @@ -0,0 +1,18 @@ +{ + "name": "@crove/sso", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "0.21.3", + "@cloudflare/workers-types": "5.20260813.1", + "typescript": "5.5.4", + "vitest": "4.1.10", + "wrangler": "4.123.0" + } +} diff --git a/apps/crove-sso/src/env.ts b/apps/crove-sso/src/env.ts new file mode 100644 index 0000000000..eb7e6214ae --- /dev/null +++ b/apps/crove-sso/src/env.ts @@ -0,0 +1,13 @@ +export interface Env { + OAUTH_STATE: DurableObjectNamespace; + UPSTREAM_AUTHORIZE_URL: string; + UPSTREAM_TOKEN_URL: string; + UPSTREAM_USERINFO_URL: string; + UPSTREAM_REDIRECT_URI: string; + UPSTREAM_CLIENT_ID: string; + UPSTREAM_CLIENT_SECRET: string; + DOWNSTREAM_REDIRECT_URI: string; + DOWNSTREAM_CLIENT_ID: string; + DOWNSTREAM_CLIENT_SECRET: string; + ALLOWED_SCOPE: string; +} diff --git a/apps/crove-sso/src/index.ts b/apps/crove-sso/src/index.ts new file mode 100644 index 0000000000..473d2e756b --- /dev/null +++ b/apps/crove-sso/src/index.ts @@ -0,0 +1,5 @@ +export default { + fetch() { + return new Response('Not found', { status: 404 }); + }, +}; diff --git a/apps/crove-sso/src/oauth.ts b/apps/crove-sso/src/oauth.ts new file mode 100644 index 0000000000..444099f624 --- /dev/null +++ b/apps/crove-sso/src/oauth.ts @@ -0,0 +1,118 @@ +import type { Env } from './env'; + +export interface AuthorizeRequest { + redirectUri: string; + scope: string; + state?: string; +} + +export interface TokenRequest { + code: string; +} + +export class OAuthRequestError extends Error { + constructor( + readonly code: string, + readonly status = 400, + ) { + super(code); + this.name = 'OAuthRequestError'; + } +} + +function base64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +async function constantTimeEqual(actual: string, expected: string): Promise { + const encoder = new TextEncoder(); + const [actualDigest, expectedDigest] = await Promise.all([ + crypto.subtle.digest('SHA-256', encoder.encode(actual)), + crypto.subtle.digest('SHA-256', encoder.encode(expected)), + ]); + const actualBytes = new Uint8Array(actualDigest); + const expectedBytes = new Uint8Array(expectedDigest); + let difference = 0; + for (let index = 0; index < actualBytes.length; index += 1) { + difference |= actualBytes[index] ^ expectedBytes[index]; + } + return difference === 0; +} + +export function parseAuthorizeRequest(request: Request, env: Env): AuthorizeRequest { + const params = new URL(request.url).searchParams; + if (params.get('client_id') !== env.DOWNSTREAM_CLIENT_ID) { + throw new OAuthRequestError('invalid_client', 401); + } + if (params.get('redirect_uri') !== env.DOWNSTREAM_REDIRECT_URI) { + throw new OAuthRequestError('invalid_request'); + } + if (params.get('response_type') !== 'code') { + throw new OAuthRequestError('unsupported_response_type'); + } + if (params.get('scope') !== env.ALLOWED_SCOPE) { + throw new OAuthRequestError('invalid_scope'); + } + + return { + redirectUri: env.DOWNSTREAM_REDIRECT_URI, + scope: env.ALLOWED_SCOPE, + state: params.get('state') || undefined, + }; +} + +export async function createPkce(): Promise<{ verifier: string; challenge: string }> { + const entropy = crypto.getRandomValues(new Uint8Array(32)); + const verifier = base64Url(entropy); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return { verifier, challenge: base64Url(new Uint8Array(digest)) }; +} + +export async function parseTokenRequest(request: Request, env: Env): Promise { + const contentType = request.headers.get('content-type')?.toLowerCase() || ''; + if (!contentType.startsWith('application/x-www-form-urlencoded')) { + throw new OAuthRequestError('invalid_request'); + } + + const form = await request.formData(); + const params = new URLSearchParams(); + for (const [key, value] of form.entries()) { + if (typeof value === 'string') params.append(key, value); + } + if (params.get('grant_type') !== 'authorization_code') { + throw new OAuthRequestError('unsupported_grant_type'); + } + const clientIdMatches = params.get('client_id') === env.DOWNSTREAM_CLIENT_ID; + const secretMatches = await constantTimeEqual( + params.get('client_secret') || '', + env.DOWNSTREAM_CLIENT_SECRET, + ); + if (!clientIdMatches || !secretMatches) { + throw new OAuthRequestError('invalid_client', 401); + } + if (params.get('redirect_uri') !== env.DOWNSTREAM_REDIRECT_URI) { + throw new OAuthRequestError('invalid_grant'); + } + const code = params.get('code'); + if (!code) { + throw new OAuthRequestError('invalid_grant'); + } + + return { code }; +} + +export function oauthError(error: string, description: string, status = 400): Response { + return Response.json( + { error, error_description: description }, + { + status, + headers: { + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + }, + }, + ); +} diff --git a/apps/crove-sso/test/oauth.test.ts b/apps/crove-sso/test/oauth.test.ts new file mode 100644 index 0000000000..5f3837814f --- /dev/null +++ b/apps/crove-sso/test/oauth.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; + +import type { Env } from '../src/env'; +import { + createPkce, + oauthError, + parseAuthorizeRequest, + parseTokenRequest, +} from '../src/oauth'; + +const env = { + DOWNSTREAM_CLIENT_ID: 'crove-postiz', + DOWNSTREAM_CLIENT_SECRET: 'bridge-secret-value', + DOWNSTREAM_REDIRECT_URI: 'https://crove.com/settings', + ALLOWED_SCOPE: 'openid profile email', +} as Env; + +function authorizeUrl(overrides: Record = {}) { + const params = new URLSearchParams({ + client_id: env.DOWNSTREAM_CLIENT_ID, + redirect_uri: env.DOWNSTREAM_REDIRECT_URI, + response_type: 'code', + scope: env.ALLOWED_SCOPE, + state: 'downstream-state', + ...overrides, + }); + return new Request(`https://sso.crove.com/authorize?${params}`); +} + +describe('parseAuthorizeRequest', () => { + it('accepts the exact Postiz authorization request', () => { + expect(parseAuthorizeRequest(authorizeUrl(), env)).toEqual({ + redirectUri: env.DOWNSTREAM_REDIRECT_URI, + scope: env.ALLOWED_SCOPE, + state: 'downstream-state', + }); + }); + + it.each([ + ['unknown client', { client_id: 'unknown' }, 'invalid_client'], + ['wrong redirect', { redirect_uri: 'https://evil.example/callback' }, 'invalid_request'], + ['wrong response type', { response_type: 'token' }, 'unsupported_response_type'], + ['extra scope', { scope: 'openid profile email admin' }, 'invalid_scope'], + ])('rejects %s', (_name, overrides, code) => { + expect(() => parseAuthorizeRequest(authorizeUrl(overrides), env)).toThrow(code); + }); +}); + +describe('createPkce', () => { + it('returns a valid S256 verifier and challenge', async () => { + const { verifier, challenge } = await createPkce(); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + const expected = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(challenge).toBe(expected); + }); +}); + +function tokenRequest(overrides: Record = {}, contentType = 'application/x-www-form-urlencoded') { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: env.DOWNSTREAM_CLIENT_ID, + client_secret: env.DOWNSTREAM_CLIENT_SECRET, + redirect_uri: env.DOWNSTREAM_REDIRECT_URI, + code: 'single-use-code', + ...overrides, + }); + return new Request('https://sso.crove.com/token', { + method: 'POST', + headers: { 'Content-Type': contentType }, + body, + }); +} + +describe('parseTokenRequest', () => { + it('accepts the exact Postiz token request', async () => { + await expect(parseTokenRequest(tokenRequest(), env)).resolves.toEqual({ code: 'single-use-code' }); + }); + + it.each([ + ['wrong grant type', { grant_type: 'refresh_token' }, 'unsupported_grant_type'], + ['unknown client', { client_id: 'unknown' }, 'invalid_client'], + ['invalid client secret', { client_secret: 'wrong-secret' }, 'invalid_client'], + ['wrong redirect', { redirect_uri: 'https://evil.example/callback' }, 'invalid_grant'], + ['missing code', { code: '' }, 'invalid_grant'], + ])('rejects %s', async (_name, overrides, code) => { + await expect(parseTokenRequest(tokenRequest(overrides), env)).rejects.toThrow(code); + }); + + it('rejects a non-form request', async () => { + await expect(parseTokenRequest(tokenRequest({}, 'application/json'), env)).rejects.toThrow( + 'invalid_request', + ); + }); +}); + +describe('oauthError', () => { + it('returns a safe no-store OAuth response', async () => { + const response = oauthError('invalid_grant', 'Authorization code is invalid', 400); + expect(response.status).toBe(400); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + await expect(response.json()).resolves.toEqual({ + error: 'invalid_grant', + error_description: 'Authorization code is invalid', + }); + }); +}); diff --git a/apps/crove-sso/tsconfig.json b/apps/crove-sso/tsconfig.json new file mode 100644 index 0000000000..4362958a76 --- /dev/null +++ b/apps/crove-sso/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "WebWorker"], + "types": ["@cloudflare/workers-types", "vitest/globals"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/apps/crove-sso/vitest.config.ts b/apps/crove-sso/vitest.config.ts new file mode 100644 index 0000000000..535c11a281 --- /dev/null +++ b/apps/crove-sso/vitest.config.ts @@ -0,0 +1,13 @@ +import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: './wrangler.jsonc' }, + }), + ], + test: { + globals: true, + }, +}); diff --git a/apps/crove-sso/wrangler.jsonc b/apps/crove-sso/wrangler.jsonc new file mode 100644 index 0000000000..0740167cef --- /dev/null +++ b/apps/crove-sso/wrangler.jsonc @@ -0,0 +1,18 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "crove-sso", + "main": "src/index.ts", + "compatibility_date": "2026-08-13", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + "vars": { + "UPSTREAM_AUTHORIZE_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/authorize", + "UPSTREAM_TOKEN_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/token", + "UPSTREAM_USERINFO_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/userinfo", + "UPSTREAM_REDIRECT_URI": "https://sso.crove.com/callback", + "DOWNSTREAM_REDIRECT_URI": "https://crove.com/settings", + "ALLOWED_SCOPE": "openid profile email" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c73e6edb0e..bf06c080fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -829,6 +829,24 @@ importers: apps/commands: {} + apps/crove-sso: + devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: 0.21.3 + version: 0.21.3(@cloudflare/workers-types@5.20260813.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(@vitest/coverage-v8@1.6.0(vitest@3.1.4))(@vitest/ui@1.6.0)(happy-dom@15.11.7)(jsdom@29.1.0(@noble/hashes@2.0.1)(canvas@2.11.2))(vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3))) + '@cloudflare/workers-types': + specifier: 5.20260813.1 + version: 5.20260813.1 + typescript: + specifier: 5.5.4 + version: 5.5.4 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(@vitest/coverage-v8@1.6.0(vitest@3.1.4))(@vitest/ui@1.6.0)(happy-dom@15.11.7)(jsdom@29.1.0(@noble/hashes@2.0.1)(canvas@2.11.2))(vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3)) + wrangler: + specifier: 4.123.0 + version: 4.123.0(@cloudflare/workers-types@5.20260813.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + apps/extension: {} apps/frontend: {} @@ -1986,6 +2004,59 @@ packages: '@clack/prompts@1.2.0': resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vitest-pool-workers@0.21.3': + resolution: {integrity: sha512-jCoGRQ6FlsP5adp1GJISvITOGdTHTpsWOq3KkSGIfeDY/5RKjTUagDwaXjpqBXY5gAk6id/QbgnGuCTAg6lSTQ==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + + '@cloudflare/workerd-darwin-64@1.20260811.1': + resolution: {integrity: sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260811.1': + resolution: {integrity: sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260811.1': + resolution: {integrity: sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260811.1': + resolution: {integrity: sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260811.1': + resolution: {integrity: sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20260813.1': + resolution: {integrity: sha512-RQNfm7xD10hNHEQZFxQPmyGMJ9+aDGPcdFZ0x1LtmjRoLFcgZkGvfaqJAbOMQBAUFSESO3bYJS4p9mOLv28Ihg==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -2086,6 +2157,9 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -2182,6 +2256,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -2200,6 +2280,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -2218,6 +2304,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -2236,6 +2328,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -2254,6 +2352,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -2272,6 +2376,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -2290,6 +2400,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -2308,6 +2424,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -2326,6 +2448,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -2344,6 +2472,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -2362,6 +2496,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -2380,6 +2520,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -2398,6 +2544,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -2416,6 +2568,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -2434,6 +2592,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -2452,6 +2616,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -2470,6 +2640,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -2488,6 +2664,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -2506,6 +2688,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -2524,6 +2712,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -2542,6 +2736,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -2560,6 +2760,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -2578,6 +2784,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -2596,6 +2808,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -2614,6 +2832,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -2632,6 +2856,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + 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} @@ -2855,6 +3085,12 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.33.5': resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2867,6 +3103,17 @@ packages: cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.0.4': resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} cpu: [arm64] @@ -2877,6 +3124,11 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.0.4': resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} cpu: [x64] @@ -2887,6 +3139,11 @@ packages: cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.0.4': resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] @@ -2897,6 +3154,11 @@ packages: cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] @@ -2907,16 +3169,31 @@ packages: cpu: [arm] os: [linux] + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] @@ -2927,6 +3204,11 @@ packages: cpu: [s390x] os: [linux] + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] @@ -2937,6 +3219,11 @@ packages: cpu: [x64] os: [linux] + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] @@ -2947,6 +3234,11 @@ packages: cpu: [arm64] os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] @@ -2957,6 +3249,11 @@ packages: cpu: [x64] os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2969,6 +3266,12 @@ packages: cpu: [arm64] os: [linux] + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2981,18 +3284,36 @@ packages: cpu: [arm] os: [linux] + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3005,6 +3326,12 @@ packages: cpu: [s390x] os: [linux] + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3017,6 +3344,12 @@ packages: cpu: [x64] os: [linux] + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3029,6 +3362,12 @@ packages: cpu: [arm64] os: [linux] + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3041,6 +3380,12 @@ packages: cpu: [x64] os: [linux] + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3051,12 +3396,27 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.33.5': resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3069,6 +3429,12 @@ packages: cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.33.5': resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3081,6 +3447,12 @@ packages: cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -3532,6 +3904,7 @@ packages: '@langchain/community@0.3.59': resolution: {integrity: sha512-lYoVFC9wArWMXaixDgIadTE22jk4ZYAvSHHmwaMRagkGr5f4kyqMeJ83UUeW76XPx2cBy2fRSO+acSgqSuWE6A==} engines: {node: '>=18'} + deprecated: This package has been deprecated. See https://github.com/langchain-ai/langchainjs-community/issues/61 for more info peerDependencies: '@arcjet/redact': ^v1.0.0-alpha.23 '@aws-crypto/sha256-js': ^5.0.0 @@ -3912,6 +4285,7 @@ packages: '@langchain/community@1.1.27': resolution: {integrity: sha512-s2U3w7QV7QpkFtY1eZMni4poz+nKLFclpDi3a7hUbZ67ttsGaU9WkZ2BiLuzLIs+IFaUvON/KcGkE8EqAl9aPA==} engines: {node: '>=20'} + deprecated: This package has been deprecated. See https://github.com/langchain-ai/langchainjs-community/issues/61 for more info peerDependencies: '@arcjet/redact': ^v1.2.0 '@aws-crypto/sha256-js': ^5.0.0 @@ -6031,6 +6405,15 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@posthog/core@1.23.2': resolution: {integrity: sha512-zTDdda9NuSHrnwSOfFMxX/pyXiycF4jtU1kTr8DL61dHhV+7LF6XF1ndRZZTuaGGbfbb/GJYkEsjEX9SXfNZeQ==} @@ -7053,6 +7436,10 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -7646,6 +8033,9 @@ packages: peerDependencies: '@solana/web3.js': '*' + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@standard-community/standard-json@0.3.5': resolution: {integrity: sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==} peerDependencies: @@ -8368,6 +8758,9 @@ packages: '@types/caseless@0.12.5': resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chrome@0.0.319': resolution: {integrity: sha512-k+E1b3VrWEj7OqQ22cPxHs3o/jrYWEEDGaocwjlIGtul+WrAn1vaBANc0iurgm0aXkyIVs7aH3ce0lpr96XYhw==} @@ -8391,6 +8784,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff-match-patch@1.0.36': resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} @@ -8871,6 +9267,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -9172,6 +9569,9 @@ packages: '@vitest/expect@3.1.4': resolution: {integrity: sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/mocker@3.1.4': resolution: {integrity: sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==} peerDependencies: @@ -9183,21 +9583,44 @@ packages: vite: optional: true + '@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@3.1.4': resolution: {integrity: sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==} '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/runner@3.1.4': resolution: {integrity: sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/snapshot@3.1.4': resolution: {integrity: sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/spy@3.1.4': resolution: {integrity: sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/ui@1.6.0': resolution: {integrity: sha512-k3Lyo+ONLOgylctiGovRKy7V4+dIN2yxstX3eY5cWFXH6WP+ooVX79YSyi0GagdTQzLmT43BF27T0s6dOIPBXA==} peerDependencies: @@ -9209,6 +9632,9 @@ packages: '@vitest/utils@3.1.4': resolution: {integrity: sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@wallet-standard/app@1.1.0': resolution: {integrity: sha512-3CijvrO9utx598kjr45hTbbeeykQrQfKmSnxeWOgU25TOEpvcipD/bYDQWIqUv1Oc6KK4YStokSMu/FBNecGUQ==} engines: {node: '>=16'} @@ -9883,6 +10309,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade bcp-47-match@2.0.3: resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} @@ -9932,6 +10359,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -10139,6 +10569,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk-template@0.4.0: resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} engines: {node: '>=12'} @@ -10243,6 +10677,9 @@ packages: resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} engines: {node: '>= 0.10'} + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -10546,6 +10983,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -10650,6 +11091,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-font-face-src@1.0.0: resolution: {integrity: sha512-kUl2r9ZMiLKY+04SM83Rk6dLnUU/hk30aN5CSZ+ZMGWT2COCU8I7yVu8fHbfCQu7Gx1ce45W0Q9hRaw2awqlww==} @@ -11144,6 +11586,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -11220,6 +11665,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -14177,6 +14627,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + miniflare@5.20260811.1-alpha: + resolution: {integrity: sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA==} + engines: {node: '>=22.0.0'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -14624,6 +15078,10 @@ packages: resolution: {integrity: sha512-szyd0ou0T8nsAqHtprRcP3WidfsN1TnAR5yWXf2mFCEr5ek3LEOkT6EZ/92Xfs74HIdyhG5WkGxIssMU0jBaeg==} engines: {node: '>=16'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -14946,6 +15404,9 @@ packages: path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -16255,6 +16716,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -16326,6 +16792,10 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -16552,6 +17022,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stealthy-require@1.1.1: resolution: {integrity: sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==} engines: {node: '>=0.10.0'} @@ -16754,6 +17227,10 @@ packages: resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} engines: {node: '>=14.0.0'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -16925,6 +17402,10 @@ packages: 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'} @@ -16937,6 +17418,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} @@ -17143,6 +17628,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -17351,6 +17837,13 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -17825,6 +18318,47 @@ packages: jsdom: 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 + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -18013,6 +18547,21 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + workerd@1.20260811.1: + resolution: {integrity: sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.123.0: + resolution: {integrity: sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260811.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -18080,6 +18629,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -18185,6 +18746,12 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yup@1.7.1: resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} @@ -18214,6 +18781,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} @@ -20124,6 +20694,46 @@ snapshots: fast-wrap-ansi: 0.1.6 sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260811.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260811.1 + + '@cloudflare/vitest-pool-workers@0.21.3(@cloudflare/workers-types@5.20260813.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(@vitest/coverage-v8@1.6.0(vitest@3.1.4))(@vitest/ui@1.6.0)(happy-dom@15.11.7)(jsdom@29.1.0(@noble/hashes@2.0.1)(canvas@2.11.2))(vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3)))': + dependencies: + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260811.1-alpha(bufferutil@4.1.0)(utf-8-validate@6.0.6) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(@vitest/coverage-v8@1.6.0(vitest@3.1.4))(@vitest/ui@1.6.0)(happy-dom@15.11.7)(jsdom@29.1.0(@noble/hashes@2.0.1)(canvas@2.11.2))(vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3)) + wrangler: 4.123.0(@cloudflare/workers-types@5.20260813.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + zod: 4.4.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20260811.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260811.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260811.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260811.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260811.1': + optional: true + + '@cloudflare/workers-types@5.20260813.1': {} + '@colors/colors@1.5.0': optional: true @@ -20490,6 +21100,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -20621,6 +21236,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true @@ -20630,6 +21248,9 @@ snapshots: '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.25.12': optional: true @@ -20639,6 +21260,9 @@ snapshots: '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.25.12': optional: true @@ -20648,6 +21272,9 @@ snapshots: '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true @@ -20657,6 +21284,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true @@ -20666,6 +21296,9 @@ snapshots: '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -20675,6 +21308,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true @@ -20684,6 +21320,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true @@ -20693,6 +21332,9 @@ snapshots: '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true @@ -20702,6 +21344,9 @@ snapshots: '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true @@ -20711,6 +21356,9 @@ snapshots: '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true @@ -20720,6 +21368,9 @@ snapshots: '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true @@ -20729,6 +21380,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true @@ -20738,6 +21392,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true @@ -20747,6 +21404,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true @@ -20756,6 +21416,9 @@ snapshots: '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -20765,6 +21428,9 @@ snapshots: '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -20774,6 +21440,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -20783,6 +21452,9 @@ snapshots: '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -20792,6 +21464,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -20801,6 +21476,9 @@ snapshots: '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true @@ -20810,6 +21488,9 @@ snapshots: '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true @@ -20819,6 +21500,9 @@ snapshots: '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true @@ -20828,6 +21512,9 @@ snapshots: '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true @@ -20837,6 +21524,9 @@ snapshots: '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -20846,6 +21536,9 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.0)': dependencies: eslint: 8.57.0 @@ -21110,8 +21803,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.33.5': optionalDependencies: @@ -21123,6 +21815,11 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + '@img/sharp-darwin-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.0.4 @@ -21133,60 +21830,100 @@ snapshots: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-libvips-darwin-arm64@1.0.4': optional: true '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + '@img/sharp-libvips-darwin-x64@1.0.4': optional: true '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + '@img/sharp-libvips-linux-arm64@1.0.4': optional: true '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + '@img/sharp-libvips-linux-arm@1.0.5': optional: true '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + '@img/sharp-libvips-linux-s390x@1.0.4': optional: true '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + '@img/sharp-libvips-linux-x64@1.0.4': optional: true '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': optional: true '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.0.4': optional: true '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + '@img/sharp-linux-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.0.4 @@ -21197,6 +21934,11 @@ snapshots: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + '@img/sharp-linux-arm@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.0.5 @@ -21207,16 +21949,31 @@ snapshots: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + '@img/sharp-linux-s390x@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.0.4 @@ -21227,6 +21984,11 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + '@img/sharp-linux-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.0.4 @@ -21237,6 +21999,11 @@ snapshots: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + '@img/sharp-linuxmusl-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 @@ -21247,6 +22014,11 @@ snapshots: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + '@img/sharp-linuxmusl-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.0.4 @@ -21257,6 +22029,11 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + '@img/sharp-wasm32@0.33.5': dependencies: '@emnapi/runtime': 1.8.1 @@ -21267,21 +22044,40 @@ snapshots: '@emnapi/runtime': 1.8.1 optional: true + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.2': + optional: true + '@img/sharp-win32-ia32@0.33.5': optional: true '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.2': + optional: true + '@img/sharp-win32-x64@0.33.5': optional: true '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.2': + optional: true + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@18.16.9)': @@ -24192,7 +24988,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -24292,6 +25088,18 @@ snapshots: '@popperjs/core@2.11.8': {} + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@posthog/core@1.23.2': dependencies: cross-spawn: 7.0.6 @@ -25179,10 +25987,10 @@ snapshots: '@rollup/pluginutils': 5.3.0(rollup@4.59.0) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.4) is-reference: 1.2.1 magic-string: 0.30.21 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: rollup: 4.59.0 @@ -25191,10 +25999,10 @@ snapshots: '@rollup/pluginutils': 5.3.0(rollup@4.59.0) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.4) is-reference: 1.2.1 magic-string: 0.30.21 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: rollup: 4.59.0 @@ -25234,7 +26042,7 @@ snapshots: dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: rollup: 4.59.0 @@ -25624,6 +26432,8 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@7.2.0': {} + '@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/slugify@2.2.1': @@ -26512,6 +27322,8 @@ snapshots: eventemitter3: 5.0.4 uuid: 9.0.1 + '@speed-highlight/core@1.2.24': {} + '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)': dependencies: '@standard-schema/spec': 1.1.0 @@ -27300,6 +28112,11 @@ snapshots: '@types/caseless@0.12.5': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/chrome@0.0.319': dependencies: '@types/filesystem': 0.0.36 @@ -27330,6 +28147,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/diff-match-patch@1.0.36': {} '@types/dom-mediacapture-transform@0.1.11': @@ -28293,6 +29112,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@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@3.1.4(vite@6.4.1(@types/node@18.16.9)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.1.4 @@ -28301,6 +29129,14 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@18.16.9)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3) + '@vitest/mocker@4.1.10(vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3) + '@vitest/pretty-format@3.1.4': dependencies: tinyrainbow: 2.0.0 @@ -28309,21 +29145,39 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + '@vitest/runner@3.1.4': dependencies: '@vitest/utils': 3.1.4 pathe: 2.0.3 + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + '@vitest/snapshot@3.1.4': dependencies: '@vitest/pretty-format': 3.1.4 magic-string: 0.30.21 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@3.1.4': dependencies: tinyspy: 3.0.2 + '@vitest/spy@4.1.10': {} + '@vitest/ui@1.6.0(vitest@3.1.4)': dependencies: '@vitest/utils': 1.6.0 @@ -28348,6 +29202,12 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@wallet-standard/app@1.1.0': dependencies: '@wallet-standard/base': 1.1.0 @@ -29600,6 +30460,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + blake3-wasm@2.1.5: {} + bluebird@3.7.2: {} blueimp-canvas-to-blob@3.29.0: {} @@ -29874,6 +30736,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk-template@0.4.0: dependencies: chalk: 4.1.2 @@ -30005,6 +30869,8 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 + cjs-module-lexer@1.2.3: {} + cjs-module-lexer@1.4.3: {} cjs-module-lexer@2.2.0: {} @@ -30284,6 +31150,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -30906,6 +31774,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} + error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -31131,6 +32001,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -31734,6 +32633,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -35158,6 +36061,18 @@ snapshots: mimic-response@3.1.0: {} + miniflare@5.20260811.1-alpha(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260811.1 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimalistic-assert@1.0.1: {} minimalistic-crypto-utils@1.0.1: {} @@ -35599,6 +36514,8 @@ snapshots: oblivious-set@1.4.0: {} + obug@2.1.4: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -35964,6 +36881,8 @@ snapshots: path-to-regexp@3.3.0: {} + path-to-regexp@6.3.0: {} + path-to-regexp@8.3.0: {} path-to-regexp@8.4.2: {} @@ -37495,7 +38414,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -37644,6 +38563,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -37829,6 +38750,38 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 @@ -38067,6 +39020,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.2.0: {} + stealthy-require@1.1.1: {} stop-iteration-iterator@1.1.0: @@ -38283,6 +39238,8 @@ snapshots: superstruct@2.0.2: {} + supports-color@10.2.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -38468,15 +39425,19 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.3.0: {} + tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinypool@1.1.1: {} tinyrainbow@2.0.0: {} + tinyrainbow@3.1.1: {} + tinyspy@3.0.2: {} tippy.js@6.3.7: @@ -38896,6 +39857,12 @@ snapshots: undici@7.25.0: {} + undici@7.29.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -39025,7 +39992,7 @@ snapshots: unplugin-utils@0.2.5: dependencies: pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 unrs-resolver@1.11.1: dependencies: @@ -39361,6 +40328,23 @@ snapshots: terser: 5.46.0 yaml: 2.8.3 + vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.37 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + sass: 1.97.3 + terser: 5.46.0 + yaml: 2.8.3 + vitest@3.1.4(@types/debug@4.1.12)(@types/node@18.16.9)(@vitest/ui@1.6.0)(happy-dom@15.11.7)(jiti@2.6.1)(jsdom@22.1.0(bufferutil@4.1.0)(canvas@2.11.2)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3): dependencies: '@vitest/expect': 3.1.4 @@ -39404,6 +40388,38 @@ snapshots: - tsx - yaml + vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(@vitest/coverage-v8@1.6.0(vitest@3.1.4))(@vitest/ui@1.6.0)(happy-dom@15.11.7)(jsdom@29.1.0(@noble/hashes@2.0.1)(canvas@2.11.2))(vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3)) + '@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.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.1 + vite: 6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 20.19.37 + '@vitest/coverage-v8': 1.6.0(vitest@3.1.4) + '@vitest/ui': 1.6.0(vitest@3.1.4) + happy-dom: 15.11.7 + jsdom: 29.1.0(@noble/hashes@2.0.1)(canvas@2.11.2) + transitivePeerDependencies: + - msw + vlq@1.0.1: {} void-elements@3.1.0: {} @@ -39649,6 +40665,31 @@ snapshots: wordwrap@1.0.0: {} + workerd@1.20260811.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260811.1 + '@cloudflare/workerd-darwin-arm64': 1.20260811.1 + '@cloudflare/workerd-linux-64': 1.20260811.1 + '@cloudflare/workerd-linux-arm64': 1.20260811.1 + '@cloudflare/workerd-windows-64': 1.20260811.1 + + wrangler@4.123.0(@cloudflare/workers-types@5.20260813.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260811.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260811.1-alpha(bufferutil@4.1.0)(utf-8-validate@6.0.6) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260811.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20260813.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -39699,6 +40740,11 @@ snapshots: bufferutil: 4.1.0 utf-8-validate: 6.0.6 + ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + xml-name-validator@4.0.0: {} xml-name-validator@5.0.0: {} @@ -39783,6 +40829,19 @@ snapshots: yoctocolors@2.1.2: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + yup@1.7.1: dependencies: property-expr: 2.0.6 @@ -39812,6 +40871,8 @@ snapshots: zod@4.3.6: {} + zod@4.4.3: {} + zustand@5.0.11(@types/react@19.1.8)(immer@9.0.21)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.1.8 From f32971f54f68ed432203ea5a5c061b16468cdce5 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:38:36 +0700 Subject: [PATCH 04/20] feat: add single-use OAuth state store --- apps/crove-sso/src/index.ts | 2 + apps/crove-sso/src/state-store.ts | 86 +++++++++++++++++++++++++ apps/crove-sso/test/env.d.ts | 5 ++ apps/crove-sso/test/state-store.test.ts | 49 ++++++++++++++ apps/crove-sso/tsconfig.json | 2 +- apps/crove-sso/wrangler.jsonc | 14 ++++ 6 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 apps/crove-sso/src/state-store.ts create mode 100644 apps/crove-sso/test/env.d.ts create mode 100644 apps/crove-sso/test/state-store.test.ts diff --git a/apps/crove-sso/src/index.ts b/apps/crove-sso/src/index.ts index 473d2e756b..4a330f824d 100644 --- a/apps/crove-sso/src/index.ts +++ b/apps/crove-sso/src/index.ts @@ -3,3 +3,5 @@ export default { return new Response('Not found', { status: 404 }); }, }; + +export { OAuthStateStore } from './state-store'; diff --git a/apps/crove-sso/src/state-store.ts b/apps/crove-sso/src/state-store.ts new file mode 100644 index 0000000000..b3ac18aaca --- /dev/null +++ b/apps/crove-sso/src/state-store.ts @@ -0,0 +1,86 @@ +import { DurableObject } from 'cloudflare:workers'; + +import type { Env } from './env'; + +interface StoredEntry { + value: T; +} + +export class OAuthStateStore extends DurableObject { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + ctx.blockConcurrencyWhile(async () => { + ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS oauth_entries ( + id TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL + ) + `); + }); + } + + async fetch(request: Request): Promise { + const path = new URL(request.url).pathname; + if (request.method === 'POST' && path === '/put') { + const entry = await request.json<{ + key: string; + value: unknown; + expiresAt: number; + }>(); + this.ctx.storage.sql.exec( + 'INSERT OR REPLACE INTO oauth_entries (id, value, expires_at) VALUES (?, ?, ?)', + entry.key, + JSON.stringify({ value: entry.value }), + entry.expiresAt, + ); + return new Response(null, { status: 204 }); + } + + if (request.method === 'POST' && path === '/take') { + const { key } = await request.json<{ key: string }>(); + const rows = [ + ...this.ctx.storage.sql.exec<{ value: string }>( + 'DELETE FROM oauth_entries WHERE id = ? AND expires_at > ? RETURNING value', + key, + Date.now(), + ), + ]; + this.ctx.storage.sql.exec('DELETE FROM oauth_entries WHERE id = ? AND expires_at <= ?', key, Date.now()); + if (rows.length !== 1) return new Response(null, { status: 404 }); + return Response.json(JSON.parse(rows[0].value) as StoredEntry); + } + + return new Response(null, { status: 404 }); + } +} + +function store(env: Env): DurableObjectStub { + return env.OAUTH_STATE.get(env.OAUTH_STATE.idFromName('oauth-state')); +} + +export async function putState( + env: Env, + key: string, + value: T, + ttlSeconds: number, +): Promise { + const response = await store(env).fetch('https://state.internal/put', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value, expiresAt: Date.now() + ttlSeconds * 1000 }), + }); + if (!response.ok) throw new Error('state_store_unavailable'); +} + +export async function takeState(env: Env, key: string): Promise { + const response = await store(env).fetch('https://state.internal/take', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error('state_store_unavailable'); + const entry = await response.json>(); + return entry.value; +} diff --git a/apps/crove-sso/test/env.d.ts b/apps/crove-sso/test/env.d.ts new file mode 100644 index 0000000000..88936844c7 --- /dev/null +++ b/apps/crove-sso/test/env.d.ts @@ -0,0 +1,5 @@ +import type { Env } from '../src/env'; + +declare module 'cloudflare:workers' { + interface ProvidedEnv extends Env {} +} diff --git a/apps/crove-sso/test/state-store.test.ts b/apps/crove-sso/test/state-store.test.ts new file mode 100644 index 0000000000..3ee490e97f --- /dev/null +++ b/apps/crove-sso/test/state-store.test.ts @@ -0,0 +1,49 @@ +import { env } from 'cloudflare:workers'; +import { evictDurableObject } from 'cloudflare:test'; +import { describe, expect, it } from 'vitest'; + +import type { Env } from '../src/env'; +import { putState, takeState } from '../src/state-store'; + +const workerEnv = env as unknown as Env; + +describe('OAuthStateStore', () => { + it('returns a stored value exactly once', async () => { + const key = crypto.randomUUID(); + await putState(workerEnv, key, { state: 'stored' }, 60); + + await expect(takeState<{ state: string }>(workerEnv, key)).resolves.toEqual({ state: 'stored' }); + await expect(takeState(workerEnv, key)).resolves.toBeNull(); + }); + + it('does not return an expired value', async () => { + const key = crypto.randomUUID(); + await putState(workerEnv, key, { state: 'expired' }, 0); + + await expect(takeState(workerEnv, key)).resolves.toBeNull(); + }); + + it('allows exactly one concurrent consumer', async () => { + const key = crypto.randomUUID(); + await putState(workerEnv, key, { state: 'race' }, 60); + + const values = await Promise.all([ + takeState<{ state: string }>(workerEnv, key), + takeState<{ state: string }>(workerEnv, key), + ]); + + expect(values.filter((value) => value !== null)).toHaveLength(1); + expect(values.find((value) => value !== null)).toEqual({ state: 'race' }); + }); + + it('preserves state across Durable Object eviction', async () => { + const key = crypto.randomUUID(); + await putState(workerEnv, key, { state: 'persisted' }, 60); + const id = workerEnv.OAUTH_STATE.idFromName('oauth-state'); + await evictDurableObject(workerEnv.OAUTH_STATE.get(id)); + + await expect(takeState<{ state: string }>(workerEnv, key)).resolves.toEqual({ + state: 'persisted', + }); + }); +}); diff --git a/apps/crove-sso/tsconfig.json b/apps/crove-sso/tsconfig.json index 4362958a76..7790bea54f 100644 --- a/apps/crove-sso/tsconfig.json +++ b/apps/crove-sso/tsconfig.json @@ -4,7 +4,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ES2022", "WebWorker"], - "types": ["@cloudflare/workers-types", "vitest/globals"], + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers/types", "vitest/globals"], "strict": true, "noEmit": true, "skipLibCheck": true diff --git a/apps/crove-sso/wrangler.jsonc b/apps/crove-sso/wrangler.jsonc index 0740167cef..6eacefeea9 100644 --- a/apps/crove-sso/wrangler.jsonc +++ b/apps/crove-sso/wrangler.jsonc @@ -7,6 +7,20 @@ "observability": { "enabled": true }, + "durable_objects": { + "bindings": [ + { + "name": "OAUTH_STATE", + "class_name": "OAuthStateStore" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["OAuthStateStore"] + } + ], "vars": { "UPSTREAM_AUTHORIZE_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/authorize", "UPSTREAM_TOKEN_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/token", From 8b8b2219d4f528a9d4259e273e3ee0ca30af1eb2 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:53:37 +0700 Subject: [PATCH 05/20] feat: bridge Postiz OAuth to DOS ID --- apps/crove-sso/package.json | 2 +- apps/crove-sso/src/index.ts | 220 ++++++++++++++++++- apps/crove-sso/src/oauth.ts | 7 +- apps/crove-sso/test/worker.test.ts | 257 +++++++++++++++++++++++ apps/crove-sso/tsconfig.json | 2 +- apps/crove-sso/vitest.config.ts | 8 + apps/crove-sso/worker-configuration.d.ts | 25 +++ 7 files changed, 513 insertions(+), 8 deletions(-) create mode 100644 apps/crove-sso/test/worker.test.ts create mode 100644 apps/crove-sso/worker-configuration.d.ts diff --git a/apps/crove-sso/package.json b/apps/crove-sso/package.json index b8ea03910a..3f20c171b1 100644 --- a/apps/crove-sso/package.json +++ b/apps/crove-sso/package.json @@ -5,7 +5,7 @@ "scripts": { "test": "vitest run", "typecheck": "tsc --noEmit", - "cf-typegen": "wrangler types", + "cf-typegen": "wrangler types --include-runtime false", "deploy": "wrangler deploy" }, "devDependencies": { diff --git a/apps/crove-sso/src/index.ts b/apps/crove-sso/src/index.ts index 4a330f824d..0099148e27 100644 --- a/apps/crove-sso/src/index.ts +++ b/apps/crove-sso/src/index.ts @@ -1,7 +1,219 @@ -export default { - fetch() { +import type { Env } from './env'; +import { + createPkce, + OAuthRequestError, + oauthError, + parseAuthorizeRequest, + parseTokenRequest, + randomToken, +} from './oauth'; +import { putState, takeState } from './state-store'; + +const AUTHORIZATION_TTL_SECONDS = 600; +const BRIDGE_CODE_TTL_SECONDS = 60; + +export interface AuthorizationTransaction { + redirectUri: string; + scope: string; + downstreamState?: string; + verifier: string; +} + +async function authorize(request: Request, env: Env): Promise { + const downstream = parseAuthorizeRequest(request, env); + const upstreamState = randomToken(); + const pkce = await createPkce(); + await putState( + env, + `authorize:${upstreamState}`, + { + redirectUri: downstream.redirectUri, + scope: downstream.scope, + downstreamState: downstream.state, + verifier: pkce.verifier, + }, + AUTHORIZATION_TTL_SECONDS, + ); + + const target = new URL(env.UPSTREAM_AUTHORIZE_URL); + target.search = new URLSearchParams({ + client_id: env.UPSTREAM_CLIENT_ID, + redirect_uri: env.UPSTREAM_REDIRECT_URI, + response_type: 'code', + scope: downstream.scope, + state: upstreamState, + code_challenge: pkce.challenge, + code_challenge_method: 'S256', + }).toString(); + + return new Response(null, { + status: 302, + headers: { + Location: target.toString(), + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + }, + }); +} + +async function callback(request: Request, env: Env): Promise { + const params = new URL(request.url).searchParams; + const code = params.get('code'); + const state = params.get('state'); + if (!code || !state) { + throw new OAuthRequestError('invalid_request'); + } + + const transaction = await takeState(env, `authorize:${state}`); + if (!transaction) { + throw new OAuthRequestError('invalid_request'); + } + + const upstreamResponse = await globalThis.fetch(env.UPSTREAM_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: env.UPSTREAM_CLIENT_ID, + client_secret: env.UPSTREAM_CLIENT_SECRET, + code, + redirect_uri: env.UPSTREAM_REDIRECT_URI, + code_verifier: transaction.verifier, + }), + }); + if (!upstreamResponse.ok) { + return oauthError('temporarily_unavailable', 'Identity provider unavailable', 502); + } + + let tokenResponse: Record; + try { + tokenResponse = await upstreamResponse.json>(); + } catch { + return oauthError('temporarily_unavailable', 'Identity provider unavailable', 502); + } + if (typeof tokenResponse.access_token !== 'string' || tokenResponse.access_token.length === 0) { + return oauthError('temporarily_unavailable', 'Identity provider unavailable', 502); + } + + const bridgeCode = randomToken(); + await putState(env, `code:${bridgeCode}`, tokenResponse, BRIDGE_CODE_TTL_SECONDS); + + const target = new URL(transaction.redirectUri); + target.searchParams.set('code', bridgeCode); + if (transaction.downstreamState) { + target.searchParams.set('state', transaction.downstreamState); + } + return new Response(null, { + status: 302, + headers: { + Location: target.toString(), + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + }, + }); +} + +async function token(request: Request, env: Env): Promise { + const tokenRequest = await parseTokenRequest(request, env); + const tokenResponse = await takeState>(env, `code:${tokenRequest.code}`); + if (!tokenResponse) { + throw new OAuthRequestError('invalid_grant'); + } + + return Response.json(tokenResponse, { + headers: { + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + }, + }); +} + +async function userinfo(request: Request, env: Env): Promise { + const authorization = request.headers.get('authorization') || ''; + const bearer = /^Bearer ([^\s]+)$/.exec(authorization)?.[1]; + if (!bearer) { + return oauthError('invalid_token', 'Bearer token required', 401); + } + + const upstreamResponse = await globalThis.fetch(env.UPSTREAM_USERINFO_URL, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${bearer}`, + }, + }); + if (!upstreamResponse.ok) { + if (upstreamResponse.status === 401 || upstreamResponse.status === 403) { + return oauthError('invalid_token', 'Identity token rejected', 401); + } + return oauthError('temporarily_unavailable', 'Identity provider unavailable', 502); + } + + let claims: Record; + try { + claims = await upstreamResponse.json>(); + } catch { + return oauthError('temporarily_unavailable', 'Identity provider unavailable', 502); + } + if (typeof claims.sub !== 'string' || claims.sub.length === 0) { + return oauthError('invalid_token', 'Identity token rejected', 401); + } + + return Response.json(claims, { + headers: { + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + }, + }); +} + +async function handleRequest(request: Request, env: Env): Promise { + const { pathname } = new URL(request.url); + try { + if (request.method === 'GET' && pathname === '/health') { + return Response.json({ status: 'ok', service: 'crove-sso' }); + } + if (request.method === 'GET' && pathname === '/authorize') { + return await authorize(request, env); + } + if (request.method === 'GET' && pathname === '/callback') { + return await callback(request, env); + } + if (request.method === 'POST' && pathname === '/token') { + return await token(request, env); + } + if (request.method === 'GET' && pathname === '/userinfo') { + return await userinfo(request, env); + } return new Response('Not found', { status: 404 }); - }, -}; + } catch (error) { + if (error instanceof OAuthRequestError) { + return oauthError(error.code, 'OAuth request rejected', error.status); + } + return oauthError('server_error', 'OAuth service unavailable', 500); + } +} + +async function fetch(request: Request, env: Env): Promise { + const requestId = crypto.randomUUID(); + const endpoint = new URL(request.url).pathname; + const startedAt = performance.now(); + const response = await handleRequest(request, env); + const logEntry: Record = { + requestId, + endpoint, + status: response.status, + durationMs: Math.round(performance.now() - startedAt), + }; + if (response.status >= 400) { + logEntry.errorCode = `http_${response.status}`; + } + console.log(JSON.stringify(logEntry)); + return response; +} + +export default { fetch }; export { OAuthStateStore } from './state-store'; diff --git a/apps/crove-sso/src/oauth.ts b/apps/crove-sso/src/oauth.ts index 444099f624..6f6c1aaac0 100644 --- a/apps/crove-sso/src/oauth.ts +++ b/apps/crove-sso/src/oauth.ts @@ -27,6 +27,10 @@ function base64Url(bytes: Uint8Array): string { .replace(/=+$/g, ''); } +export function randomToken(): string { + return base64Url(crypto.getRandomValues(new Uint8Array(32))); +} + async function constantTimeEqual(actual: string, expected: string): Promise { const encoder = new TextEncoder(); const [actualDigest, expectedDigest] = await Promise.all([ @@ -65,8 +69,7 @@ export function parseAuthorizeRequest(request: Request, env: Env): AuthorizeRequ } export async function createPkce(): Promise<{ verifier: string; challenge: string }> { - const entropy = crypto.getRandomValues(new Uint8Array(32)); - const verifier = base64Url(entropy); + const verifier = randomToken(); const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); return { verifier, challenge: base64Url(new Uint8Array(digest)) }; } diff --git a/apps/crove-sso/test/worker.test.ts b/apps/crove-sso/test/worker.test.ts new file mode 100644 index 0000000000..b928d2b216 --- /dev/null +++ b/apps/crove-sso/test/worker.test.ts @@ -0,0 +1,257 @@ +import { env, exports } from 'cloudflare:workers'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Env } from '../src/env'; + +const workerEnv = env as unknown as Env; +const worker = exports as unknown as { + default: { fetch(request: Request): Promise }; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function authorizeUrl(overrides: Record = {}) { + const params = new URLSearchParams({ + client_id: workerEnv.DOWNSTREAM_CLIENT_ID, + redirect_uri: workerEnv.DOWNSTREAM_REDIRECT_URI, + response_type: 'code', + scope: workerEnv.ALLOWED_SCOPE, + state: 'postiz-state', + ...overrides, + }); + return new Request(`https://sso.crove.com/authorize?${params}`, { redirect: 'manual' }); +} + +async function issueBridgeCode(): Promise { + const authorization = await worker.default.fetch(authorizeUrl()); + const upstreamState = new URL(authorization.headers.get('location')!).searchParams.get('state')!; + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + Response.json({ + access_token: 'upstream-access-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'upstream-refresh-token', + }), + ); + const callback = await worker.default.fetch( + new Request(`https://sso.crove.com/callback?code=supabase-code&state=${upstreamState}`, { + redirect: 'manual', + }), + ); + vi.restoreAllMocks(); + return new URL(callback.headers.get('location')!).searchParams.get('code')!; +} + +function tokenRequest(code: string, overrides: Record = {}): Request { + return new Request('https://sso.crove.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: workerEnv.DOWNSTREAM_CLIENT_ID, + client_secret: workerEnv.DOWNSTREAM_CLIENT_SECRET, + redirect_uri: workerEnv.DOWNSTREAM_REDIRECT_URI, + code, + ...overrides, + }), + }); +} + +describe('Crove SSO Worker', () => { + it('serves a static health response', async () => { + const response = await worker.default.fetch(new Request('https://sso.crove.com/health')); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'ok', service: 'crove-sso' }); + }); + + it('redirects an exact authorization request to DOS ID with S256 PKCE', async () => { + const response = await worker.default.fetch(authorizeUrl()); + expect(response.status).toBe(302); + const location = new URL(response.headers.get('location')!); + expect(location.origin + location.pathname).toBe(workerEnv.UPSTREAM_AUTHORIZE_URL); + expect(location.searchParams.get('client_id')).toBe(workerEnv.UPSTREAM_CLIENT_ID); + expect(location.searchParams.get('redirect_uri')).toBe(workerEnv.UPSTREAM_REDIRECT_URI); + expect(location.searchParams.get('response_type')).toBe('code'); + expect(location.searchParams.get('scope')).toBe(workerEnv.ALLOWED_SCOPE); + expect(location.searchParams.get('code_challenge_method')).toBe('S256'); + expect(location.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(location.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(location.searchParams.get('state')).not.toContain('postiz-state'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + }); + + it('rejects an invalid authorization request without redirecting', async () => { + const response = await worker.default.fetch( + authorizeUrl({ redirect_uri: 'https://evil.example/callback' }), + ); + expect(response.status).toBe(400); + expect(response.headers.get('location')).toBeNull(); + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }); + }); + + it('exchanges an upstream callback and preserves downstream state', async () => { + const authorization = await worker.default.fetch(authorizeUrl()); + const upstreamState = new URL(authorization.headers.get('location')!).searchParams.get('state')!; + let submittedVerifier = ''; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = new Request(input, init); + expect(request.url).toBe(workerEnv.UPSTREAM_TOKEN_URL); + const form = await request.formData(); + submittedVerifier = String(form.get('code_verifier')); + expect(form.get('code')).toBe('supabase-code'); + expect(form.get('client_id')).toBe(workerEnv.UPSTREAM_CLIENT_ID); + expect(form.get('client_secret')).toBe(workerEnv.UPSTREAM_CLIENT_SECRET); + expect(form.get('redirect_uri')).toBe(workerEnv.UPSTREAM_REDIRECT_URI); + return Response.json({ + access_token: 'upstream-access-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'upstream-refresh-token', + }); + }); + + const callback = await worker.default.fetch( + new Request( + `https://sso.crove.com/callback?code=supabase-code&state=${encodeURIComponent(upstreamState)}`, + { redirect: 'manual' }, + ), + ); + + expect(callback.status).toBe(302); + expect(submittedVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(callback.headers.get('referrer-policy')).toBe('no-referrer'); + const downstream = new URL(callback.headers.get('location')!); + expect(downstream.origin + downstream.pathname).toBe(workerEnv.DOWNSTREAM_REDIRECT_URI); + expect(downstream.searchParams.get('state')).toBe('postiz-state'); + expect(downstream.searchParams.get('code')).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('rejects a replayed upstream callback', async () => { + const authorization = await worker.default.fetch(authorizeUrl()); + const upstreamState = new URL(authorization.headers.get('location')!).searchParams.get('state')!; + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + Response.json({ access_token: 'upstream-access-token', token_type: 'bearer' }), + ); + const callbackUrl = `https://sso.crove.com/callback?code=supabase-code&state=${upstreamState}`; + + expect((await worker.default.fetch(new Request(callbackUrl, { redirect: 'manual' }))).status).toBe(302); + const replay = await worker.default.fetch(new Request(callbackUrl, { redirect: 'manual' })); + expect(replay.status).toBe(400); + await expect(replay.json()).resolves.toMatchObject({ error: 'invalid_request' }); + }); + + it('does not expose an upstream token error body', async () => { + const authorization = await worker.default.fetch(authorizeUrl()); + const upstreamState = new URL(authorization.headers.get('location')!).searchParams.get('state')!; + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + Response.json({ error: 'invalid_client', leaked: 'upstream-secret-body' }, { status: 401 }), + ); + + const response = await worker.default.fetch( + new Request(`https://sso.crove.com/callback?code=bad&state=${upstreamState}`), + ); + expect(response.status).toBe(502); + expect(await response.text()).not.toContain('upstream-secret-body'); + }); + + it('exchanges a bridge code for the upstream token response', async () => { + const bridgeCode = await issueBridgeCode(); + const response = await worker.default.fetch(tokenRequest(bridgeCode)); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + await expect(response.json()).resolves.toEqual({ + access_token: 'upstream-access-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'upstream-refresh-token', + }); + }); + + it('rejects a replayed bridge code', async () => { + const bridgeCode = await issueBridgeCode(); + expect((await worker.default.fetch(tokenRequest(bridgeCode))).status).toBe(200); + + const replay = await worker.default.fetch(tokenRequest(bridgeCode)); + expect(replay.status).toBe(400); + await expect(replay.json()).resolves.toMatchObject({ error: 'invalid_grant' }); + }); + + it('rejects an invalid downstream secret without consuming the bridge code', async () => { + const bridgeCode = await issueBridgeCode(); + const invalid = await worker.default.fetch( + tokenRequest(bridgeCode, { client_secret: 'wrong-secret' }), + ); + expect(invalid.status).toBe(401); + await expect(invalid.json()).resolves.toMatchObject({ error: 'invalid_client' }); + + expect((await worker.default.fetch(tokenRequest(bridgeCode))).status).toBe(200); + }); + + it('rejects a wrong downstream redirect URI without consuming the bridge code', async () => { + const bridgeCode = await issueBridgeCode(); + const invalid = await worker.default.fetch( + tokenRequest(bridgeCode, { redirect_uri: 'https://evil.example/callback' }), + ); + expect(invalid.status).toBe(400); + await expect(invalid.json()).resolves.toMatchObject({ error: 'invalid_grant' }); + + expect((await worker.default.fetch(tokenRequest(bridgeCode))).status).toBe(200); + }); + + it('proxies userinfo with the bearer token and strips upstream headers', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const upstream = new Request(input, init); + expect(upstream.url).toBe(workerEnv.UPSTREAM_USERINFO_URL); + expect(upstream.headers.get('authorization')).toBe('Bearer upstream-access-token'); + return Response.json( + { sub: 'dos-id-subject', email: 'joy@dos.ai', email_verified: true, name: 'JOY' }, + { headers: { 'Set-Cookie': 'should-not-pass=1', 'X-Upstream': 'private' } }, + ); + }); + + const response = await worker.default.fetch( + new Request('https://sso.crove.com/userinfo', { + headers: { Authorization: 'Bearer upstream-access-token' }, + }), + ); + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(response.headers.get('x-upstream')).toBeNull(); + expect(response.headers.get('cache-control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ + sub: 'dos-id-subject', + email: 'joy@dos.ai', + email_verified: true, + name: 'JOY', + }); + const logOutput = log.mock.calls.flat().join(' '); + expect(logOutput).toContain('"endpoint":"/userinfo"'); + expect(logOutput).not.toContain('upstream-access-token'); + expect(logOutput).not.toContain('joy@dos.ai'); + expect(logOutput).not.toContain('dos-id-subject'); + }); + + it('rejects userinfo without an exact bearer token', async () => { + const response = await worker.default.fetch(new Request('https://sso.crove.com/userinfo')); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_token' }); + }); + + it('does not expose an upstream userinfo error body', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + Response.json({ leaked: 'upstream-private-error' }, { status: 401 }), + ); + const response = await worker.default.fetch( + new Request('https://sso.crove.com/userinfo', { + headers: { Authorization: 'Bearer bad-token' }, + }), + ); + expect(response.status).toBe(401); + expect(await response.text()).not.toContain('upstream-private-error'); + }); +}); diff --git a/apps/crove-sso/tsconfig.json b/apps/crove-sso/tsconfig.json index 7790bea54f..ad9b11aed0 100644 --- a/apps/crove-sso/tsconfig.json +++ b/apps/crove-sso/tsconfig.json @@ -9,5 +9,5 @@ "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] + "include": ["worker-configuration.d.ts", "src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] } diff --git a/apps/crove-sso/vitest.config.ts b/apps/crove-sso/vitest.config.ts index 535c11a281..4ecdecb3b8 100644 --- a/apps/crove-sso/vitest.config.ts +++ b/apps/crove-sso/vitest.config.ts @@ -5,6 +5,14 @@ export default defineConfig({ plugins: [ cloudflareTest({ wrangler: { configPath: './wrangler.jsonc' }, + miniflare: { + bindings: { + UPSTREAM_CLIENT_ID: 'supabase-crove-client', + UPSTREAM_CLIENT_SECRET: 'supabase-crove-secret', + DOWNSTREAM_CLIENT_ID: 'crove-postiz', + DOWNSTREAM_CLIENT_SECRET: 'bridge-secret-value', + }, + }, }), ], test: { diff --git a/apps/crove-sso/worker-configuration.d.ts b/apps/crove-sso/worker-configuration.d.ts new file mode 100644 index 0000000000..d3f92695a7 --- /dev/null +++ b/apps/crove-sso/worker-configuration.d.ts @@ -0,0 +1,25 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 5c92285b797f3da694d2250982e79c06) +interface __BaseEnv_Env { + UPSTREAM_AUTHORIZE_URL: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/authorize"; + UPSTREAM_TOKEN_URL: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/token"; + UPSTREAM_USERINFO_URL: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/userinfo"; + UPSTREAM_REDIRECT_URI: "https://sso.crove.com/callback"; + DOWNSTREAM_REDIRECT_URI: "https://crove.com/settings"; + ALLOWED_SCOPE: "openid profile email"; + OAUTH_STATE: DurableObjectNamespace; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + durableNamespaces: "OAuthStateStore"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} From b117dfcca8077ea6974ab2bd75cc995e0966ac3a Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:14:11 +0700 Subject: [PATCH 06/20] chore: configure Crove SSO deployment --- apps/crove-sso/wrangler.jsonc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/crove-sso/wrangler.jsonc b/apps/crove-sso/wrangler.jsonc index 6eacefeea9..7d4f5de6ca 100644 --- a/apps/crove-sso/wrangler.jsonc +++ b/apps/crove-sso/wrangler.jsonc @@ -4,6 +4,12 @@ "main": "src/index.ts", "compatibility_date": "2026-08-13", "compatibility_flags": ["nodejs_compat"], + "routes": [ + { + "pattern": "sso.crove.com", + "custom_domain": true + } + ], "observability": { "enabled": true }, From 6a4f257ca1249a08634b12f24a0747a048967d4a Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:31:49 +0700 Subject: [PATCH 07/20] docs: record Crove DOS ID SSO rollout --- .../plans/2026-08-14-crove-dos-id-sso.md | 108 ++++++++++-------- .../2026-08-14-crove-dos-id-sso-design.md | 4 +- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/docs/superpowers/plans/2026-08-14-crove-dos-id-sso.md b/docs/superpowers/plans/2026-08-14-crove-dos-id-sso.md index f8ab31294e..77a65273ca 100644 --- a/docs/superpowers/plans/2026-08-14-crove-dos-id-sso.md +++ b/docs/superpowers/plans/2026-08-14-crove-dos-id-sso.md @@ -1,6 +1,6 @@ # Crove DOS ID SSO 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. +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Completed steps use checked boxes. **Goal:** Deploy a PKCE compatibility bridge at `sso.crove.com` so the upstream Postiz image can use Supabase DOS ID for SSO without creating a duplicate Crove user. @@ -42,33 +42,33 @@ - Produces: `createPkce(): Promise<{ verifier: string; challenge: string }>` - Produces: `oauthError(error: string, description: string, status?: number): Response` -- [ ] **Step 1: Add the package manifest and test configuration** +- [x] **Step 1: Add the package manifest and test configuration** Create a workspace package with scripts `test`, `typecheck`, `cf-typegen`, and `deploy`. Pin Wrangler 4.123.0, Vitest 4.1.10, the Workers test pool 0.21.3, and TypeScript 5.5.4. Configure `cloudflareTest()` to load `wrangler.jsonc`. -- [ ] **Step 2: Write failing authorization-validation tests** +- [x] **Step 2: Write failing authorization-validation tests** Add tests that call `parseAuthorizeRequest()` and prove it rejects an unknown client ID, a callback other than `https://crove.com/settings`, a response type other than `code`, and a scope outside `openid profile email`. -- [ ] **Step 3: Run the targeted tests and verify RED** +- [x] **Step 3: Run the targeted tests and verify RED** Run: `pnpm.cmd --filter @crove/sso test -- test/oauth.test.ts` Expected: FAIL because `src/oauth.ts` does not exist. -- [ ] **Step 4: Implement minimal authorization validation** +- [x] **Step 4: Implement minimal authorization validation** Define `Env` with all Worker secrets and the Durable Object binding. Parse query parameters with `URL`, compare exact strings, and return safe OAuth errors without echoing values. -- [ ] **Step 5: Add and verify token-validation tests** +- [x] **Step 5: Add and verify token-validation tests** Test form content type, `grant_type=authorization_code`, exact downstream client ID and redirect URI, missing code, and invalid client secret. Use a fixed-length byte comparison implemented with Web Crypto-compatible primitives. -- [ ] **Step 6: Add and verify the PKCE test** +- [x] **Step 6: Add and verify the PKCE test** Generate a verifier from 32 random bytes, compute SHA-256, encode with base64url without padding, and assert the challenge matches an independent digest of the returned verifier. -- [ ] **Step 7: Run tests and typecheck** +- [x] **Step 7: Run tests and typecheck** Run: @@ -79,7 +79,7 @@ pnpm.cmd --filter @crove/sso typecheck Expected: all tests pass and TypeScript exits 0. -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** ```powershell git add pnpm-workspace.yaml pnpm-lock.yaml apps/crove-sso @@ -96,29 +96,29 @@ git commit -m "feat: validate Crove SSO OAuth requests" **Interfaces:** - Consumes: `Env.OAUTH_STATE: DurableObjectNamespace` -- Produces: `OAuthStateStore.fetch()` private operations for `put`, `take`, and `delete-expired` +- Produces: `OAuthStateStore.fetch()` private operations for `put` and atomic `take`, including expired-key deletion - Produces: `putState(env, key, value, ttlSeconds): Promise` - Produces: `takeState(env, key): Promise` -- [ ] **Step 1: Write failing Durable Object tests** +- [x] **Step 1: Write failing Durable Object tests** Test that a stored value can be taken exactly once, a replay returns null, and an expired value returns null. Use the Workers Vitest integration with a real Durable Object binding. -- [ ] **Step 2: Run the targeted tests and verify RED** +- [x] **Step 2: Run the targeted tests and verify RED** Run: `pnpm.cmd --filter @crove/sso test -- test/state-store.test.ts` Expected: FAIL because `OAuthStateStore` is missing. -- [ ] **Step 3: Implement the strongly consistent store** +- [x] **Step 3: Implement the strongly consistent store** Use Durable Object SQLite storage with a single table created in `blockConcurrencyWhile()`. Store JSON, absolute expiry milliseconds, and delete on atomic take. Return 404 for missing and expired keys. -- [ ] **Step 4: Add eviction and concurrency coverage** +- [x] **Step 4: Add eviction and concurrency coverage** Use `evictDurableObject()` to prove persisted state survives eviction. Start two parallel `takeState()` calls and assert exactly one receives the value. -- [ ] **Step 5: Run tests and typecheck** +- [x] **Step 5: Run tests and typecheck** Run: @@ -129,7 +129,7 @@ pnpm.cmd --filter @crove/sso typecheck Expected: all tests pass and TypeScript exits 0. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```powershell git add apps/crove-sso @@ -149,45 +149,45 @@ git commit -m "feat: add single-use OAuth state store" - Consumes: the validation, PKCE, and state-store interfaces from Tasks 1 and 2 - Produces: Worker routes `GET /health`, `GET /authorize`, `GET /callback`, `POST /token`, and `GET /userinfo` -- [ ] **Step 1: Write failing `/health` and `/authorize` integration tests** +- [x] **Step 1: Write failing `/health` and `/authorize` integration tests** Call `exports.default.fetch()` in the Workers runtime. Assert `/health` returns a static JSON response. Assert `/authorize` redirects to Supabase with exact client ID, callback, scope, random state, `code_challenge_method=S256`, and no downstream state exposed upstream. -- [ ] **Step 2: Run the targeted tests and verify RED** +- [x] **Step 2: Run the targeted tests and verify RED** Run: `pnpm.cmd --filter @crove/sso test -- test/worker.test.ts` Expected: FAIL because `src/index.ts` does not exist. -- [ ] **Step 3: Implement `/health` and `/authorize`** +- [x] **Step 3: Implement `/health` and `/authorize`** Route by exact method and pathname. Persist the downstream request, generated verifier, and downstream state for 600 seconds before redirecting to Supabase. -- [ ] **Step 4: Write failing `/callback` tests** +- [x] **Step 4: Write failing `/callback` tests** Mock only the upstream Supabase token endpoint. Prove callback consumes state, posts the stored PKCE verifier with `client_secret_post`, creates a one-time bridge code, preserves the original downstream state, adds `Referrer-Policy: no-referrer`, and rejects missing or replayed upstream state. -- [ ] **Step 5: Implement `/callback`** +- [x] **Step 5: Implement `/callback`** Exchange the upstream code, retain the token response only behind a 60-second random bridge code, and redirect to `https://crove.com/settings`. On upstream error, return a safe `temporarily_unavailable` response without including the upstream body. -- [ ] **Step 6: Write failing `/token` tests** +- [x] **Step 6: Write failing `/token` tests** Prove exact client authentication, exact redirect URI, single-use bridge code consumption, replay rejection, and `Cache-Control: no-store` plus `Pragma: no-cache` on every token response. -- [ ] **Step 7: Implement `/token`** +- [x] **Step 7: Implement `/token`** Consume the bridge code atomically and return the stored upstream JSON response once. Never refresh or transform upstream tokens. -- [ ] **Step 8: Write failing `/userinfo` tests** +- [x] **Step 8: Write failing `/userinfo` tests** Mock only the Supabase UserInfo endpoint. Prove the bridge requires a Bearer token, forwards it without logging, preserves safe status and JSON body, and adds `Cache-Control: no-store`. -- [ ] **Step 9: Implement `/userinfo` and safe structured logging** +- [x] **Step 9: Implement `/userinfo` and safe structured logging** Log only request ID, endpoint, HTTP status, safe error code, and duration. Do not log URLs with query strings, request bodies, authorization headers, token bodies, or claims. -- [ ] **Step 10: Run the full quality gate** +- [x] **Step 10: Run the full quality gate** Run: @@ -200,7 +200,7 @@ git diff --check Expected: all tests pass, type generation and typecheck exit 0, and no whitespace errors exist. -- [ ] **Step 11: Commit** +- [x] **Step 11: Commit** ```powershell git add apps/crove-sso pnpm-lock.yaml @@ -219,31 +219,31 @@ git commit -m "feat: bridge Postiz OAuth to DOS ID" - Produces: GCP secrets `CROVE_SSO_BRIDGE_CLIENT_ID` and `CROVE_SSO_BRIDGE_CLIENT_SECRET` - Produces: deployed origin `https://sso.crove.com` -- [ ] **Step 1: Generate and store downstream credentials** +- [x] **Step 1: Generate and store downstream credentials** Generate a public client ID and a 256-bit client secret with Web Crypto. Add them to GCP Secret Manager over stdin without printing values. -- [ ] **Step 2: Configure Cloudflare secrets** +- [x] **Step 2: Configure Cloudflare secrets** Copy the upstream and downstream credentials from GCP Secret Manager directly into Worker secrets named `UPSTREAM_CLIENT_ID`, `UPSTREAM_CLIENT_SECRET`, `DOWNSTREAM_CLIENT_ID`, and `DOWNSTREAM_CLIENT_SECRET` without writing plaintext temp files. -- [ ] **Step 3: Deploy Worker and custom domain** +- [x] **Step 3: Deploy Worker and custom domain** -Deploy Worker `crove-sso` with compatibility date `2026-08-14`, `nodejs_compat`, Durable Object migration `v1`, observability enabled, and custom domain `sso.crove.com`. +Deploy Worker `crove-sso` with compatibility date `2026-08-13`, `nodejs_compat`, Durable Object migration `v1`, observability enabled, and custom domain `sso.crove.com`. -- [ ] **Step 4: Verify deployed fail-closed behavior** +- [x] **Step 4: Verify deployed fail-closed behavior** Verify `/health` returns 200. Verify unknown client, wrong callback, wrong response type, missing code, and unauthenticated token requests fail with the documented OAuth errors. Confirm no secret values occur in recent Worker logs. -- [ ] **Step 5: Update Supabase OAuth client** +- [x] **Step 5: Update Supabase OAuth client** Using the official Supabase OAuth Admin API with the service-role key from GCP Secret Manager, update Crove client `18790ccb-4d71-48cd-ad24-aee5f3ced3da` to redirect only to `https://sso.crove.com/callback`. Read the client back and assert the exact callback, name, grant types, and authentication method. -- [ ] **Step 6: Configure Postiz production endpoints but keep SSO hidden** +- [x] **Step 6: Configure Postiz production endpoints but keep SSO hidden** Create a root-owned 0600 backup of `/opt/crove/.env`. Set the Postiz OAuth URLs to bridge endpoints and its client credentials to the downstream bridge credentials. Keep `POSTIZ_GENERIC_OAUTH` empty until identity binding finishes. -- [ ] **Step 7: Commit deployment configuration** +- [x] **Step 7: Commit deployment configuration** ```powershell git add apps/crove-sso/wrangler.jsonc @@ -260,23 +260,23 @@ git commit -m "chore: configure Crove SSO deployment" - Consumes: verified DOS ID `sub` and email from Supabase UserInfo - Produces: existing user `c5c577f6-4aef-491a-8f6a-6b975f8b9678` bound to provider `GENERIC` -- [ ] **Step 1: Obtain a verified upstream identity** +- [x] **Step 1: Obtain a verified upstream identity** Run the bridge authorization flow in Chrome. Confirm the visible DOS ID account is `joy@dos.ai`. Complete consent, exchange the returned bridge code through the bridge token endpoint, call UserInfo, and retain only `{sub,email}` in memory. Do not log or persist the access token. -- [ ] **Step 2: Re-run exact database preconditions** +- [x] **Step 2: Re-run exact database preconditions** Assert in one read-only query: one user for `joy@dos.ai`, provider `LOCAL`, empty provider ID, zero `GENERIC` conflicts, one membership to organization `314a2673-b9c3-49d6-b916-6836513381c0`, 15 integrations, and 10 posts. -- [ ] **Step 3: Create a minimal identity backup** +- [x] **Step 3: Create a minimal identity backup** Export the exact user row and membership rows with `pg_dump --data-only --column-inserts` to a root-owned 0600 file. Verify its checksum and nonzero size without printing password or provider tokens. -- [ ] **Step 4: Bind the identity transactionally** +- [x] **Step 4: Bind the identity transactionally** Inside one transaction, lock the exact user row, repeat every precondition, update only `providerName='GENERIC'`, `providerId=`, and `updatedAt=now()`, then require exactly one affected row. Roll back automatically if any assertion fails. -- [ ] **Step 5: Verify preservation** +- [x] **Step 5: Verify preservation** Assert the same user ID, email, membership, organization ID, integration count, and post count. Assert there is one `GENERIC` identity for the verified subject and no remaining `LOCAL` identity for that email. @@ -289,37 +289,49 @@ Assert the same user ID, email, membership, organization ID, integration count, - Produces: visible DOS ID SSO on `https://crove.com/auth` - Produces: local registration disabled while Generic OAuth remains available -- [ ] **Step 1: Activate DOS ID and recreate Postiz** +- [x] **Step 1: Activate DOS ID and recreate Postiz** Set `POSTIZ_GENERIC_OAUTH=true` and `DISABLE_REGISTRATION=true` in `/opt/crove/.env`, recreate only the Postiz application container, and wait for its health check to pass. -- [ ] **Step 2: Verify unauthenticated UI with Playwright** +- [x] **Step 2: Verify unauthenticated UI with Playwright** Assert the auth page shows `DOS ID`, does not offer local registration, and clicking DOS ID redirects through `sso.crove.com` to the Supabase DOS ID consent flow. -- [ ] **Step 3: Verify positive login** +- [x] **Step 3: Verify positive login** Complete DOS ID login and assert Crove opens the existing organization. Verify the UI exposes the previously connected channels and existing posts rather than onboarding a new organization. -- [ ] **Step 4: Verify logout and login again** +- [x] **Step 4: Verify logout and login again** Log out, repeat DOS ID login, and assert the same Postiz user ID and organization are used. -- [ ] **Step 5: Verify negative registration behavior** +- [x] **Step 5: Verify negative registration behavior** Call `/auth/can-register` and assert local registration is false. Attempt a local email registration request and assert it fails with `Registration is disabled`. Confirm DOS ID login still succeeds afterward. -- [ ] **Step 6: Inspect health and logs** +- [x] **Step 6: Inspect health and logs** Assert the Postiz container is healthy, `https://crove.com` returns a successful response, Worker logs contain no secrets or tokens, and there are no OAuth error spikes from the verified run. -- [ ] **Step 7: Run completion audit** +- [x] **Step 7: Run completion audit** Re-read the approved design and this plan. Map every goal and invariant to fresh test, runtime, database, and browser evidence. Do not declare completion if any item lacks direct evidence. -- [ ] **Step 8: Commit final operational documentation changes if any** +- [x] **Step 8: Commit final operational documentation changes if any** ```powershell git add apps/crove-sso docs/superpowers git commit -m "docs: record Crove DOS ID SSO rollout" ``` + +## Production Rollout Evidence + +- Cloudflare Worker deployment: `crove-sso`, deployment `03ce550a5b784411829012e4361f248f`. +- Custom domain: `https://sso.crove.com` with successful health and fail-closed checks. +- Supabase OAuth client: `18790ccb-4d71-48cd-ad24-aee5f3ced3da`, redirect URI restricted to `https://sso.crove.com/callback`. +- Verified DOS ID subject: `48fc3631-ec8c-4e78-aa98-ec89c1c3624d` for `joy@dos.ai`. +- Existing Postiz user preserved: `c5c577f6-4aef-491a-8f6a-6b975f8b9678`, one membership, 15 integrations, and 10 posts. +- Identity backup: `/opt/crove/backups/postiz-identity.pre-dos-id.20260813T211816Z.sql`, SHA-256 `2712a260c8f3002220cad43971d92e33f0f5aebf72a85b10d97bd21494115672`. +- Postiz container verified healthy with zero restarts and no OAuth, unhandled, uncaught, or panic lines in the final log window. +- Cloudflare structured-log audit covered 51 events and found no unexpected application log keys or forbidden secret/token/claim terms. +- Playwright verified the DOS ID login, existing workspace and channels, logout, repeat login, and local registration rejection. diff --git a/docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md b/docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md index de7b9c33a5..4838d85786 100644 --- a/docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md +++ b/docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md @@ -2,7 +2,7 @@ ## Status -Approved architecture direction: deploy a standalone OAuth compatibility bridge and keep the upstream Postiz image unchanged. +Deployed and production-verified on 2026-08-14. The upstream Postiz image remains unchanged. ## Goal @@ -141,7 +141,7 @@ If any precondition fails, stop without mutating the identity. - Worker name: `crove-sso` - Custom domain: `sso.crove.com` -- Compatibility date: `2026-08-14` +- Compatibility date: `2026-08-13` - Compatibility flag: `nodejs_compat` - Observability: enabled with structured logs - Durable Object migration tag: `v1` From 3dc59bc1c0ab31daec12055a5191fbc81e4f240f Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:00:48 +0700 Subject: [PATCH 08/20] feat: implement Crove landing page, SSO bridge, branding and deployment workflows - Add apps/web: Next.js 16 landing page with dark/light mode and purple branding - Update apps/crove-sso: Cloudflare Worker OAuth 2.1 PKCE bridge for DOS ID - Add runtime branding engine, validation guards, and synchronization workflows - Add PowerShell automation scripts for testing, building, and deployment Co-authored-by: Cursor --- .env.example | 23 ++ .github/workflows/branding-guard.yml | 30 ++ .github/workflows/build-containers.yml | 14 + .github/workflows/sync-upstream.yml | 88 +++++ .gitignore | 6 + README.md | 10 + ROADMAP.md | 13 + apps/crove-sso/package.json | 4 +- apps/crove-sso/test/oauth.test.ts | 2 +- apps/crove-sso/worker-configuration.d.ts | 2 +- apps/crove-sso/wrangler.jsonc | 35 +- apps/extension/vite.config.base.ts | 7 +- .../src/app/(app)/(preview)/p/[id]/page.tsx | 4 +- .../app/(app)/(site)/admin/errors/page.tsx | 4 +- .../src/app/(app)/(site)/admin/stats/page.tsx | 4 +- .../src/app/(app)/(site)/agents/[id]/page.tsx | 4 +- .../src/app/(app)/(site)/agents/layout.tsx | 3 +- .../src/app/(app)/(site)/agents/page.tsx | 3 +- .../src/app/(app)/(site)/analytics/page.tsx | 4 +- .../(app)/(site)/billing/lifetime/page.tsx | 4 +- .../src/app/(app)/(site)/billing/page.tsx | 4 +- .../src/app/(app)/(site)/launches/page.tsx | 4 +- .../src/app/(app)/(site)/media/page.tsx | 4 +- .../src/app/(app)/(site)/plugs/page.tsx | 4 +- .../src/app/(app)/(site)/settings/page.tsx | 4 +- .../src/app/(app)/(site)/third-party/page.tsx | 6 +- .../app/(app)/auth/activate/[code]/page.tsx | 6 +- .../src/app/(app)/auth/activate/page.tsx | 6 +- .../app/(app)/auth/forgot/[token]/page.tsx | 4 +- .../src/app/(app)/auth/forgot/page.tsx | 4 +- apps/frontend/src/app/(app)/auth/layout.tsx | 4 +- .../src/app/(app)/auth/login/page.tsx | 4 +- apps/frontend/src/app/(app)/auth/page.tsx | 4 +- apps/frontend/src/app/(app)/layout.tsx | 7 +- apps/frontend/src/app/(extension)/layout.tsx | 5 +- apps/frontend/src/app/(provider)/layout.tsx | 5 +- .../components/layout/logout.component.tsx | 7 +- .../src/components/new-layout/logo.tsx | 48 ++- .../src/components/ui/logo-text.component.tsx | 45 ++- apps/web/.gitignore | 1 + apps/web/next-env.d.ts | 6 + apps/web/next.config.mjs | 14 + apps/web/package.json | 15 + apps/web/postcss.config.mjs | 9 + apps/web/src/app/globals.css | 126 +++++++ apps/web/src/app/layout.tsx | 76 ++++ apps/web/src/app/page.tsx | 55 +++ apps/web/src/components/channels.tsx | 178 +++++++++ apps/web/src/components/comparison.tsx | 173 +++++++++ apps/web/src/components/crove-logo.tsx | 48 +++ apps/web/src/components/cta.tsx | 64 ++++ apps/web/src/components/faq.tsx | 96 +++++ apps/web/src/components/features.tsx | 203 ++++++++++ apps/web/src/components/footer.tsx | 149 ++++++++ apps/web/src/components/hero.tsx | 286 +++++++++++++++ apps/web/src/components/how-it-works.tsx | 103 ++++++ .../src/components/interactive-preview.tsx | 346 ++++++++++++++++++ apps/web/src/components/navbar.tsx | 186 ++++++++++ apps/web/src/components/pricing.tsx | 214 +++++++++++ apps/web/src/components/testimonials.tsx | 102 ++++++ .../src/components/theme/theme-provider.tsx | 117 ++++++ .../web/src/components/theme/theme-toggle.tsx | 31 ++ apps/web/tailwind.config.cjs | 56 +++ apps/web/tsconfig.json | 32 ++ crove-runtime-branding-design-v2.png | Bin 0 -> 161784 bytes crove-runtime-branding-design.png | Bin 0 -> 161215 bytes docker-compose.yaml | 23 ++ docs/README.md | 59 +++ docs/branding.md | 105 ++++++ docs/first-party-provisioning.md | 58 +++ docs/sso-integration.md | 74 ++++ .../plans/2026-08-14-crove-dos-id-sso.md | 337 ----------------- .../2026-08-14-crove-dos-id-sso-design.md | 192 ---------- docs/upstream-sync.md | 48 +++ libraries/helpers/src/swagger/load.swagger.ts | 6 +- libraries/helpers/src/utils/brand.config.ts | 163 +++++++++ .../src/utils/is.general.server.side.ts | 6 + .../nestjs-libraries/src/chat/start.mcp.ts | 4 +- .../src/sentry/initialize.sentry.ts | 3 + .../src/services/email.service.ts | 26 +- .../src/helpers/variable.context.tsx | 15 +- .../get.transation.service.client.ts | 19 +- .../get.translation.service.backend.ts | 14 +- package.json | 4 + pnpm-lock.yaml | 33 +- ...crove-first-party-provisioning-design.html | 98 +++++ scripts/branding-guard.ts | 102 ++++++ scripts/build-all.ps1 | 41 +++ scripts/deploy-sso.ps1 | 98 +++++ scripts/dev.ps1 | 45 +++ tsconfig.base.json | 3 +- vercel.json | 7 + 92 files changed, 4095 insertions(+), 613 deletions(-) create mode 100644 .github/workflows/branding-guard.yml create mode 100644 .github/workflows/sync-upstream.yml create mode 100644 ROADMAP.md create mode 100644 apps/web/.gitignore create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/page.tsx create mode 100644 apps/web/src/components/channels.tsx create mode 100644 apps/web/src/components/comparison.tsx create mode 100644 apps/web/src/components/crove-logo.tsx create mode 100644 apps/web/src/components/cta.tsx create mode 100644 apps/web/src/components/faq.tsx create mode 100644 apps/web/src/components/features.tsx create mode 100644 apps/web/src/components/footer.tsx create mode 100644 apps/web/src/components/hero.tsx create mode 100644 apps/web/src/components/how-it-works.tsx create mode 100644 apps/web/src/components/interactive-preview.tsx create mode 100644 apps/web/src/components/navbar.tsx create mode 100644 apps/web/src/components/pricing.tsx create mode 100644 apps/web/src/components/testimonials.tsx create mode 100644 apps/web/src/components/theme/theme-provider.tsx create mode 100644 apps/web/src/components/theme/theme-toggle.tsx create mode 100644 apps/web/tailwind.config.cjs create mode 100644 apps/web/tsconfig.json create mode 100644 crove-runtime-branding-design-v2.png create mode 100644 crove-runtime-branding-design.png create mode 100644 docs/README.md create mode 100644 docs/branding.md create mode 100644 docs/first-party-provisioning.md create mode 100644 docs/sso-integration.md delete mode 100644 docs/superpowers/plans/2026-08-14-crove-dos-id-sso.md delete mode 100644 docs/superpowers/specs/2026-08-14-crove-dos-id-sso-design.md create mode 100644 docs/upstream-sync.md create mode 100644 libraries/helpers/src/utils/brand.config.ts create mode 100644 reports/crove-first-party-provisioning-design.html create mode 100644 scripts/branding-guard.ts create mode 100644 scripts/build-all.ps1 create mode 100644 scripts/deploy-sso.ps1 create mode 100644 scripts/dev.ps1 create mode 100644 vercel.json diff --git a/.env.example b/.env.example index b6f600ddac..eac62203d4 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,29 @@ NEXT_PUBLIC_POLOTNO="" # NOT_SECURED=false API_LIMIT=30 # The limit of the public API hour limit +# === Brand Customization & White-labeling Settings (Optional) +# Override Postiz branding at runtime without rebuilding Docker images. +# BRAND_NAME="Postiz" +# BRAND_SHORT_NAME="Postiz" +# BRAND_DESCRIPTION="The open-source social media management platform" +# BRAND_COMPANY_NAME="Postiz" +# BRAND_LOGO_URL="" +# BRAND_LOGO_DARK_URL="" +# BRAND_ICON_URL="" +# BRAND_FAVICON_URL="/favicon.ico" +# BRAND_EMAIL_LOGO_URL="" +# BRAND_PRIMARY_COLOR="#612BD3" +# BRAND_WEBSITE_URL="" # Defaults to MAIN_URL or FRONTEND_URL if not set +# BRAND_SUPPORT_URL="https://discord.gg/postiz" +# BRAND_DOCS_URL="https://docs.postiz.com" +# BRAND_SOURCE_URL="https://github.com/gitroomhq/postiz-app" # AGPL-3.0 requirement +# BRAND_TERMS_URL="/terms" +# BRAND_PRIVACY_URL="/privacy" +# BRAND_SUPPORT_EMAIL="" # Defaults to support@ +# BRAND_DEFAULT_EMAIL_DOMAIN="postiz.com" +# BRAND_EXTENSION_STORE_URL="" +# BRAND_TUTORIAL_URL="" + # When connecting providers that take a self-hosted URL (WordPress, Mastodon, # Lemmy, Listmonk, Bluesky PDS, etc.) Postiz fetches that URL server-side and # blocks requests that resolve to private/internal/loopback/link-local IPs to diff --git a/.github/workflows/branding-guard.yml b/.github/workflows/branding-guard.yml new file mode 100644 index 0000000000..aee4e50faf --- /dev/null +++ b/.github/workflows/branding-guard.yml @@ -0,0 +1,30 @@ +name: "Branding Guard" + +on: + push: + branches: ["main", "master"] + pull_request: + branches: ["main", "master"] + workflow_dispatch: + +jobs: + branding-guard: + name: "Validate Brand Engine & Contracts" + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup PNPM + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Run Branding Guard Tests + run: | + pnpm exec tsx scripts/branding-guard.ts diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index a6932814c7..7db9704208 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -34,6 +34,20 @@ jobs: with: fetch-depth: 0 + - name: Setup Node.js & PNPM + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup PNPM + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Verify Branding Guard Before Build + run: | + pnpm exec tsx scripts/branding-guard.ts + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 0000000000..c0c3b1afe1 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,88 @@ +name: "Sync Upstream Postiz" + +on: + schedule: + - cron: "0 3 * * *" # Run daily at 03:00 UTC + workflow_dispatch: + inputs: + auto_merge: + description: "Auto merge into main if no conflicts" + required: false + default: "false" + type: boolean + +permissions: + contents: write + pull-requests: write + +jobs: + sync-upstream: + name: "Fetch & Sync Upstream Releases" + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Git Credentials + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add Upstream Remote + run: | + git remote add upstream https://github.com/gitroomhq/postiz-app.git || true + git fetch upstream main --tags + + - name: Sync and Create PR + id: sync + run: | + BRANCH_NAME="upstream-sync-$(date +%Y%m%d)" + git checkout -b "$BRANCH_NAME" upstream/main || git checkout -b "$BRANCH_NAME" + + # Check if there are changes between main and upstream + COMMITS_BEHIND=$(git rev-list --count HEAD..upstream/main) + echo "Commits behind upstream: $COMMITS_BEHIND" + + if [ "$COMMITS_BEHIND" -gt 0 ]; then + git merge upstream/main -m "chore(sync): automated sync with upstream main" --no-edit || { + echo "Merge conflict detected. Please resolve manually." + exit 1 + } + git push -u origin "$BRANCH_NAME" + echo "has_changes=true" >> "$GITHUB_OUTPUT" + echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" + else + echo "Already up-to-date with upstream." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Setup Node.js & PNPM + if: steps.sync.outputs.has_changes == 'true' + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup PNPM + if: steps.sync.outputs.has_changes == 'true' + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Run Branding Guard on Synced Code + if: steps.sync.outputs.has_changes == 'true' + run: | + pnpm exec tsx scripts/branding-guard.ts + + - name: Create Pull Request + if: steps.sync.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr create \ + --title "chore: sync with upstream Postiz ($(date +%Y-%m-%d))" \ + --body "Automated synchronization with upstream \`gitroomhq/postiz-app\` main branch. Branding guard tests verified." \ + --head "${{ steps.sync.outputs.branch_name }}" \ + --base main diff --git a/.gitignore b/.gitignore index 5cb60826d6..ba968da51e 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,9 @@ i18n.cache # Generated by apps/frontend/scripts/fetch-gtm.mjs on install apps/frontend/public/g.js +.vercel +.env* +.artifacts/ +.codex-artifacts/ +.playwright-mcp/ + diff --git a/README.md b/README.md index f982792668..9215be6ca5 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,16 @@ - Temporal - Resend (email notifications) +## 📖 Official Architecture & Extension Documentation + +For detailed technical guides on self-hosting, white-labeling, SSO, and automated sync: + +- **[Documentation Index](docs/README.md)** +- **[Runtime Branding & White-labeling via ENV](docs/branding.md)** +- **[Upstream Sync & Branding Guard CI](docs/upstream-sync.md)** +- **[Single Sign-On (SSO) & OAuth 2.1 PKCE Bridge](docs/sso-integration.md)** +- **[First-Party Provisioning API](docs/first-party-provisioning.md)** + ## Quick Start To have the project up and running, please follow the [Quick Start Guide](https://docs.postiz.com/quickstart) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000000..3e2684f5a8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,13 @@ +# Crove Roadmap + +## Provider readiness + +### TikTok Content Posting API + +- [ ] Wait for the production TikTok app revision containing `https://crove.com/integrations/social/tiktok` to become Live. +- [ ] Re-run TikTok Login Kit from Crove and verify the production callback end to end. +- [ ] Record a current end-to-end review video covering authorization, profile and statistics, video history, Direct Post, Upload Draft, and the post-action result on `crove.com`. +- [ ] Use a neutral, unbranded media asset and explicitly select `SELF_ONLY` with comments, Duet, and Stitch disabled for the audit demonstration. +- [ ] Resolve or document the stock Postiz defaults that preselect public visibility and enable comments before submitting the TikTok audit. +- [ ] Submit the Content Posting API audit only after the recorded behavior matches the requested products and scopes. + diff --git a/apps/crove-sso/package.json b/apps/crove-sso/package.json index 3f20c171b1..b2cfa0794c 100644 --- a/apps/crove-sso/package.json +++ b/apps/crove-sso/package.json @@ -6,7 +6,9 @@ "test": "vitest run", "typecheck": "tsc --noEmit", "cf-typegen": "wrangler types --include-runtime false", - "deploy": "wrangler deploy" + "deploy": "wrangler deploy", + "deploy:beta": "wrangler deploy -e beta", + "deploy:prod": "wrangler deploy" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.21.3", diff --git a/apps/crove-sso/test/oauth.test.ts b/apps/crove-sso/test/oauth.test.ts index 5f3837814f..58914ed491 100644 --- a/apps/crove-sso/test/oauth.test.ts +++ b/apps/crove-sso/test/oauth.test.ts @@ -11,7 +11,7 @@ import { const env = { DOWNSTREAM_CLIENT_ID: 'crove-postiz', DOWNSTREAM_CLIENT_SECRET: 'bridge-secret-value', - DOWNSTREAM_REDIRECT_URI: 'https://crove.com/settings', + DOWNSTREAM_REDIRECT_URI: 'https://app.crove.com/settings', ALLOWED_SCOPE: 'openid profile email', } as Env; diff --git a/apps/crove-sso/worker-configuration.d.ts b/apps/crove-sso/worker-configuration.d.ts index d3f92695a7..eb5f253b25 100644 --- a/apps/crove-sso/worker-configuration.d.ts +++ b/apps/crove-sso/worker-configuration.d.ts @@ -5,7 +5,7 @@ interface __BaseEnv_Env { UPSTREAM_TOKEN_URL: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/token"; UPSTREAM_USERINFO_URL: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/userinfo"; UPSTREAM_REDIRECT_URI: "https://sso.crove.com/callback"; - DOWNSTREAM_REDIRECT_URI: "https://crove.com/settings"; + DOWNSTREAM_REDIRECT_URI: "https://app.crove.com/settings"; ALLOWED_SCOPE: "openid profile email"; OAUTH_STATE: DurableObjectNamespace; } diff --git a/apps/crove-sso/wrangler.jsonc b/apps/crove-sso/wrangler.jsonc index 7d4f5de6ca..2f8f7802c1 100644 --- a/apps/crove-sso/wrangler.jsonc +++ b/apps/crove-sso/wrangler.jsonc @@ -32,7 +32,40 @@ "UPSTREAM_TOKEN_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/token", "UPSTREAM_USERINFO_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/userinfo", "UPSTREAM_REDIRECT_URI": "https://sso.crove.com/callback", - "DOWNSTREAM_REDIRECT_URI": "https://crove.com/settings", + "DOWNSTREAM_REDIRECT_URI": "https://app.crove.com/settings", "ALLOWED_SCOPE": "openid profile email" + }, + "env": { + "beta": { + "name": "crove-sso-beta", + "routes": [ + { + "pattern": "beta-sso.crove.com", + "custom_domain": true + } + ], + "durable_objects": { + "bindings": [ + { + "name": "OAUTH_STATE", + "class_name": "OAuthStateStore" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["OAuthStateStore"] + } + ], + "vars": { + "UPSTREAM_AUTHORIZE_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/authorize", + "UPSTREAM_TOKEN_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/token", + "UPSTREAM_USERINFO_URL": "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/userinfo", + "UPSTREAM_REDIRECT_URI": "https://beta-sso.crove.com/callback", + "DOWNSTREAM_REDIRECT_URI": "https://beta-app.crove.com/settings", + "ALLOWED_SCOPE": "openid profile email" + } + } } } diff --git a/apps/extension/vite.config.base.ts b/apps/extension/vite.config.base.ts index 6a8808976b..18f0f2d126 100644 --- a/apps/extension/vite.config.base.ts +++ b/apps/extension/vite.config.base.ts @@ -13,10 +13,15 @@ const isDev = process.env.NODE_ENV === 'development'; // set this flag to true, if you want localization support const localize = false; +const brandName = process.env.BRAND_NAME || process.env.NEXT_PUBLIC_BRAND_NAME || manifest.name; +const brandDesc = process.env.BRAND_DESCRIPTION || process.env.NEXT_PUBLIC_BRAND_DESCRIPTION || `${brandName} browser extension for social media scheduling`; + const merge = isDev ? devManifest : ({} as ManifestV3Export); export const baseManifest = { ...manifest, + name: brandName, + description: brandDesc, host_permissions: [ import.meta.env?.FRONTEND_URL || process?.env?.FRONTEND_URL + '/*', (import.meta.env?.NEXT_PUBLIC_BACKEND_URL || process?.env?.NEXT_PUBLIC_BACKEND_URL || '') + '/*', @@ -40,7 +45,7 @@ export const baseBuildOptions: BuildOptions = { }; export default defineConfig({ - envPrefix: ['NEXT_PUBLIC_', 'FRONTEND_URL', 'NEXT_PUBLIC_BACKEND_URL'], + envPrefix: ['NEXT_PUBLIC_', 'FRONTEND_URL', 'NEXT_PUBLIC_BACKEND_URL', 'BRAND_'], plugins: [ tsconfigPaths(), react(), diff --git a/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx b/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx index f5dc7b463a..17a6b60719 100644 --- a/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx +++ b/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx @@ -2,7 +2,7 @@ import { internalFetch } from '@gitroom/helpers/utils/internal.fetch'; import { sanitizePostContent } from '@gitroom/helpers/utils/sanitize.post.content'; export const dynamic = 'force-dynamic'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; import SafeImage from '@gitroom/react/helpers/safe.image'; import Link from 'next/link'; import { CommentsComponents } from '@gitroom/frontend/components/preview/comments.components'; @@ -16,7 +16,7 @@ import { CreationMethodBadge } from '@gitroom/frontend/components/launches/creat dayjs.extend(utc); export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Preview`, + title: `${getBrandNameServerSide()} Preview`, description: '', }; export default async function Auth( diff --git a/apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx b/apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx index c915101adc..0d08a86888 100644 --- a/apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx @@ -1,10 +1,10 @@ export const dynamic = 'force-dynamic'; import { AdminErrorsComponent } from '@gitroom/frontend/components/admin/admin-errors.component'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Admin Errors`, + title: `${getBrandNameServerSide()} Admin Errors`, description: '', }; diff --git a/apps/frontend/src/app/(app)/(site)/admin/stats/page.tsx b/apps/frontend/src/app/(app)/(site)/admin/stats/page.tsx index 684297fe3f..3972bafa98 100644 --- a/apps/frontend/src/app/(app)/(site)/admin/stats/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/admin/stats/page.tsx @@ -1,10 +1,10 @@ export const dynamic = 'force-dynamic'; import { AdminStatsComponent } from '@gitroom/frontend/components/admin/admin-stats.component'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Admin Stats`, + title: `${getBrandNameServerSide()} Admin Stats`, description: '', }; diff --git a/apps/frontend/src/app/(app)/(site)/agents/[id]/page.tsx b/apps/frontend/src/app/(app)/(site)/agents/[id]/page.tsx index dad5a9dcf9..109d4556ab 100644 --- a/apps/frontend/src/app/(app)/(site)/agents/[id]/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/agents/[id]/page.tsx @@ -1,8 +1,8 @@ import { Metadata } from 'next'; -import { Agent } from '@gitroom/frontend/components/agents/agent'; import { AgentChat } from '@gitroom/frontend/components/agents/agent.chat'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: 'Postiz - Agent', + title: `${getBrandNameServerSide()} - Agent`, description: '', }; export default async function Page() { diff --git a/apps/frontend/src/app/(app)/(site)/agents/layout.tsx b/apps/frontend/src/app/(app)/(site)/agents/layout.tsx index 7ab74559c4..66348e35b5 100644 --- a/apps/frontend/src/app/(app)/(site)/agents/layout.tsx +++ b/apps/frontend/src/app/(app)/(site)/agents/layout.tsx @@ -1,7 +1,8 @@ import { Metadata } from 'next'; import { Agent } from '@gitroom/frontend/components/agents/agent'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: 'Postiz - Agent', + title: `${getBrandNameServerSide()} - Agent`, description: 'agents', }; export default async function Layout({ diff --git a/apps/frontend/src/app/(app)/(site)/agents/page.tsx b/apps/frontend/src/app/(app)/(site)/agents/page.tsx index bc1005ea71..7f8b333bd7 100644 --- a/apps/frontend/src/app/(app)/(site)/agents/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/agents/page.tsx @@ -1,8 +1,9 @@ import { Metadata } from 'next'; import { redirect } from 'next/navigation'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: 'Postiz - Agent', + title: `${getBrandNameServerSide()} - Agent`, description: '', }; diff --git a/apps/frontend/src/app/(app)/(site)/analytics/page.tsx b/apps/frontend/src/app/(app)/(site)/analytics/page.tsx index 8931fead86..66e51a6bb7 100644 --- a/apps/frontend/src/app/(app)/(site)/analytics/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/analytics/page.tsx @@ -1,9 +1,9 @@ export const dynamic = 'force-dynamic'; import { Metadata } from 'next'; import { PlatformAnalytics } from '@gitroom/frontend/components/platform-analytics/platform.analytics'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Analytics`, + title: `${getBrandNameServerSide()} Analytics`, description: '', }; export default async function Index() { diff --git a/apps/frontend/src/app/(app)/(site)/billing/lifetime/page.tsx b/apps/frontend/src/app/(app)/(site)/billing/lifetime/page.tsx index d952f878a1..e4cff200fb 100644 --- a/apps/frontend/src/app/(app)/(site)/billing/lifetime/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/billing/lifetime/page.tsx @@ -1,9 +1,9 @@ import { LifetimeDeal } from '@gitroom/frontend/components/billing/lifetime.deal'; export const dynamic = 'force-dynamic'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Lifetime deal`, + title: `${getBrandNameServerSide()} Lifetime deal`, description: '', }; export default async function Page() { diff --git a/apps/frontend/src/app/(app)/(site)/billing/page.tsx b/apps/frontend/src/app/(app)/(site)/billing/page.tsx index bb879c108b..cc353d1352 100644 --- a/apps/frontend/src/app/(app)/(site)/billing/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/billing/page.tsx @@ -1,9 +1,9 @@ export const dynamic = 'force-dynamic'; import { BillingComponent } from '@gitroom/frontend/components/billing/billing.component'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Billing`, + title: `${getBrandNameServerSide()} Billing`, description: '', }; export default async function Page() { diff --git a/apps/frontend/src/app/(app)/(site)/launches/page.tsx b/apps/frontend/src/app/(app)/(site)/launches/page.tsx index 28295b62d0..99092da57a 100644 --- a/apps/frontend/src/app/(app)/(site)/launches/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/launches/page.tsx @@ -1,9 +1,9 @@ export const dynamic = 'force-dynamic'; import { LaunchesComponent } from '@gitroom/frontend/components/launches/launches.component'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz Calendar' : 'Gitroom Launches'}`, + title: `${getBrandNameServerSide()} Calendar`, description: '', }; export default async function Index() { diff --git a/apps/frontend/src/app/(app)/(site)/media/page.tsx b/apps/frontend/src/app/(app)/(site)/media/page.tsx index 9eac08e1dc..19fecfce52 100644 --- a/apps/frontend/src/app/(app)/(site)/media/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/media/page.tsx @@ -1,9 +1,9 @@ import { MediaLayoutComponent } from '@gitroom/frontend/components/new-layout/layout.media.component'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Media`, + title: `${getBrandNameServerSide()} Media`, description: '', }; diff --git a/apps/frontend/src/app/(app)/(site)/plugs/page.tsx b/apps/frontend/src/app/(app)/(site)/plugs/page.tsx index af6f39bc45..119f3c9b63 100644 --- a/apps/frontend/src/app/(app)/(site)/plugs/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/plugs/page.tsx @@ -1,9 +1,9 @@ import { Plugs } from '@gitroom/frontend/components/plugs/plugs'; export const dynamic = 'force-dynamic'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Plugs`, + title: `${getBrandNameServerSide()} Plugs`, description: '', }; export default async function Index() { diff --git a/apps/frontend/src/app/(app)/(site)/settings/page.tsx b/apps/frontend/src/app/(app)/(site)/settings/page.tsx index 5a84f2704c..46d383d7f8 100644 --- a/apps/frontend/src/app/(app)/(site)/settings/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/settings/page.tsx @@ -1,9 +1,9 @@ import { SettingsPopup } from '@gitroom/frontend/components/layout/settings.component'; export const dynamic = 'force-dynamic'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Settings`, + title: `${getBrandNameServerSide()} Settings`, description: '', }; export default async function Index(props: { diff --git a/apps/frontend/src/app/(app)/(site)/third-party/page.tsx b/apps/frontend/src/app/(app)/(site)/third-party/page.tsx index fef12c61e4..32176265da 100644 --- a/apps/frontend/src/app/(app)/(site)/third-party/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/third-party/page.tsx @@ -2,11 +2,9 @@ import { ThirdPartyComponent } from '@gitroom/frontend/components/third-parties/ export const dynamic = 'force-dynamic'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${ - isGeneralServerSide() ? 'Postiz Integrations' : 'Gitroom Integrations' - }`, + title: `${getBrandNameServerSide()} Integrations`, description: '', }; export default async function Index() { diff --git a/apps/frontend/src/app/(app)/auth/activate/[code]/page.tsx b/apps/frontend/src/app/(app)/auth/activate/[code]/page.tsx index c6dcb1860f..875bf83b37 100644 --- a/apps/frontend/src/app/(app)/auth/activate/[code]/page.tsx +++ b/apps/frontend/src/app/(app)/auth/activate/[code]/page.tsx @@ -1,11 +1,9 @@ export const dynamic = 'force-dynamic'; import { Metadata } from 'next'; import { AfterActivate } from '@gitroom/frontend/components/auth/after.activate'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${ - isGeneralServerSide() ? 'Postiz' : 'Gitroom' - } - Activate your account`, + title: `${getBrandNameServerSide()} - Activate your account`, description: '', }; export default async function Auth() { diff --git a/apps/frontend/src/app/(app)/auth/activate/page.tsx b/apps/frontend/src/app/(app)/auth/activate/page.tsx index b6a3962fe7..56d12490ac 100644 --- a/apps/frontend/src/app/(app)/auth/activate/page.tsx +++ b/apps/frontend/src/app/(app)/auth/activate/page.tsx @@ -1,11 +1,9 @@ export const dynamic = 'force-dynamic'; import { Metadata } from 'next'; import { Activate } from '@gitroom/frontend/components/auth/activate'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${ - isGeneralServerSide() ? 'Postiz' : 'Gitroom' - } - Activate your account`, + title: `${getBrandNameServerSide()} - Activate your account`, description: '', }; export default async function Auth() { diff --git a/apps/frontend/src/app/(app)/auth/forgot/[token]/page.tsx b/apps/frontend/src/app/(app)/auth/forgot/[token]/page.tsx index fbcbfa0782..71154089c7 100644 --- a/apps/frontend/src/app/(app)/auth/forgot/[token]/page.tsx +++ b/apps/frontend/src/app/(app)/auth/forgot/[token]/page.tsx @@ -1,9 +1,9 @@ export const dynamic = 'force-dynamic'; import { ForgotReturn } from '@gitroom/frontend/components/auth/forgot-return'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Forgot Password`, + title: `${getBrandNameServerSide()} Forgot Password`, description: '', }; export default async function Auth(params: { diff --git a/apps/frontend/src/app/(app)/auth/forgot/page.tsx b/apps/frontend/src/app/(app)/auth/forgot/page.tsx index 3118fd2f71..36046a7022 100644 --- a/apps/frontend/src/app/(app)/auth/forgot/page.tsx +++ b/apps/frontend/src/app/(app)/auth/forgot/page.tsx @@ -1,9 +1,9 @@ export const dynamic = 'force-dynamic'; import { Forgot } from '@gitroom/frontend/components/auth/forgot'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Forgot Password`, + title: `${getBrandNameServerSide()} Forgot Password`, description: '', }; export default async function Auth() { diff --git a/apps/frontend/src/app/(app)/auth/layout.tsx b/apps/frontend/src/app/(app)/auth/layout.tsx index b53f0f18b6..3ac56f0f30 100644 --- a/apps/frontend/src/app/(app)/auth/layout.tsx +++ b/apps/frontend/src/app/(app)/auth/layout.tsx @@ -5,6 +5,7 @@ import { ReactNode } from 'react'; import loadDynamic from 'next/dynamic'; import { TestimonialComponent } from '@gitroom/frontend/components/auth/testimonial.component'; import { LogoTextComponent } from '@gitroom/frontend/components/ui/logo-text.component'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; const ReturnUrlComponent = loadDynamic(() => import('./return.url.component')); export default async function AuthLayout({ children, @@ -12,6 +13,7 @@ export default async function AuthLayout({ children: ReactNode; }) { const t = await getT(); + const brandName = getBrandNameServerSide(); return (
@@ -28,7 +30,7 @@ export default async function AuthLayout({ Over 20,000+{' '} Entrepreneurs use
- Postiz To Grow Their Social Presence + {brandName} To Grow Their Social Presence
diff --git a/apps/frontend/src/app/(app)/auth/login/page.tsx b/apps/frontend/src/app/(app)/auth/login/page.tsx index 2a29d096fb..c51f754208 100644 --- a/apps/frontend/src/app/(app)/auth/login/page.tsx +++ b/apps/frontend/src/app/(app)/auth/login/page.tsx @@ -1,9 +1,9 @@ export const dynamic = 'force-dynamic'; import { Login } from '@gitroom/frontend/components/auth/login'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Login`, + title: `${getBrandNameServerSide()} Login`, description: '', }; export default async function Auth() { diff --git a/apps/frontend/src/app/(app)/auth/page.tsx b/apps/frontend/src/app/(app)/auth/page.tsx index d5e6847e62..ca1ff26422 100644 --- a/apps/frontend/src/app/(app)/auth/page.tsx +++ b/apps/frontend/src/app/(app)/auth/page.tsx @@ -2,12 +2,12 @@ import { internalFetch } from '@gitroom/helpers/utils/internal.fetch'; export const dynamic = 'force-dynamic'; import { Register } from '@gitroom/frontend/components/auth/register'; import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; import Link from 'next/link'; import { getT } from '@gitroom/react/translation/get.translation.service.backend'; import { LoginWithOidc } from '@gitroom/frontend/components/auth/login.with.oidc'; export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Register`, + title: `${getBrandNameServerSide()} Register`, description: '', }; export default async function Auth(params: {searchParams: Promise<{provider: string}>}) { diff --git a/apps/frontend/src/app/(app)/layout.tsx b/apps/frontend/src/app/(app)/layout.tsx index 3a46e8d6f9..def87b1b5a 100644 --- a/apps/frontend/src/app/(app)/layout.tsx +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -10,6 +10,7 @@ import { Plus_Jakarta_Sans } from 'next/font/google'; import PlausibleProvider from 'next-plausible'; import clsx from 'clsx'; import { VariableContextComponent } from '@gitroom/react/helpers/variable.context'; +import { getBrandConfig } from '@gitroom/helpers/utils/brand.config'; import { Fragment } from 'react'; import { PHProvider } from '@gitroom/react/helpers/posthog'; import UtmSaver from '@gitroom/helpers/utils/utm.saver'; @@ -34,17 +35,18 @@ const jakartaSans = Plus_Jakarta_Sans({ export default async function AppLayout({ children }: { children: ReactNode }) { const cookieStore = await cookies(); const language = cookieStore.get(cookieName)?.value || fallbackLng; + const brandConfig = getBrandConfig(process.env); const Plausible = !!process.env.STRIPE_PUBLISHABLE_KEY ? PlausibleProvider : Fragment; return ( - + {!!process.env.DATAFAST_WEBSITE_ID && ( ') === undefined, 'Reject data: URLs'); + assert(sanitizeUrl('//evil.com/phishing') === undefined, 'Reject protocol-relative URLs'); + assert(sanitizeUrl('https://user:password@evil.com') === undefined, 'Reject embedded credentials'); + assert(sanitizeUrl('/custom-logo.svg') === '/custom-logo.svg', 'Allow root-relative paths'); + assert(sanitizeUrl('https://cdn.example.com/logo.png') === 'https://cdn.example.com/logo.png', 'Allow valid HTTPS URLs'); + + assert(sanitizeHexColor('red') === undefined, 'Reject named CSS color'); + assert(sanitizeHexColor('#XYZ123') === undefined, 'Reject invalid hex color'); + assert(sanitizeHexColor('#ff0055') === '#ff0055', 'Allow 6-digit hex color'); + assert(sanitizeHexColor('#f05') === '#f05', 'Allow 3-digit hex color'); + assert(sanitizeHexColor('#ff0055aa') === '#ff0055aa', 'Allow 8-digit hex color'); +} + +// 4. Dynamic string replacement test +{ + const template = 'Welcome to Postiz! Postiz is great.'; + const replaced = applyBrandToString(template, 'Crove'); + assert(replaced === 'Welcome to Crove! Crove is great.', 'applyBrandToString replaces brand words correctly'); + + const unchanged = applyBrandToString(template, 'Postiz'); + assert(unchanged === template, 'applyBrandToString retains string when brand is Postiz'); +} + +// 5. AGPL Compliance test (Source code URL must be preserved) +{ + const custom = getBrandConfig({ + BRAND_NAME: 'MyCompany', + }); + assert( + !!custom.sourceUrl && custom.sourceUrl.includes('postiz-app'), + 'AGPL requirement: sourceUrl must default to original upstream repository' + ); +} + +console.log('\n=== Branding Guard Summary ==='); +if (failed) { + console.error('\nBranding Guard validations FAILED! Check logs above.\n'); + process.exit(1); +} else { + console.log('\nAll Branding Guard validations PASSED successfully!\n'); + process.exit(0); +} diff --git a/scripts/build-all.ps1 b/scripts/build-all.ps1 new file mode 100644 index 0000000000..2479ab7242 --- /dev/null +++ b/scripts/build-all.ps1 @@ -0,0 +1,41 @@ +<# +.SYNOPSIS + Kiểm tra và Build toàn bộ các ứng dụng trong monorepo Crove. +.DESCRIPTION + Tự động hóa build song song / tuần tự cho: + - apps/web (Landing page Next.js) + - apps/frontend (App Dashboard Next.js) + - apps/backend (API NestJS) + - apps/orchestrator (Temporal Background Jobs) + - apps/crove-sso (Cloudflare Worker SSO Bridge) +.EXAMPLE + .\scripts\build-all.ps1 +#> + +$ErrorActionPreference = "Stop" + +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host " CROVE MONOREPO - FULL BUILD & VALIDATION" -ForegroundColor Yellow +Write-Host "==========================================================" -ForegroundColor Cyan + +# 1. Build & Test SSO Worker +Write-Host "`n[1/4] Build & Test Cloudflare Worker SSO (@crove/sso)..." -ForegroundColor Green +pnpm --filter @crove/sso test +if ($LASTEXITCODE -ne 0) { Write-Error "SSO Test thất bại!"; exit 1 } + +# 2. Build Landing Page +Write-Host "`n[2/4] Build Landing Page (@crove/web)..." -ForegroundColor Green +pnpm --filter @crove/web run build +if ($LASTEXITCODE -ne 0) { Write-Error "Web Build thất bại!"; exit 1 } + +# 3. Prisma Generate +Write-Host "`n[3/4] Generate Prisma Client..." -ForegroundColor Green +pnpm run prisma-generate + +# 4. Build Core Apps +Write-Host "`n[4/4] Build Core Backend & Frontend..." -ForegroundColor Green +pnpm run build + +Write-Host "`n==========================================================" -ForegroundColor Green +Write-Host " TẤT CẢ CÁC APP ĐÃ BUILD THÀNH CÔNG!" -ForegroundColor Green +Write-Host "==========================================================" -ForegroundColor Green diff --git a/scripts/deploy-sso.ps1 b/scripts/deploy-sso.ps1 new file mode 100644 index 0000000000..200fa79d2e --- /dev/null +++ b/scripts/deploy-sso.ps1 @@ -0,0 +1,98 @@ +<# +.SYNOPSIS + Tự động hóa build, test và deploy Cloudflare Worker Crove SSO (Beta & Prod). +.DESCRIPTION + Script thực hiện: + 1. Kiểm tra môi trường Node.js & pnpm + 2. Chạy test bộ mã nguồn SSO (31/31 unit tests) + 3. Deploy worker lên Cloudflare qua Wrangler CLI tương ứng với môi trường chỉ định +.PARAMETER Environment + Môi trường triển khai: 'beta' (mặc định) hoặc 'prod' +.EXAMPLE + .\scripts\deploy-sso.ps1 -Environment beta +.EXAMPLE + .\scripts\deploy-sso.ps1 -Environment prod +#> + +[CmdletBinding()] +param ( + [Parameter(Position = 0)] + [ValidateSet("beta", "prod")] + [string]$Environment = "beta", + + [Parameter()] + [string]$UpstreamClientId = "", + + [Parameter()] + [string]$UpstreamClientSecret = "", + + [Parameter()] + [string]$DownstreamClientId = "", + + [Parameter()] + [string]$DownstreamClientSecret = "" +) + +$ErrorActionPreference = "Stop" + +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host " CROVE SSO WORKER - AUTOMATED DEPLOYMENT ($($Environment.ToUpper()))" -ForegroundColor Yellow +Write-Host "==========================================================" -ForegroundColor Cyan + +# 1. Chạy unit tests SSO +Write-Host "`n[1/3] Đang chạy bộ kiểm thử Vitest cho @crove/sso..." -ForegroundColor Green +$testResult = pnpm --filter @crove/sso test +if ($LASTEXITCODE -ne 0) { + Write-Error "Kiểm thử thất bại! Dừng quá trình deploy." + exit 1 +} +Write-Host "-> Toàn bộ tests đã vượt qua thành công!" -ForegroundColor Green + +# 2. Cấu hình Secrets nếu có truyền vào +if ($UpstreamClientId -or $UpstreamClientSecret) { + Write-Host "`n[2/3] Đang cấu hình Cloudflare Secrets..." -ForegroundColor Green + $envFlag = if ($Environment -eq "beta") { "-e beta" } else { "" } + + if ($UpstreamClientId) { + Write-Host "-> Thiết lập UPSTREAM_CLIENT_ID..." + $UpstreamClientId | pnpm --filter @crove/sso exec wrangler secret put UPSTREAM_CLIENT_ID $envFlag + } + if ($UpstreamClientSecret) { + Write-Host "-> Thiết lập UPSTREAM_CLIENT_SECRET..." + $UpstreamClientSecret | pnpm --filter @crove/sso exec wrangler secret put UPSTREAM_CLIENT_SECRET $envFlag + } + if ($DownstreamClientId) { + Write-Host "-> Thiết lập DOWNSTREAM_CLIENT_ID..." + $DownstreamClientId | pnpm --filter @crove/sso exec wrangler secret put DOWNSTREAM_CLIENT_ID $envFlag + } + if ($DownstreamClientSecret) { + Write-Host "-> Thiết lập DOWNSTREAM_CLIENT_SECRET..." + $DownstreamClientSecret | pnpm --filter @crove/sso exec wrangler secret put DOWNSTREAM_CLIENT_SECRET $envFlag + } +} else { + Write-Host "`n[2/3] Bỏ qua nạp secrets (sử dụng secrets đã lưu trên Cloudflare Dashboard)." -ForegroundColor Gray +} + +# 3. Deploy Worker +Write-Host "`n[3/3] Đang deploy Cloudflare Worker ($Environment)..." -ForegroundColor Green +if ($Environment -eq "beta") { + pnpm --filter @crove/sso run deploy:beta +} else { + pnpm --filter @crove/sso run deploy:prod +} + +if ($LASTEXITCODE -eq 0) { + Write-Host "`n==========================================================" -ForegroundColor Green + Write-Host " DEPLOY THÀNH CÔNG CHO MÔI TRƯỜNG $($Environment.ToUpper())!" -ForegroundColor Green + if ($Environment -eq "beta") { + Write-Host " SSO Endpoint: https://beta-sso.crove.com" -ForegroundColor Cyan + Write-Host " App Callback: https://beta-app.crove.com/settings" -ForegroundColor Cyan + } else { + Write-Host " SSO Endpoint: https://sso.crove.com" -ForegroundColor Cyan + Write-Host " App Callback: https://app.crove.com/settings" -ForegroundColor Cyan + } + Write-Host "==========================================================" -ForegroundColor Green +} else { + Write-Error "Deployment thất bại. Vui lòng kiểm tra quyền đăng nhập Cloudflare (wrangler login) hoặc token API." + exit 1 +} diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 0000000000..2f9b050404 --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,45 @@ +<# +.SYNOPSIS + Khởi động môi trường phát triển (Dev) cho Crove Monorepo trên Windows PowerShell. +.DESCRIPTION + Hỗ trợ chạy đồng thời: + - Landing Page Next.js (Port 3000) + - App Frontend Dashboard (Port 4200) + - Backend API & Orchestrator (Port 3000 internal / background) +.PARAMETER Mode + 'all' (toàn bộ gồm Web + App + Backend), 'web' (chỉ Landing Page), 'app' (chỉ App Frontend) +.EXAMPLE + .\scripts\dev.ps1 -Mode all +.EXAMPLE + .\scripts\dev.ps1 -Mode web +#> + +[CmdletBinding()] +param ( + [Parameter(Position = 0)] + [ValidateSet("all", "web", "app", "sso")] + [string]$Mode = "all" +) + +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host " STARTING CROVE DEV ENVIRONMENT [Mode: $($Mode.ToUpper())]" -ForegroundColor Yellow +Write-Host "==========================================================" -ForegroundColor Cyan + +switch ($Mode) { + "web" { + Write-Host "Khởi chạy Landing Page (apps/web) trên http://localhost:3000..." -ForegroundColor Green + pnpm --filter @crove/web run dev + } + "app" { + Write-Host "Khởi chạy App Frontend (apps/frontend)..." -ForegroundColor Green + pnpm --filter @crove/frontend run dev + } + "sso" { + Write-Host "Khởi chạy SSO Worker cục bộ..." -ForegroundColor Green + pnpm --filter @crove/sso run dev + } + "all" { + Write-Host "Khởi chạy song song Landing Page, Frontend & Backend..." -ForegroundColor Green + pnpm run dev + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index c2abe7bf1f..95539a0891 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -33,7 +33,8 @@ "@gitroom/react/*": ["libraries/react-shared-libraries/src/*"], "@gitroom/plugins/*": ["libraries/plugins/src/*"], "@gitroom/orchestrator/*": ["apps/orchestrator/src/*"], - "@gitroom/extension/*": ["apps/extension/src/*"] + "@gitroom/extension/*": ["apps/extension/src/*"], + "@crove/web/*": ["apps/web/src/*"] } }, "exclude": ["node_modules", "tmp"] diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000000..dcf80fb9f8 --- /dev/null +++ b/vercel.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "buildCommand": "pnpm --filter @crove/web run build", + "outputDirectory": "apps/web/.next", + "installCommand": "pnpm install" +} From 8e96d8c0517bdaa8f4660a128975ff6c35b821da Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:13:51 +0700 Subject: [PATCH 09/20] feat(web): redesign landing page with modern violet theme, dark/light mode and VI/EN i18n - Upgrade landing page visual design to clean, modern Linear/Raycast aesthetic with purple brand accents - Add full bilingual (Vietnamese/English) i18n support with language toggle - Refactor Hero, Channels, Features Bento Grid, Studio Preview, Pricing, FAQ and Footer - Add language switcher to auth layout Co-authored-by: Cursor --- apps/frontend/src/app/(app)/auth/layout.tsx | 16 +- apps/web/src/app/globals.css | 62 +- apps/web/src/app/layout.tsx | 7 +- apps/web/src/app/page.tsx | 16 +- apps/web/src/components/channels.tsx | 154 ++-- apps/web/src/components/comparison.tsx | 173 ++-- apps/web/src/components/cta.tsx | 46 +- apps/web/src/components/faq.tsx | 66 +- apps/web/src/components/features.tsx | 188 ++-- apps/web/src/components/footer.tsx | 82 +- apps/web/src/components/hero.tsx | 394 +++++---- apps/web/src/components/how-it-works.tsx | 95 ++- .../src/components/interactive-preview.tsx | 452 +++++----- apps/web/src/components/language-toggle.tsx | 77 ++ apps/web/src/components/navbar.tsx | 133 +-- apps/web/src/components/pricing.tsx | 240 +++--- apps/web/src/components/testimonials.tsx | 96 ++- apps/web/src/i18n/i18n-context.tsx | 83 ++ apps/web/src/i18n/translations.ts | 802 ++++++++++++++++++ apps/web/tailwind.config.cjs | 40 +- 20 files changed, 2142 insertions(+), 1080 deletions(-) create mode 100644 apps/web/src/components/language-toggle.tsx create mode 100644 apps/web/src/i18n/i18n-context.tsx create mode 100644 apps/web/src/i18n/translations.ts diff --git a/apps/frontend/src/app/(app)/auth/layout.tsx b/apps/frontend/src/app/(app)/auth/layout.tsx index 3ac56f0f30..8157e6b131 100644 --- a/apps/frontend/src/app/(app)/auth/layout.tsx +++ b/apps/frontend/src/app/(app)/auth/layout.tsx @@ -6,7 +6,10 @@ import loadDynamic from 'next/dynamic'; import { TestimonialComponent } from '@gitroom/frontend/components/auth/testimonial.component'; import { LogoTextComponent } from '@gitroom/frontend/components/ui/logo-text.component'; import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { LanguageComponent } from '@gitroom/frontend/components/layout/language.component'; + const ReturnUrlComponent = loadDynamic(() => import('./return.url.component')); + export default async function AuthLayout({ children, }: { @@ -16,8 +19,10 @@ export default async function AuthLayout({ const brandName = getBrandNameServerSide(); return ( -
- {/**/} +
+
+ +
@@ -27,10 +32,11 @@ export default async function AuthLayout({
- Over 20,000+{' '} - Entrepreneurs use + {t('auth_social_proof_prefix', 'Over')}{' '} + 20,000+{' '} + {t('auth_social_proof_middle', 'Entrepreneurs use')}
- {brandName} To Grow Their Social Presence + {brandName} {t('auth_social_proof_suffix', 'To Grow Their Social Presence')}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index e18e809d22..b38542efe9 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -4,19 +4,19 @@ @layer base { :root { - --bg-main: #fafafa; + --bg-main: #faf9fe; --text-main: #09090b; --card-bg: rgba(255, 255, 255, 0.85); - --card-border: rgba(228, 228, 231, 0.8); - --brand-glow: rgba(124, 58, 237, 0.15); + --card-border: rgba(226, 224, 240, 0.85); + --brand-glow: rgba(124, 58, 237, 0.12); } .dark { - --bg-main: #07070d; + --bg-main: #08080f; --text-main: #fafafa; - --card-bg: rgba(18, 18, 28, 0.75); + --card-bg: rgba(16, 16, 26, 0.75); --card-border: rgba(255, 255, 255, 0.08); - --brand-glow: rgba(124, 58, 237, 0.25); + --brand-glow: rgba(124, 58, 237, 0.28); } html { @@ -27,40 +27,40 @@ background-color: var(--bg-main); color: var(--text-main); font-feature-settings: "cv02", "cv03", "cv04", "cv11"; - transition: background-color 0.3s ease, color 0.3s ease; + transition: background-color 0.25s ease, color 0.25s ease; } } -/* Custom background grid & glow patterns */ +/* Background grid & subtle dot pattern */ .bg-grid-pattern { - background-size: 40px 40px; + background-size: 36px 36px; background-image: - linear-gradient(to right, rgba(120, 119, 198, 0.07) 1px, transparent 1px), - linear-gradient(to bottom, rgba(120, 119, 198, 0.07) 1px, transparent 1px); + linear-gradient(to right, rgba(124, 58, 237, 0.05) 1px, transparent 1px), + linear-gradient(to bottom, rgba(124, 58, 237, 0.05) 1px, transparent 1px); } .dark .bg-grid-pattern { background-image: - linear-gradient(to right, rgba(168, 85, 247, 0.07) 1px, transparent 1px), - linear-gradient(to bottom, rgba(168, 85, 247, 0.07) 1px, transparent 1px); + linear-gradient(to right, rgba(168, 85, 247, 0.06) 1px, transparent 1px), + linear-gradient(to bottom, rgba(168, 85, 247, 0.06) 1px, transparent 1px); } .bg-dot-pattern { background-size: 24px 24px; - background-image: radial-gradient(rgba(124, 58, 237, 0.15) 1px, transparent 1px); + background-image: radial-gradient(rgba(124, 58, 237, 0.12) 1px, transparent 1px); } .dark .bg-dot-pattern { - background-image: radial-gradient(rgba(168, 85, 247, 0.18) 1px, transparent 1px); + background-image: radial-gradient(rgba(168, 85, 247, 0.16) 1px, transparent 1px); } /* Purple Glow utility */ .purple-glow-radial { - background: radial-gradient(circle at 50% 30%, rgba(124, 58, 237, 0.22) 0%, rgba(97, 43, 211, 0.08) 45%, transparent 70%); + background: radial-gradient(circle at 50% 30%, rgba(124, 58, 237, 0.18) 0%, rgba(97, 43, 211, 0.06) 45%, transparent 70%); } .dark .purple-glow-radial { - background: radial-gradient(circle at 50% 30%, rgba(124, 58, 237, 0.35) 0%, rgba(97, 43, 211, 0.15) 45%, transparent 75%); + background: radial-gradient(circle at 50% 30%, rgba(124, 58, 237, 0.3) 0%, rgba(97, 43, 211, 0.12) 50%, transparent 75%); } /* Glass card styling */ @@ -72,18 +72,18 @@ } .glass-card-hover { - transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); } .glass-card-hover:hover { - transform: translateY(-3px); - border-color: rgba(168, 85, 247, 0.35); - box-shadow: 0 16px 32px -12px rgba(124, 58, 237, 0.18); + transform: translateY(-2px); + border-color: rgba(168, 85, 247, 0.4); + box-shadow: 0 12px 28px -10px rgba(124, 58, 237, 0.18); } /* Gradient Text */ .text-gradient-purple { - background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #ec4899 100%); + background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #d946ef 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } @@ -94,19 +94,7 @@ -webkit-text-fill-color: transparent; } -.text-gradient-subtle { - background: linear-gradient(180deg, #18181b 0%, #71717a 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.dark .text-gradient-subtle { - background: linear-gradient(180deg, #ffffff 0%, #a1a1aa 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -/* Custom scrollbar */ +/* Custom subtle scrollbar */ ::-webkit-scrollbar { width: 8px; height: 8px; @@ -117,10 +105,10 @@ } ::-webkit-scrollbar-thumb { - background: rgba(161, 161, 170, 0.3); + background: rgba(124, 58, 237, 0.2); border-radius: 9999px; } ::-webkit-scrollbar-thumb:hover { - background: rgba(124, 58, 237, 0.5); + background: rgba(124, 58, 237, 0.4); } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c27f33cf7e..0107813e53 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { Plus_Jakarta_Sans } from 'next/font/google'; import './globals.css'; import { ThemeProvider } from '../components/theme/theme-provider'; +import { I18nProvider } from '../i18n/i18n-context'; const plusJakartaSans = Plus_Jakarta_Sans({ subsets: ['latin'], @@ -65,10 +66,12 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 1bb9a777c7..84797ed40f 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -14,29 +14,29 @@ import { Footer } from '../components/footer'; export default function LandingPage() { return ( -
- {/* Navbar Header */} +
+ {/* Floating Glass Navbar Header */} - {/* Hero Section */} + {/* Hero Section & Interactive App Mockup Showcase */} - {/* 28+ Channels Section */} + {/* 28+ Channels Grid */} - {/* Interactive Live Generator & Preview */} + {/* Interactive Studio Preview */} {/* Features Bento Grid */} - {/* How It Works (3 Steps) */} + {/* 3-Step Effortless Workflow */} - {/* Comparison with competitors */} + {/* Objective Comparison */} - {/* Pricing Plans (Monthly / Yearly) */} + {/* Transparent Pricing (Monthly / Yearly) */} {/* Testimonials & Social Proof */} diff --git a/apps/web/src/components/channels.tsx b/apps/web/src/components/channels.tsx index b2df4b6275..f09351e085 100644 --- a/apps/web/src/components/channels.tsx +++ b/apps/web/src/components/channels.tsx @@ -1,10 +1,10 @@ 'use client'; import React, { useState } from 'react'; +import { useI18n } from '../i18n/i18n-context'; import { Share2, Video, - MessageSquare, FileText, Users, Code2, @@ -16,158 +16,154 @@ interface Channel { name: string; category: 'video' | 'social' | 'professional' | 'community' | 'web3'; badge?: string; - description: string; + descriptionVi: string; + descriptionEn: string; color: string; } const CHANNELS: Channel[] = [ // Video & Visuals - { name: 'TikTok', category: 'video', badge: 'Direct Video', description: 'Đăng video & Shorts 9:16 tự động kèm hashtags tối ưu', color: 'from-zinc-900 to-zinc-700' }, - { name: 'YouTube Shorts', category: 'video', badge: 'Full 4K', description: 'Tải lên Shorts & Video dài với thumbnail tùy chỉnh', color: 'from-red-600 to-rose-700' }, - { name: 'Instagram', category: 'video', badge: 'Reels & Carousel', description: 'Đăng Reels, ảnh đơn, Carousel và story trực tiếp', color: 'from-pink-600 via-purple-600 to-amber-500' }, - { name: 'Facebook', category: 'video', badge: 'Pages & Groups', description: 'Hỗ trợ Fanpage doanh nghiệp, Group cộng đồng và Reels', color: 'from-blue-600 to-indigo-700' }, - { name: 'Pinterest', category: 'video', badge: 'Pins & Boards', description: 'Tự động tạo Pin, ghim vào bảng mục tiêu kèm backlink', color: 'from-red-700 to-rose-800' }, - { name: 'Dribbble', category: 'video', badge: 'Design Shots', description: 'Chia sẻ portfolio và shot thiết kế cho designers', color: 'from-pink-500 to-rose-600' }, + { name: 'TikTok', category: 'video', badge: 'Direct Video', descriptionVi: 'Đăng video & Shorts 9:16 tự động kèm hashtags tối ưu', descriptionEn: 'Auto-publish 9:16 vertical video & trending hashtags', color: 'from-zinc-900 to-zinc-700' }, + { name: 'YouTube Shorts', category: 'video', badge: 'Full 4K', descriptionVi: 'Tải lên Shorts & Video dài với thumbnail tùy chỉnh', descriptionEn: 'Upload Shorts & Long-form 4K videos with custom thumbnails', color: 'from-red-600 to-rose-700' }, + { name: 'Instagram', category: 'video', badge: 'Reels & Carousel', descriptionVi: 'Đăng Reels, ảnh đơn, Carousel và story trực tiếp', descriptionEn: 'Directly schedule Reels, Carousels, Stories, and posts', color: 'from-pink-600 via-purple-600 to-amber-500' }, + { name: 'Facebook', category: 'video', badge: 'Pages & Groups', descriptionVi: 'Hỗ trợ Fanpage doanh nghiệp, Group cộng đồng và Reels', descriptionEn: 'Full support for Pages, Groups, and Facebook Reels', color: 'from-blue-600 to-indigo-700' }, + { name: 'Pinterest', category: 'video', badge: 'Pins & Boards', descriptionVi: 'Tự động tạo Pin, ghim vào bảng mục tiêu kèm backlink', descriptionEn: 'Auto-create Pins to designated boards with backlinks', color: 'from-red-700 to-rose-800' }, + { name: 'Dribbble', category: 'video', badge: 'Design Shots', descriptionVi: 'Chia sẻ portfolio và shot thiết kế cho designers', descriptionEn: 'Publish design shots and portfolio updates', color: 'from-pink-500 to-rose-600' }, // Social & Microblog - { name: 'X (Twitter)', category: 'social', badge: 'Auto Thread', description: 'Tự động chia nhỏ bài viết thành Twitter Threads mượt mà', color: 'from-zinc-900 to-black' }, - { name: 'Threads', category: 'social', badge: 'Meta Ecosystem', description: 'Đồng bộ bài viết sang Instagram Threads tức thì', color: 'from-zinc-800 to-zinc-950' }, - { name: 'Bluesky', category: 'social', badge: 'Decentralized', description: 'Phân phối tới mạng xã hội phi tập trung thế hệ mới', color: 'from-sky-500 to-blue-600' }, - { name: 'Mastodon', category: 'social', badge: 'Fediverse', description: 'Hỗ trợ kết nối custom instance trên Fediverse', color: 'from-indigo-600 to-purple-700' }, - { name: 'Farcaster', category: 'social', badge: 'Web3 Social', description: 'Đăng Cast trực tiếp lên giao thức Web3 Farcaster', color: 'from-purple-700 to-violet-900' }, - { name: 'MeWe', category: 'social', description: 'Đăng bài lên mạng xã hội bảo mật quyền riêng tư', color: 'from-amber-600 to-orange-700' }, - { name: 'VKontakte (VK)', category: 'social', description: 'Kết nối và phát triển cộng đồng người dùng Đông Âu', color: 'from-blue-700 to-blue-900' }, + { name: 'X (Twitter)', category: 'social', badge: 'Auto Thread', descriptionVi: 'Tự động chia nhỏ bài viết thành Twitter Threads mượt mà', descriptionEn: 'Automatically split long thoughts into seamless Twitter threads', color: 'from-zinc-900 to-black' }, + { name: 'Threads', category: 'social', badge: 'Meta Ecosystem', descriptionVi: 'Đồng bộ bài viết sang Instagram Threads tức thì', descriptionEn: 'Instant cross-posting to Meta Instagram Threads', color: 'from-zinc-800 to-zinc-950' }, + { name: 'Bluesky', category: 'social', badge: 'Decentralized', descriptionVi: 'Phân phối tới mạng xã hội phi tập trung AT Protocol', descriptionEn: 'Publish to the next-gen decentralized AT Protocol network', color: 'from-sky-500 to-blue-600' }, + { name: 'Mastodon', category: 'social', badge: 'Fediverse', descriptionVi: 'Hỗ trợ kết nối custom instance trên Fediverse', descriptionEn: 'Connect to any custom Fediverse Mastodon instance', color: 'from-indigo-600 to-purple-700' }, + { name: 'Farcaster', category: 'social', badge: 'Web3 Social', descriptionVi: 'Đăng Cast trực tiếp lên giao thức Web3 Farcaster', descriptionEn: 'Direct cast publishing to decentralized Farcaster protocol', color: 'from-purple-700 to-violet-900' }, + { name: 'MeWe', category: 'social', descriptionVi: 'Đăng bài lên mạng xã hội bảo mật quyền riêng tư', descriptionEn: 'Share updates on privacy-first community social network', color: 'from-amber-600 to-orange-700' }, + { name: 'VKontakte (VK)', category: 'social', descriptionVi: 'Kết nối và phát triển cộng đồng người dùng Đông Âu', descriptionEn: 'Expand reach across Eastern European social ecosystem', color: 'from-blue-700 to-blue-900' }, // Professional & Articles - { name: 'LinkedIn Personal', category: 'professional', badge: 'B2B Lead', description: 'Xây dựng thương hiệu cá nhân với bài viết & PDF carousel', color: 'from-blue-700 to-cyan-800' }, - { name: 'LinkedIn Company', category: 'professional', badge: 'Organization', description: 'Quản lý bài đăng doanh nghiệp nhiều chi nhánh', color: 'from-blue-800 to-indigo-900' }, - { name: 'Medium', category: 'professional', badge: 'Canonical URL', description: 'Xuất bản bài viết dài, giữ nguyên SEO canonical link', color: 'from-zinc-900 to-zinc-700' }, - { name: 'Hashnode', category: 'professional', badge: 'Dev Blogs', description: 'Tự động đồng bộ bài viết kỹ thuật lên blog cá nhân', color: 'from-blue-600 to-sky-700' }, - { name: 'Dev.to', category: 'professional', badge: 'Developer SEO', description: 'Chia sẻ kiến thức lập trình tới cộng đồng developer', color: 'from-zinc-800 to-black' }, - { name: 'WordPress', category: 'professional', badge: 'REST API', description: 'Tự động tạo bản nháp hoặc xuất bản bài viết WordPress', color: 'from-sky-700 to-blue-900' }, + { name: 'LinkedIn Personal', category: 'professional', badge: 'B2B Lead', descriptionVi: 'Xây dựng thương hiệu cá nhân với bài viết & PDF carousel', descriptionEn: 'Build personal brand with thought-leadership posts & PDF carousels', color: 'from-blue-700 to-cyan-800' }, + { name: 'LinkedIn Company', category: 'professional', badge: 'Organization', descriptionVi: 'Quản lý bài đăng doanh nghiệp nhiều chi nhánh', descriptionEn: 'Manage official organization company page broadcasts', color: 'from-blue-800 to-indigo-900' }, + { name: 'Medium', category: 'professional', badge: 'Canonical URL', descriptionVi: 'Xuất bản bài viết dài, giữ nguyên SEO canonical link', descriptionEn: 'Publish long-form stories with SEO canonical URL preservation', color: 'from-zinc-900 to-zinc-700' }, + { name: 'Hashnode', category: 'professional', badge: 'Dev Blogs', descriptionVi: 'Tự động đồng bộ bài viết kỹ thuật lên blog cá nhân', descriptionEn: 'Sync technical markdown blogs to your custom developer domain', color: 'from-blue-600 to-sky-700' }, + { name: 'Dev.to', category: 'professional', badge: 'Developer SEO', descriptionVi: 'Chia sẻ kiến thức lập trình tới cộng đồng developer', descriptionEn: 'Broadcast developer articles to global programming audience', color: 'from-zinc-800 to-black' }, + { name: 'WordPress', category: 'professional', badge: 'REST API', descriptionVi: 'Tự động tạo bản nháp hoặc xuất bản bài viết WordPress', descriptionEn: 'Draft or publish full posts to WordPress via REST API', color: 'from-sky-700 to-blue-900' }, // Community & Messaging - { name: 'Reddit', category: 'community', badge: 'Subreddits', description: 'Tự động chọn Subreddit và gắn Flair chuẩn quy tắc', color: 'from-orange-600 to-red-600' }, - { name: 'Discord', category: 'community', badge: 'Webhooks & Bot', description: 'Bắn thông báo bài viết mới vào từng Channel Discord', color: 'from-indigo-600 to-violet-800' }, - { name: 'Telegram', category: 'community', badge: 'Channels & Groups', description: 'Gửi tin nhắn định dạng Markdown và hình ảnh tới channel', color: 'from-sky-500 to-blue-600' }, - { name: 'Slack', category: 'community', badge: 'Workspace', description: 'Thông báo tới các kênh làm việc nội bộ', color: 'from-emerald-600 to-teal-800' }, - { name: 'Skool', category: 'community', badge: 'Course & Community', description: 'Đăng bài thông báo tới các nhóm học viên Skool', color: 'from-amber-500 to-yellow-600' }, - { name: 'Whop', category: 'community', badge: 'Creator Hub', description: 'Kết nối sản phẩm số và cộng đồng thành viên Whop', color: 'from-orange-500 to-rose-600' }, - { name: 'Listmonk', category: 'community', badge: 'Newsletter', description: 'Tự động gửi bản tin qua email marketing self-hosted', color: 'from-purple-600 to-indigo-700' }, + { name: 'Reddit', category: 'community', badge: 'Subreddits', descriptionVi: 'Tự động chọn Subreddit và gắn Flair chuẩn quy tắc', descriptionEn: 'Post to target subreddits with rules & automated flairs', color: 'from-orange-600 to-red-600' }, + { name: 'Discord', category: 'community', badge: 'Webhooks & Bot', descriptionVi: 'Bắn thông báo bài viết mới vào từng Channel Discord', descriptionEn: 'Broadcast post alerts into designated Discord community channels', color: 'from-indigo-600 to-violet-800' }, + { name: 'Telegram', category: 'community', badge: 'Channels & Groups', descriptionVi: 'Gửi tin nhắn định dạng Markdown và hình ảnh tới channel', descriptionEn: 'Push formatted markdown announcements to channels & groups', color: 'from-sky-500 to-blue-600' }, + { name: 'Slack', category: 'community', badge: 'Workspace', descriptionVi: 'Thông báo tới các kênh làm việc nội bộ công ty', descriptionEn: 'Notify internal team workspace channels automatically', color: 'from-emerald-600 to-teal-800' }, + { name: 'Skool', category: 'community', badge: 'Courses', descriptionVi: 'Đăng bài thông báo tới các nhóm học viên Skool', descriptionEn: 'Deliver announcements to student community groups on Skool', color: 'from-amber-500 to-yellow-600' }, + { name: 'Whop', category: 'community', badge: 'Creator Hub', descriptionVi: 'Kết nối sản phẩm số và cộng đồng thành viên Whop', descriptionEn: 'Connect digital products and premium member discussions', color: 'from-orange-500 to-rose-600' }, + { name: 'Listmonk', category: 'community', badge: 'Newsletter', descriptionVi: 'Tự động gửi bản tin qua email marketing self-hosted', descriptionEn: 'Dispatch newsletter campaigns via self-hosted Listmonk', color: 'from-purple-600 to-indigo-700' }, // Web3 & Custom - { name: 'Moltbook', category: 'web3', badge: 'Web3', description: 'Tích hợp mạng xã hội phi tập trung', color: 'from-brand-600 to-purple-800' }, - { name: 'Wrapcaster', category: 'web3', badge: 'Warpcast', description: 'Hỗ trợ Warpcast Frame và post', color: 'from-purple-600 to-indigo-900' }, - { name: 'Custom Webhooks', category: 'web3', badge: 'API Power', description: 'Gửi payload JSON tới bất kỳ hệ thống nào bạn muốn', color: 'from-zinc-700 to-zinc-900' }, -]; - -const CATEGORIES = [ - { id: 'all', label: 'Tất cả (28+ Nền tảng)' }, - { id: 'video', label: 'Video & Short Form', icon: Video }, - { id: 'social', label: 'Social & Microblog', icon: Share2 }, - { id: 'professional', label: 'B2B & Blog Dài', icon: FileText }, - { id: 'community', label: 'Cộng đồng & Chat', icon: Users }, - { id: 'web3', label: 'Web3 & Webhook API', icon: Code2 }, + { name: 'Moltbook', category: 'web3', badge: 'Web3', descriptionVi: 'Tích hợp mạng xã hội phi tập trung', descriptionEn: 'Decentralized social integration for Web3 communities', color: 'from-brand-600 to-purple-800' }, + { name: 'Wrapcaster', category: 'web3', badge: 'Warpcast', descriptionVi: 'Hỗ trợ Warpcast Frame và post', descriptionEn: 'Native Warpcast Frames and interactive casts', color: 'from-purple-600 to-indigo-900' }, + { name: 'Custom Webhooks', category: 'web3', badge: 'REST API', descriptionVi: 'Gửi payload JSON tới bất kỳ hệ thống nào bạn muốn', descriptionEn: 'Trigger custom HTTP JSON payloads to any server endpoint', color: 'from-zinc-700 to-zinc-900' }, ]; export function Channels() { + const { t, lang } = useI18n(); const [activeTab, setActiveTab] = useState('all'); + const categories = [ + { id: 'all', label: t.channels.tabAll }, + { id: 'video', label: t.channels.tabVideo, icon: Video }, + { id: 'social', label: t.channels.tabSocial, icon: Share2 }, + { id: 'professional', label: t.channels.tabProfessional, icon: FileText }, + { id: 'community', label: t.channels.tabCommunity, icon: Users }, + { id: 'web3', label: t.channels.tabWeb3, icon: Code2 }, + ]; + const filteredChannels = activeTab === 'all' ? CHANNELS : CHANNELS.filter((c) => c.category === activeTab); return ( -
+
{/* Glow background */} -
+
{/* Section Header */}
- - Hệ sinh thái phân phối mạnh mẽ nhất + + {t.channels.pill}

- Kết Nối 28+ Mạng Xã Hội Trong 1 Nơi Duy Nhất + {t.channels.titleStart} + {t.channels.titleGradient} + {t.channels.titleEnd}

-

- Không còn phải đăng nhập qua lại giữa hàng chục ứng dụng. Crove tích hợp sẵn - mọi nền tảng từ video ngắn, bài viết B2B, diễn đàn cho đến webhook tuỳ chỉnh. +

+ {t.channels.subtitle}

{/* Category Tabs */} -
- {CATEGORIES.map((cat) => { +
+ {categories.map((cat) => { const Icon = cat.icon; const isActive = activeTab === cat.id; return ( ); })}
- {/* Channels Grid */} -
+ {/* Channel Grid */} +
{filteredChannels.map((channel, idx) => (
-
+
-
+
- {channel.name.substring(0, 2).toUpperCase()} -
-
-

- {channel.name} -

- - {channel.category} - + {channel.name.slice(0, 2)}
+ + {channel.name} +
{channel.badge && ( - + {channel.badge} )}
-

- {channel.description} +

+ {lang === 'vi' ? channel.descriptionVi : channel.descriptionEn}

-
- - - Sẵn sàng kết nối - - - 1-Click OAuth → +
+ + + {t.channels.directBadge} + {channel.category}
))} diff --git a/apps/web/src/components/comparison.tsx b/apps/web/src/components/comparison.tsx index 44724c2eaa..56a87a6054 100644 --- a/apps/web/src/components/comparison.tsx +++ b/apps/web/src/components/comparison.tsx @@ -1,135 +1,140 @@ 'use client'; import React from 'react'; +import { useI18n } from '../i18n/i18n-context'; import { Check, X, Sparkles, Scale } from 'lucide-react'; -const COMPARISON_ROWS = [ - { - feature: 'Số lượng mạng xã hội hỗ trợ', - crove: '28+ Kênh (TikTok, YT, FB, X, LinkedIn, Discord, Reddit...)', - buffer: '8 Kênh cơ bản', - hootsuite: '7 Kênh cơ bản', - later: '6 Kênh cơ bản', - }, - { - feature: 'Đăng trực tiếp TikTok, Shorts, Reels', - crove: true, - buffer: true, - hootsuite: true, - later: 'Một số kênh cần duyệt qua app', - }, - { - feature: 'AI Content Copilot & Auto-Repurpose', - crove: 'Tích hợp sẵn & Tối ưu theo từng MXH', - buffer: 'Cơ bản', - hootsuite: 'Tính phí thêm (OwlyWriter)', - later: 'Rất hạn chế', - }, - { - feature: 'Tự động tạo Twitter/X Threads & Reddit Flair', - crove: true, - buffer: false, - hootsuite: false, - later: false, - }, - { - feature: 'Lịch kéo thả (Drag & Drop) trực quan', - crove: true, - buffer: true, - hootsuite: true, - later: true, - }, - { - feature: 'Hỗ trợ Web3 & Custom Webhooks / REST API', - crove: true, - buffer: false, - hootsuite: false, - later: false, - }, - { - feature: 'Tùy chọn Self-host / Cloud linh hoạt', - crove: true, - buffer: false, - hootsuite: false, - later: false, - }, - { - feature: 'Chi phí cho Creator & Agency', - crove: 'Tiết kiệm đến 70% ngân sách', - buffer: 'Tăng theo số lượng kênh', - hootsuite: 'Rất đắt ($99+/tháng)', - later: 'Giới hạn số bài đăng', - }, -]; - export function Comparison() { + const { t } = useI18n(); + + const comparisonRows = [ + { + feature: t.comparison.row1Feature, + crove: t.comparison.row1Crove, + buffer: t.comparison.row1Buffer, + hootsuite: t.comparison.row1Hootsuite, + later: t.comparison.row1Later, + }, + { + feature: t.comparison.row2Feature, + crove: true, + buffer: true, + hootsuite: true, + later: t.comparison.row2Later, + }, + { + feature: t.comparison.row3Feature, + crove: t.comparison.row3Crove, + buffer: t.comparison.row3Buffer, + hootsuite: t.comparison.row3Hootsuite, + later: t.comparison.row3Later, + }, + { + feature: t.comparison.row4Feature, + crove: true, + buffer: false, + hootsuite: false, + later: false, + }, + { + feature: t.comparison.row5Feature, + crove: true, + buffer: true, + hootsuite: true, + later: true, + }, + { + feature: t.comparison.row6Feature, + crove: true, + buffer: false, + hootsuite: false, + later: false, + }, + { + feature: t.comparison.row7Feature, + crove: true, + buffer: false, + hootsuite: false, + later: false, + }, + { + feature: t.comparison.row8Feature, + crove: t.comparison.row8Crove, + buffer: t.comparison.row8Buffer, + hootsuite: t.comparison.row8Hootsuite, + later: t.comparison.row8Later, + }, + ]; + return ( -
+
{/* Section Header */}
- Tại sao chọn Crove? + {t.comparison.pill}

- So Sánh Crove Với Các Công Cụ Khác + {t.comparison.titleStart} + {t.comparison.titleGradient} + {t.comparison.titleEnd}

-

- Khám phá lý do tại sao các Content Creators và Agency chuyển sang Crove để tiết kiệm hàng triệu đồng mỗi tháng. +

+ {t.comparison.subtitle}

{/* Comparison Table */} -
- +
+
- - + - - - - - {COMPARISON_ROWS.map((row, idx) => ( + {comparisonRows.map((row, idx) => ( - - - - -
- Tính Năng & Năng Lực +
+ {t.comparison.featureHeader} +
- Crove (Postiz) + {t.comparison.croveCol}
+ Buffer + Hootsuite + Later
+ {row.feature} + {typeof row.crove === 'boolean' ? ( row.crove ? ( - + - Có sẵn + Yes ) : ( - + ) ) : ( - + {row.crove} )} + {typeof row.buffer === 'boolean' ? ( row.buffer ? ( @@ -140,7 +145,7 @@ export function Comparison() { row.buffer )} + {typeof row.hootsuite === 'boolean' ? ( row.hootsuite ? ( @@ -151,7 +156,7 @@ export function Comparison() { row.hootsuite )} + {typeof row.later === 'boolean' ? ( row.later ? ( diff --git a/apps/web/src/components/cta.tsx b/apps/web/src/components/cta.tsx index 9118d20853..3ab923f968 100644 --- a/apps/web/src/components/cta.tsx +++ b/apps/web/src/components/cta.tsx @@ -1,59 +1,63 @@ 'use client'; import React from 'react'; +import { useI18n } from '../i18n/i18n-context'; import { ArrowRight, Sparkles, ShieldCheck, Zap } from 'lucide-react'; const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://app.crove.com'; export function CTA() { + const { t } = useI18n(); + return ( -
+
-
+
{/* Ambient Lighting inside banner */} -
-
+
+
-
+
- Sẵn sàng bứt phá tăng trưởng mạng xã hội? + {t.cta.pill}

- Bắt Đầu Quản Lý & Tự Động Hoá
- 28+ Kênh Mạng Xã Hội Ngay Hôm Nay + {t.cta.titleStart} + {t.cta.titleGradient} + {t.cta.titleEnd}

-

- Gia nhập cùng hơn 10,000+ nhà sáng tạo và doanh nghiệp đang tiết kiệm 20+ giờ mỗi tuần nhờ Crove. +

+ {t.cta.subtitle}

-
+ -
+
- - Không cần thẻ ngân hàng + + {t.cta.trustCard}
- - Kích hoạt trong 60 giây + + {t.cta.trustTime}
diff --git a/apps/web/src/components/faq.tsx b/apps/web/src/components/faq.tsx index 37d6f2b05e..0e24c8000b 100644 --- a/apps/web/src/components/faq.tsx +++ b/apps/web/src/components/faq.tsx @@ -1,88 +1,74 @@ 'use client'; import React, { useState } from 'react'; +import { useI18n } from '../i18n/i18n-context'; import { ChevronDown, HelpCircle } from 'lucide-react'; -const FAQS = [ - { - q: 'Crove có an toàn cho tài khoản mạng xã hội của tôi không? Có bị khoá nick không?', - a: 'Crove sử dụng 100% Official API chính thức được cấp phép từ Meta (Facebook, Instagram, Threads), TikTok Developer Partner, Google (YouTube), X Developer, LinkedIn và Reddit. Chúng tôi không sử dụng bot ngầm hay crawler trái phép, vì vậy tài khoản của bạn được bảo đảm an toàn tuyệt đối và không bị bóp tương tác.', - }, - { - q: 'Crove hỗ trợ những định dạng nội dung nào?', - a: 'Crove hỗ trợ đầy đủ mọi định dạng: Video ngắn 9:16 (TikTok, YouTube Shorts, Reels), Video dài 16:9, Bài viết có hình ảnh đơn/carousel, Twitter Threads, Bài viết blog định dạng Markdown (Medium, Hashnode, Dev.to), và thông báo cộng đồng (Discord, Telegram, Slack).', - }, - { - q: 'AI Copilot hoạt động như thế nào?', - a: 'AI của Crove được tích hợp mô hình ngôn ngữ lớn tiên tiến nhất để phân tích nội dung gốc của bạn. Nó hiểu thuật toán và văn hóa người dùng trên từng nền tảng, tự động biến 1 đoạn văn bản thành caption ngắn gọn có hook cho TikTok, bài viết chuyên nghiệp cho LinkedIn hoặc chuỗi Twitter Thread hấp dẫn.', - }, - { - q: 'Tôi có thể dùng thử miễn phí trước khi trả phí không?', - a: 'Có! Gói Starter cho phép bạn sử dụng miễn phí vĩnh viễn với tối đa 3 kênh mạng xã hội. Đối với các gói Pro Creator và Agency, bạn được trải nghiệm 14 ngày miễn phí với đầy đủ mọi tính năng cao cấp mà không cần nhập thẻ tín dụng.', - }, - { - q: 'Tôi có thể mời thành viên team hoặc khách hàng vào cùng làm việc không?', - a: 'Hoàn toàn được. Gói Agency & Team cung cấp không gian làm việc Workspace đa người dùng, cho phép bạn phân quyền Admin, Editor, Reviewer để phân chia công việc và kiểm duyệt nội dung trước khi xuất bản.', - }, - { - q: 'Tôi có thể huỷ gói đăng ký bất kỳ lúc nào không?', - a: 'Bạn có thể nâng cấp, hạ gói hoặc huỷ gia hạn bất kỳ lúc nào trực tiếp trong phần Cài đặt thanh toán. Không có ràng buộc hợp đồng và không có chi phí ẩn.', - }, -]; - export function FAQ() { + const { t } = useI18n(); const [openIndex, setOpenIndex] = useState(0); + const faqs = [ + { q: t.faq.q1, a: t.faq.a1 }, + { q: t.faq.q2, a: t.faq.a2 }, + { q: t.faq.q3, a: t.faq.a3 }, + { q: t.faq.q4, a: t.faq.a4 }, + { q: t.faq.q5, a: t.faq.a5 }, + { q: t.faq.q6, a: t.faq.a6 }, + ]; + const toggle = (idx: number) => { setOpenIndex(openIndex === idx ? null : idx); }; return ( -
+
{/* Section Header */}
- Giải đáp thắc mắc + {t.faq.pill}

- Câu Hỏi Thường Gặp + {t.faq.titleStart} + {t.faq.titleGradient} + {t.faq.titleEnd}

-

- Mọi thông tin bạn cần biết về nền tảng và dịch vụ của Crove. +

+ {t.faq.subtitle}

{/* FAQ Accordion */} -
- {FAQS.map((faq, idx) => { +
+ {faqs.map((faq, idx) => { const isOpen = openIndex === idx; return (
{isOpen && ( -
+
{faq.a}
)} diff --git a/apps/web/src/components/features.tsx b/apps/web/src/components/features.tsx index ebe1896e8d..f6dc2e7422 100644 --- a/apps/web/src/components/features.tsx +++ b/apps/web/src/components/features.tsx @@ -1,199 +1,221 @@ 'use client'; import React from 'react'; +import { useI18n } from '../i18n/i18n-context'; import { - Sparkles, Calendar, Layers, BarChart3, Users2, - Image as ImageIcon, Bot, Zap, CheckCircle2, Clock, ShieldCheck, - SplitSquareVertical, + Video, + Sparkles, } from 'lucide-react'; export function Features() { + const { t } = useI18n(); + return ( -
+
{/* Background ambient radial lights */} -
-
+
+
{/* Section Header */}
- Tính năng đột phá + {t.features.pill}

- Mọi Công Cụ Bạn Cần Để{' '} - Thống Lĩnh Mạng Xã Hội + {t.features.titleStart} + {t.features.titleGradient} + {t.features.titleEnd}

-

- Được xây dựng cho các Content Creators, Digital Agencies, Growth Hackers - và Doanh nghiệp muốn mở rộng quy mô hiện diện trực tuyến nhanh gấp 10 lần. +

+ {t.features.subtitle}

{/* Bento Grid */} -
+
{/* Card 1 - AI Repurposer (Span 2 cols on md) */} -
-
+
+
-
-
- +
+
+
- AI Copilot & Repurposing + {t.features.card1Badge}
-

- Biến 1 Bài Viết Thành 10 Định Dạng Tối Ưu Cho Từng MXH +

+ {t.features.card1Title}

-

- Nhập 1 đường link blog, video YouTube hoặc ý tưởng thô. AI Copilot của Crove - tự động viết caption hài hước cho TikTok, tóm tắt chuyên sâu cho LinkedIn, tạo Twitter Thread cuốn hút và hashtag trending cho Instagram. +

+ {t.features.card1Desc}

{/* Visual Simulated snippet */} -
+
-
🔥 TikTok Script
+
+ {t.features.card1Tag1} +

- "Hook 3s giữ chân người xem + CTA link bio..." + "Hook 3s + viral CTA link in bio..."

-
💼 LinkedIn B2B
+
+ {t.features.card1Tag2} +

- "Bullet points chia sẻ kinh nghiệm + số liệu cụ thể..." + "Thought leadership & metric takeaways..."

-
🧵 X Threads
+
+ {t.features.card1Tag3} +

- "Tự động chia thành chuỗi 5 tweets liền mạch..." + "1/5 Thread: Why automation wins..."

{/* Card 2 - Visual Drag & Drop Calendar */} -
+
-
- +
+
- Lịch Biểu Trực Quan + {t.features.card2Badge}
-

- Lịch Kéo Thả Trực Quan Theo Tuần & Tháng +

+ {t.features.card2Title}

-

- Xem toàn cảnh kế hoạch nội dung tuần tới. Chỉ cần kéo thả bài viết để đổi giờ đăng hoặc phân loại theo chiến dịch màu sắc. +

+ {t.features.card2Desc}

-
- - - Gợi ý khung giờ vàng (AI Best Time) +
+ + + {t.features.card2BestTime} - 20:30
- {/* Card 3 - Unified Analytics */} -
+ {/* Card 3 - Video Automation (TikTok & Reels) */} +
-
- +
+
-
- Thống Kê Thống Nhất +
+ {t.features.card3Badge}
-

- Analytics Đo Lường Hiệu Suất Toàn Diện +

+ {t.features.card3Title}

-

- Theo dõi tăng trưởng người theo dõi, lượt xem video, tương tác và tỷ lệ chuyển đổi của tất cả các kênh trên cùng 1 bảng điều khiển. +

+ {t.features.card3Desc}

-
- Tăng trưởng tháng - +184.5% 📈 +
+ + TikTok 9:16 + + + Shorts 4K + + + Reels +
- {/* Card 4 - Multi-User Workspace */} -
+ {/* Card 4 - Cross-channel Analytics */} +
-
- +
+
-
- Team & Phân Quyền +
+ {t.features.card4Badge}
-

- Không Gian Làm Việc & Phê Duyệt Bài Viết +

+ {t.features.card4Title}

-

- Mời thành viên team, copywriter hoặc khách hàng vào workspace. Phân quyền Admin, Editor, Reviewer để kiểm duyệt trước khi đăng bài. +

+ {t.features.card4Desc}

-
- - Bảo mật dữ liệu & phân quyền chi tiết +
+ Impressions Growth + + +248.5% +
- {/* Card 5 - Media Asset Hub & AI Image (Span 1 or 2) */} -
+ {/* Card 5 - Agency & Workspaces */} +
-
- +
+
-
- Media Hub & Tự Động Crop +
+ {t.features.card5Badge}
-

- Kho Media Lưu Trữ & Tối Ưu Tỉ Lệ Khung Hình +

+ {t.features.card5Title}

-

- Upload video, ảnh 1 lần và Crove tự động resize chuẩn tỉ lệ 9:16 (Reels/TikTok), 1:1 (Instagram), 16:9 (YouTube/Facebook) không bị vỡ hình. +

+ {t.features.card5Desc}

-
- - Tích hợp tạo ảnh AI & kho ảnh bản quyền +
+ + Admin + + + Editor + + + Client Reviewer +
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 4db3fd522a..19f981c6fb 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -4,73 +4,80 @@ import React from 'react'; import Link from 'next/link'; import { CroveLogo } from './crove-logo'; import { ThemeToggle } from './theme/theme-toggle'; -import { ShieldCheck, Heart } from 'lucide-react'; +import { LanguageToggle } from './language-toggle'; +import { useI18n } from '../i18n/i18n-context'; const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://app.crove.com'; export function Footer() { + const { t } = useI18n(); + return ( -