diff --git a/.cursor/rules/00-project.mdc b/.cursor/rules/00-project.mdc deleted file mode 100644 index 4c5ba03..0000000 --- a/.cursor/rules/00-project.mdc +++ /dev/null @@ -1,14 +0,0 @@ ---- -description: Project-wide NO SUS Monad experiment rules and handover map. -alwaysApply: true ---- - -Read `AGENTS.md` and `docs/STATUS.md` before changing code. This is a separate, testnet-only NO SUS -experiment; do not read, edit, deploy, or reuse secrets from the production NO SUS repository. - -Preserve the threshold-custody honesty boundary: no plaintext, key, salt, filename, private key, or -service-role key may reach the contract, client logs, or public environment. Do not claim a human read, -legal proof, screenshot prevention, or independently verifiable deletion. - -Use focused checks in normal work. Ask the exact finalization question from `AGENTS.md` after every -substantive file-changing response. diff --git a/.cursor/rules/10-contracts.mdc b/.cursor/rules/10-contracts.mdc deleted file mode 100644 index 9efbd0d..0000000 --- a/.cursor/rules/10-contracts.mdc +++ /dev/null @@ -1,9 +0,0 @@ ---- -description: Solidity contract scope and receipt invariants. -globs: contracts/** -alwaysApply: false ---- - -The contract records a ciphertext digest and wallet acknowledgement only. Never add plaintext, keys, -salts, filenames, payment logic, admin bypasses, or an on-chain key-proof function. Preserve one open, -recipient, and expiry invariants. Add a failing test before every contract behavior change. diff --git a/.cursor/rules/20-web.mdc b/.cursor/rules/20-web.mdc deleted file mode 100644 index b190ba6..0000000 --- a/.cursor/rules/20-web.mdc +++ /dev/null @@ -1,9 +0,0 @@ ---- -description: Web experience and client security rules. -globs: web/** -alwaysApply: false ---- - -Keep browser crypto and wallet actions in client components; keep Supabase service-role access in server -route handlers only. Show explicit testnet and experimental labels. Dynamic wallet and receipt state must -be accessible, keyboard usable, and respect reduced-motion preferences. diff --git a/.env.example b/.env.example index 5c580a0..ef8a342 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,28 @@ -# Public browser configuration — safe to expose in the client bundle. -NEXT_PUBLIC_MONAD_RPC_URL=https://rpc.testnet.monad.xyz -NEXT_PUBLIC_MONAD_EXPLORER_URL=https://testnet.monadscan.com -NEXT_PUBLIC_DROPS_CONTRACT_ADDRESS= -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +# NO SUS Canary: every secret and setting, with no values. +# Nothing here is needed to run the app locally (it uses the hosted devnet backend). +# Real values go in Supabase function secrets (`supabase secrets set ...`) or a git-ignored +# .env file. Never commit a filled-in copy. See docs/SELF_HOSTING.md. -# Server-only values — never prefix these with NEXT_PUBLIC_. -SUPABASE_SERVICE_ROLE_KEY= -LIT_NETWORK=naga-dev -LIT_ACCOUNT_API_KEY= -DEPLOYER_PRIVATE_KEY= -CONFIRM_MONAD_DEPLOY= +# --- Relayer: Supabase Edge Function `canary` (function secrets) --- +# 64 hex chars, random (openssl rand -hex 32). Salts the anonymous reader tag. Never share it. +CANARY_TAG_SALT= +# Devnet relayer keypairs, comma-separated: base58 64-byte secret keys or solana-keygen JSON +# arrays. Fund them at https://faucet.solana.com. Devnet only. +SOLANA_RELAYER_KEYS= +# Optional. Default https://api.devnet.solana.com +SOLANA_RPC_URL= +# Optional. Default MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr (SPL Memo) +SOLANA_MEMO_PROGRAM= +# Leave unset. Setting it turns on a legacy prototype path the product does not use. +CANARY_CONTRACT_ADDRESS= +# SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected by Supabase. Never set them by hand +# and never put the service-role key in the app. -# Gate T1 live runner only (tool/threshold-spike). Never put these in NEXT_PUBLIC_*. -# DROPS_CONTRACT_ADDRESS= -# RECIPIENT_PRIVATE_KEY= -# STRANGER_PRIVATE_KEY= -# THRESHOLD_SPIKE_RUN= +# --- Flutter build (--dart-define, public by design) --- +# true = the standalone Canary web app that is deployed. +NOSUS_CANARY_ONLY=true +# Repo link shown in the app. +CANARY_REPO_URL=https://github.com/https-shubhamsahu/nosus-canary + +# --- tool/solana-spike (optional, local only) --- +# The spike writes its own devnet key to tool/solana-spike/.keys/ (git-ignored). diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 473051f..6383c44 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,8 +1 @@ -# Replace the handle if the GitHub repository uses a different owner account. * @https-shubhamsahu - -/contracts/ @https-shubhamsahu -/web/ @https-shubhamsahu -/supabase/ @https-shubhamsahu -/.github/ @https-shubhamsahu -/docs/ @https-shubhamsahu diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml deleted file mode 100644 index d91d95d..0000000 --- a/.github/workflows/checks.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: repository checks - -on: - pull_request: - push: - branches: [main, "codex/**"] - -permissions: - contents: read - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 - with: - node-version: 22 - cache: npm - cache-dependency-path: | - contracts/package-lock.json - web/package-lock.json - - name: Check repository navigation - run: node tool/check-repository.mjs - - name: Check contracts - working-directory: contracts - run: | - npm ci - npm test - - name: Check web types - working-directory: web - run: | - npm ci - npm run check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6445938 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + flutter: + name: Flutter app + runs-on: ubuntu-latest + defaults: + run: + working-directory: app + steps: + - uses: actions/checkout@v5 + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: 3.47.7 + cache: true + - run: flutter pub get + - name: Analyze + run: flutter analyze + - name: Test + run: flutter test + - name: Fingerprint harness (must print FAILURES 0) + run: dart run tool/canary_fingerprint_harness.dart | tail -4 + + receipts-dart: + name: Receipts library (Dart) + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/receipts-dart + steps: + - uses: actions/checkout@v5 + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: 3.47.7 + cache: true + - run: dart pub get + - run: dart analyze --fatal-infos + - run: dart format --output=none --set-exit-if-changed . + - run: dart test + + deno: + name: Relayer + receipts library (TypeScript) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.9.x + - name: Receipts library checks + working-directory: packages/receipts-ts + run: | + deno check mod.ts cli.ts test/ + deno lint + deno fmt --check + deno test + - name: Relayer checks + working-directory: app/supabase/functions/canary + run: | + deno check index.ts + deno test --allow-env + - name: Relayer bundles for deploy + run: deno bundle --platform=deno app/supabase/functions/canary/index.ts -o "$RUNNER_TEMP/canary.bundle.js" + - name: Devnet spike type-checks + run: deno check tool/solana-spike/send_memo.ts diff --git a/.gitignore b/.gitignore index 3ef2b6d..720e43e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,11 @@ Thumbs.db .idea/ .vscode/ *.log + +# Flutter experiment and hosting metadata +app/.dart_tool/ +app/.flutter-plugins-dependencies +app/build/ +**/.vercel/ +app/supabase/.temp/ +*.local.json diff --git a/AGENTS.md b/AGENTS.md index 550d333..d732cc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,47 +1,45 @@ -# NO SUS — Monad Experiment agent map - -This repository is an isolated, **testnet-only** experiment. Read -[docs/STATUS.md](docs/STATUS.md), then only the local guide for files you will change. - -## Product boundaries - -- Brand it **NO SUS — Monad Experiment**. Do not call it Rasid and do not change the production - NO SUS repository, credentials, Supabase project, deployment, or URL. -- Use only harmless, personally owned test notes. Never use credentials, third-party documents, - employment records, financial information, or sensitive personal data. -- The product may say "threshold-gated decryption after a wallet acknowledgement." It must never - claim legal proof, a human read, screenshot prevention, serverless deletion, or identity proof. -- The contract must never receive plaintext, encryption keys, salts, filenames, or document content. -- Threshold custody is a release gate, not decoration: if the Monad-to-Lit proof is not clean, do - not present it as shipped and never substitute a hidden server key-release path. - -## How to navigate - -| Area | Read first | -|---|---| -| Contract | `contracts/README.md` and `contracts/AGENTS.md` | -| Web app | `web/README.md` and `web/AGENTS.md` | -| Storage schema | `supabase/README.md` | -| Handover | `docs/CURSOR_HANDOFF.md` | -| Lit technical gate | `docs/LIT_MONAD_SPIKE.md` | -| Parallel work | `docs/AGENT_COLLABORATION.md` | - -## Working rules - -- Use the smallest necessary change. Do not add an abstraction, dependency, code generator, or - generic utility without a current consumer. -- Keep all secrets in ignored environment files. `NEXT_PUBLIC_*` values are public by design; never - put service-role keys, private keys, Lit account keys, or test-wallet keys there. -- Work in a dedicated branch and small commits. Update `docs/STATUS.md` and local README files when - behavior, contracts, or setup changes. -- Normal work uses focused checks and localhost. Do not deploy, fund wallets, apply a Supabase - migration, or use a real test note without explicit user direction. -- After each substantive response that changes files, ask exactly: "Would you like to finalize today's - work? If yes, say whether to verify only, build a target, or deploy a named target." - -Would you like to finalize today's work? If yes, say whether to verify only, build a target, or deploy a named target. - -## Finalization - -"Verify only" permits the contract tests, web checks, repository checks, and the real Monad/Lit -technical gate. A deployment additionally needs an explicitly named target. +# NO SUS Canary: agent map + +Read [docs/STATUS.md](docs/STATUS.md) first, then only the guide for the files you will change. + +## What this is + +NO SUS Canary: every reader of a note gets a uniquely marked copy, and every seal and every open is +one SPL Memo transaction on **Solana devnet**, sent by a relayer (Supabase Edge Function `canary`) +so readers need no wallet. Leak matching runs on the sender's device. + +## Map + +| Area | Code | Read first | +|---|---|---| +| Canary UI and fingerprinting | `app/lib/features/canary/` | `app/AGENTS.md` | +| Relayer | `app/supabase/functions/canary/` | `docs/ARCHITECTURE.md` | +| Receipt format and libraries | `packages/receipts-ts/`, `packages/receipts-dart/`, `packages/test-vectors/` | `docs/RECEIPTS_SPEC.md` | +| Schema | `app/supabase/migrations/2026091909*`, `2026101009*` | `docs/SELF_HOSTING.md` | +| Security model | — | `SECURITY.md` | + +## Rules + +- Backend: experiment project `ffidvfguojalzpclipzi` only. Never touch the NO SUS production + project, its credentials or deployment. +- Never put note text, keys, names or device ids on chain. Memos carry hashes only; the format is + frozen at v1 (see the spec) and both libraries must stay byte-identical with the test vectors. +- Secrets live in Supabase function secrets or git-ignored files (`.env.example` lists them). The + Supabase publishable key in `app/lib/config/supabase_credentials.dart` is public by design. +- Keep the legacy EVM prototype path off: `CANARY_MONAD` stays false in Flutter and + `CANARY_CONTRACT_ADDRESS` stays unset for the function. User-facing text mentions Solana only. +- Never claim screenshot prevention, legal proof, identity proof or proof that a human read a note. +- Use harmless test notes only. +- Smallest change that works. No new dependency or abstraction without a current consumer. +- Owner approval is needed every time for: commits and pushes, merges, deploying the function, + applying SQL, setting secrets, funding keys or spending devnet SOL, changing gh-pages, and + making anything public. + +## Checks (CI runs all of these) + +```bash +(cd app && flutter analyze && flutter test && dart run tool/canary_fingerprint_harness.dart) +(cd app/supabase/functions/canary && deno check index.ts && deno test --allow-env) +(cd packages/receipts-ts && deno check mod.ts cli.ts && deno lint && deno fmt --check && deno test) +(cd packages/receipts-dart && dart pub get && dart analyze && dart format --set-exit-if-changed . && dart test) +``` diff --git a/CLAUDE.md b/CLAUDE.md index edc3f27..3085c49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,3 @@ -# NO SUS — Monad Experiment +# NO SUS Canary Read [AGENTS.md](AGENTS.md). It is the single source of truth for humans and coding agents. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11069ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..1f01b77 --- /dev/null +++ b/NOTICE @@ -0,0 +1,8 @@ +NO SUS Canary +Copyright 2026 Shubham Sahu (NO SUS) + +Licensed under the Apache License, Version 2.0 (see LICENSE). + +Third-party packages listed in app/pubspec.lock, packages/receipts-dart/pubspec.lock and +packages/receipts-ts/deno.lock keep their own licenses. The fonts in app/assets/google_fonts/ +are from Google Fonts and keep their own (SIL Open Font) licenses. diff --git a/README.md b/README.md index ca7c358..e981e96 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,219 @@ -# NO SUS — Monad Experiment +

+ NO SUS Canary: every reader gets their own copy. Receipts on Solana devnet. +

-An isolated Monad-testnet experiment for one-time encrypted notes. A recipient wallet may decrypt -only after its acknowledgement is recorded on-chain and the threshold-custody gate accepts it. +

+ CI + Solana devnet receipts + Live demo + License: Apache-2.0 +

-This is not the production NO SUS application. It has a separate Git repository, Supabase project, -deployment, credentials, and product decision process. +**NO SUS Canary lets you share a sensitive note with a group so that every reader gets a secretly +different copy, and every seal and every open is recorded as a Solana transaction. If a screenshot +leaks, NO SUS tells you whose copy it was.** -## Start here +[**Live demo**](https://shubham-sahu.me/nosus-canary/) · +[Pitch video (2:24)](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-pitch.mp4) · +[Demo video (1:18)](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-demo.mp4) · +[Judge FAQ](SUBMISSION.md) · +[A real receipt on Solana Explorer](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet) · +[Security model](SECURITY.md) -1. Read [AGENTS.md](AGENTS.md) and [docs/CURSOR_HANDOFF.md](docs/CURSOR_HANDOFF.md). -2. Complete the threshold-gate technical spike before marketing or demo claims. -3. Use only harmless, personally owned test notes on Monad testnet. +

+ Demo on the live site: the sender writes a note and it is sealed on Solana devnet; Priya opens her own copy with no wallet; the sender sees who opened which copy; the leaked text is pasted in and the canary names copy 1, Priya's; the open receipt is shown on Solana Explorer. +

+

Recorded on the live site on 20 Oct 2026, no edits to the app. +Receipts from this run: +seal · +Priya's open · +Ben's open. +MP4 version.

-## Layout +## The problem -- `web/` — Next.js recipient, creator, verifier, and live-wall experience. -- `contracts/` — Solidity contract and local test suite. -- `supabase/` — isolated storage metadata schema; do not apply it to NO SUS. -- `docs/` — security model, status, setup, and Cursor continuation materials. +Salary sheets, offer letters, investor decks, exam papers and internal memos leak as screenshots +and forwards. When they do, nobody can show whose copy it was. Name watermarks get cropped. +Document-security tools ask every reader to make an account or install an app, so people go back +to WhatsApp and email. -## Honest product statement +## The insight -The on-chain receipt proves that a wallet acknowledged a committed encrypted drop. It does not prove -human comprehension, real-world identity, screenshot prevention, legal admissibility, or independently -verifiable deletion of remote storage. +Give every reader a slightly different copy (a "canary trap"), and make the record of who got +which copy something neither side can edit. + +- **Small wording changes survive.** `do not` / `don't`, `5` / `five`, `until` / `till`, plus an + invisible marker. They survive copy-paste, retyping into a chat and OCR, so even part of a + leaked text points to one copy. +- **Receipts are now cheap and fast.** A Solana memo costs about 0.000005 SOL and confirms in about + a second. That makes one neutral, timestamped receipt per reader affordable. A row in our own + database is only our word. + +## The product + +| | What happens | Where it runs | +|---|---|---| +| **1. Write once** | You write the note. Canary makes up to 100 copies that read the same but are each unique, and encrypts them. A **seal** memo goes to Solana with one hash of all copies. | Your browser (AES-256, key only in the link's `#fragment`) + relayer | +| **2. Share one link** | Each reader types a name and gets the next unused copy. An **open** memo goes to Solana with an anonymous tag. **Readers need no wallet, no app and no sign-up.** | Reader's browser + relayer | +| **3. Catch a leak** | Paste the leaked text (or part of it). Canary names the copy, or names nobody if too little survived. | Your device only | + + + + + + + + + + + + +
Priya's own copy, marked 'Copy 1 / 10 · made for Priya', with a 'Recorded on Solana' chip.Leak check result: 'The canary sang. Priya. Copy 1 / 10.' with a 'Proof on Solana' chip.Solana Explorer showing one Memo instruction with the JSON receipt: hashes only.
Each reader gets their own copyA pasted leak names the copyEvery open is a memo on Solana
+ +## Traction + +We are early: live, pre-revenue, devnet only. + +- **Canary on Solana devnet:** live at [shubham-sahu.me/nosus-canary](https://shubham-sahu.me/nosus-canary/) + since 10 Oct 2026. The relayer has written **31 receipts** so far (20 Oct 2026), all from our own + tests and demos. Every one is public on the + [relayer's account page](https://explorer.solana.com/address/CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx?cluster=devnet). +- **Parent product, [NO SUS](https://nosus.foo):** a privacy app for notes and files that open once + and are watermarked with the viewer's name. In production since July 2026 (Google Play closed + test, APK and web), 10+ releases. On 10 Oct 2026: 18 registered users, 6 active in the last 30 + days. + +## Why Solana + +1. **Cost per open.** A receipt is one SPL Memo instruction with one signature, so it pays the + 5,000-lamport base fee (0.000005 SOL). One receipt per reader stays affordable even for a + 100-reader note. +2. **Speed.** Receipts confirm in about a second, so the sender sees "opened" while the reader is + still reading. In the recording above, the sealed link was ready about 3 s after clicking + Create, including encryption and confirmation. +3. **Neutral and append-only.** Once written, neither the sender, the reader nor NO SUS can edit + a receipt. Anyone can check it on Solana Explorer without trusting our database. +4. **No program to audit, no wallet to install.** We use the standard SPL Memo program, which + Explorer shows as plain text. The relayer pays fees, so readers never see a wallet. +5. **Room to scale.** Batching many opens into one transaction (Merkle roots, state compression) + and the Solana dApp Store for the Android app are both on the roadmap below. + +## Architecture + +```mermaid +flowchart LR + subgraph S["Sender's device"] + W["Write note"] --> K["Make N marked copies
word swaps + invisible marker"] + K --> E["Encrypt each copy
AES-256, key stays in the link #fragment"] + L["Leaked text"] --> M["Match on device
names a copy or nobody"] + end + E -- "ciphertexts + one hash of all copies" --> F["Relayer
Supabase Edge Function 'canary'"] + RD["Reader's browser
no wallet"] -- "open + typed name" --> F + F -- "their ciphertext only" --> RD + F -- "seal memo / open memo
hashed ids only" --> SOL[("Solana devnet
SPL Memo program")] + SOL -- "signature" --> X["Solana Explorer link
on every proof chip"] + F <--> DB[("Postgres
ciphertexts, names, signatures")] +``` + +| Where | What it holds | What it never holds | +|---|---|---| +| Solana (public) | Hashed note id, copy count, one hash of all copies, expiry, copy number, salted reader tag | Note text, keys, names, device ids | +| Server (Supabase) | Encrypted copies, reader names and open times (shown to the sender), signatures | Note text, the decryption key | +| Sender's device | The note, every copy's fingerprint, the owner secret | — | +| The link | Note id and the AES key, in the `#fragment` (browsers never send it to a server) | — | + +More: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and the receipt format in +[docs/RECEIPTS_SPEC.md](docs/RECEIPTS_SPEC.md). + +## Receipts you can check now + +| Receipt | Signature | Memo | +|---|---|---| +| Seal, 10 copies (20 Oct 2026) | [`QqdiqT84…uL11wb`](https://explorer.solana.com/tx/QqdiqT84UfFyHNhpfLrK32HfqbUB5vF8qtC4DiAQ7wZZGn2MkUVJGGpVPpgmk4MCxVLtkhmZm6rbuBxqyuL11wb?cluster=devnet) | `{"p":"nosus-canary","v":1,"t":"seal","note":"0x6b0b…3698","copies":10,"hash":"0x…","exp":…}` | +| Open, copy 1 (Priya) | [`5TXFPfTD…oW3wAsy2`](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet) | `{"p":"nosus-canary","v":1,"t":"open","note":"0x6b0b…3698","copy":0,"tag":"0x4096…c2b0"}` | +| Open, copy 2 (Ben) | [`3tqhYUQ2…h7GMTcT1`](https://explorer.solana.com/tx/3tqhYUQ2BppvWh6dy8q5u7U23NpZ7FqfAnd34B58GTriGVZXtykcPj1i4sF7bNh4t8ShnhQokySNz7Uwh7GMTcT1?cluster=devnet) | same note, `"copy":1` | +| First seal ever (10 Oct 2026) | [`2BHqnuCx…FRk512bQ`](https://explorer.solana.com/tx/2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ?cluster=devnet) | `"t":"seal"`, 3 copies | +| All receipts | [relayer `CCD4SUQt…hueQJx`](https://explorer.solana.com/address/CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx?cluster=devnet) | | + +Decode any receipt yourself, without our app or server: + +```bash +deno run --allow-net packages/receipts-ts/cli.ts 5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2 +``` + +## Run it locally in under 5 minutes + +You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47+ and Chrome. The app talks +to the hosted devnet backend with its public (publishable) key, so there is nothing else to set up. + +```bash +git clone https://github.com/https-shubhamsahu/nosus-canary.git +cd nosus-canary/app +flutter pub get +flutter run -d chrome --dart-define=NOSUS_CANARY_ONLY=true +``` + +Run the checks that CI runs (add [Deno](https://deno.com) 2.x for the last three): + +```bash +(cd app && flutter analyze && flutter test) +(cd packages/receipts-dart && dart pub get && dart analyze && dart test) +(cd packages/receipts-ts && deno test) +(cd app/supabase/functions/canary && deno check index.ts && deno test --allow-env) +``` + +Your own backend and relayer: [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md). Secrets template: +[.env.example](.env.example). + +## Security notes + +- **On chain:** only sha256-based ids and hashes. The reader tag is salted with a server secret + and bound to the note and copy, so tags can't be linked across notes. Never note text, keys, + names or device ids. +- **On the server:** encrypted copies it cannot read (the key never leaves the link fragment), + plus each reader's typed name and open time, which the sender sees. +- **On the sender's device:** the original note and the fingerprint of every copy. Leak matching + runs here. Clearing browser data deletes it. +- **What a receipt proves:** that the NO SUS relayer recorded this seal or this open at this time, + and that the note's copies were committed before anyone read them. It does **not** prove who a + person is, that a human read the text, or who shared a leak. Canary names *whose copy* leaked. +- **Known limits:** it does not stop screenshots; a reader who rewrites the text in their own + words can defeat it; two readers who blend their copies can confuse the match (measured rates + in the threat model). Encryption is AES-256 in CTR mode without an authentication tag today; + moving to AES-GCM is on the roadmap. +- **Secrets:** none in the repo. The whole history (46 commits, all branches) was scanned on + 20 Oct 2026; the only key found is the Supabase publishable key, which is public by design. + +Full threat model and how to report a problem: [SECURITY.md](SECURITY.md). + +## Roadmap + +| Next | What | Status | +|---|---|---| +| **Mainnet** | Receipts on Solana mainnet-beta with priority fees, retries with backoff, and relayer key rotation | Devnet live | +| **Open-source receipts library** | `@nosus/receipts` (TypeScript) and `nosus_receipts` (Dart): encode, parse, send, fetch and verify Canary receipts, with shared test vectors | **v0.1 in [`packages/`](packages/)**; the live relayer runs it; not yet published to JSR, npm or pub.dev | +| **Solana dApp Store / Seeker** | Canary inside the NO SUS Android app, submitted to the Solana dApp Store | Android build exists; not submitted | +| **PDFs and images** | Per-copy marks for files, not only text | Not started | +| **Merkle / compressed batching** | Many opens in one transaction (a Merkle root per batch, or state compression) to cut cost at scale | Not started | +| **Authenticated encryption** | AES-GCM per copy, so a tampered copy fails to open | Not started | + +## What's in this repo + +| Path | What | +|---|---| +| [`app/`](app/) | The NO SUS Flutter app (web + Android). Canary lives in [`app/lib/features/canary/`](app/lib/features/canary/); the deployed site is the standalone build (`NOSUS_CANARY_ONLY=true`). | +| [`app/supabase/functions/canary/`](app/supabase/functions/canary/) | The relayer: one Supabase Edge Function (Deno) that stores ciphertexts and writes the Solana memos | +| [`app/supabase/migrations/`](app/supabase/migrations/) | Database schema, including `canary_notes` and `canary_copies` | +| [`packages/receipts-ts/`](packages/receipts-ts/) · [`packages/receipts-dart/`](packages/receipts-dart/) | The receipts library, v0.1, plus [shared test vectors](packages/test-vectors/) from real devnet transactions | +| [`docs/`](docs/) | Architecture, receipt spec, self-hosting, status and verification log | +| [`tool/solana-spike/`](tool/solana-spike/) | Script that sends a test seal and open memo to devnet with your own key | + +## Team + +**Shubham Sahu**, founder of NO SUS. B.E. Electronics & Computer Engineering, TSEC Mumbai (2028). +[nosus.foo](https://nosus.foo) · [shubhamsahu@nosus.foo](mailto:shubhamsahu@nosus.foo) · +[LinkedIn](https://www.linkedin.com/in/shubhamsahu9372) · [X @isagi0011](https://x.com/isagi0011) + +## License + +[Apache-2.0](LICENSE). Use harmless test notes only on the devnet demo. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d93cef1 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,93 @@ +# Security + +## Reporting a problem + +Email **shubhamsahu@nosus.foo** with "security" in the subject. Please don't open a public issue +for a vulnerability. The demo runs on Solana devnet and a test +backend; never put real secrets or personal data into it. + +## What goes where + +| Data | Sender's device | Link | Server (Supabase) | Solana (public) | +|---|---|---|---|---| +| Note text and every copy | ✅ | — | encrypted only | — | +| AES-256 key | ✅ | in the `#fragment` | — | — | +| Fingerprint of each copy (which words were swapped) | ✅ | — | — | — | +| Owner secret (unlocks the dashboard) | ✅ | — | stored as sha256 only | — | +| Reader's typed name, open time | shown on dashboard | — | ✅ | — | +| Reader's device id (random, per browser) | — | — | stored as sha256 only | — | +| Note id | ✅ | ✅ | ✅ | sha256 only | +| Commitment to all copies | ✅ | — | ✅ | ✅ | +| Which copy was opened | ✅ | — | ✅ | ✅ | +| Reader tag | — | — | ✅ | ✅ | + +The browser never sends the `#fragment` to a server, so the key reaches only people who have the +link. The Canary tables have row-level security on and no grants to the public (`anon`) role; only +the edge function, using the service role, can read or write them. + +## How each value is computed + +| Value | Formula | Computed by | +|---|---|---| +| Chain note id | `0x` + sha256(`"nosus-canary:" + noteId`) | device and relayer | +| Copy digest | sha256(`salt + ":" + index + ":" + copyText`) with a random 32-byte salt kept on the device | device | +| Copies hash (sealed on chain) | `0x` + sha256(all copy digests joined in copy order) | device | +| Owner hash | sha256(`ownerSecret`), the secret is 32 random bytes kept on the device | device | +| Device hash | sha256(`"device:" + deviceId`) | relayer | +| Reader tag (on chain) | `0x` + sha256(`CANARY_TAG_SALT + ":" + noteId + ":" + copyIndex + ":" + deviceHash`) | relayer | + +`CANARY_TAG_SALT` is a 32-byte server secret. Without it nobody can link a tag to a device or link +two tags from different notes. The memo format is specified in +[docs/RECEIPTS_SPEC.md](docs/RECEIPTS_SPEC.md). + +## Threat model + +**What Canary is for:** naming which reader's copy a leaked text came from, and keeping a neutral, +timestamped record of the seal and of each open. + +| Who | What they try | What happens | +|---|---|---| +| A reader | Screenshot or copy-paste their copy | The copy is identified if enough text survives. See the measured rates below. | +| A reader | Strip invisible characters, change case or punctuation, OCR a screenshot | Still identified: the word swaps carry the fingerprint. | +| A reader | Retype the note in their own words | Can defeat it. Canary marks wording, not meaning. | +| Two readers | Blend their two copies | Usually names one of them; sometimes names an innocent reader (rates below). The app warns about this. | +| Anyone with the link | Open a copy and type a fake name | Works. A typed name is not an identity. The link is the access control: share it like a password. | +| Someone with the sender's device | Read the note, open the dashboard | Works. Device security is out of scope. | +| A passive observer of Solana | Learn the note, names or devices | Sees only hashes, copy counts, copy numbers, expiry and timestamps. | +| The server operator (us) | Read notes | Cannot: the server never has the key. | +| The server operator (us) | Give two readers the same copy, swap ciphertexts, or skip a receipt | Possible today. Partly detectable: the seal commits to every copy before anyone reads, and the sender's device can check any copy a reader shows them against it. Authenticated encryption (AES-GCM) is on the roadmap so a tampered copy fails to open. | +| Whoever steals the relayer key | Write fake memos from our relayer address | Possible. The key is a devnet key stored as a Supabase secret, never in the repo. Mainnet adds key rotation. | + +**Measured leak matching** (`app/tool/canary_fingerprint_harness.dart`, run in CI; a note with 15 +swappable words, like the app's demo note): + +| Leak | 20 copies | 50 copies | 100 copies | +|---|---|---|---| +| Full copy without invisible marker, OCR-style text, 2% typos | 300 / 300 each, 0 wrong | 300 / 300 each, 0 wrong | 300 / 300 each, 0 wrong | +| Partial excerpts: named / wrong | 431 / 600, 0 wrong | 313 / 600, 0 wrong | 188 / 600, 0 wrong | +| Two copies blended: an innocent named | 1 / 600 | 8 / 600 | 23 / 600 | + +## What we do not claim + +- Canary does not stop screenshots. It makes them traceable. +- A receipt proves that the NO SUS relayer recorded an event at a time. It does not prove who a + person is, that a human read the text, or who shared a leak. +- It is not legal evidence by itself. +- Deleting data from the server is an operational step, not something anyone can verify. + +## Known gaps + +- **Encryption mode.** Copies use AES-256 from `package:encrypt` with its default mode (SIC, which + is CTR) and a random 16-byte IV per copy. There is no authentication tag. Planned: AES-GCM. +- **Relayer trust.** One relayer key signs every memo. Planned: several keys with rotation, and + receipts batched under a Merkle root so a reader can check their own open. +- **Devnet only.** Devnet can be reset; receipts there are for demos, not records. + +## Secrets in this repository + +None. On 20 Oct 2026 every commit on every branch (46 commits, 17 refs, plus unreachable objects) +was scanned for Solana and EVM private keys, Supabase service-role and secret keys, GitHub, Google, +AWS, Stripe and AI-provider tokens, PEM keys and signing files. The only key-like value is the +Supabase **publishable** key in `app/lib/config/supabase_credentials.dart`, which is public by +design and gated by row-level security. Real secrets live only in Supabase function secrets and in +git-ignored files; see [.env.example](.env.example). diff --git a/SUBMISSION.md b/SUBMISSION.md new file mode 100644 index 0000000..722bd42 --- /dev/null +++ b/SUBMISSION.md @@ -0,0 +1,112 @@ +# NO SUS Canary: for judges and reviewers + +**One line:** share a sensitive note with a group; every reader gets a secretly different copy; +every seal and every open is a Solana transaction; if a screenshot leaks, NO SUS names whose copy +it was. + +| | Link | +|---|---| +| Live app (Solana devnet) | https://shubham-sahu.me/nosus-canary/ | +| Code (Apache-2.0) | https://github.com/https-shubhamsahu/nosus-canary | +| Pitch video (2:24) | [nosus-canary-pitch.mp4](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-pitch.mp4) | +| Product demo, narrated (1:18) | [nosus-canary-demo.mp4](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-demo.mp4) | +| 36-second silent demo | [GIF](docs/media/canary-demo.gif) · [MP4](docs/media/canary-demo.mp4) | +| A real open receipt | [`5TXFPfTD…oW3wAsy2` on Solana Explorer](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet) | +| Every receipt so far | [Relayer account on Solana Explorer](https://explorer.solana.com/address/CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx?cluster=devnet) | +| Receipt format | [docs/RECEIPTS_SPEC.md](docs/RECEIPTS_SPEC.md) | +| Threat model | [SECURITY.md](SECURITY.md) | +| Contact | Shubham Sahu · [shubhamsahu@nosus.foo](mailto:shubhamsahu@nosus.foo) | + +## Check it yourself in 60 seconds + +1. Open the [live app](https://shubham-sahu.me/nosus-canary/) → **Create a Canary link** → + **Use the demo note** → **Create Canary link**. A "Sealed on Solana" chip appears. +2. Open the link in a private window, type any name, tap **Open my copy**. Your copy shows a + "Recorded on Solana" chip. Click it: Solana Explorer shows one Memo instruction with hashes only. +3. Copy the reader's text. Back in the first window, open **Who opened it** and paste it into + **Check a leak** → **Find the copy**. It names the reader. + +Use harmless text only. This is devnet. + +## 2-minute demo script + +| Time | Do | Say | +|---|---|---| +| 0:00 | Show the landing page | "Salary sheets, offer letters and decks leak as screenshots. Today nobody can show whose copy it was." | +| 0:15 | **Create a Canary link** → **Use the demo note** | "I write the note once. Canary makes up to 100 copies that read the same, with tiny word swaps like *don't* and *do not*, and an invisible marker." | +| 0:30 | Point at the fingerprint meter, pick 10 readers, **Create Canary link** | "Every copy is encrypted in my browser. The key stays in the link. Before anyone reads, a seal goes to Solana: one hash of all ten copies." | +| 0:45 | Click **Sealed on Solana** → Explorer | "That's a real devnet transaction. One SPL Memo. No text, no names, just hashes." | +| 1:00 | Open the link in a private window, type "Priya", **Open my copy** | "Priya needs no wallet and no app. She gets her own copy, and her open is another Solana memo, paid by our relayer for about 0.000005 SOL." | +| 1:20 | Copy Priya's text. Sender window: **Who opened it**, paste into **Check a leak**, **Find the copy** | "Now imagine Priya's copy shows up in a group chat. I paste it in. Matching runs on my device." | +| 1:35 | Result card: **The canary sang · Priya · Copy 1 / 10** | "It names her copy. If too little text survived, it names nobody. It never guesses." | +| 1:45 | Click **Proof on Solana** | "And the receipt shows when her copy was opened, on a chain neither of us can edit." | +| 1:55 | Back to README roadmap | "Next: mainnet, the open receipts library, PDFs, and the Solana dApp Store." | + +## FAQ + +**What does a receipt prove, exactly?** +That the NO SUS relayer recorded a seal (with a commitment to every copy) or an open of copy N, at +a chain timestamp, and that the copies were committed before anyone read them. It does not prove +a person's identity, that a human read the text, or who shared a leak. Canary names *whose copy* +leaked. Phones get lost and screens get shown to friends. + +**Why put this on a chain instead of in your database?** +A database row is our word, and we can edit it. A Solana memo is public, timestamped and +append-only, so the sender, the reader and a third party can all check the same record without +trusting NO SUS. + +**Why Solana?** +One receipt per reader has to be cheap (each receipt pays the 5,000-lamport base fee) and fast (about a +second to confirm, so the sender sees "opened" right away). The standard SPL Memo program means +there is no custom program to audit, and Explorer shows the memo as plain text. + +**Does the server see the note?** +No. Copies are encrypted on the sender's device and the AES key lives in the link's `#fragment`, +which browsers never send to a server. The server does see each reader's typed name and when they +opened, because the sender's dashboard shows it. Names never go on chain. + +**Can a reader strip the marks?** +Removing the invisible marker is not enough: the word swaps still identify the copy. From the +fingerprint test harness (`app/tool/canary_fingerprint_harness.dart`, run in CI), on a note with +15 swappable words like the app's demo note: + +| Leak | 20 copies | 50 copies | 100 copies | +|---|---|---|---| +| Full copy, invisible marker removed | 300 / 300 right | 300 / 300 | 300 / 300 | +| OCR-style (caps, no punctuation) | 300 / 300 right | 300 / 300 | 300 / 300 | +| 2% random typos | 300 / 300 right, 0 wrong | 300 / 300, 0 wrong | 300 / 300, 0 wrong | +| Partial excerpts (named / wrong) | 431 / 600, 0 wrong | 313 / 600, 0 wrong | 188 / 600, 0 wrong | +| Two readers blend their copies: an innocent named | 1 / 600 | 8 / 600 | 23 / 600 | + +When an excerpt is too short, Canary names nobody instead of guessing. A reader who rewrites the +whole note in their own words can defeat it. Blending is the known weak spot; the app says so. + +**Who pays the fees? Do readers need a wallet?** +The relayer (a Supabase Edge Function) pays and signs every memo. Readers never see a wallet. + +**Are reader tags private?** +Each tag is `sha256(server salt : note id : copy number : sha256(device id))`. Without the server +salt nobody can link tags across notes or back to a device. + +**Is it on mainnet?** +Not yet. Devnet only. Mainnet with priority fees and retries is the first roadmap item. + +**What is open source?** +Everything in this repo, under Apache-2.0: the Flutter app, the relayer, the database schema, and +the receipts library v0.1 in TypeScript and Dart (`packages/`), which any app can use to write and +verify the same receipts. + +**What is the business?** +Pre-revenue. First users we are building for: small teams, HR, founders sharing decks, and college +groups in India. Planned paid features for teams: leak alerts, a team dashboard and audit exports. +None of those are built yet. + +**What traction is there?** +Canary has been live on devnet since 10 Oct 2026; its relayer has written 31 receipts (20 Oct +2026), all from our own tests and demos. The parent app, NO SUS, has been in production since July +2026 with 18 registered users and 6 active in the last 30 days (10 Oct 2026). We do not claim more +than that. + +**Who is building it?** +Shubham Sahu, founder of NO SUS, B.E. Electronics & Computer Engineering at TSEC Mumbai (2028). +NO SUS has shipped 10+ releases since July 2026. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..791c253 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,120 @@ +# === NO SUS — .gitignore === + +# --- Secrets & Credentials --- +.env +.env.local +.env.production +.env.development +*.pem +*.key +signing_keys.json + +# Firebase per-project config. This repo is public, so the file is injected in +# CI from GOOGLE_SERVICES_JSON_BASE64 rather than checked in — same convention +# as ANDROID_KEYSTORE_BASE64. android/app/build.gradle.kts applies the +# google-services plugin only when the file exists, so a clone without it still +# builds; it just ships without push. +android/app/google-services.json +ios/Runner/GoogleService-Info.plist + +# --- Supabase temp files --- +supabase/.temp/ +supabase/.branches/ + +# --- Flutter / Dart --- +**/doc/api/ +.dart_tool/ +.pub-cache/ +.pub/ +**/build/ +coverage/ +.flutter-plugins-dependencies +*.flutter-plugins + +# --- Rust (fhe-compute) --- +# CRITICAL: target/ is the Rust build cache (~39GB). Never commit it. +**/target/ +**/*.rs.bk + +# --- Release artifacts --- +# Built APKs/bundles are large; keep them out of git (archive separately). +releases/ + +# --- Agent / editor tooling --- +.agents/ +.cursor/ + +# --- IDE --- +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.swp +*.swo +*~ + +# --- OS --- +.DS_Store +Thumbs.db +*.log +*.class +*.pyc + +# --- Android --- +/android/app/debug +/android/app/profile +/android/app/release +/local.properties +android/local.properties +/key.properties +*.jks +*.keystore + +# --- iOS --- +**/ios/Flutter/.last_build_id +**/ios/Pods/ +**/ios/.symlinks/ +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Runner.xcworkspace/ +*.xcworkspace + +# --- macOS --- +**/macos/Pods/ +**/macos/.symlinks/ + +# --- Windows --- +**/windows/out/ +**/windows/runner/Debug/ +**/windows/runner/Release/ +*.vcxproj.user + +# --- Build outputs --- +app.*.symbols +app.*.map.json +*.dill +*.dill.map + +# --- Generated files --- +*.g.dart +*.freezed.dart +*.mocks.dart + +# --- Temporary --- +migrate_working_dir/ +.history +.svn/ +.atom/ +.build/ +.buildlog/ +.swiftpm/ + +# --- Node / Edge Functions --- +supabase/functions/**/node_modules/ +supabase/functions/**/dist/ + +*.lcov + +# Local marker for the AGENTS.md change-log hook (per-checkout state) +.claude/.changelog-head diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 0000000..1e65aef --- /dev/null +++ b/app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "924134a44c189315be2148659913dda1671cbe99" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + - platform: android + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 0000000..44bbea4 --- /dev/null +++ b/app/AGENTS.md @@ -0,0 +1,18 @@ +# NO SUS Canary: Flutter app + +This is the NO SUS Flutter app (copied from the production `no_sus` repo at +`ec29fdae94a68fefcb5efea2797224dc2245549c`). NO SUS Canary lives in `lib/features/canary/`. The +root `AGENTS.md` still applies. + +- The deployed site is the standalone Canary build: `--dart-define=NOSUS_CANARY_ONLY=true`, base + href `/nosus-canary/`. Without that flag you get the full NO SUS app, with Canary reachable from + the welcome screen and `#/canary`. +- Backend: `ffidvfguojalzpclipzi` only. Never apply this fork's SQL to the production project. No + production users or storage were copied. The publishable Supabase key is a public client key. +- Native source is kept for parity. Do not release it with the production application or signing + identity. +- Leave `CANARY_MONAD` off (default). User-facing text names Solana only. +- After changing the word list in `canary_fingerprint.dart`, run + `dart run tool/canary_fingerprint_harness.dart`; it must end with `FAILURES: 0`. +- Run `flutter analyze` and `flutter test` before every commit. Do not reformat unrelated copied + files. Migrations are immutable; add new ones. diff --git a/app/MASCOT_GUIDE.md b/app/MASCOT_GUIDE.md new file mode 100644 index 0000000..01dd61e --- /dev/null +++ b/app/MASCOT_GUIDE.md @@ -0,0 +1,65 @@ +# NO SUS — MASCOT GUIDE +> Exclusive documentation for Lux and Nox — the emotional guides and silent security guards of NO SUS. + +--- + +## 1. Mascot Profiles + +### Lux (Light Guide) +* **Personality**: Curious, encouraging, observant. +* **Role**: Primary UI navigator, study desk companion, and success celebrator. Lux helps users feel oriented and supported in their workspace tasks. +* **Visual Theme**: Light-aligned monochrome, clean strokes. + +### Nox (Dark Guard) +* **Personality**: Protective, alerts-driven, quiet. +* **Role**: Visual guardian. Appears during high-security moments, security event notices, and when recipients open documents (e.g. notifications snackbars). +* **Visual Theme**: Dark-aligned monochrome, sharp outlines. + +### Duo (The Composition) +* **Role**: Reserved for scarce bookend moments, such as session logins and log summaries. The launch screen stays deliberately mascot-free so it can load quickly and remain focused. +* **Visual Asset**: Falls back to `assets/icon/LuxandNox.png` when the `.riv` asset is absent. + +--- + +## 2. Animation States & Moods +The mascots use the Rive State Machine (`SM`) with a control input named `stateIndex` (mapping to the integer values of `MascotMood` below) and `reducedMotion` (boolean). + +| Mood Index | Mood Name | Rive Artboard Behavior | Trigger Condition / Usage | +|---|---|---|---| +| 0 | `idle` | Slow breathing loop | Default state | +| 1 | `sleep` | Closed eyes, slow pacing | App inactive / idle timer | +| 2 | `wake` | Eyes opening, small stretch | App resume | +| 3 | `walk` | Moving legs in profile | Transition animations | +| 4 | `lookAround` | Head turns left/right | WorkspaceTab initialization (played once on tab boot) | +| 5 | `think` | Hand-to-chin, eyes upward | Running computation or search | +| 6 | `observe` | Leaning forward, watching | Reading state or page scroll | +| 7 | `guide` | Pointing gesture | Onboarding tutorials / tooltips | +| 8 | `celebrate` | Small jump / wave | Upload completed, milestone achieved | +| 9 | `wait` | Tapping foot | Async job in queue / pending state | +| 10 | `sit` | Resting pose | Inactive on workspace desk | +| 11 | `stretch` | Quick body extension | Long session transition | +| 12 | `protect` | Arms crossed, alert posture | Private directory focus | +| 13 | `approve` | Nods head | Validation checks pass | +| 14 | `verify` | Adjusts glasses / lens lookup | Cryptographic chain validation success | +| 15 | `guard` | Standing sentinel | RLS check active / Vault tab focus | +| 16 | `alert` | Surprised expression, flash | Threat detected / screenshot block | +| 17 | `stamp` | Overlaying action | Watermark successfully stamped on view | +| 18 | `returnToLogo` | Fades out to logo mark | Transition to main navigation logo | + +--- + +## 3. Motion Language & Interaction Rules +1. **Never Interfere with Content**: Mascots must only occupy designated empty spaces or side containers (like headers, snackbars, and teaser card elements). +2. **Reduced Motion Compliance**: + * If `MediaQuery.of(context).disableAnimations` is `true`, the `reducedMotion` input is set on the Rive artboard, forcing the mascot controller to lock visual states directly to `MascotMood.idle`. +3. **Imperative Control**: + * Mascots are driven imperatively via Riverpod providers. Use `ref.read(luxMascotProvider.notifier).play(MascotMood.lookAround)` to trigger animations. + +--- + +## 4. Design Constraints +* **Format**: Rive `.riv` assets located in `assets/mascot/.riv`. +* **Fallback Behavior**: + * `MascotView` uses a fallback widget placeholder before a `.riv` file is loaded to prevent visual breaks. + * Duo falls back to the static png `assets/icon/LuxandNox.png`. + * Solo Lux/Nox fallback defaults to `SizedBox.shrink()` (silent hidden state) or explicit icons (e.g. `Icon(Icons.notifications_active)` in share notifications). diff --git a/app/PROJECT_CONSTITUTION.md b/app/PROJECT_CONSTITUTION.md new file mode 100644 index 0000000..075b5ba --- /dev/null +++ b/app/PROJECT_CONSTITUTION.md @@ -0,0 +1,65 @@ +# NO SUS — PROJECT CONSTITUTION +> Authoritative, permanent guide on product vision, standards, and rules. This document does NOT track active developments or session logs. + +--- + +## 1. Product Vision +**One-Sentence Vision:** +NO SUS lets people share a sensitive document, know when it was opened, and retain simple control over the link without forcing recipients to register or install anything. + +**Target Users:** +1. **Students, professionals, and small teams**: Sharing documents that need context, visibility, or a revocable link. +2. **Privacy-conscious communicators**: Exchanging one-time secrets (credentials, private messages) over a client-encrypted, self-destructing channel. + +--- + +## 2. Founder Philosophy +1. **Zero-Budget, Solo Leverage**: Optimize all architecture for a single developer. Maximize generous free tiers (Supabase, Deno, Cloudflare) and avoid operational overhead. +2. **Viral-Growth Distribution**: To access a SecureSend document or open a Burn Note, the recipient must interact with the platform. The product drives its own distribution. +3. **Cryptographic & Technical Honesty**: Copy and brand claims must never outrun the actual cryptography. Do not claim screenshot-proofing on browsers or total server blindness until client-side key generation (M10) is built. + +--- + +## 3. Version 1 Scope +V1 focuses on shipping a highly stable, low-maintenance, polished web-first document link security tool: +* **SecureSend**: Share links with expiration dates, maximum view counts, touch-to-reveal blur, dynamic recipient-email watermarks, and real-time view logs. +* **Burn Notes**: Client-side AES-256 encrypted short text notes with keys kept in the URL hash fragment (zero-knowledge) and destroyed atomically on read. + +--- + +## 4. Long-Term Vision +* **Optional document assistance**: A future Gemini or on-device adapter may help an authorized user summarize or understand a document. It is always optional, feature-flagged, and cannot block core sharing. + +--- + +## 5. Engineering & Security Philosophy +* **RLS-First Security**: Every table must have Row-Level Security enabled. RLS policies must prevent data leaks even if client applications are fully compromised. +* **Server Boundary**: The mobile/web client must never receive server-side credentials or communicate directly with external cloud storage. Sensitive server operations go through Supabase Edge Functions. +* **Client-Side Secret Ownership**: For zero-knowledge features, the keys must be generated, stored, and used entirely in the client browser. No key material may ever touch the server database or logs. +* **Shipping Over Perfection**: Focus on production readiness, simplicity, and maintainability over unnecessary optimizations or architectural complexity. + +--- + +## 6. UI Philosophy +* **Monochrome Paper-Ink Identity**: Clean, distraction-free near-black/near-white visuals. Use `NoSusTheme` spacing, radii, and color tokens. +* **Typography**: Outfit for display/headings (bold, large letter-spacing), Inter for body/labels (high readability). +* **Frictionless Onboarding**: Skip gates, magic links, or OTP recovery should guide users into utility as fast as possible. + +--- + +## 7. Coding Standards & Conventions +* **Clean Architecture**: Core and new features must follow strict separation of layers: + * `Domain`: Pure Dart interfaces, entities, and business logic. + * `Data`: Supabase implementations, local/network data sources, and models. + * `Presentation`: Riverpod state providers, controllers, and UI widgets. +* **Service Singletons**: Legacy or auxiliary features may use simple singleton helper services (`SupabaseService`, `AuditService`, `ScreenshotGuard`) injected via Riverpod providers. +* **No Duplicate Migrations**: SQL migrations must be incremental, idempotent, and fully reversible. +* **Remove Dead Code**: Clean up unused imports, deprecated assets, and unreferenced functions after edits. + +--- + +## 8. Non-Negotiable Rules +1. **Never hardcode secrets** (must use `.env` / `--dart-define-from-file`). +2. **Never bypass repositories** in presentation code. +3. **Never allow unauthenticated INSERTs** into system logs or tables except where explicitly designed (e.g. Burn Notes insert, which is encrypted client-side). +4. **No v2/v3 duplicate files** or split branch iterations. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 0000000..ec9fbef --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1,40 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + prefer_const_constructors: true + prefer_const_constructors_in_immutables: true + prefer_const_literals_to_create_immutables: true + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts new file mode 100644 index 0000000..6c8a703 --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -0,0 +1,143 @@ +import java.util.Properties + +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// Firebase Cloud Messaging, applied only when the project has actually been +// provisioned. The google-services plugin fails the build with "File +// google-services.json is missing" if it is applied without one, so putting it +// in the plugins {} block above would break every build in CI and on every +// fresh clone — the repo has no such file and must not (it is per-project +// config, and checking one in would tie the open repo to one Firebase project). +// +// Drop the file in android/app/ and this wires itself up on the next build. +// Nothing else needs to change. See AGENTS.md §8. +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} + +// Repo-root .env, the same file CI writes and `--dart-define-from-file=.env` +// reads. Git-ignored and usually absent, in which case this stays empty and +// every lookup below falls back to "". rootProject here is android/, so the +// repo root is one level up. +val measureEnv = Properties().apply { + val envFile = rootProject.file("../.env") + if (envFile.exists()) { + envFile.inputStream().use { load(it) } + } +} + +android { + namespace = "foo.nosus.app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // Separate app id so this build installs next to the production + // NO SUS app and never uses its Play identity. namespace stays + // foo.nosus.app because the Kotlin sources live in that package. + applicationId = "foo.nosus.canary" + minSdk = flutter.minSdkVersion // flutter_secure_storage requires API 23+ + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + + // Measure (measure.sh) credentials for AndroidManifest.xml. + // + // The native SDK initialises in Application.onCreate, before the + // Flutter engine exists, so it cannot be handed a --dart-define. It + // reads meta-data from the manifest instead. Sourcing both sides from + // the one repo-root .env is what keeps the native gate and the Dart + // gate (lib/config/measure_reporting_config.dart, fed by + // --dart-define-from-file=.env) in agreement. + // + // Empty when .env is absent — a fresh clone and a CI run without the + // secrets both build fine and ship the SDK inert. Placeholders must + // always be set to *something*: an unresolved ${...} in the manifest + // is a merge failure, not a warning. + manifestPlaceholders["measureApiKey"] = measureEnv.getProperty("MEASURE_API_KEY") ?: "" + manifestPlaceholders["measureApiUrl"] = measureEnv.getProperty("MEASURE_API_URL") ?: "" + } + + signingConfigs { + create("release") { + val keystorePropertiesFile = rootProject.file("key.properties") + val keystoreProperties = Properties() + if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.inputStream().use { stream -> + keystoreProperties.load(stream) + } + // A present-but-blank key.properties (e.g. checked out fresh, + // template never filled in) must be treated the same as a + // missing file. Properties.getProperty("storeFile") on a line + // like "storeFile=" returns "" (empty string), which is not + // null — file("") resolves to the project directory itself, + // a non-null File that would otherwise slip past the + // `storeFile != null` check below and get selected as a + // broken "release" signing config instead of falling back + // to debug signing. + val storeFilePath = keystoreProperties.getProperty("storeFile") + if (!storeFilePath.isNullOrBlank()) { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = file(storeFilePath) + storePassword = keystoreProperties.getProperty("storePassword") + } + } + } + } + + buildTypes { + release { + val releaseConfig = signingConfigs.findByName("release") + if (releaseConfig != null && releaseConfig.storeFile != null && releaseConfig.storeFile!!.exists()) { + signingConfig = releaseConfig + } else { + signingConfig = signingConfigs.getByName("debug") + } + // Enable R8 minification and resource shrinking with custom rules + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + // Required for pdfrx native PDF renderer — AGP 8+ equivalent of extractNativeLibs="true" + packaging { + jniLibs { + useLegacyPackaging = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} + +dependencies { + // Modern Android 12+ SplashScreen API, backported to minSdk via the + // compat shim so pre-12 devices get equivalent behavior through the + // same windowSplashScreen* theme attributes (see styles.xml). + implementation("androidx.core:core-splashscreen:1.0.1") + + // Play Integrity API (standard request) — server-verifiable device/app + // attestation, complementing the client-side heuristics in + // RootDetector.kt/InstrumentationDetector.kt (which a sufficiently + // capable attacker can spoof; Play Integrity's response is signed by + // Google and verified server-side in supabase/functions/verify-play-integrity). + // See PlayIntegrityManager.kt for why this is scaffolded, not enabled. + implementation("com.google.android.play:integrity:1.4.0") +} diff --git a/app/android/app/proguard-rules.pro b/app/android/app/proguard-rules.pro new file mode 100644 index 0000000..9641948 --- /dev/null +++ b/app/android/app/proguard-rules.pro @@ -0,0 +1,52 @@ +# Custom ProGuard / R8 Rules for NO SUS +# Applied in release builds via build.gradle.kts + +# ─── Flutter Engine ──────────────────────────────────────────────────────────── +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.embedding.** { *; } +-keep class io.flutter.plugins.** { *; } + +# ─── Platform Channel Plugins ────────────────────────────────────────────────── +-keep class com.it_club.flutter_secure_storage.** { *; } +-keep class dk.itst.android.security.** { *; } +-keep class com.mr.flutter.filepicker.** { *; } +-keep class com.protect.screen_protector.** { *; } + +# ─── pdfrx (native PDF renderer — JNI bridge must be preserved) ─────────────── +-keep class dev.majrie.pdfrx.** { *; } +-keep class com.pdfrx.** { *; } +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +# ─── Supabase / Ktor / OkHttp (WebSocket + HTTP client) ────────────────────── +-keep class io.github.jan.supabase.** { *; } +-keep class io.ktor.** { *; } +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } +-dontwarn okhttp3.** +-dontwarn io.ktor.** + +# ─── JSON / Serialization ───────────────────────────────────────────────────── +-keep class com.google.gson.** { *; } +-dontwarn com.google.gson.** + +# Keep Kotlin serialization metadata (used by Supabase Kotlin client) +-keepattributes *Annotation* +-keepattributes Signature +-keepattributes InnerClasses,EnclosingMethod + +# ─── Suppress known harmless warnings ───────────────────────────────────────── +-dontwarn com.google.android.play.core.** +-dontwarn android.support.** +-dontwarn androidx.** + +# --- NO SUS Canary: ML Kit text recognition (Latin model only) --- +# The plugin references optional script recognizers that are not bundled. +-dontwarn com.google.mlkit.vision.text.chinese.** +-dontwarn com.google.mlkit.vision.text.devanagari.** +-dontwarn com.google.mlkit.vision.text.japanese.** +-dontwarn com.google.mlkit.vision.text.korean.** diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bc30f6b --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/MainActivity.kt b/app/android/app/src/main/kotlin/foo/nosus/app/MainActivity.kt new file mode 100644 index 0000000..a351405 --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/MainActivity.kt @@ -0,0 +1,373 @@ +package foo.nosus.app + +import android.app.Activity +import android.content.Intent +import android.database.ContentObserver +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.view.MotionEvent +import android.view.WindowManager +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel +import foo.nosus.app.security.DeviceIntegrityManager +import foo.nosus.app.security.KeyAttestationManager +import foo.nosus.app.security.PlayIntegrityManager +import java.io.File +import java.io.FileOutputStream + +class MainActivity : FlutterActivity() { + private val SECURITY_CHANNEL = "co.nosus.app/security" + private val SCREENSHOT_CHANNEL = "co.nosus.app/screenshot" + private val SHARE_CHANNEL = "co.nosus.app/share" + private val INTEGRITY_CHANNEL = "co.nosus.app/device_integrity" + private val PLAY_INTEGRITY_CHANNEL = "co.nosus.app/play_integrity" + + private var screenshotObserver: ContentObserver? = null + private var screenCaptureCallback: Activity.ScreenCaptureCallback? = null + private var eventSink: EventChannel.EventSink? = null + private var methodChannel: MethodChannel? = null + private var sharedData: Map? = null + private var overlayEventCoolingDown = false + + override fun onCreate(savedInstanceState: Bundle?) { + // Must be called before super.onCreate() — installs the AndroidX + // SplashScreen compat shim (styles.xml's windowSplashScreen* + // attributes) so it's active on API < 31 too, and hands off cleanly + // to the real platform SplashScreen on API 31+. Kept up only until + // Flutter's first frame; lib/screens/splash_screen.dart's + // VideoSplashScreen owns everything the user actually watches. + installSplashScreen() + super.onCreate(savedInstanceState) + // Block screenshots and screen recordings — content appears black + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + if (savedInstanceState == null) { + handleIntent(intent) + } + registerModernScreenshotCallback() + // Must exist before the first push arrives — Android drops a + // notification addressed to an unknown channel without any error. + // Idempotent and permission-free, so it runs unconditionally. + NotificationChannels.register(applicationContext) + } + + /** + * Android 14+ fires this synchronously the moment a screenshot is taken, + * system-wide — far more reliable than the MediaStore heuristic below, + * which only notices *after* the file lands on disk. Older OS versions + * fall back to the ContentObserver path started in configureFlutterEngine. + */ + private fun registerModernScreenshotCallback() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + try { + val callback = Activity.ScreenCaptureCallback { + eventSink?.success(mapOf("type" to "screenshot", "source" to "system_api")) + } + registerScreenCaptureCallback(mainExecutor, callback) + screenCaptureCallback = callback + } catch (_: Exception) { + // OEM skin variance — silently rely on the ContentObserver fallback. + } + } + } + + private fun unregisterModernScreenshotCallback() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + screenCaptureCallback?.let { + try { + unregisterScreenCaptureCallback(it) + } catch (_: Exception) { } + } + } + } + + /** + * Detects tapjacking/overlay attacks: the system sets these MotionEvent + * flags whenever a touch was delivered through another app's window + * overlaying ours (SYSTEM_ALERT_WINDOW). Requires + * android:filterTouchesWhenObscured="true" on the activity (set in + * AndroidManifest.xml) to actually be populated by the framework. + */ + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + val obscured = (ev.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED) != 0 || + (ev.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED) != 0 + if (obscured && !overlayEventCoolingDown) { + overlayEventCoolingDown = true + eventSink?.success(mapOf("type" to "overlay_detected")) + Handler(Looper.getMainLooper()).postDelayed({ overlayEventCoolingDown = false }, 3000) + } + return super.dispatchTouchEvent(ev) + } + + override fun onNewIntent(intent: Intent) { + setIntent(intent) + super.onNewIntent(intent) + handleIntent(intent) + sendSharedDataToFlutter() + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, SHARE_CHANNEL) + methodChannel?.setMethodCallHandler { call, result -> + when (call.method) { + "getInitialShare" -> { + result.success(sharedData) + sharedData = null + } + "openUrl" -> { + val url = call.argument("url") + if (url != null) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + result.success(true) + } catch (e: Exception) { + result.error("FAILED", e.message, null) + } + } else { + result.error("BAD_ARGS", "Missing url parameter", null) + } + } + else -> result.notImplemented() + } + } + + // ── MethodChannel: toggle FLAG_SECURE from Dart ── + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, SECURITY_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "enableSecure" -> { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + result.success(true) + } + "disableSecure" -> { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + result.success(true) + } + else -> result.notImplemented() + } + } + + // ── MethodChannel: on-demand device-integrity scan (root / Frida / Xposed / display mirroring / accessibility) ── + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INTEGRITY_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "runChecks" -> { + try { + result.success(DeviceIntegrityManager.runAllChecks(applicationContext)) + } catch (e: Exception) { + result.error("INTEGRITY_SCAN_FAILED", e.message, null) + } + } + // Hardware-backed device identity. Shares this channel + // rather than opening a new one — same subsystem, same + // Dart-side service consumes both. + "getDeviceKeyId" -> { + try { + result.success(KeyAttestationManager.getOrCreateDeviceKeyId()) + } catch (e: Exception) { + result.error("DEVICE_KEY_FAILED", e.message, null) + } + } + else -> result.notImplemented() + } + } + + // ── MethodChannel: Play Integrity standard token request (scaffolded, + // not enabled — see PlayIntegrityManager.kt) ── + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PLAY_INTEGRITY_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "requestToken" -> { + val nonce = call.argument("nonce") + if (nonce == null) { + result.error("BAD_ARGS", "Missing nonce parameter", null) + } else { + PlayIntegrityManager.requestToken(applicationContext, nonce, result) + } + } + else -> result.notImplemented() + } + } + + // ── EventChannel: push screenshot/recording/overlay events to Dart ── + EventChannel(flutterEngine.dartExecutor.binaryMessenger, SCREENSHOT_CHANNEL) + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + startScreenshotObserver() + } + + override fun onCancel(arguments: Any?) { + stopScreenshotObserver() + eventSink = null + } + }) + } + + private fun startScreenshotObserver() { + val handler = Handler(Looper.getMainLooper()) + + screenshotObserver = object : ContentObserver(handler) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + uri ?: return + try { + // Resolve the file path to check if it's a screenshot/recording + val cursor = contentResolver.query( + uri, + arrayOf(MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME), + null, null, null + ) + cursor?.use { + if (it.moveToFirst()) { + val pathIndex = it.getColumnIndex(MediaStore.MediaColumns.DATA) + val nameIndex = it.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + val path = if (pathIndex >= 0) it.getString(pathIndex)?.lowercase() ?: "" else "" + val name = if (nameIndex >= 0) it.getString(nameIndex)?.lowercase() ?: "" else "" + + val isScreenshot = path.contains("screenshot") || name.contains("screenshot") + val isRecording = path.contains("screenrecord") || name.contains("screenrecord") || + path.contains("screen_record") || name.contains("screen_record") + + if (isScreenshot || isRecording) { + eventSink?.success(mapOf( + "type" to if (isRecording) "recording" else "screenshot", + "source" to "media_store" + )) + } + } + } + } catch (_: Exception) { /* Ignore permission or cursor errors */ } + } + } + + // Watch Images (screenshots) and Videos (screen recordings) + contentResolver.registerContentObserver( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, screenshotObserver!! + ) + contentResolver.registerContentObserver( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, screenshotObserver!! + ) + } + + private fun stopScreenshotObserver() { + screenshotObserver?.let { contentResolver.unregisterContentObserver(it) } + screenshotObserver = null + } + + override fun onDestroy() { + stopScreenshotObserver() + unregisterModernScreenshotCallback() + super.onDestroy() + } + + private fun handleIntent(intent: Intent?) { + if (intent == null) return + val action = intent.action + val type = intent.type + + if ((Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) && type != null) { + if (type.startsWith("text/")) { + val text = intent.getStringExtra(Intent.EXTRA_TEXT) + ?: intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + if (text != null) { + val trimmed = text.trim() + val isUrl = trimmed.startsWith("http://") || trimmed.startsWith("https://") + sharedData = mapOf( + "type" to if (isUrl) "url" else "text", + "data" to trimmed, + "name" to null + ) + } + } else { + val uris = if (Intent.ACTION_SEND_MULTIPLE == action) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + } else { + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + if (uri != null) arrayListOf(uri) else null + } + + val uri = uris?.firstOrNull() + if (uri != null) { + val resolved = saveUriToCache(uri, type) + if (resolved != null) { + sharedData = mapOf( + "type" to if (type.startsWith("image/")) "image" else "pdf", + "data" to resolved.absolutePath, + "name" to resolved.name + ) + } + } + } + } + } + + private fun saveUriToCache(uri: Uri, mimeType: String): File? { + try { + var fileName = "shared_file_${System.currentTimeMillis()}" + val extension = when { + mimeType.startsWith("image/") -> { + val ext = mimeType.substringAfter("/", "") + if (ext.isNotEmpty()) ext else "png" + } + mimeType == "application/pdf" -> "pdf" + else -> "bin" + } + + val projection = arrayOf(MediaStore.MediaColumns.DISPLAY_NAME) + contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + if (index >= 0) { + val name = cursor.getString(index) + if (!name.isNullOrEmpty()) { + fileName = name + } + } + } + } + + if (!fileName.contains(".")) { + fileName = "$fileName.$extension" + } + + val cacheFile = File(cacheDir, fileName) + contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(cacheFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + return cacheFile + } catch (e: Exception) { + // Deliberately swallowed, not logged — printStackTrace() writes + // to stderr/logcat unconditionally, including in release builds. + return null + } + } + + private fun sendSharedDataToFlutter() { + sharedData?.let { + methodChannel?.invokeMethod("onShareReceived", it) + sharedData = null + } + } +} diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/NoSusApplication.kt b/app/android/app/src/main/kotlin/foo/nosus/app/NoSusApplication.kt new file mode 100644 index 0000000..58c257e --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/NoSusApplication.kt @@ -0,0 +1,98 @@ +package foo.nosus.app + +import android.app.Application +import android.content.pm.PackageManager +import android.os.Build +import sh.measure.android.Measure +import sh.measure.android.config.MeasureConfig + +/** + * Exists for exactly one reason: the Measure SDK has no Dart-side + * initialisation path. + * + * `measure_flutter`'s MethodChannel exposes `start`/`stop`/`trackEvent`/… but + * no `init` — the native SDK must already be running by the time Dart calls + * anything, and it has to come up in `Application.onCreate` to catch crashes + * that happen before the Flutter engine exists. That is why this class is + * registered as `android:name` in AndroidManifest.xml instead of the + * `${applicationName}` placeholder Flutter fills in by default. + * + * It extends plain [Application]: this app is on Flutter's v2 embedding + * (`flutterEmbedding` = 2 in the manifest), which does not require + * `FlutterApplication`. + */ +class NoSusApplication : Application() { + override fun onCreate() { + super.onCreate() + initMeasureIfConfigured() + } + + /** + * No-op unless both `MEASURE_API_KEY` and `MEASURE_API_URL` reached the + * manifest. They are injected as manifest placeholders by + * `android/app/build.gradle.kts`, read out of the repo-root `.env` — the + * same file `--dart-define-from-file` feeds to + * `lib/config/measure_reporting_config.dart`. A build without that `.env` + * (fresh clone, CI without the secrets) gets empty strings here and the SDK + * never starts. + * + * Guarding on the value rather than letting the SDK read the meta-data + * itself is deliberate: an unconfigured `Measure.init` would still spin up + * the SDK's storage and its periodic export machinery pointed at nothing. + */ + private fun initMeasureIfConfigured() { + try { + val metaData = applicationMetaData() ?: return + val apiKey = metaData.getString(META_API_KEY).orEmpty() + val apiUrl = metaData.getString(META_API_URL).orEmpty() + if (apiKey.isBlank() || apiUrl.isBlank()) return + + Measure.init( + this, + MeasureConfig( + // Initialise, but collect nothing until Dart explicitly + // calls start() — see main.dart. This is the single control + // point for collection, and the place a user-consent gate + // would attach. With the default (true) the native SDK + // would already be recording during the anonymous + // burn-note / share-viewer entry points, which never reach + // the normal app bootstrap at all. + autoStart = false, + // Load-bearing, despite matching the SDK default. Every + // link this app mints carries its AES key in the URL + // fragment (#/burn/?k=&v=), and those arrive + // as Activity intent data. Flipping this to true would + // upload plaintext decryption keys to a third party. Do + // not remove this line on the grounds that it is + // redundant — it is pinning a default that must never + // drift. See AGENTS.md §5. + trackActivityIntentData = false, + ), + ) + } catch (_: Exception) { + // Monitoring must never be the reason the app fails to launch. + // Deliberately not logged: printStackTrace() writes to logcat + // unconditionally, including in release builds (same reasoning as + // MainActivity.saveUriToCache). + } + } + + private fun applicationMetaData() = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getApplicationInfo( + packageName, + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()), + ).metaData + } else { + @Suppress("DEPRECATION") + packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA).metaData + } + } catch (_: PackageManager.NameNotFoundException) { + null + } + + private companion object { + const val META_API_KEY = "sh.measure.android.API_KEY" + const val META_API_URL = "sh.measure.android.API_URL" + } +} diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/NotificationChannels.kt b/app/android/app/src/main/kotlin/foo/nosus/app/NotificationChannels.kt new file mode 100644 index 0000000..1405bfb --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/NotificationChannels.kt @@ -0,0 +1,94 @@ +package foo.nosus.app + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build + +/** + * Registers the notification channels that `supabase/functions/send-push` + * addresses by id. + * + * This is load-bearing, not cosmetic. On Android 8+ a notification whose + * `channel_id` does not exist on the device is **dropped silently** — no error, + * no fallback, nothing on screen. send-push sets + * `android.notification.channel_id` to `nosus_`, so without these + * every push would vanish and look like a broken FCM setup. + * + * Done in Kotlin rather than by adding flutter_local_notifications: channel + * creation is a dozen lines of platform API, and the plugin would be a new + * dependency carrying a whole local-notification stack this app does not + * otherwise use. + * + * Creating channels needs no permission and is safe with no Firebase project + * configured — the channels simply sit unused. Re-creating an existing channel + * is a no-op, so calling this on every launch is correct and lets a later + * release add a channel without any migration step. + * + * Note: name and importance are only honoured on first creation. Android hands + * channel settings to the user after that, deliberately, and an app cannot take + * them back. Renaming a channel means shipping a new id. + */ +object NotificationChannels { + + /** Must match the `category` CHECK constraint on `public.notifications`. */ + private val CHANNELS = listOf( + ChannelSpec( + id = "nosus_security", + name = "Security alerts", + description = "Access revoked, unusual sign-ins, and integrity warnings.", + importance = NotificationManager.IMPORTANCE_HIGH, + ), + ChannelSpec( + id = "nosus_invites", + name = "Invites and requests", + description = "Group invitations and requests to join groups you administer.", + importance = NotificationManager.IMPORTANCE_DEFAULT, + ), + ChannelSpec( + id = "nosus_membership", + name = "Membership and roles", + description = "Changes to your groups and your role in them.", + importance = NotificationManager.IMPORTANCE_DEFAULT, + ), + ChannelSpec( + id = "nosus_documents", + name = "Document activity", + description = "New documents shared in your groups.", + importance = NotificationManager.IMPORTANCE_LOW, + ), + // Backstop for the default channel declared in AndroidManifest.xml, + // which catches any message that arrives without a channel id. + ChannelSpec( + id = "nosus_general", + name = "General", + description = "Everything else.", + importance = NotificationManager.IMPORTANCE_DEFAULT, + ), + ) + + fun register(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val manager = context.getSystemService(NotificationManager::class.java) ?: return + + for (spec in CHANNELS) { + val channel = NotificationChannel(spec.id, spec.name, spec.importance).apply { + description = spec.description + // Notification *content* already names no document and no group + // (see enqueue_notification), but setting this explicitly means + // a future payload change cannot start leaking onto a locked + // screen without someone also changing this line. + lockscreenVisibility = android.app.Notification.VISIBILITY_PRIVATE + } + manager.createNotificationChannel(channel) + } + } + + private data class ChannelSpec( + val id: String, + val name: String, + val description: String, + val importance: Int, + ) +} diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/AccessibilityAuditor.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/AccessibilityAuditor.kt new file mode 100644 index 0000000..3ed691a --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/AccessibilityAuditor.kt @@ -0,0 +1,39 @@ +package foo.nosus.app.security + +import android.accessibilityservice.AccessibilityServiceInfo +import android.content.Context +import android.view.accessibility.AccessibilityManager + +/** + * Flags enabled accessibility services outside a known-legitimate allowlist. + * Accessibility-service abuse (auto-clickers, overlay-based credential + * theft, remote-control trojans) is a well-documented Android attack + * pattern — a hit here does not prove malicious intent, it surfaces a + * service worth a user prompt or admin review. + */ +object AccessibilityAuditor { + + private val KNOWN_SAFE_PREFIXES = arrayOf( + "com.google.android.marvin.talkback", + "com.google.android.accessibility", + "com.android.talkback", + "com.samsung.android.accessibility", + "com.samsung.accessibility" + ) + + fun scan(context: Context): Map { + val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager + val enabled = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) + + val suspicious = enabled.mapNotNull { serviceInfo -> + val id = serviceInfo.id ?: return@mapNotNull null + val isKnownSafe = KNOWN_SAFE_PREFIXES.any { id.startsWith(it) } + if (isKnownSafe) null else id + } + + return mapOf( + "suspiciousServicesFound" to suspicious.isNotEmpty(), + "services" to suspicious + ) + } +} diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/DeviceIntegrityManager.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/DeviceIntegrityManager.kt new file mode 100644 index 0000000..a3e1ff5 --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/DeviceIntegrityManager.kt @@ -0,0 +1,20 @@ +package foo.nosus.app.security + +import android.content.Context + +/** + * Aggregates the individual detectors into one payload the Dart-side + * MethodChannel handler in MainActivity returns as-is. Kept as a thin + * facade so MainActivity's platform-channel wiring doesn't need to know + * about each detector individually. + */ +object DeviceIntegrityManager { + fun runAllChecks(context: Context): Map { + return mapOf( + "root" to RootDetector.scan(context), + "instrumentation" to InstrumentationDetector.scan(context), + "displayMirroring" to DisplayMirrorDetector.scan(context), + "accessibility" to AccessibilityAuditor.scan(context) + ) + } +} diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/DisplayMirrorDetector.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/DisplayMirrorDetector.kt new file mode 100644 index 0000000..577e432 --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/DisplayMirrorDetector.kt @@ -0,0 +1,34 @@ +package foo.nosus.app.security + +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display + +/** + * Flags additional displays (wired HDMI/DisplayPort, wireless + * Miracast mirroring, or a Cast "presentation" virtual display) beyond the + * device's own built-in screen. This is checked per viewing session (see + * DeviceIntegrityService.runViewingChecks) rather than once at launch, + * since mirroring can start after a document is already open. + */ +object DisplayMirrorDetector { + + fun scan(context: Context): Map { + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val displays = displayManager.displays + val external = displays.filter { it.displayId != Display.DEFAULT_DISPLAY } + + val info = external.map { display -> + mapOf( + "id" to display.displayId, + "name" to display.name, + "isPresentation" to ((display.flags and Display.FLAG_PRESENTATION) != 0) + ) + } + + return mapOf( + "mirroring" to external.isNotEmpty(), + "displays" to info + ) + } +} diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/InstrumentationDetector.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/InstrumentationDetector.kt new file mode 100644 index 0000000..6497148 --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/InstrumentationDetector.kt @@ -0,0 +1,81 @@ +package foo.nosus.app.security + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Debug +import java.io.File +import java.net.InetSocketAddress +import java.net.Socket + +/** + * Best-effort detection of dynamic instrumentation (Frida) and hook + * frameworks (Xposed/LSPosed). These are signals, not proof — LSPosed's + * per-app hide list and Frida's stealth/agent-injection modes can evade the + * file, port, and class checks below. Treated as "critical" severity + * upstream because, unlike root, active instrumentation implies someone is + * *right now* attached to this process, not merely that the device could be + * tampered with. + */ +object InstrumentationDetector { + + private val XPOSED_PACKAGES = arrayOf( + "de.robv.android.xposed.installer", + "org.meowcat.edxposed.manager", + "io.github.lsposed.manager", + "org.lsposed.manager" + ) + + private val FRIDA_MAP_MARKERS = arrayOf("frida", "gum-js-loop", "linjector", "frida-agent") + + fun scan(context: Context): Map { + val reasons = mutableListOf() + + if (Debug.isDebuggerConnected() || Debug.waitingForDebugger()) { + reasons.add("debugger attached") + } + + try { + Class.forName("de.robv.android.xposed.XposedBridge") + reasons.add("xposed bridge class loaded") + } catch (_: ClassNotFoundException) { + // Not present — expected when no hook framework is active. + } + + val pm = context.packageManager + for (pkg in XPOSED_PACKAGES) { + try { + pm.getPackageInfo(pkg, 0) + reasons.add("hook framework manager installed: $pkg") + } catch (_: PackageManager.NameNotFoundException) { + // Not installed. + } + } + + try { + val maps = File("/proc/self/maps").readLines() + val hasFridaMarker = maps.any { line -> + FRIDA_MAP_MARKERS.any { marker -> line.contains(marker, ignoreCase = true) } + } + if (hasFridaMarker) { + reasons.add("frida artifact in process memory map") + } + } catch (_: Exception) { + // SELinux may block reading our own maps on newer Android — this + // is inconclusive, not a false negative claim. + } + + try { + Socket().use { socket -> + socket.connect(InetSocketAddress("127.0.0.1", 27042), 150) + reasons.add("frida-server listening on default port 27042") + } + } catch (_: Exception) { + // Closed/filtered — expected on clean devices. + } + + return mapOf( + "instrumented" to reasons.isNotEmpty(), + "reasons" to reasons.distinct() + ) + } +} diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/KeyAttestationManager.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/KeyAttestationManager.kt new file mode 100644 index 0000000..bd41dc2 --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/KeyAttestationManager.kt @@ -0,0 +1,153 @@ +package foo.nosus.app.security + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.ECGenParameterSpec + +/** + * Derives this install's device identity from a non-extractable EC keypair + * held in the Android Keystore, instead of a random UUID in SharedPreferences. + * + * Why: the previous identity was a plain `Uuid().v4()` written to prefs, which + * on a rooted device — exactly the population DeviceIntegrityService exists to + * notice — is editable in seconds. That made two detectors defeatable: + * a device flagged for root could rotate its id and come back clean, and the + * 'multiple_device_access' detector could be nulled out entirely by reporting + * one id from every device. The private key here never leaves the TEE (or + * StrongBox where available), so the id can't be forged or transplanted to + * another device; clearing app data destroys the key, which is correct. + * + * Scope note: this is the *local* half. The server still takes the client's + * word that the id came from hardware — proving that requires verifying an + * attestation certificate chain server-side, which needs a real physical + * device to test against and is deliberately not attempted here. See the + * "device identity" section of CLAUDE.md. + */ +object KeyAttestationManager { + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val KEY_ALIAS = "nosus_device_identity_v1" + + /** + * Returns the stable device id plus the security level the platform + * actually granted the key. Throws if the Keystore is unusable at all — + * the Dart side treats that as "no hardware id available" and falls back + * to the legacy UUID rather than failing the launch. + */ + fun getOrCreateDeviceKeyId(): Map { + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + + val publicKey = existingPublicKey(keyStore) ?: generateKeyPair() + val level = securityLevelOf(keyStore) + + return mapOf( + "deviceId" to sha256Hex(publicKey.encoded), + "securityLevel" to level, + "hardwareBacked" to (level != LEVEL_SOFTWARE) + ) + } + + /** + * An alias can survive in the keystore while its key material is gone or + * unreadable (OEM quirks, partial restores). Treat any failure to read it + * back as "no key" so the caller regenerates rather than throwing. + */ + private fun existingPublicKey(keyStore: KeyStore): PublicKey? { + return try { + if (!keyStore.containsAlias(KEY_ALIAS)) return null + keyStore.getCertificate(KEY_ALIAS)?.publicKey + } catch (_: Exception) { + try { + keyStore.deleteEntry(KEY_ALIAS) + } catch (_: Exception) { } + null + } + } + + /** + * StrongBox (a discrete secure element) is strictly better than the TEE + * but only exists on some devices, and OEMs have shipped implementations + * that throw variants beyond StrongBoxUnavailableException — hence the + * broad catch and unconditional retry without it. + */ + private fun generateKeyPair(): PublicKey { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + return generateWith(strongBox = true) + } catch (_: Exception) { + try { + KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }.deleteEntry(KEY_ALIAS) + } catch (_: Exception) { } + } + } + return generateWith(strongBox = false) + } + + private fun generateWith(strongBox: Boolean): PublicKey { + val generator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + KEYSTORE_PROVIDER + ) + + val builder = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY + ) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setDigests(KeyProperties.DIGEST_SHA256) + + // Deliberately no setUserAuthenticationRequired: the id has to be + // readable at launch, before any biometric prompt would be shown, and + // this key signs nothing sensitive — it exists to *be* an identity. + if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(true) + } + + generator.initialize(builder.build()) + return generator.generateKeyPair().public + } + + private fun securityLevelOf(keyStore: KeyStore): String { + return try { + val privateKey = keyStore.getKey(KEY_ALIAS, null) as? PrivateKey ?: return LEVEL_SOFTWARE + val keyInfo = KeyFactory + .getInstance(privateKey.algorithm, KEYSTORE_PROVIDER) + .getKeySpec(privateKey, KeyInfo::class.java) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + when (keyInfo.securityLevel) { + KeyProperties.SECURITY_LEVEL_STRONGBOX -> LEVEL_STRONGBOX + KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> LEVEL_TEE + KeyProperties.SECURITY_LEVEL_SOFTWARE -> LEVEL_SOFTWARE + // UNKNOWN / UNKNOWN_SECURE: the platform is telling us it + // can't characterise the backing. Don't claim hardware. + else -> LEVEL_UNKNOWN + } + } else { + @Suppress("DEPRECATION") + if (keyInfo.isInsideSecureHardware) LEVEL_TEE else LEVEL_SOFTWARE + } + } catch (_: Exception) { + LEVEL_UNKNOWN + } + } + + private fun sha256Hex(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + val out = StringBuilder(digest.size * 2) + for (b in digest) out.append("%02x".format(b)) + return out.toString() + } + + private const val LEVEL_STRONGBOX = "strongbox" + private const val LEVEL_TEE = "tee" + private const val LEVEL_SOFTWARE = "software" + private const val LEVEL_UNKNOWN = "unknown" +} diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/PlayIntegrityManager.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/PlayIntegrityManager.kt new file mode 100644 index 0000000..bb97a82 --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/PlayIntegrityManager.kt @@ -0,0 +1,50 @@ +package foo.nosus.app.security + +import android.content.Context +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.IntegrityTokenRequest +import io.flutter.plugin.common.MethodChannel + +/** + * Requests a Play Integrity standard token — a Google-signed attestation of + * app/device/account integrity, verified server-side (never trust-on-read + * client-side) by supabase/functions/verify-play-integrity. + * + * SCAFFOLDING, NOT ENABLED: [cloudProjectNumber] must be replaced with the + * real Google Cloud project number linked to this app in Play Console + * (Play Console → App integrity → Play Integrity API → your project + * number) before any token request can succeed — 0 will always fail + * (Google rejects it). The Dart-side call site + * (lib/services/play_integrity_service.dart) is also gated behind + * AppIntegrityConfig.enabled, which defaults false, so this native code + * exists but is never invoked until BOTH are switched on. See CLAUDE.md's + * "Play Integrity — scaffolded, not enabled" note for the full setup path. + */ +object PlayIntegrityManager { + private const val cloudProjectNumber: Long = 0L + + fun requestToken(context: Context, nonce: String, result: MethodChannel.Result) { + if (cloudProjectNumber == 0L) { + result.error( + "NOT_CONFIGURED", + "PlayIntegrityManager.cloudProjectNumber is unset — see the class doc.", + null, + ) + return + } + + val integrityManager = IntegrityManagerFactory.create(context) + val request = IntegrityTokenRequest.builder() + .setNonce(nonce) + .setCloudProjectNumber(cloudProjectNumber) + .build() + + integrityManager.requestIntegrityToken(request) + .addOnSuccessListener { response -> + result.success(response.token()) + } + .addOnFailureListener { exception -> + result.error("INTEGRITY_TOKEN_FAILED", exception.message, null) + } + } +} diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/RootDetector.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/RootDetector.kt new file mode 100644 index 0000000..4651fc8 --- /dev/null +++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/RootDetector.kt @@ -0,0 +1,85 @@ +package foo.nosus.app.security + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import java.io.File + +/** + * Heuristic root detection. No single check here is authoritative — a + * determined attacker with Magisk's DenyList/Zygisk hiding enabled can evade + * every file-path and package check below. These findings feed risk scoring + * as signals, not a hard gate: see DeviceIntegrityService (Dart side) for + * the "log, don't block" policy this project has chosen. + */ +object RootDetector { + + private val ROOT_PATHS = arrayOf( + "/system/bin/su", + "/system/xbin/su", + "/sbin/su", + "/su/bin/su", + "/system/app/Superuser.apk", + "/system/app/SuperSU", + "/data/local/xbin/su", + "/data/local/bin/su", + "/system/sd/xbin/su", + "/system/bin/failsafe/su", + "/system/xbin/busybox", + "/data/local/su" + ) + + private val ROOT_PACKAGES = arrayOf( + "com.topjohnwu.magisk", + "eu.chainfire.supersu", + "com.noshufou.android.su", + "com.koushikdutta.superuser", + "com.thirdparty.superuser", + "com.yellowes.su", + "me.phh.superuser", + "com.kingroot.kinguser", + "com.kingouser.com", + "com.smedialink.oneclickroot", + "com.zhiqupk.root.global", + "com.alephzain.framaroot" + ) + + fun scan(context: Context): Map { + val reasons = mutableListOf() + + if (Build.TAGS?.contains("test-keys") == true) { + reasons.add("test-keys build signature") + } + + for (path in ROOT_PATHS) { + if (File(path).exists()) { + reasons.add("root binary present: $path") + break // one hit is enough signal — avoid noisy metadata + } + } + + val pm = context.packageManager + for (pkg in ROOT_PACKAGES) { + try { + pm.getPackageInfo(pkg, 0) + reasons.add("root management app installed: $pkg") + } catch (_: PackageManager.NameNotFoundException) { + // Not installed — expected on clean devices. + } + } + + try { + val buildProp = File("/system/build.prop") + if (buildProp.exists() && buildProp.canWrite()) { + reasons.add("system partition writable") + } + } catch (_: SecurityException) { + // Sandbox denies the check — inconclusive, not a finding. + } + + return mapOf( + "rooted" to reasons.isNotEmpty(), + "reasons" to reasons + ) + } +} diff --git a/app/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..466bfdb Binary files /dev/null and b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a22cdb5 Binary files /dev/null and b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..a99f98e Binary files /dev/null and b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..105ab18 Binary files /dev/null and b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..324564a Binary files /dev/null and b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..54db746 Binary files /dev/null and b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/drawable-night/launch_background.xml b/app/android/app/src/main/res/drawable-night/launch_background.xml new file mode 100644 index 0000000..26c1583 --- /dev/null +++ b/app/android/app/src/main/res/drawable-night/launch_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..d977420 --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..9372bcf Binary files /dev/null and b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5900648 Binary files /dev/null and b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..f429fe1 Binary files /dev/null and b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..d0502c4 Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..478f475 Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..d0986b0 Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..d842a06 Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6d41851 Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..78038bb Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..d977420 --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/app/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml new file mode 100644 index 0000000..f606c4d --- /dev/null +++ b/app/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000..1729ae1 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000..8699594 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000..a5ef40b Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000..2ee4c44 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000..9d2722f Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..275bb86 --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/colors.xml b/app/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..beab31f --- /dev/null +++ b/app/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #000000 + \ No newline at end of file diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..a09629a --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/android/app/src/main/res/xml/network_security_config.xml b/app/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..a9f457e --- /dev/null +++ b/app/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts new file mode 100644 index 0000000..abb193e --- /dev/null +++ b/app/android/build.gradle.kts @@ -0,0 +1,96 @@ +apply(from = "jcenter_stub.gradle") + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} + +subprojects { + project.evaluationDependsOn(":app") +} + +subprojects { + if (name == "file_picker" || name == "screen_protector") { + plugins.apply("org.jetbrains.kotlin.android") + } +} + +// Path A: Proper Gradle JVM Toolchain fix for JVM Target mismatches +subprojects { + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + // Some third-party plugin modules (observed: sentry_flutter) ship + // without an explicit Kotlin languageVersion, which Gradle/AGP + // then defaults to something ancient (1.6) that this project's + // Kotlin 2.2.x compiler rejects outright ("Language version 1.6 + // is no longer supported"). Same fix shape as the jvmTarget + // force above — apply a sane modern floor across every + // subproject rather than patching one plugin at a time. + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9) + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9) + } + } +} + +subprojects { + val configureAndroid = { + val android = project.extensions.findByName("android") + if (android != null) { + // Force compileSdk 36 (Android 16/Baklava) + try { + val setCompileSdk = android.javaClass.getMethod("setCompileSdk", Int::class.javaPrimitiveType) + setCompileSdk.invoke(android, 36) + } catch (_: Exception) { + try { + val compileSdkVersion = android.javaClass.getMethod("compileSdkVersion", Int::class.javaPrimitiveType) + compileSdkVersion.invoke(android, 36) + } catch (_: Exception) { } + } + + // Force Java 17 Compatibility across all modules (Path A) + try { + val compileOptions = android.javaClass.getMethod("getCompileOptions").invoke(android) + val setSourceCompatibility = compileOptions.javaClass.getMethod("setSourceCompatibility", JavaVersion::class.java) + val setTargetCompatibility = compileOptions.javaClass.getMethod("setTargetCompatibility", JavaVersion::class.java) + setSourceCompatibility.invoke(compileOptions, JavaVersion.VERSION_17) + setTargetCompatibility.invoke(compileOptions, JavaVersion.VERSION_17) + } catch (_: Exception) { } + } + } + + if (project.state.executed) { + configureAndroid() + } else { + project.afterEvaluate { + configureAndroid() + } + } +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 0000000..6d60b4b --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m +android.useAndroidX=true +# Reverted to false as AGP 9.0/Flutter plugin still requires legacy DSL +android.newDsl=false +android.builtInKotlin=false diff --git a/app/android/gradle/gradle-daemon-jvm.properties b/app/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..baa28d1 --- /dev/null +++ b/app/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6d24045 --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip diff --git a/app/android/jcenter_stub.gradle b/app/android/jcenter_stub.gradle new file mode 100644 index 0000000..ed55c15 --- /dev/null +++ b/app/android/jcenter_stub.gradle @@ -0,0 +1,18 @@ +// Dynamic Groovy metaclass injection to mock jcenter() repository method in older plugins +try { + org.gradle.api.artifacts.dsl.RepositoryHandler.metaClass.jcenter = { -> + delegate.mavenCentral() + } +} catch (Exception e) { + println "jcenter-stub: RepositoryHandler registration failed: $e" +} + +try { + Class defaultRepoHandler = Class.forName("org.gradle.api.internal.artifacts.dsl.DefaultRepositoryHandler") + defaultRepoHandler.metaClass.jcenter = { -> + delegate.mavenCentral() + } + println "jcenter-stub: Successfully registered jcenter() stub on DefaultRepositoryHandler" +} catch (Exception e) { + println "jcenter-stub: DefaultRepositoryHandler registration failed: $e" +} diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts new file mode 100644 index 0000000..c5d59cf --- /dev/null +++ b/app/android/settings.gradle.kts @@ -0,0 +1,32 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.2.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false + // Declared but never applied here — app/build.gradle.kts applies it only + // when google-services.json is present. Applying it unconditionally makes + // the build fail outright on any checkout without that file, which is + // every checkout today. + id("com.google.gms.google-services") version "4.4.2" apply false + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +include(":app") diff --git a/app/assets/google_fonts/Inter-Bold.ttf b/app/assets/google_fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9c2f47d Binary files /dev/null and b/app/assets/google_fonts/Inter-Bold.ttf differ diff --git a/app/assets/google_fonts/Inter-Medium.ttf b/app/assets/google_fonts/Inter-Medium.ttf new file mode 100644 index 0000000..4a57a1a Binary files /dev/null and b/app/assets/google_fonts/Inter-Medium.ttf differ diff --git a/app/assets/google_fonts/Inter-Regular.ttf b/app/assets/google_fonts/Inter-Regular.ttf new file mode 100644 index 0000000..399a6e0 Binary files /dev/null and b/app/assets/google_fonts/Inter-Regular.ttf differ diff --git a/app/assets/google_fonts/Inter-SemiBold.ttf b/app/assets/google_fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..67fda28 Binary files /dev/null and b/app/assets/google_fonts/Inter-SemiBold.ttf differ diff --git a/app/assets/google_fonts/Outfit-Bold.ttf b/app/assets/google_fonts/Outfit-Bold.ttf new file mode 100644 index 0000000..0389ff2 Binary files /dev/null and b/app/assets/google_fonts/Outfit-Bold.ttf differ diff --git a/app/assets/google_fonts/Outfit-Regular.ttf b/app/assets/google_fonts/Outfit-Regular.ttf new file mode 100644 index 0000000..0b0a0d7 Binary files /dev/null and b/app/assets/google_fonts/Outfit-Regular.ttf differ diff --git a/app/assets/google_fonts/Outfit-SemiBold.ttf b/app/assets/google_fonts/Outfit-SemiBold.ttf new file mode 100644 index 0000000..52e25bc Binary files /dev/null and b/app/assets/google_fonts/Outfit-SemiBold.ttf differ diff --git a/app/assets/google_fonts/VT323-Regular.ttf b/app/assets/google_fonts/VT323-Regular.ttf new file mode 100644 index 0000000..afa6909 Binary files /dev/null and b/app/assets/google_fonts/VT323-Regular.ttf differ diff --git a/app/assets/icon/LuxandNox.png b/app/assets/icon/LuxandNox.png new file mode 100644 index 0000000..1d6f0cf Binary files /dev/null and b/app/assets/icon/LuxandNox.png differ diff --git a/app/assets/icon/adaptive_foreground.png b/app/assets/icon/adaptive_foreground.png new file mode 100644 index 0000000..4173f30 Binary files /dev/null and b/app/assets/icon/adaptive_foreground.png differ diff --git a/app/assets/icon/adaptive_monochrome.png b/app/assets/icon/adaptive_monochrome.png new file mode 100644 index 0000000..246dda4 Binary files /dev/null and b/app/assets/icon/adaptive_monochrome.png differ diff --git a/app/assets/icon/app_icon.png b/app/assets/icon/app_icon.png new file mode 100644 index 0000000..dacc90f Binary files /dev/null and b/app/assets/icon/app_icon.png differ diff --git a/app/assets/icon/founder_avatar.jpeg b/app/assets/icon/founder_avatar.jpeg new file mode 100644 index 0000000..01a6a7c Binary files /dev/null and b/app/assets/icon/founder_avatar.jpeg differ diff --git a/app/assets/icon/transparent_foreground.png b/app/assets/icon/transparent_foreground.png new file mode 100644 index 0000000..0320df1 Binary files /dev/null and b/app/assets/icon/transparent_foreground.png differ diff --git a/app/assets/images/avatar_academic.png b/app/assets/images/avatar_academic.png new file mode 100644 index 0000000..5a84933 Binary files /dev/null and b/app/assets/images/avatar_academic.png differ diff --git a/app/assets/images/avatar_archivist.png b/app/assets/images/avatar_archivist.png new file mode 100644 index 0000000..b9aa74c Binary files /dev/null and b/app/assets/images/avatar_archivist.png differ diff --git a/app/assets/images/avatar_builder.png b/app/assets/images/avatar_builder.png new file mode 100644 index 0000000..90bedb7 Binary files /dev/null and b/app/assets/images/avatar_builder.png differ diff --git a/app/assets/images/avatar_chaos.png b/app/assets/images/avatar_chaos.png new file mode 100644 index 0000000..be35058 Binary files /dev/null and b/app/assets/images/avatar_chaos.png differ diff --git a/app/assets/images/avatar_creator.png b/app/assets/images/avatar_creator.png new file mode 100644 index 0000000..405cf01 Binary files /dev/null and b/app/assets/images/avatar_creator.png differ diff --git a/app/assets/images/avatar_researcher.png b/app/assets/images/avatar_researcher.png new file mode 100644 index 0000000..5c184b0 Binary files /dev/null and b/app/assets/images/avatar_researcher.png differ diff --git a/app/assets/mascot/README.md b/app/assets/mascot/README.md new file mode 100644 index 0000000..3e9277c --- /dev/null +++ b/app/assets/mascot/README.md @@ -0,0 +1,22 @@ +# Mascot assets + +Drop the authored Rive files here with these exact names — `MascotView` (see +`lib/core/mascot/mascot_view.dart`) looks them up by convention: + +- `lux.riv` +- `nox.riv` +- `duo.riv` + +Each file must expose a State Machine named `SM` with: + +- a `Number` input named `stateIndex` (see `MascotMood` order in + `lib/core/mascot/mascot_state.dart` for the index mapping) +- a `Boolean` input named `reducedMotion` (when true, the artboard should hold on its + Idle frame regardless of `stateIndex`) + +Until a given file exists, `MascotView` falls back automatically (see its `placeHolder`) — +solo Lux/Nox render nothing, and the duo placeholder renders the static +`assets/icon/LuxandNox.png` mark. No code changes are needed when the real files are added, +just drop them in. + +See `MASCOT_GUIDE.md` (repo root) for the full animation spec. diff --git a/app/assets/screenshots/1.png b/app/assets/screenshots/1.png new file mode 100644 index 0000000..c3ca471 Binary files /dev/null and b/app/assets/screenshots/1.png differ diff --git a/app/assets/screenshots/10.png b/app/assets/screenshots/10.png new file mode 100644 index 0000000..0bae54d Binary files /dev/null and b/app/assets/screenshots/10.png differ diff --git a/app/assets/screenshots/11.png b/app/assets/screenshots/11.png new file mode 100644 index 0000000..9fe8422 Binary files /dev/null and b/app/assets/screenshots/11.png differ diff --git a/app/assets/screenshots/13.png b/app/assets/screenshots/13.png new file mode 100644 index 0000000..6b3d19f Binary files /dev/null and b/app/assets/screenshots/13.png differ diff --git a/app/assets/screenshots/15.png b/app/assets/screenshots/15.png new file mode 100644 index 0000000..bde0abe Binary files /dev/null and b/app/assets/screenshots/15.png differ diff --git a/app/assets/screenshots/16.png b/app/assets/screenshots/16.png new file mode 100644 index 0000000..5a72101 Binary files /dev/null and b/app/assets/screenshots/16.png differ diff --git a/app/assets/screenshots/17.png b/app/assets/screenshots/17.png new file mode 100644 index 0000000..51556c1 Binary files /dev/null and b/app/assets/screenshots/17.png differ diff --git a/app/assets/screenshots/18.png b/app/assets/screenshots/18.png new file mode 100644 index 0000000..0683135 Binary files /dev/null and b/app/assets/screenshots/18.png differ diff --git a/app/assets/screenshots/19.png b/app/assets/screenshots/19.png new file mode 100644 index 0000000..6fd94a2 Binary files /dev/null and b/app/assets/screenshots/19.png differ diff --git a/app/assets/screenshots/2.png b/app/assets/screenshots/2.png new file mode 100644 index 0000000..50410df Binary files /dev/null and b/app/assets/screenshots/2.png differ diff --git a/app/assets/screenshots/4.png b/app/assets/screenshots/4.png new file mode 100644 index 0000000..9eee01e Binary files /dev/null and b/app/assets/screenshots/4.png differ diff --git a/app/assets/screenshots/5.png b/app/assets/screenshots/5.png new file mode 100644 index 0000000..bc7b255 Binary files /dev/null and b/app/assets/screenshots/5.png differ diff --git a/app/assets/screenshots/6.png b/app/assets/screenshots/6.png new file mode 100644 index 0000000..b040b14 Binary files /dev/null and b/app/assets/screenshots/6.png differ diff --git a/app/assets/screenshots/7.png b/app/assets/screenshots/7.png new file mode 100644 index 0000000..d10d54e Binary files /dev/null and b/app/assets/screenshots/7.png differ diff --git a/app/assets/screenshots/8.png b/app/assets/screenshots/8.png new file mode 100644 index 0000000..86df7a4 Binary files /dev/null and b/app/assets/screenshots/8.png differ diff --git a/app/assets/screenshots/9.png b/app/assets/screenshots/9.png new file mode 100644 index 0000000..f91a4a0 Binary files /dev/null and b/app/assets/screenshots/9.png differ diff --git a/app/ios/.gitignore b/app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..03a374d --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/app/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..57ff2ed Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..710b6e7 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..a06f10d Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..ade47ee Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..fad73ad Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..58d2c5f Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..ff362f0 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..a06f10d Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..a4139d6 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..278973f Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..04f472d Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..f556f7f Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..eef6af3 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..0bf566a Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..278973f Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e4fef2c Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..d6c2ec3 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..2ee4c44 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..21c6a6f Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..d6258d1 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..2e08867 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist new file mode 100644 index 0000000..60b226e --- /dev/null +++ b/app/ios/Runner/Info.plist @@ -0,0 +1,95 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + No Sus + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + no_sus + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSPhotoLibraryUsageDescription + This app requires access to your photo library to pick and share secure study notes and documents. + NSPhotoLibraryAddUsageDescription + This app requires permission to save secure documents to your photo library. + FlutterDeepLinkingEnabled + + CFBundleURLTypes + + + CFBundleURLName + io.supabase.nosus + CFBundleURLSchemes + + io.supabase.nosus + + + + CFBundleURLName + foo.nosus.app + CFBundleURLSchemes + + foo.nosus.app + + + + + diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/lib/components/async_state_view.dart b/app/lib/components/async_state_view.dart new file mode 100644 index 0000000..e34ac0e --- /dev/null +++ b/app/lib/components/async_state_view.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../theme.dart'; + +/// Generic loading/error/data wrapper for an [AsyncValue], generalizing the +/// `.when(loading:, error:, data:)` + skeleton + error-with-retry pattern +/// originally hand-written in `groups_screen.dart` (`_SkeletonList`, +/// `_ErrorState`). Does not own a `RefreshIndicator` — callers keep their +/// own since the invalidate/await-future call differs per provider. +class AsyncStateView extends StatelessWidget { + final AsyncValue value; + final Widget Function(BuildContext context, T data) data; + final WidgetBuilder? loading; + final String errorMessage; + final VoidCallback onRetry; + final bool Function(T data)? isEmpty; + final WidgetBuilder? empty; + + const AsyncStateView({ + super.key, + required this.value, + required this.data, + this.loading, + this.errorMessage = 'Something went wrong.', + required this.onRetry, + this.isEmpty, + this.empty, + }); + + @override + Widget build(BuildContext context) { + return value.when( + loading: () => + loading?.call(context) ?? + const Center(child: CircularProgressIndicator()), + error: (error, stack) => + _AsyncErrorState(message: errorMessage, onRetry: onRetry), + data: (value) { + if (isEmpty != null && empty != null && isEmpty!(value)) { + return empty!(context); + } + return data(context, value); + }, + ); + } +} + +class _AsyncErrorState extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _AsyncErrorState({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final subtle = Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4); + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wifi_off_outlined, size: 36, color: subtle), + const SizedBox(height: 12), + Text(message, style: TextStyle(color: subtle, fontSize: 14)), + const SizedBox(height: 16), + Semantics( + button: true, + label: 'Retry', + child: InkWell( + onTap: onRetry, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text( + 'RETRY', + style: TextStyle( + fontSize: 11, + color: subtle, + letterSpacing: 2.0, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/components/coach_mark.dart b/app/lib/components/coach_mark.dart new file mode 100644 index 0000000..42cd912 --- /dev/null +++ b/app/lib/components/coach_mark.dart @@ -0,0 +1,525 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../features/onboarding/presentation/providers/tour_providers.dart'; +import '../theme.dart'; + +/// One anchored tip: a spotlight cut out around [targetKey]'s widget, plus a +/// bubble explaining what it is for. +class CoachMarkStep { + /// Must be one of [TourSteps] — it is the persistence key. + final String id; + + /// The widget to spotlight. If it is not mounted and laid out when the step + /// comes up, the bubble is centred instead of anchored, rather than skipped: + /// the explanation is the point, the highlight is decoration. + final GlobalKey targetKey; + final String title; + final String body; + + /// Extra padding around the cut-out, so the highlight does not sit flush + /// against the control's edge. + final double inflate; + + const CoachMarkStep({ + required this.id, + required this.targetKey, + required this.title, + required this.body, + this.inflate = 8, + }); +} + +/// Geometry for keeping a tip inside the visible safe rectangle. +/// +/// Flutter web typically reports `padding.bottom == 0` even when Chrome's +/// toolbar and the Android gesture bar overlay the canvas. Placement uses +/// view padding plus a web chrome floor so the action row is never clipped. +@visibleForTesting +abstract final class CoachMarkPlacement { + static const double webBottomChrome = 32; + + static double topInset(MediaQueryData media) => + math.max(media.padding.top, media.viewPadding.top); + + static double bottomInset(MediaQueryData media, {required bool isWeb}) { + return math.max( + media.viewInsets.bottom, + math.max( + media.padding.bottom, + math.max(media.viewPadding.bottom, isWeb ? webBottomChrome : 0), + ), + ); + } + + static Rect safeViewport( + Size screen, + MediaQueryData media, { + required bool isWeb, + }) { + final top = topInset(media) + NoSusTheme.s12; + final bottom = + screen.height - bottomInset(media, isWeb: isWeb) - NoSusTheme.s12; + return Rect.fromLTRB( + NoSusTheme.s24, + top, + math.max(NoSusTheme.s24, screen.width - NoSusTheme.s24), + math.max(top, bottom), + ); + } + + static bool targetVisible(Rect? target, Rect viewport) { + if (target == null) return false; + return target.overlaps(viewport); + } + + static bool placeBelow(Rect target, Rect viewport) { + final spaceBelow = viewport.bottom - (target.bottom + NoSusTheme.s16); + final spaceAbove = target.top - NoSusTheme.s16 - viewport.top; + const minimumReadableHeight = 160.0; + return spaceBelow >= minimumReadableHeight || spaceBelow >= spaceAbove; + } +} + +/// Presents a sequence of [CoachMarkStep]s, one at a time, over the current +/// route. +/// +/// Call from a post-frame callback — it measures the target's render box, which +/// does not exist until after layout. +/// +/// Accessibility: the bubble carries the full explanation as text and is +/// announced as a live region, so the tip is complete without seeing the +/// highlight. The scrim animation is dropped when the platform asks for reduced +/// motion. +abstract final class CoachMarks { + static bool _isShowing = false; + + static Future showSequence( + BuildContext context, + WidgetRef ref, + List steps, + ) async { + // One sequence at a time. Two tabs racing to present on the same frame + // would otherwise stack overlays on top of each other. + if (_isShowing) return; + if (!ref.read(tipsEnabledProvider)) return; + + final progress = ref.read(tourProgressProvider.notifier); + final pending = steps + .where((s) => !progress.hasSeen(s.id)) + .toList(growable: false); + if (pending.isEmpty) return; + + final overlay = Overlay.maybeOf(context); + if (overlay == null) return; + + _isShowing = true; + try { + for (var i = 0; i < pending.length; i++) { + if (!context.mounted) break; + final outcome = await _showOne( + overlay, + context, + pending[i], + isLast: i == pending.length - 1, + index: i, + total: pending.length, + ); + await progress.markSeen([pending[i].id]); + if (outcome == _CoachMarkOutcome.skipAll) { + // "Skip" means skip the rest of this sequence, not suppress tips + // forever — that is what the Settings toggle is for. + await progress.markSeen(pending.skip(i + 1).map((s) => s.id)); + break; + } + } + } finally { + _isShowing = false; + } + } + + static Future<_CoachMarkOutcome> _showOne( + OverlayState overlay, + BuildContext context, + CoachMarkStep step, { + required bool isLast, + required int index, + required int total, + }) { + final completer = Completer<_CoachMarkOutcome>(); + late OverlayEntry entry; + + void close(_CoachMarkOutcome outcome) { + if (completer.isCompleted) return; + entry.remove(); + completer.complete(outcome); + } + + entry = OverlayEntry( + builder: (overlayContext) => _CoachMarkLayer( + step: step, + isLast: isLast, + index: index, + total: total, + onNext: () => close(_CoachMarkOutcome.next), + onSkip: () => close(_CoachMarkOutcome.skipAll), + ), + ); + + overlay.insert(entry); + return completer.future; + } +} + +enum _CoachMarkOutcome { next, skipAll } + +class _CoachMarkLayer extends StatefulWidget { + final CoachMarkStep step; + final bool isLast; + final int index; + final int total; + final VoidCallback onNext; + final VoidCallback onSkip; + + const _CoachMarkLayer({ + required this.step, + required this.isLast, + required this.index, + required this.total, + required this.onNext, + required this.onSkip, + }); + + @override + State<_CoachMarkLayer> createState() => _CoachMarkLayerState(); +} + +class _CoachMarkLayerState extends State<_CoachMarkLayer> + with WidgetsBindingObserver { + Rect? _targetRect; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + SchedulerBinding.instance.addPostFrameCallback((_) => _measure()); + } + + @override + void didChangeMetrics() { + SchedulerBinding.instance.addPostFrameCallback((_) => _measure()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + void _measure() { + if (!mounted) return; + final targetContext = widget.step.targetKey.currentContext; + if (targetContext == null) { + if (_targetRect != null) setState(() => _targetRect = null); + return; + } + final box = targetContext.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return; + final offset = box.localToGlobal(Offset.zero); + setState(() { + _targetRect = (offset & box.size).inflate(widget.step.inflate); + }); + } + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final screen = media.size; + final reduceMotion = media.disableAnimations; + + final rect = _targetRect; + final bubble = _CoachMarkBubble( + step: widget.step, + isLast: widget.isLast, + index: widget.index, + total: widget.total, + onNext: widget.onNext, + onSkip: widget.onSkip, + ); + + final safe = CoachMarkPlacement.safeViewport(screen, media, isWeb: kIsWeb); + final anchored = CoachMarkPlacement.targetVisible(rect, safe); + final placeBelow = + anchored && rect != null && CoachMarkPlacement.placeBelow(rect, safe); + + return Material( + type: MaterialType.transparency, + child: Stack( + children: [ + // Scrim. Tapping anywhere advances — a coach mark that traps you + // until you find the right button is worse than the confusion it + // was meant to fix. + Positioned.fill( + child: Semantics( + button: true, + label: 'Dismiss tip', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onNext, + child: CustomPaint( + painter: _SpotlightPainter( + target: rect, + color: Colors.black.withValues(alpha: isDark ? 0.72 : 0.55), + ), + ), + ), + ), + ), + if (rect != null) + Positioned( + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.9), + width: 1.5, + ), + ), + ), + ), + ), + Positioned( + left: safe.left, + right: screen.width - safe.right, + top: safe.top, + bottom: screen.height - safe.bottom, + child: Align( + alignment: !anchored + ? Alignment.center + : placeBelow + ? Alignment.topCenter + : Alignment.bottomCenter, + child: _animateBubble(bubble, reduceMotion), + ), + ), + ], + ), + ); + } + + Widget _animateBubble(Widget bubble, bool reduceMotion) { + if (reduceMotion) return bubble; + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + builder: (context, t, child) => Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, (1 - t) * 8), + child: child, + ), + ), + child: bubble, + ); + } +} + +class _CoachMarkBubble extends StatelessWidget { + final CoachMarkStep step; + final bool isLast; + final int index; + final int total; + final VoidCallback onNext; + final VoidCallback onSkip; + + const _CoachMarkBubble({ + required this.step, + required this.isLast, + required this.index, + required this.total, + required this.onNext, + required this.onSkip, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? NoSusTheme.dCard : Colors.white; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + + final actions = Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (!isLast) + TextButton( + onPressed: onSkip, + child: Text( + 'SKIP', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: fg.withValues(alpha: 0.55), + ), + ), + ), + const SizedBox(width: NoSusTheme.s8), + FilledButton( + onPressed: onNext, + style: FilledButton.styleFrom( + backgroundColor: fg, + foregroundColor: isDark ? Colors.black : Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + ), + child: Text( + isLast ? 'DONE' : 'NEXT', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + ), + ], + ); + + final copy = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (total > 1) + Padding( + padding: const EdgeInsets.only(bottom: NoSusTheme.s8), + child: Text( + 'TIP ${index + 1} OF $total', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 9, + letterSpacing: 1.5, + color: fg.withValues(alpha: 0.45), + ), + ), + ), + Text( + step.title, + style: theme.textTheme.titleMedium?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w800, + color: fg, + ), + ), + const SizedBox(height: NoSusTheme.s8), + Text( + step.body, + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 13, + height: 1.55, + color: fg.withValues(alpha: 0.75), + ), + ), + ], + ); + + return Semantics( + liveRegion: true, + // The whole bubble reads as one announcement; the buttons below carry + // their own labels. + container: true, + child: Container( + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(NoSusTheme.r16), + border: Border.all(color: fg.withValues(alpha: 0.12), width: 0.75), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.28), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: LayoutBuilder( + builder: (context, constraints) { + // Keep DONE/SKIP pinned even when the copy is taller than the + // remaining safe viewport — the previous single scroll view let + // the action row clip under the Android home indicator. + const actionsReserve = 76.0; + final maxCopyHeight = constraints.hasBoundedHeight + ? math.max(64.0, constraints.maxHeight - actionsReserve) + : double.infinity; + return Padding( + padding: const EdgeInsets.fromLTRB( + NoSusTheme.s24, + NoSusTheme.s24, + NoSusTheme.s24, + NoSusTheme.s16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxCopyHeight), + child: SingleChildScrollView(child: copy), + ), + const SizedBox(height: NoSusTheme.s16), + actions, + ], + ), + ); + }, + ), + ), + ); + } +} + +/// Fills the screen with [color], minus a rounded hole over [target]. +class _SpotlightPainter extends CustomPainter { + final Rect? target; + final Color color; + + const _SpotlightPainter({required this.target, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final screen = Offset.zero & size; + final paint = Paint()..color = color; + + if (target == null) { + canvas.drawRect(screen, paint); + return; + } + + final hole = Path() + ..addRRect( + RRect.fromRectAndRadius(target!, const Radius.circular(NoSusTheme.r12)), + ); + final scrim = Path.combine( + PathOperation.difference, + Path()..addRect(screen), + hole, + ); + canvas.drawPath(scrim, paint); + } + + @override + bool shouldRepaint(_SpotlightPainter old) => + old.target != target || old.color != color; +} diff --git a/app/lib/components/experiment_frame.dart b/app/lib/components/experiment_frame.dart new file mode 100644 index 0000000..04d5887 --- /dev/null +++ b/app/lib/components/experiment_frame.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class ExperimentFrame extends StatelessWidget { + const ExperimentFrame({super.key, required this.child}); + final Widget child; + @override + Widget build(BuildContext context) => Column( + children: [ + Material( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 7), + child: Center( + child: Text( + 'NO SUS · experiment build · use harmless test notes only', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ), + ), + ), + Expanded(child: child), + ], + ); +} diff --git a/app/lib/components/floating_nav.dart b/app/lib/components/floating_nav.dart new file mode 100644 index 0000000..b2741fa --- /dev/null +++ b/app/lib/components/floating_nav.dart @@ -0,0 +1,168 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../theme.dart'; + +class FloatingNav extends StatelessWidget { + final int currentIndex; + final ValueChanged onTap; + + const FloatingNav({ + super.key, + required this.currentIndex, + required this.onTap, + }); + + static bool isCompact(BuildContext context) => + MediaQuery.sizeOf(context).width < 480; + + static double contentClearance(BuildContext context) { + return isCompact(context) ? 92 : 110; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final compact = isCompact(context); + final horizontalInset = compact ? NoSusTheme.s12 : NoSusTheme.s24; + final bottomInset = compact ? NoSusTheme.s12 : NoSusTheme.s24; + final height = compact ? 64.0 : 72.0; + + final List items = [ + NavTabItem( + icon: Icons.dashboard_outlined, + selectedIcon: Icons.dashboard, + label: 'Workspace', + ), + NavTabItem( + icon: Icons.shield_outlined, + selectedIcon: Icons.shield, + label: 'Vault', + ), + NavTabItem( + icon: Icons.remove_red_eye_outlined, + selectedIcon: Icons.remove_red_eye, + label: 'Study Desk', + ), + NavTabItem( + icon: Icons.history_edu_outlined, + selectedIcon: Icons.history_edu, + label: 'Audit Log', + ), + NavTabItem( + icon: Icons.group_outlined, + selectedIcon: Icons.group, + label: 'Groups', + ), + ]; + + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(left: 0, right: 0, bottom: 0), + child: Padding( + padding: EdgeInsets.only( + left: horizontalInset, + right: horizontalInset, + bottom: bottomInset, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(NoSusTheme.r24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12.0, sigmaY: 12.0), + child: Container( + height: height, + decoration: BoxDecoration( + color: isDark + ? NoSusTheme.dCard.withValues(alpha: 0.7) + : Colors.white.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(NoSusTheme.r24), + border: Border.all( + color: theme.colorScheme.outline, + width: 1.2, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(items.length, (index) { + final item = items[index]; + final isSelected = currentIndex == index; + + return Expanded( + child: Semantics( + button: true, + selected: isSelected, + label: '${item.label} tab', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + alignment: Alignment.center, + child: ExcludeSemantics( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: isSelected ? 1.12 : 1.0, + child: Icon( + isSelected + ? item.selectedIcon + : item.icon, + color: isSelected + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface + .withValues(alpha: 0.4), + size: 24, + ), + ), + if (!compact) ...[ + const SizedBox(height: NoSusTheme.s4), + Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge + ?.copyWith( + fontSize: 10, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface + .withValues(alpha: 0.4), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + }), + ), + ), + ), + ), + ), + ), + ); + } +} + +class NavTabItem { + final IconData icon; + final IconData selectedIcon; + final String label; + + NavTabItem({ + required this.icon, + required this.selectedIcon, + required this.label, + }); +} diff --git a/app/lib/components/offline_banner.dart b/app/lib/components/offline_banner.dart new file mode 100644 index 0000000..5c6540e --- /dev/null +++ b/app/lib/components/offline_banner.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import '../services/supabase_service.dart'; +import '../theme.dart'; + +/// Dismissible strip shown when [SupabaseService.isReachable] is false — a +/// lighter-weight alternative to full-screen dead-end blocks (see +/// AuthGate's unreachable state) or the purely cosmetic offline dot +/// previously used on its own in workspace_tab.dart. +/// +/// [isReachable] is a point-in-time flag set once during startup (see +/// SupabaseService.initialize) rather than a live connectivity stream, so +/// this banner's state is read once per build like every other consumer of +/// that flag in the app — it does not re-check reachability itself. +class OfflineBanner extends StatefulWidget { + const OfflineBanner({super.key}); + + @override + State createState() => _OfflineBannerState(); +} + +class _OfflineBannerState extends State { + bool _dismissed = false; + + @override + Widget build(BuildContext context) { + if (_dismissed || SupabaseService.instance.isReachable) { + return const SizedBox.shrink(); + } + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s16, + vertical: NoSusTheme.s12, + ), + decoration: BoxDecoration( + color: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + border: Border( + bottom: BorderSide(color: theme.colorScheme.outline, width: 1.0), + ), + ), + child: Row( + children: [ + Icon( + Icons.wifi_off_outlined, + size: 16, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: NoSusTheme.s8), + Expanded( + child: Text( + "You're offline — showing the last saved data.", + style: theme.textTheme.bodyMedium?.copyWith(fontSize: 12), + ), + ), + IconButton( + onPressed: () => setState(() => _dismissed = true), + tooltip: 'Dismiss offline notice', + icon: Icon( + Icons.close, + size: 16, + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/components/secure_viewer/blur_reveal_layer.dart b/app/lib/components/secure_viewer/blur_reveal_layer.dart new file mode 100644 index 0000000..9ffe481 --- /dev/null +++ b/app/lib/components/secure_viewer/blur_reveal_layer.dart @@ -0,0 +1,227 @@ +import 'dart:async'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'models/viewer_config.dart'; +import '../../core/mascot/mascot_controller.dart'; +import '../../core/mascot/mascot_state.dart'; +import '../../core/mascot/mascot_view.dart'; + +/// Core touch-blur engine for the NO SUS secure document viewer. +/// +/// Obscures the entire canvas behind a blur/mask overlay by default. +/// When the user touches the screen, the entire blur layer animates to clear (unblurred), +/// allowing view of the whole screen. When the finger lifts, it blurs again. +class BlurRevealLayer extends ConsumerStatefulWidget { + /// The protected document content. Wrapped in [RepaintBoundary] internally. + final Widget child; + + /// An always-visible overlay that sits above the blur (e.g., watermark). + final Widget? overlay; + + /// Animation + blur behavior configuration. + final ViewerConfig config; + + /// Whether to show the "TOUCH TO REVEAL" hint when content is blurred. + final bool showHint; + + /// Whether the layer starts revealed (false) or concealed (true, the + /// existing default). Web viewers that don't want the sender's default + /// touch-to-reveal blur still pass a [forceConcealSignal] so a detected + /// security event can conceal on demand — this lets them start revealed. + final bool initiallyConcealed; + + /// Emits to force an immediate conceal regardless of current touch state + /// — e.g. [WebSecurityGuard] detected DevTools opening or repeated tab + /// hiding. Does not change [showHint] or block the user from touching to + /// reveal again afterward; this is a reactive deterrent, not a lock. + final Stream? forceConcealSignal; + + const BlurRevealLayer({ + super.key, + required this.child, + this.overlay, + this.config = const ViewerConfig(), + this.showHint = true, + this.initiallyConcealed = true, + this.forceConcealSignal, + }); + + @override + ConsumerState createState() => _BlurRevealLayerState(); +} + +class _BlurRevealLayerState extends ConsumerState + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + StreamSubscription? _forceConcealSub; + + // Number of currently active pointers (supports multi-touch tracking) + int _activePointers = 0; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + value: widget.initiallyConcealed ? 1.0 : 0.0, + vsync: this, + ); + + _forceConcealSub = widget.forceConcealSignal?.listen((_) { + if (!mounted) return; + _controller.animateTo( + 1.0, + duration: widget.config.concealDuration, + curve: widget.config.concealCurve, + ); + ref.read(noxMascotProvider.notifier).play(MascotMood.alert); + }); + + if (widget.initiallyConcealed) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Blurred on open — Nox is guarding this document. + if (mounted) ref.read(noxMascotProvider.notifier).play(MascotMood.protect); + }); + } + } + + @override + void dispose() { + _forceConcealSub?.cancel(); + _controller.dispose(); + super.dispose(); + } + + // ─── Pointer Event Handlers ─────────────────────────────────────────────── + + void _onPointerDown(PointerDownEvent event) { + _activePointers++; + + if (_activePointers == 1) { + _controller.animateTo( + 0.0, + duration: widget.config.revealDuration, + curve: widget.config.revealCurve, + ); + // The reveal tap is the exact moment Nox nods it through. + ref.read(noxMascotProvider.notifier).play(MascotMood.approve); + } + } + + void _onPointerUp(PointerEvent event) { + _activePointers = (_activePointers - 1).clamp(0, 20); + + if (_activePointers == 0) { + _controller.animateTo( + 1.0, + duration: widget.config.concealDuration, + curve: widget.config.concealCurve, + ); + ref.read(noxMascotProvider.notifier).play(MascotMood.protect); + } + } + + // ─── Build ──────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _onPointerDown, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerUp, // System interruptions = conceal + child: Stack( + fit: StackFit.expand, + children: [ + // ── Layer 1: Protected document content ────────────────────────── + RepaintBoundary(child: widget.child), + + // ── Layer 2: Animated blur overlay ─────────────────────────────── + IgnorePointer( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final sigma = _controller.value * widget.config.maxBlurSigma; + if (sigma < 0.05) { + return const SizedBox.shrink(); + } + + final tintAlpha = _controller.value * (isDark ? 0.25 : 0.15); + + return BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: sigma, + sigmaY: sigma, + tileMode: ui.TileMode.clamp, + ), + child: ColoredBox( + color: Colors.black.withValues(alpha: tintAlpha), + ), + ); + }, + ), + ), + + // ── Layer 3: Always-visible overlay (watermark) ────────────────── + if (widget.overlay != null) widget.overlay!, + + // ── Layer 4: Touch hint ─────────────────────────────────────────── + if (widget.showHint) _TouchRevealHint(animation: _controller), + ], + ), + ); + } +} + +// ─── Touch Reveal Hint ─────────────────────────────────────────────────────── + +class _TouchRevealHint extends StatelessWidget { + final Animation animation; + + const _TouchRevealHint({required this.animation}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textColor = isDark ? Colors.white : Colors.black; + + return IgnorePointer( + child: FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: const Interval(0.4, 1.0, curve: Curves.easeIn), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MascotView( + character: MascotCharacter.nox, + size: 28, + fallback: Icon( + Icons.touch_app_outlined, + size: 28, + color: textColor.withValues(alpha: 0.35), + ), + ), + const SizedBox(height: 10), + Text( + 'TOUCH TO REVEAL', + style: TextStyle( + color: textColor.withValues(alpha: 0.35), + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2.5, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/components/secure_viewer/models/viewer_config.dart b/app/lib/components/secure_viewer/models/viewer_config.dart new file mode 100644 index 0000000..22138ce --- /dev/null +++ b/app/lib/components/secure_viewer/models/viewer_config.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// Immutable configuration for the blur-reveal animation behavior and appearance. +@immutable +class ViewerConfig { + /// Maximum Gaussian blur sigma when no finger is touching. + /// 18–24 is visually opaque while remaining GPU-performant. + final double maxBlurSigma; + + /// Duration of the reveal animation (finger down → content clears). + /// Keep ≤ 150ms for a snappy, responsive feel. + final Duration revealDuration; + + /// Duration of the conceal animation (finger up → content blurs). + /// Slightly longer than reveal gives a satisfying "security seal" feel. + final Duration concealDuration; + + /// Easing curve for the reveal animation. + final Curve revealCurve; + + /// Easing curve for the conceal animation. + final Curve concealCurve; + + /// Optional tint applied over the blur layer (e.g., a very subtle dark wash). + /// Set to null for a pure Gaussian blur with no color overlay. + final Color? backdropTint; + + /// The radius of the spyglass viewport (clear touch window). + final double spyglassRadius; + + const ViewerConfig({ + this.maxBlurSigma = 22.0, + this.revealDuration = const Duration(milliseconds: 120), + this.concealDuration = const Duration(milliseconds: 220), + this.revealCurve = Curves.easeOut, + this.concealCurve = Curves.easeIn, + this.backdropTint, + this.spyglassRadius = 75.0, + }); +} diff --git a/app/lib/components/secure_viewer/models/watermark_config.dart b/app/lib/components/secure_viewer/models/watermark_config.dart new file mode 100644 index 0000000..d1e0e59 --- /dev/null +++ b/app/lib/components/secure_viewer/models/watermark_config.dart @@ -0,0 +1,107 @@ +import 'package:flutter/foundation.dart'; + +/// Immutable configuration for the diagonal repeating watermark overlay. +/// All fields participate in equality — changing any field triggers a repaint. +@immutable +class WatermarkConfig { + final String name; + final String role; + final String email; + final String timestamp; + + /// Opacity of the watermark text. Subtle but always readable. + /// Range: 0.0 – 1.0. Default 0.07 (7%). + final double opacity; + + /// Base font size for secondary lines (email, phone, timestamp). + final double fontSize; + + /// Horizontal spacing between watermark tile columns (in logical pixels). + /// Wider = sparser grid, fewer repeats on screen. + final double tileSpacingX; + + /// Vertical GAP between watermark blocks, on top of the block's own text + /// height (in logical pixels). Wider = sparser grid. + final double tileSpacingY; + + /// Rotation angle of the watermark grid in degrees (negative = tilts left). + final double angleDegrees; + + const WatermarkConfig({ + required this.name, + required this.role, + required this.email, + required this.timestamp, + this.opacity = 0.12, + this.fontSize = 12.0, + this.tileSpacingX = 340.0, + this.tileSpacingY = 240.0, + this.angleDegrees = -28.0, + }); + + /// Returns a copy scaled for the risk engine's watermark_intensity + /// (normal/increased/maximum — see + /// supabase/migrations/20260710030000_risk_engine.sql and + /// RiskEngineService). Denser + more opaque watermarking is one of the + /// engine's few automatic responses that's visible to the viewer rather + /// than silent — deliberately so, since a would-be leaker seeing heavier + /// tracing on-screen is itself a deterrent. + WatermarkConfig scaledForIntensity(String intensity) { + switch (intensity) { + case 'maximum': + return WatermarkConfig( + name: name, + role: role, + email: email, + timestamp: timestamp, + opacity: (opacity * 2.2).clamp(0.0, 0.5), + fontSize: fontSize, + tileSpacingX: tileSpacingX * 0.45, + tileSpacingY: tileSpacingY * 0.45, + angleDegrees: angleDegrees, + ); + case 'increased': + return WatermarkConfig( + name: name, + role: role, + email: email, + timestamp: timestamp, + opacity: (opacity * 1.5).clamp(0.0, 0.35), + fontSize: fontSize, + tileSpacingX: tileSpacingX * 0.7, + tileSpacingY: tileSpacingY * 0.7, + angleDegrees: angleDegrees, + ); + default: + return this; + } + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WatermarkConfig && + runtimeType == other.runtimeType && + name == other.name && + role == other.role && + email == other.email && + timestamp == other.timestamp && + opacity == other.opacity && + fontSize == other.fontSize && + tileSpacingX == other.tileSpacingX && + tileSpacingY == other.tileSpacingY && + angleDegrees == other.angleDegrees; + + @override + int get hashCode => Object.hash( + name, + role, + email, + timestamp, + opacity, + fontSize, + tileSpacingX, + tileSpacingY, + angleDegrees, + ); +} diff --git a/app/lib/components/secure_viewer/painters/watermark_painter.dart b/app/lib/components/secure_viewer/painters/watermark_painter.dart new file mode 100644 index 0000000..64cb97a --- /dev/null +++ b/app/lib/components/secure_viewer/painters/watermark_painter.dart @@ -0,0 +1,118 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../models/watermark_config.dart'; + +/// Renders a sparse, diagonally-repeating watermark grid across the full canvas. +/// +/// Contrast is DOCUMENT-adaptive, not app-theme-adaptive: each glyph is drawn +/// twice — a translucent dark outline (reads on light document pages) plus a +/// translucent light fill on top (reads on dark document pages) — so the same +/// watermark stays legible regardless of the underlying PDF/image background, +/// independent of whether the app itself is in light or dark mode. +/// +/// Performance profile: +/// - Wrapped in RepaintBoundary → repaints ONLY when [shouldRepaint] returns true +/// - All TextPainter objects are created and disposed within a single [paint] call +/// - Canvas rotate + translate = single GPU matrix multiplication (essentially free) +/// +/// Security note: This watermark provides LEGAL traceability and VISUAL deterrence. +/// It does NOT cryptographically prevent screen capture by an external camera. +class WatermarkPainter extends CustomPainter { + final WatermarkConfig config; + + const WatermarkPainter({required this.config}); + + @override + void paint(Canvas canvas, Size size) { + // Only the essentials: who, and when. ("CONFIDENTIAL" / role were dropped + // — decorative bulk, not traceability-essential — to cut text density.) + final identity = + config.name.trim().isEmpty || config.name.trim().toLowerCase() == config.email.trim().toLowerCase() + ? config.email + : '${config.name} · ${config.email}'; + final lines = <(String, double, FontWeight)>[ + (identity, config.fontSize + 0.5, FontWeight.w600), + (config.timestamp, config.fontSize - 1.0, FontWeight.w400), + ]; + + const intraLineGap = 3.5; + final lineHeight = config.fontSize + intraLineGap; + + // Layout each line once; render as an outline pass + fill pass on top so + // it contrasts against both light and dark document backgrounds. + final outlinePaint = Paint() + ..color = Colors.black.withValues(alpha: config.opacity) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.2; + final fillColor = Colors.white.withValues(alpha: config.opacity * 1.4); + + final outlinePainters = []; + final fillPainters = []; + for (final line in lines) { + final baseStyle = TextStyle( + fontSize: line.$2, + fontWeight: line.$3, + letterSpacing: 0.6, + height: 1.15, + ); + outlinePainters.add(TextPainter( + text: TextSpan(text: line.$1, style: baseStyle.copyWith(foreground: outlinePaint)), + textDirection: TextDirection.ltr, + )..layout()); + fillPainters.add(TextPainter( + text: TextSpan(text: line.$1, style: baseStyle.copyWith(color: fillColor)), + textDirection: TextDirection.ltr, + )..layout()); + } + + // Block metrics — sparse: a generous gap between blocks, controlled by + // tileSpacingY (previously unused; now the primary density knob). + final blockH = lines.length * lineHeight; + final rowStep = blockH + config.tileSpacingY; + + // Canvas diagonal determines tile count needed to cover all corners after rotation. + final diagonal = math.sqrt(size.width * size.width + size.height * size.height); + final cols = (diagonal / config.tileSpacingX).ceil() + 2; + final rows = (diagonal / rowStep).ceil() + 2; + + final angleRad = config.angleDegrees * math.pi / 180.0; + + canvas.save(); + // Rotate around the canvas center so the grid appears centered. + canvas.translate(size.width / 2.0, size.height / 2.0); + canvas.rotate(angleRad); + + for (int row = -rows; row <= rows; row++) { + for (int col = -cols; col <= cols; col++) { + canvas.save(); + canvas.translate(col * config.tileSpacingX, row * rowStep); + + double yOffset = 0.0; + for (var i = 0; i < outlinePainters.length; i++) { + final outline = outlinePainters[i]; + final fill = fillPainters[i]; + outline.paint(canvas, Offset(-outline.width / 2.0, yOffset)); + fill.paint(canvas, Offset(-fill.width / 2.0, yOffset)); + yOffset += lineHeight; + } + + canvas.restore(); + } + } + + canvas.restore(); + + for (final tp in outlinePainters) { + tp.dispose(); + } + for (final tp in fillPainters) { + tp.dispose(); + } + } + + @override + bool shouldRepaint(WatermarkPainter oldDelegate) => oldDelegate.config != config; + + @override + bool shouldRebuildSemantics(WatermarkPainter oldDelegate) => false; +} diff --git a/app/lib/components/secure_viewer/secure_document_viewer.dart b/app/lib/components/secure_viewer/secure_document_viewer.dart new file mode 100644 index 0000000..e73be2f --- /dev/null +++ b/app/lib/components/secure_viewer/secure_document_viewer.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'models/watermark_config.dart'; +import 'models/viewer_config.dart'; +import 'blur_reveal_layer.dart'; +import 'watermark_overlay.dart'; + +/// Public API widget for the NO SUS secure document viewer system. +/// +/// Composes [BlurRevealLayer] and [WatermarkOverlay] into a single, clean +/// surface. Callers provide [child] (any scrollable document widget) plus +/// the watermark and viewer configurations. +/// +/// ## Usage +/// +/// ```dart +/// SecureDocumentViewer( +/// watermarkConfig: WatermarkConfig( +/// email: 'user@example.com', +/// phone: '+91 98765 43210', +/// timestamp: '2026-06-09 13:06 IST', +/// ), +/// child: SingleChildScrollView( +/// child: MarkdownBody(data: markdownContent), +/// ), +/// ) +/// ``` +/// +/// ## Security notes +/// +/// - Content is blurred by default; cleared only while finger touches screen. +/// - Watermark persists at all blur states — always present on external camera footage. +/// - Screenshot blocking requires platform-level integration (FLAG_SECURE on Android). +/// - External cameras CANNOT be blocked; this system provides friction + traceability. +class SecureDocumentViewer extends StatelessWidget { + /// The document content to protect. Typically a [SingleChildScrollView] + /// containing text, a PDF widget, or a [ListView] of pages. + final Widget child; + + /// Watermark configuration: user identity + timestamp. + final WatermarkConfig watermarkConfig; + + /// Blur behavior and animation configuration. + final ViewerConfig viewerConfig; + + /// Whether to show the "TOUCH TO REVEAL" interaction hint. + final bool showHint; + + /// Whether the touch-to-reveal blur layer is enabled. + final bool touchToRevealEnabled; + + /// Whether the watermark overlay layer is enabled. + final bool watermarkEnabled; + + /// Forces an immediate conceal (blur) regardless of [touchToRevealEnabled] + /// — wired to [WebSecurityGuard]'s detections on the web viewers (DevTools + /// opened, repeated tab-hiding). When this is provided, the blur layer is + /// always present so it has something to conceal, even if the sender + /// didn't request touch-to-reveal-by-default; it just starts revealed in + /// that case (see [BlurRevealLayer.initiallyConcealed]) rather than + /// blurred until the first tap. + final Stream? forceConcealSignal; + + const SecureDocumentViewer({ + super.key, + required this.child, + required this.watermarkConfig, + this.viewerConfig = const ViewerConfig(), + this.showHint = true, + this.touchToRevealEnabled = true, + this.watermarkEnabled = true, + this.forceConcealSignal, + }); + + @override + Widget build(BuildContext context) { + if (touchToRevealEnabled || forceConcealSignal != null) { + return BlurRevealLayer( + config: viewerConfig, + showHint: showHint && touchToRevealEnabled, + initiallyConcealed: touchToRevealEnabled, + forceConcealSignal: forceConcealSignal, + overlay: watermarkEnabled + ? WatermarkOverlay(config: watermarkConfig) + : const SizedBox.shrink(), + child: child, + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + // ── Layer 1: Protected document content ────────────────────────── + RepaintBoundary(child: child), + // ── Layer 2: Always-visible overlay (watermark) ────────────────── + if (watermarkEnabled) WatermarkOverlay(config: watermarkConfig), + ], + ); + } +} + diff --git a/app/lib/components/secure_viewer/watermark_overlay.dart b/app/lib/components/secure_viewer/watermark_overlay.dart new file mode 100644 index 0000000..83fd7d7 --- /dev/null +++ b/app/lib/components/secure_viewer/watermark_overlay.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'models/watermark_config.dart'; +import 'painters/watermark_painter.dart'; + +/// A stateless overlay that renders the [WatermarkPainter] over its parent. +/// +/// Isolation guarantees: +/// - [RepaintBoundary]: this layer never repaints when the document scrolls +/// or when the blur animation runs. +/// - [IgnorePointer]: events (touch, scroll) pass through transparently. +/// - [SizedBox.expand]: ensures CustomPaint fills the full available space. +/// +/// Contrast is document-adaptive (dark outline + light fill on every glyph), +/// not app-theme-adaptive — the app's own light/dark mode has no bearing on +/// what color the PDF/image underneath actually is, so there is nothing to +/// resolve from [Theme.of(context)] here. +class WatermarkOverlay extends StatelessWidget { + final WatermarkConfig config; + + const WatermarkOverlay({super.key, required this.config}); + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: IgnorePointer( + child: CustomPaint( + painter: WatermarkPainter(config: config), + child: const SizedBox.expand(), + ), + ), + ); + } +} diff --git a/app/lib/components/shimmer_box.dart b/app/lib/components/shimmer_box.dart new file mode 100644 index 0000000..da956e2 --- /dev/null +++ b/app/lib/components/shimmer_box.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +/// Shared shimmer-sweep animation driver — the 1200ms color-sweep loop +/// originally hand-rolled inside `GroupCardSkeleton` +/// (lib/features/groups/widgets/group_card.dart), extracted so every +/// skeleton loader in the app reuses one animation controller +/// implementation instead of each hand-rolling its own. +class ShimmerSweep extends StatefulWidget { + final Widget Function(BuildContext context, double shimmerValue) builder; + + const ShimmerSweep({super.key, required this.builder}); + + @override + State createState() => _ShimmerSweepState(); +} + +class _ShimmerSweepState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) => widget.builder(context, _controller.value), + ); + } +} + +/// A single shimmering placeholder block — generalizes the `_Bone` widget +/// from `GroupCardSkeleton`. Sweeps a gradient highlight left-to-right on a +/// continuous 1200ms loop, theme-aware for light/dark. +class ShimmerBox extends StatelessWidget { + final double width; + final double height; + final double radius; + + const ShimmerBox({ + super.key, + required this.width, + required this.height, + this.radius = 4, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final base = isDark ? const Color(0xFF1E1E1E) : const Color(0xFFEEEEEC); + final highlight = + isDark ? const Color(0xFF2A2A2A) : const Color(0xFFFFFFFF); + + return ShimmerSweep( + builder: (context, v) => Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + gradient: LinearGradient( + begin: Alignment(-1.5 + v * 3, 0), + end: Alignment(-0.5 + v * 3, 0), + colors: [base, highlight, base], + stops: const [0.0, 0.5, 1.0], + ), + ), + ), + ); + } +} + +/// Generalizes `groups_screen.dart`'s `_SkeletonList` — a non-scrollable +/// stack of placeholder items shown while an `AsyncValue` is loading. +class ShimmerListSkeleton extends StatelessWidget { + final int itemCount; + final WidgetBuilder itemBuilder; + final double spacing; + + const ShimmerListSkeleton({ + super.key, + this.itemCount = 3, + required this.itemBuilder, + this.spacing = 12, + }); + + @override + Widget build(BuildContext context) { + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + itemCount: itemCount, + separatorBuilder: (context, index) => SizedBox(height: spacing), + itemBuilder: (context, index) => itemBuilder(context), + ); + } +} diff --git a/app/lib/components/spyglass_viewer.dart b/app/lib/components/spyglass_viewer.dart new file mode 100644 index 0000000..ac3b929 --- /dev/null +++ b/app/lib/components/spyglass_viewer.dart @@ -0,0 +1,656 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../theme.dart'; +import '../features/files/presentation/providers/secure_file_providers.dart'; +import 'secure_viewer/models/watermark_config.dart'; +import 'secure_viewer/models/viewer_config.dart'; +import 'secure_viewer/secure_document_viewer.dart'; +import 'package:pdfrx/pdfrx.dart'; +import 'package:screen_protector/screen_protector.dart'; +import '../features/auth/presentation/providers/auth_providers.dart'; +import '../features/profile/providers/profile_provider.dart'; +import '../features/profile/providers/settings_provider.dart'; +import '../services/screenshot_guard.dart'; +import '../services/audit_service.dart'; +import '../services/device_integrity_service.dart'; +import '../features/auth/presentation/providers/risk_state_provider.dart'; + +// ─── User identity resolved from live auth providers ──────────────────────── + +/// The full-screen secure document viewer page. +/// +/// Loads, renders, and purges volatile document bytes from memory upon exit. +class SpyglassViewer extends ConsumerStatefulWidget { + final String? fileId; + final String? groupId; + final String? email; + final String? phone; + final String? documentTitle; + final String? documentCategory; + + const SpyglassViewer({ + super.key, + this.fileId, + this.groupId, + this.email, + this.phone, + this.documentTitle, + this.documentCategory, + }); + + @override + ConsumerState createState() => _SpyglassViewerState(); +} + +class _SpyglassViewerState extends ConsumerState { + final String _sessionTimestamp = _buildTimestamp(); + bool _isLoading = true; + String _documentContent = ''; + double _streamProgress = 0.0; + String _loadingStatus = 'Requesting secure proxy...'; + bool _isPdf = false; + bool _isImage = false; + bool _downloadFailed = false; + Uint8List? _documentBytes; + + static String _buildTimestamp() { + final now = DateTime.now(); + return '${now.year}-${_pad(now.month)}-${_pad(now.day)} ' + '${_pad(now.hour)}:${_pad(now.minute)} IST'; + } + + static String _pad(int n) => n.toString().padLeft(2, '0'); + + @override + void initState() { + super.initState(); + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarBrightness: Brightness.dark, + )); + + // Register active document context for screenshot/recording audit logs + ScreenshotGuard.instance.activeGroupId = widget.groupId; + ScreenshotGuard.instance.activeFileId = widget.fileId; + ScreenshotGuard.instance.activeFileName = widget.documentTitle; + + _initScreenshotProtection(); + _loadSecureEnclavePayload(); + + // Display-mirroring / accessibility-abuse check for this viewing + // session — logged into this group's tamper-evident audit trail. + unawaited(DeviceIntegrityService.instance.runViewingChecks( + groupId: widget.groupId, + fileId: widget.fileId, + )); + } + + Future _initScreenshotProtection() async { + await ScreenProtector.preventScreenshotOn(); + await ScreenProtector.protectDataLeakageOn(); + } + + @override + void dispose() { + // Clear active document context from screenshot audit logger + ScreenshotGuard.instance.activeGroupId = null; + ScreenshotGuard.instance.activeFileId = null; + ScreenshotGuard.instance.activeFileName = null; + + _documentBytes = null; + ScreenProtector.preventScreenshotOff(); + ScreenProtector.protectDataLeakageOff(); + super.dispose(); + } + + Future _loadSecureEnclavePayload() async { + Uint8List? plainBytes; + + if (widget.fileId != null) { + setState(() { + _loadingStatus = 'Connecting to secure repository...'; + }); + + final streamedBytes = await ref.read(secureFileRepositoryProvider).downloadFile( + fileId: widget.fileId!, + onProgress: (progress) { + if (mounted) { + setState(() { + _streamProgress = progress; + _loadingStatus = 'Downloading: ${(progress * 100).toStringAsFixed(0)}%'; + }); + } + }, + ); + + if (streamedBytes != null) { + if (mounted) { + setState(() { + _loadingStatus = 'Loading document...'; + }); + } + plainBytes = streamedBytes; + } + } + + bool downloadFailed = false; + + if (plainBytes == null && widget.fileId != null) { + downloadFailed = true; + if (widget.groupId != null) { + AuditService.instance.logEvent( + 'download_failed', + 'FAILURE', + groupId: widget.groupId!, + fileId: widget.fileId, + metadata: {'file_name': widget.documentTitle ?? 'document'}, + ); + } + } + + bool isPdfFile = false; + if (plainBytes != null && + plainBytes.length >= 4 && + plainBytes[0] == 0x25 && + plainBytes[1] == 0x50 && + plainBytes[2] == 0x44 && + plainBytes[3] == 0x46) { + isPdfFile = true; + } else if (plainBytes != null && widget.documentCategory == 'PDF' && !downloadFailed) { + isPdfFile = true; + } + + // Detect Image by magic bytes or category + bool isImageFile = false; + if (plainBytes != null && + plainBytes.length >= 3 && + plainBytes[0] == 0xFF && + plainBytes[1] == 0xD8 && + plainBytes[2] == 0xFF) { // JPEG + isImageFile = true; + } else if (plainBytes != null && + plainBytes.length >= 4 && + plainBytes[0] == 0x89 && + plainBytes[1] == 0x50 && + plainBytes[2] == 0x4E && + plainBytes[3] == 0x47) { // PNG + isImageFile = true; + } else if (plainBytes != null && (widget.documentCategory == 'IMAGE' || widget.documentCategory == 'SCAN') && !downloadFailed) { + isImageFile = true; + } + + String decoded = ''; + if (plainBytes != null && !isPdfFile && !isImageFile) { + try { + decoded = utf8.decode(plainBytes); + } catch (_) { + decoded = '[SECURED DOCUMENT PREVIEW]\nThis document is a binary PDF/media asset loaded securely inside the RAM buffer.'; + } + } + + if (mounted) { + setState(() { + _isPdf = isPdfFile; + _isImage = isImageFile; + _documentContent = decoded; + _documentBytes = plainBytes; + _downloadFailed = downloadFailed; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? NoSusTheme.dBackground : NoSusTheme.lBackground; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + + final authState = ref.watch(authStateProvider); + final profileAsync = ref.watch(profileProvider); + final isWatermarkVisible = ref.watch(watermarkVisibilityProvider); + + final String userEmail = authState.value?.email ?? widget.email ?? 'student@nosus.app'; + final String displayName = profileAsync.maybeWhen( + data: (p) => p.displayName, + orElse: () => userEmail.contains('@') ? userEmail.split('@').first : 'Guest Scholar', + ); + + final watermarkIntensity = + ref.watch(riskStateProvider).value?.watermarkIntensity ?? 'normal'; + final watermarkConfig = WatermarkConfig( + name: displayName.toUpperCase(), + role: 'SECURE MEMBER', + email: userEmail, + timestamp: _sessionTimestamp, + opacity: isWatermarkVisible ? 0.14 : 0.0, + fontSize: 12.0, + angleDegrees: -28.0, + ).scaledForIntensity(watermarkIntensity); + + const viewerConfig = ViewerConfig( + maxBlurSigma: 22.0, + revealDuration: Duration(milliseconds: 115), + concealDuration: Duration(milliseconds: 210), + revealCurve: Curves.easeOut, + concealCurve: Curves.easeIn, + ); + + return Scaffold( + backgroundColor: bg, + appBar: AppBar( + backgroundColor: bg, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: fg, size: 20), + onPressed: () => Navigator.of(context).maybePop(), + tooltip: 'Back', + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.documentTitle ?? 'Study Notes', + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + ), + if (widget.documentCategory != null) + Text( + widget.documentCategory!, + style: TextStyle( + color: fg.withValues(alpha: 0.45), + fontSize: 11, + fontWeight: FontWeight.w400, + letterSpacing: 0.3, + ), + ), + ], + ), + actions: [ + Container( + margin: const EdgeInsets.only(right: 16), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + border: Border.all(color: fg.withValues(alpha: 0.18), width: 0.75), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline, size: 11, color: fg.withValues(alpha: 0.5)), + const SizedBox(width: 5), + Text( + 'SECURE', + style: TextStyle( + color: fg.withValues(alpha: 0.5), + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 1.5, + ), + ), + ], + ), + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container( + height: 0.5, + color: fg.withValues(alpha: 0.1), + ), + ), + ), + body: _isLoading + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 1.5, color: Colors.grey), + ), + const SizedBox(height: 24), + Text( + _loadingStatus.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 2.0, + color: fg.withValues(alpha: 0.5), + ), + ), + if (widget.fileId != null) ...[ + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: _streamProgress, + backgroundColor: fg.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation(fg.withValues(alpha: 0.6)), + minHeight: 2, + ), + ), + ], + ], + ), + ), + ) + : _downloadFailed + ? _buildDownloadFailedBody(fg) + : _buildDocumentBody(watermarkConfig, viewerConfig, isDark, fg, bg), + ); + } + + Widget _buildDownloadFailedBody(Color fg) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off_outlined, + size: 40, + color: fg.withValues(alpha: 0.4), + ), + const SizedBox(height: 20), + Text( + 'DOWNLOAD FAILED', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 2.0, + color: fg, + ), + ), + const SizedBox(height: 12), + Text( + 'The document could not be loaded.\n\nPlease verify your connection and ensure you are a registered enclave member of this study group.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: fg.withValues(alpha: 0.55), + height: 1.5, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDocumentBody( + WatermarkConfig watermarkConfig, + ViewerConfig viewerConfig, + bool isDark, + Color fg, + Color bg, + ) { + final buffer = _documentBytes; + + // Null guard — if buffer was purged (e.g. widget re-entry) show a safe error state. + if (buffer == null && (_isPdf || _isImage)) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.lock_reset_outlined, size: 36, color: fg.withValues(alpha: 0.3)), + const SizedBox(height: 16), + Text( + 'SESSION EXPIRED', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 2.0, + color: fg.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: 8), + Text( + 'Document buffer was purged.\nReturn and open the document again.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: fg.withValues(alpha: 0.5)), + ), + ], + ), + ); + } + + return SecureDocumentViewer( + watermarkConfig: watermarkConfig, + viewerConfig: viewerConfig, + showHint: true, + touchToRevealEnabled: ref.watch(touchToRevealProvider), + child: _isPdf + ? PdfViewer.data( + buffer!, + sourceName: widget.documentTitle ?? 'document.pdf', + // Text selection off: it's a copy-out leak in a protected + // viewer, and pdfrx 2.4.4's selection painter crashes on + // pages with no extractable text (StateError: No element). + params: const PdfViewerParams( + textSelectionParams: PdfTextSelectionParams(enabled: false), + ), + ) + : _isImage + ? InteractiveViewer( + child: Center( + child: Image.memory(buffer!), + ), + ) + : _DocumentContent( + isDark: isDark, + fg: fg, + bg: bg, + text: _documentContent, + ), + ); + } +} + +// ─── Demo Document Content ──────────────────────────────────────────────────── + +class _DocumentContent extends StatelessWidget { + final bool isDark; + final Color fg; + final Color bg; + final String text; + + const _DocumentContent({ + required this.isDark, + required this.fg, + required this.bg, + required this.text, + }); + + @override + Widget build(BuildContext context) { + final dividerColor = fg.withValues(alpha: 0.08); + final subtleColor = fg.withValues(alpha: 0.45); + final cardBg = isDark ? const Color(0xFF1A1A1A) : Colors.white; + + final paragraphs = text.split('\n\n'); + + return SingleChildScrollView( + physics: NoSusTheme.getScrollPhysics(context), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DocHeader( + subject: 'DOCUMENT VIEWER', + chapter: paragraphs.isNotEmpty ? paragraphs.first.split('\n').first : 'Document Content', + date: 'SECURE VIEWER', + fg: fg, + subtleColor: subtleColor, + ), + const SizedBox(height: 28), + Divider(color: dividerColor, thickness: 0.5), + const SizedBox(height: 28), + for (final p in paragraphs) ...[ + if (p.trim().isNotEmpty) ...[ + _renderParagraph(p, cardBg, dividerColor), + const SizedBox(height: 24), + ], + ], + const SizedBox(height: 40), + Center( + child: Text( + '— DOCUMENT CLEARED ON EXIT —', + style: TextStyle( + color: subtleColor, + fontSize: 10, + letterSpacing: 2.0, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 40), + ], + ), + ); + } + + Widget _renderParagraph(String p, Color cardBg, Color dividerColor) { + final lines = p.split('\n'); + final title = lines.first; + final body = lines.skip(1).join('\n'); + + if (body.isEmpty) { + return Text( + title, + style: TextStyle( + color: fg.withValues(alpha: 0.8), + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.75, + ), + ); + } + + return _Section( + title: title, + body: body, + fg: fg, + subtleColor: fg.withValues(alpha: 0.45), + cardBg: cardBg, + dividerColor: dividerColor, + ); + } +} + +// ─── Document sub-widgets ───────────────────────────────────────────────────── + +class _DocHeader extends StatelessWidget { + final String subject; + final String chapter; + final String date; + final Color fg; + final Color subtleColor; + + const _DocHeader({ + required this.subject, + required this.chapter, + required this.date, + required this.fg, + required this.subtleColor, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subject.toUpperCase(), + style: TextStyle( + color: subtleColor, + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: 8), + Text( + chapter, + style: TextStyle( + color: fg, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, + ), + ), + const SizedBox(height: 8), + Text( + date, + style: TextStyle( + color: subtleColor, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } +} + +class _Section extends StatelessWidget { + final String title; + final String body; + final Color fg; + final Color subtleColor; + final Color cardBg; + final Color dividerColor; + + const _Section({ + required this.title, + required this.body, + required this.fg, + required this.subtleColor, + required this.cardBg, + required this.dividerColor, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: fg, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 12), + Text( + body, + style: TextStyle( + color: fg.withValues(alpha: 0.75), + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.75, + letterSpacing: 0.1, + ), + ), + ], + ); + } +} diff --git a/app/lib/components/study_chart.dart b/app/lib/components/study_chart.dart new file mode 100644 index 0000000..407a58b --- /dev/null +++ b/app/lib/components/study_chart.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../theme.dart'; +import '../features/focus/providers/focus_provider.dart'; + +class StudyChart extends ConsumerStatefulWidget { + const StudyChart({super.key}); + + @override + ConsumerState createState() => _StudyChartState(); +} + +class _StudyChartState extends ConsumerState { + int _selectedDayIndex = 6; // Default to Friday/Today (last element) + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final timelineAsync = ref.watch(studyTimelineProvider); + + return timelineAsync.when( + data: (data) { + if (data.isEmpty) { + return const Center(child: Text("No focus data")); + } + + // Find max hours to scale bars + double maxHours = data + .map((d) => d.hours) + .fold(1.0, (max, val) => val > max ? val : max); + if (maxHours < 1.0) maxHours = 1.0; + + // Ensure selected index is within bounds + if (_selectedDayIndex >= data.length) { + _selectedDayIndex = data.length - 1; + } + + final selectedData = data[_selectedDayIndex]; + + return Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'FOCUS TIMELINE', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.5, + ), + ), + ), + const SizedBox(height: NoSusTheme.s4), + Text( + '${selectedData.hours}h Focused', + style: theme.textTheme.displayMedium?.copyWith( + fontSize: 24, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s12, + vertical: NoSusTheme.s8, + ), + decoration: NoSusTheme.buttonDecoration( + context, + radius: 10, + ), + child: Row( + children: [ + Icon( + Icons.shield_outlined, + size: 14, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: NoSusTheme.s4), + Text( + '${selectedData.scans} Checks', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: NoSusTheme.s32), + // Chart Graphic + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(data.length, (index) { + final dayData = data[index]; + final isSelected = index == _selectedDayIndex; + final percentage = dayData.hours / maxHours; + + return Expanded( + child: Semantics( + button: true, + selected: isSelected, + label: + '${dayData.day}: ${dayData.hours.toStringAsFixed(1)} hours', + child: GestureDetector( + onTap: () { + setState(() { + _selectedDayIndex = index; + }); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final barHeight = + constraints.maxHeight * percentage; + return Stack( + alignment: Alignment.bottomCenter, + children: [ + // Base track guide line + Positioned( + top: 0, + bottom: 0, + child: Container( + width: 1, + color: theme.colorScheme.outline + .withValues(alpha: 0.1), + ), + ), + // Animated Bar + AnimatedContainer( + duration: const Duration( + milliseconds: 300, + ), + curve: Curves.fastOutSlowIn, + width: 28, + height: barHeight.clamp( + 4.0, + double.infinity, + ), + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.onSurface + : (isDark + ? Colors.white + .withValues( + alpha: 0.08, + ) + : Colors.black + .withValues( + alpha: 0.04, + )), + borderRadius: + const BorderRadius.vertical( + top: Radius.circular(6), + ), + border: Border.all( + color: isSelected + ? theme + .colorScheme + .onSurface + : theme.colorScheme.outline, + width: 1.2, + ), + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: NoSusTheme.s12), + Text( + dayData.day, + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 11, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface + .withValues(alpha: 0.4), + ), + ), + ], + ), + ), + ), + ), + ); + }), + ), + ), + ], + ), + ); + }, + loading: () => Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: const Center( + child: CircularProgressIndicator(color: Colors.grey), + ), + ), + error: (err, stack) => Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: Center( + child: Text( + 'Error loading timeline: $err', + style: const TextStyle(color: Colors.redAccent), + ), + ), + ), + ); + } +} + +class StudyDayData { + final String day; + final double hours; + final int scans; + + StudyDayData({required this.day, required this.hours, required this.scans}); +} diff --git a/app/lib/components/update_banner.dart b/app/lib/components/update_banner.dart new file mode 100644 index 0000000..cd045f0 --- /dev/null +++ b/app/lib/components/update_banner.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../features/config/domain/app_update.dart'; +import '../features/config/presentation/providers/app_update_provider.dart'; +import '../theme.dart'; + +/// Dismissible strip shown when a newer app version is available — same +/// visual language and per-session dismissal contract as [OfflineBanner]. +/// Renders nothing on web/non-Android or when the app is current (see +/// appUpdateProvider for the platform gating). +class UpdateBanner extends ConsumerStatefulWidget { + const UpdateBanner({super.key}); + + @override + ConsumerState createState() => _UpdateBannerState(); +} + +class _UpdateBannerState extends ConsumerState { + bool _dismissed = false; + + Future _openDownload(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open download link')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_dismissed) return const SizedBox.shrink(); + final status = ref.watch(appUpdateProvider).value; + if (status is! AppUpdateAvailable) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s16, + vertical: NoSusTheme.s12, + ), + decoration: BoxDecoration( + color: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + border: Border( + bottom: BorderSide(color: theme.colorScheme.outline, width: 1.0), + ), + ), + child: Row( + children: [ + Icon( + Icons.system_update_alt_outlined, + size: 16, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: NoSusTheme.s8), + Expanded( + child: Text( + 'Update available: v${status.latestVersion}', + style: theme.textTheme.bodyMedium?.copyWith(fontSize: 12), + ), + ), + TextButton( + onPressed: () => _openDownload(status.downloadUrl), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: NoSusTheme.s8), + minimumSize: const Size(0, 44), + ), + child: Text( + 'UPDATE', + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + ), + ), + ), + IconButton( + onPressed: () => setState(() => _dismissed = true), + tooltip: 'Dismiss update notice', + icon: Icon( + Icons.close, + size: 16, + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/config/app_integrity_config.dart b/app/lib/config/app_integrity_config.dart new file mode 100644 index 0000000..905b93c --- /dev/null +++ b/app/lib/config/app_integrity_config.dart @@ -0,0 +1,27 @@ +/// Play Integrity API feature flag. +/// +/// Off by default. Even once [enabled] is flipped on via +/// `--dart-define=APP_INTEGRITY_ENABLED=true`, requesting a real token still +/// requires: +/// 1. A Google Cloud project linked to this app in Play Console (App +/// integrity → Play Integrity API), with the Play Integrity API enabled. +/// 2. That project's number filled into `cloudProjectNumber` in +/// android/app/src/main/kotlin/foo/nosus/app/security/PlayIntegrityManager.kt +/// (currently 0, which fails fast rather than silently misbehaving). +/// 3. The app installed via Play (or Play's internal testing track) — +/// Play Integrity tokens can't be minted for a sideloaded debug build. +/// 4. supabase/functions/verify-play-integrity deployed and able to call +/// Google's decodeIntegrityToken endpoint with a service account that +/// has Play Integrity API access (GOOGLE_PLAY_SERVICE_ACCOUNT_JSON may +/// or may not already have this scope — verify before relying on it). +/// +/// None of this has been exercised against a real device/token in this +/// environment — the plumbing is written and internally consistent, not +/// verified end-to-end. Treat as scaffolding until someone completes the +/// four steps above and tests on a real device. +class AppIntegrityConfig { + AppIntegrityConfig._(); + + static const bool enabled = + bool.fromEnvironment('APP_INTEGRITY_ENABLED', defaultValue: false); +} diff --git a/app/lib/config/app_mode.dart b/app/lib/config/app_mode.dart new file mode 100644 index 0000000..23551cf --- /dev/null +++ b/app/lib/config/app_mode.dart @@ -0,0 +1,26 @@ +/// True only for the standalone NO SUS Canary web build +/// (flutter build web ... --dart-define=NOSUS_CANARY_ONLY=true). +/// The full NO SUS app (APK, main web app) never sets it. +const bool kCanaryOnly = bool.fromEnvironment('NOSUS_CANARY_ONLY'); + +/// Shows the Monad testnet prototype in NO SUS Canary: the wallet button, +/// Monad proof chips, "Verify on Monad" and all Monad wording. Off by +/// default: Solana devnet is the only receipt the product shows. Build with +/// --dart-define=CANARY_MONAD=true to reproduce the earlier Monad build. +const bool kCanaryMonad = bool.fromEnvironment( + 'CANARY_MONAD', + defaultValue: false, +); + +/// Optional deployed contract address to show on the Canary landing page +/// (Monad testnet; only shown when [kCanaryMonad] is true). +const String kCanaryContract = String.fromEnvironment( + 'CANARY_CONTRACT', + defaultValue: '0xb1a1858866122c84cf97861ca815fb070e010e68', +); + +/// Optional repo URL for the open-source link in the hero/footer. +const String kCanaryRepoUrl = String.fromEnvironment( + 'CANARY_REPO_URL', + defaultValue: 'https://github.com/https-shubhamsahu/nosus-canary', +); diff --git a/app/lib/config/crash_reporting_config.dart b/app/lib/config/crash_reporting_config.dart new file mode 100644 index 0000000..8506368 --- /dev/null +++ b/app/lib/config/crash_reporting_config.dart @@ -0,0 +1,34 @@ +/// Crash reporting configuration (Sentry). +/// +/// Off by default — [sentryDsn] is empty until explicitly provided via +/// `--dart-define=SENTRY_DSN=...` (or a `.env` picked up by +/// `--dart-define-from-file`) and is disabled unless explicitly configured. +/// To turn this on: +/// 1. Create a free project at sentry.io (Flutter platform). +/// 2. Copy its DSN. +/// 3. Add `SENTRY_DSN=https://...` to your `.env` / CI secret and pass it +/// through `--dart-define-from-file`. +/// 4. Update `store_listing/data_safety_answers.md` — enabling this adds a +/// new third-party data flow (crash/error metadata to Sentry) that the +/// Play Console Data Safety form does not currently disclose. +/// +/// This product's whole premise is zero-knowledge/no-tracking (see +/// PROJECT_CONSTITUTION.md), so the wiring in main.dart deliberately sets +/// `sendDefaultPii = false` and keeps tracing off by default — this is +/// crash/error visibility, not analytics. +class CrashReportingConfig { + CrashReportingConfig._(); + + static const String sentryDsn = + String.fromEnvironment('SENTRY_DSN', defaultValue: ''); + + static bool get isEnabled => sentryDsn.isNotEmpty; + + /// Performance tracing is separate from error capture and stays off even + /// when error capture is enabled — it's a distinct data flow (full + /// request/navigation traces) this product doesn't opt into silently. + /// (No dart-define for this one — there's no `double.fromEnvironment` in + /// Dart, and tracing isn't something to flip on casually for this + /// product anyway; change this constant directly if it's ever needed.) + static const double tracesSampleRate = 0.0; +} diff --git a/app/lib/config/intelligence_config.dart b/app/lib/config/intelligence_config.dart new file mode 100644 index 0000000..75cf3ff --- /dev/null +++ b/app/lib/config/intelligence_config.dart @@ -0,0 +1,26 @@ +/// Optional document-intelligence layer. Completely independent of sharing, +/// auth, and viewing. +/// +/// The core product works with every flag false. Providers are selected at +/// compile time so a Gemini competition build does not ship a local model, +/// and a local-AI build does not need a cloud key. +/// +/// --dart-define=INTELLIGENCE_PROVIDER=gemini +/// --dart-define=INTELLIGENCE_PROVIDER=local +/// +/// Credentials never live in the client. Gemini goes through a server +/// function that holds `GEMINI_API_KEY`. Local runs on-device with no +/// network. +class IntelligenceConfig { + IntelligenceConfig._(); + + static const String provider = String.fromEnvironment( + 'INTELLIGENCE_PROVIDER', + defaultValue: 'disabled', + ); + + static bool get isDisabled => provider == 'disabled' || provider.isEmpty; + static bool get isEnabled => !isDisabled; + static bool get useGemini => provider == 'gemini'; + static bool get useLocal => provider == 'local'; +} diff --git a/app/lib/config/measure_reporting_config.dart b/app/lib/config/measure_reporting_config.dart new file mode 100644 index 0000000..9c3d89e --- /dev/null +++ b/app/lib/config/measure_reporting_config.dart @@ -0,0 +1,75 @@ +import 'package:flutter/foundation.dart'; + +/// Measure (measure.sh) monitoring configuration. +/// +/// Off by default, exactly like [CrashReportingConfig] — both values are empty +/// until supplied via `--dart-define=MEASURE_API_KEY=...` / +/// `--dart-define=MEASURE_API_URL=...` (or a `.env` picked up by +/// `--dart-define-from-file`, which is how CI does it). With either one blank +/// the SDK is never initialised and never started, so an unconfigured build +/// carries the dependency but performs no collection and makes no network +/// calls. +/// +/// **Android only.** `measure_flutter` registers plugin implementations for +/// `android` and `ios` only. It is conditionally imported by +/// `services/measure_reporting.dart`, so its JavaScript-incompatible package +/// code is excluded from the web program rather than merely skipped at runtime. +/// iOS is excluded too: that target has never shipped (still on Flutter's +/// `com.example.*` template ids, see AGENTS.md §5) and Measure's iOS setup +/// needs a `pod install` + `AppDelegate` change that does not exist here. +/// +/// **The same two values are also read by Gradle**, out of the repo-root +/// `.env`, and injected into `AndroidManifest.xml` as the +/// `sh.measure.android.API_KEY` / `API_URL` meta-data the native SDK reads — +/// see `android/app/build.gradle.kts`. That is not duplication for its own +/// sake: the native SDK initialises in `Application.onCreate`, long before Dart +/// exists, so it cannot be told anything by a `--dart-define`. Keeping both +/// sides fed from one `.env` is what stops the Dart gate and the native gate +/// from disagreeing. If you set one without the other, the half that is +/// configured runs and the half that is not stays dark. +/// +/// ## Before enabling this, know what it collects +/// +/// Measure is session monitoring, not just crash capture, and two of its +/// behaviours are actively dangerous for *this* product: +/// +/// 1. **Screenshot-on-crash cannot be turned off from app code.** It lives in +/// the SDK's server-driven `DynamicConfig` (`crash_take_screenshot`, +/// defaults to `true`), fetched from the Measure backend — neither the Dart +/// nor the Android `MeasureConfig` exposes it. It must be disabled in the +/// Measure dashboard. Until it is, a crash inside `BurnNoteViewerScreen`, +/// `BurnFileViewerScreen` or `SpyglassViewer` can upload decrypted content. +/// `MainActivity` sets `FLAG_SECURE` window-wide, which blanks the `PixelCopy` +/// capture path the SDK uses on API 26+, but it falls back to the Canvas API +/// on older devices and `FLAG_SECURE` does not block that. +/// 2. **Gesture tracking records widget labels.** `ClickData` carries `label` +/// and `semanticLabel`, which in this app are note titles, group names and +/// filenames. This is why `main.dart` deliberately does **not** wrap the app +/// in `MeasureWidget` and does not install `MsrNavigatorObserver` — without +/// that wrapper the SDK captures crashes and app-health metrics but no +/// interaction timeline. Adding either re-opens this; don't, without +/// re-reading `store_listing/data_safety_answers.md`. +/// +/// Turning this on is a Play Data Safety change (a new third-party data flow), +/// same as `SENTRY_DSN` — see `store_listing/data_safety_answers.md`. +class MeasureReportingConfig { + MeasureReportingConfig._(); + + static const String apiKey = + String.fromEnvironment('MEASURE_API_KEY', defaultValue: ''); + + static const String apiUrl = + String.fromEnvironment('MEASURE_API_URL', defaultValue: ''); + + /// Both halves of the credential must be present — a key without a URL (or + /// vice versa) is a misconfiguration, not a partial enablement. + static bool get isConfigured => apiKey.isNotEmpty && apiUrl.isNotEmpty; + + /// `kIsWeb` is checked *first* and separately: [defaultTargetPlatform] reports + /// `TargetPlatform.android` for Chrome on an Android phone, so a web-only + /// check via the platform enum alone would let the web build through. + static bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + static bool get isEnabled => isConfigured && isSupportedPlatform; +} diff --git a/app/lib/config/storage_router_config.dart b/app/lib/config/storage_router_config.dart new file mode 100644 index 0000000..dad109d --- /dev/null +++ b/app/lib/config/storage_router_config.dart @@ -0,0 +1,15 @@ +/// Feature flags and endpoint names for the optional multi-cloud storage router. +/// +/// Existing Supabase Storage remains the default. When STORAGE_MULTICLOUD=true, +/// uploads/downloads/deletes are routed through the `storage-router` Edge +/// Function, which owns S3-compatible provider credentials and provider choice. +class StorageRouterConfig { + StorageRouterConfig._(); + + static const bool enableMultiCloudStorage = bool.fromEnvironment( + 'STORAGE_MULTICLOUD', + defaultValue: false, + ); + + static const String edgeFunctionName = 'storage-router'; +} diff --git a/app/lib/config/supabase_credentials.dart b/app/lib/config/supabase_credentials.dart new file mode 100644 index 0000000..30666dc --- /dev/null +++ b/app/lib/config/supabase_credentials.dart @@ -0,0 +1,12 @@ +/// Configuration file hosting the credentials for the Supabase backend client. +/// +/// Leave both [url] and [anonKey] empty to run the application in mock fallback mode. +/// +/// To configure, create a `.env` file in the project root or set environment variables: +/// SUPABASE_URL=https://your-project.supabase.co +/// SUPABASE_ANON_KEY=your-anon-key +class SupabaseCredentials { + static const String url = 'https://ffidvfguojalzpclipzi.supabase.co'; + + static const String anonKey = 'sb_publishable_D_mmlb1jjS9Cl6QCuFwEJw_zPAb6kHS'; +} diff --git a/app/lib/core/auth/session_recovery.dart b/app/lib/core/auth/session_recovery.dart new file mode 100644 index 0000000..12ad6b9 --- /dev/null +++ b/app/lib/core/auth/session_recovery.dart @@ -0,0 +1,47 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Distinguishes a dead session from a transient failure. +/// +/// A cached JWT whose *access* token has expired is normal — GoTrue will +/// refresh it. Signing the user out because `getUser(expiredAccessToken)` +/// threw is what made reopening the app feel like a logout. +class SessionRecovery { + const SessionRecovery._(); + + /// True only for errors that mean the refresh token itself is unusable + /// (revoked, user deleted, corrupted storage). Network blips, 5xx, and + /// "JWT expired" on the access token are not permanent. + static bool isPermanentAuthFailure(Object error) { + if (error is! AuthException) return false; + final code = (error.code ?? '').toLowerCase(); + final message = error.message.toLowerCase(); + if (code == 'refresh_token_not_found' || + code == 'user_not_found' || + code == 'session_not_found' || + code == 'invalid_refresh_token') { + return true; + } + return message.contains('refresh_token_not_found') || + message.contains('invalid refresh token') || + message.contains('user not found') || + (message.contains('refresh') && message.contains('not found')); + } + + /// If a session is already expired, ask GoTrue to refresh it. + /// + /// Never signs out on network errors. Only clears local state when the + /// server says the refresh token is dead — otherwise the next launch + /// (or the next successful refresh) still has a valid session. + static Future recoverIfNeeded(GoTrueClient auth) async { + final session = auth.currentSession; + if (session == null) return; + if (!session.isExpired) return; + try { + await auth.refreshSession(); + } catch (error) { + if (isPermanentAuthFailure(error)) { + await auth.signOut(); + } + } + } +} diff --git a/app/lib/core/cache/document_cache.dart b/app/lib/core/cache/document_cache.dart new file mode 100644 index 0000000..86cb3e8 --- /dev/null +++ b/app/lib/core/cache/document_cache.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; + +/// Session-scoped LRU for downloaded document bytes. +/// +/// Re-opening a document currently re-downloads it every time. This keeps the +/// most recently viewed files in memory so a re-open is instant. Deliberately +/// memory-only: nothing secure ever touches disk, it vanishes with the +/// process, and there's no I/O that could jank the UI. Bounded by both total +/// bytes and entry count so it can never balloon. +class DocumentCache { + DocumentCache._(); + static final DocumentCache instance = DocumentCache._(); + + static const int _maxTotalBytes = 40 * 1024 * 1024; // 40 MB + static const int _maxEntries = 12; + + // LinkedHashMap iteration order = insertion order; re-inserting on access + // makes the first key the least recently used. + final _entries = {}; + int _totalBytes = 0; + + Uint8List? get(String fileId) { + final bytes = _entries.remove(fileId); + if (bytes == null) return null; + _entries[fileId] = bytes; // refresh recency + return bytes; + } + + void put(String fileId, Uint8List bytes) { + // A single file larger than the whole budget just isn't cached. + if (bytes.length > _maxTotalBytes) return; + final existing = _entries.remove(fileId); + if (existing != null) _totalBytes -= existing.length; + + _entries[fileId] = bytes; + _totalBytes += bytes.length; + + while (_totalBytes > _maxTotalBytes || _entries.length > _maxEntries) { + final oldestKey = _entries.keys.first; + _totalBytes -= _entries.remove(oldestKey)!.length; + } + } + + /// Called when a file is deleted or replaced so stale bytes can't be shown. + void evict(String fileId) { + final removed = _entries.remove(fileId); + if (removed != null) _totalBytes -= removed.length; + } + + void clear() { + _entries.clear(); + _totalBytes = 0; + } +} diff --git a/app/lib/core/constants/app_constants.dart b/app/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..d506b2f --- /dev/null +++ b/app/lib/core/constants/app_constants.dart @@ -0,0 +1,110 @@ +/// Central constants registry for NO SUS. +/// +/// Use this file instead of scattering magic strings across the codebase. +/// Prevents duplicate literal values and makes global changes a single edit. +library; + +import 'package:flutter/material.dart'; + +class AppConstants { + const AppConstants._(); + + // ─── App Identity ────────────────────────────────────────────────────────── + static const String appName = 'NO SUS'; + static const String appTagline = 'See who opened it'; + static const String appVersion = '1.4.0'; + + // ─── Avatar Colors → Asset Paths ────────────────────────────────────────── + static const String defaultAvatarColorStart = 'FF0072FF'; + static const String defaultAvatarColorEnd = 'FF00F2FE'; + + static const Map avatarColorToAsset = { + 'FF0072FF': 'assets/images/avatar_builder.png', + 'FFCCCCCC': 'assets/images/avatar_researcher.png', + 'FFFF0072': 'assets/images/avatar_creator.png', + 'FFF5A623': 'assets/images/avatar_academic.png', + 'FF800080': 'assets/images/avatar_chaos.png', + 'FFADF474': 'assets/images/avatar_archivist.png', + }; + + /// Returns the avatar asset path for a given [colorStart] hex string. + static String avatarAsset(String colorStart) => + avatarColorToAsset[colorStart] ?? avatarColorToAsset[defaultAvatarColorStart]!; + + // ─── Avatar Roles ───────────────────────────────────────────────────────── + static const Map avatarColorToRole = { + 'FF0072FF': 'SYSTEMS ARCHITECT', + 'FFCCCCCC': 'RESEARCH SCHOLAR', + 'FFFF0072': 'SECURITY SPECIALIST', + 'FFF5A623': 'COMPLIANCE AUDITOR', + 'FF800080': 'THREAT INTELLIGENCE', + 'FFADF474': 'INFORMATION TRUSTEE', + }; + + static const String defaultRole = 'SYSTEMS ARCHITECT'; + + /// Returns the persona role label for a given [colorStart] hex string. + static String avatarRole(String colorStart) => + avatarColorToRole[colorStart] ?? defaultRole; + + /// Returns the professional icon for a given [colorStart] hex string. + static IconData avatarIcon(String colorStart) { + switch (colorStart) { + case 'FF0072FF': + return Icons.dns_outlined; + case 'FFCCCCCC': + return Icons.science_outlined; + case 'FFFF0072': + return Icons.security_outlined; + case 'FFF5A623': + return Icons.gavel_outlined; + case 'FF800080': + return Icons.psychology_outlined; + case 'FFADF474': + return Icons.folder_shared_outlined; + default: + return Icons.person_outline; + } + } + + /// Returns the pixel avatar asset path for a given [avatarId]. + static String avatarAssetForId(String avatarId) { + switch (avatarId) { + case 'avatar_01': + return 'assets/images/avatar_builder.png'; + case 'avatar_02': + return 'assets/images/avatar_researcher.png'; + case 'avatar_03': + return 'assets/images/avatar_creator.png'; + case 'avatar_04': + return 'assets/images/avatar_academic.png'; + case 'avatar_05': + return 'assets/images/avatar_chaos.png'; + case 'avatar_06': + return 'assets/images/avatar_archivist.png'; + default: + return 'assets/images/avatar_builder.png'; + } + } + + // ─── Default Note Content ───────────────────────────────────────────────── + static const String defaultNoteContent = + "Welcome to NO SUS.\n\n" + "Send a sensitive document and still see who opened it.\n\n" + "1. Share a file or a one-time Burn drop from Home.\n" + "2. Send the link. Tell them the 2-digit code.\n" + "3. Watch activity when they open it.\n"; + + // ─── SharedPreferences Keys ─────────────────────────────────────────────── + static const String kOnboardingKey = 'nosus_onboarding_done'; + static const String kGuestModeKey = 'nosus_guest_mode'; + static const String kUserTypeKey = 'nosus_user_type'; + + // ─── Storage Buckets ────────────────────────────────────────────────────── + static const String storageSecureFiles = 'secure-files'; + + // ─── Audit Statuses ─────────────────────────────────────────────────────── + static const String auditSuccess = 'SUCCESS'; + static const String auditSecurity = 'SECURITY'; + static const String auditInfo = 'INFO'; +} diff --git a/app/lib/core/layout/app_breakpoints.dart b/app/lib/core/layout/app_breakpoints.dart new file mode 100644 index 0000000..e079585 --- /dev/null +++ b/app/lib/core/layout/app_breakpoints.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +/// Layout breakpoints for the Flutter shell. +/// +/// Compact is the phone/nav-bar layout. Expanded is a side rail plus a +/// wider content column — not a stretched phone screen. +abstract final class AppBreakpoints { + static const double expanded = 900; + static const double contentMaxCompact = 760; + static const double contentMaxExpanded = 1180; + + static bool isExpanded(BuildContext context) => + MediaQuery.sizeOf(context).width >= expanded; +} diff --git a/app/lib/core/layout/app_layout.dart b/app/lib/core/layout/app_layout.dart new file mode 100644 index 0000000..3f19134 --- /dev/null +++ b/app/lib/core/layout/app_layout.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +/// Responsive content widths. Phone layouts stay compact; larger screens +/// get a readable column rather than a stretched phone UI. +class AppLayout { + AppLayout._(); + + static const double phone = 560; + static const double tablet = 840; + static const double desktop = 1080; + + static double maxContentWidth(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + if (width >= 1280) return desktop; + if (width >= 840) return tablet; + return phone; + } + + static bool isWide(BuildContext context) => + MediaQuery.sizeOf(context).width >= 840; + + static bool isDesktop(BuildContext context) => + MediaQuery.sizeOf(context).width >= 1100; + + static Widget constrain( + BuildContext context, { + required Widget child, + double? maxWidth, + }) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth ?? maxContentWidth(context)), + child: child, + ), + ); + } +} diff --git a/app/lib/core/mascot/mascot_controller.dart b/app/lib/core/mascot/mascot_controller.dart new file mode 100644 index 0000000..39f5a08 --- /dev/null +++ b/app/lib/core/mascot/mascot_controller.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rive/rive.dart'; + +import 'mascot_state.dart'; + +/// Drives one mascot artboard's Rive State Machine inputs (`stateIndex`, +/// `reducedMotion`) from app-level events. [state] tracks the current mood +/// for anything that wants to observe it; `reducedMotion` is intentionally +/// kept as a plain field (not Riverpod state) since it's toggled from +/// [MascotView]'s build method and must never trigger a provider mutation +/// during a widget build. +class MascotNotifier extends Notifier { + StateMachineController? _smController; + SMINumber? _stateIndexInput; + SMIBool? _reducedMotionInput; + bool _reducedMotion = false; + + @override + MascotMood build() { + ref.onDispose(() => _smController?.dispose()); + return MascotMood.idle; + } + + /// Called from [MascotView.onInit] once the artboard has loaded. + void attach(Artboard artboard) { + final controller = StateMachineController.fromArtboard(artboard, 'SM'); + if (controller == null) return; + _smController = controller; + artboard.addController(controller); + _stateIndexInput = controller.findInput('stateIndex') as SMINumber?; + _reducedMotionInput = controller.findInput('reducedMotion') as SMIBool?; + _apply(); + } + + /// Plays a catalogued mood. One-shots are expected to be followed by a + /// call back to [MascotMood.idle] by the caller once the moment has passed + /// (Rive's `oneShotDone` trigger output can automate this once authored). + void play(MascotMood mood) { + state = mood; + _apply(); + } + + void setReducedMotion(bool value) { + if (_reducedMotion == value) return; + _reducedMotion = value; + _apply(); + } + + void _apply() { + _reducedMotionInput?.value = _reducedMotion; + _stateIndexInput?.value = + (_reducedMotion ? MascotMood.idle : state).index.toDouble(); + } +} + +final luxMascotProvider = + NotifierProvider(MascotNotifier.new); +final noxMascotProvider = + NotifierProvider(MascotNotifier.new); +final duoMascotProvider = + NotifierProvider(MascotNotifier.new); + +/// Looks up the right provider instance for a given character so call sites +/// can write `ref.read(mascotProvider(MascotCharacter.nox).notifier)` instead +/// of branching on the enum themselves. +NotifierProvider mascotProvider( + MascotCharacter character, +) { + switch (character) { + case MascotCharacter.lux: + return luxMascotProvider; + case MascotCharacter.nox: + return noxMascotProvider; + case MascotCharacter.duo: + return duoMascotProvider; + } +} diff --git a/app/lib/core/mascot/mascot_state.dart b/app/lib/core/mascot/mascot_state.dart new file mode 100644 index 0000000..e936572 --- /dev/null +++ b/app/lib/core/mascot/mascot_state.dart @@ -0,0 +1,29 @@ +/// Which mascot artboard/state-machine to drive. `duo` is reserved for the +/// scarce together-moments (session bookends) — see docs/design/MASCOT_MOTION_BIBLE.md. +enum MascotCharacter { lux, nox, duo } + +/// Union of every catalogued animation state across Lux and Nox. Not every +/// mood applies to every character — a character's .riv simply won't define +/// an animation for moods it doesn't use, and the state machine holds on +/// Idle instead. Order matters: it is the `stateIndex` sent to Rive. +enum MascotMood { + idle, + sleep, + wake, + walk, + lookAround, + think, + observe, + guide, + celebrate, + wait, + sit, + stretch, + protect, + approve, + verify, + guard, + alert, + stamp, + returnToLogo, +} diff --git a/app/lib/core/mascot/mascot_view.dart b/app/lib/core/mascot/mascot_view.dart new file mode 100644 index 0000000..a9f70cb --- /dev/null +++ b/app/lib/core/mascot/mascot_view.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rive/rive.dart' as rive; + +import 'mascot_controller.dart'; +import 'mascot_state.dart'; + +/// One load attempt per character per app run, shared by every [MascotView] +/// instance. Resolves to null when the .riv doesn't exist yet — that's the +/// designed pre-asset state, so it must NOT surface as an unhandled async +/// error (RiveAnimation.asset throws one into the root zone on every mount +/// otherwise, polluting production logs). +final _riveFileCache = >{}; + +Future _loadRiveFile(MascotCharacter character) { + return _riveFileCache.putIfAbsent(character, () async { + try { + return await rive.RiveFile.asset('assets/mascot/${character.name}.riv'); + } catch (_) { + return null; + } + }); +} + +/// Renders one mascot (Lux, Nox, or the scarce Duo bookend composition), +/// driven imperatively by [mascotProvider] — call +/// `ref.read(mascotProvider(character).notifier).play(mood)` from wherever +/// the app state change happens; this widget doesn't need to rebuild for +/// that to take effect. +/// +/// Looks up `assets/mascot/.riv` by convention (see +/// assets/mascot/README.md). Until that file exists, Rive's own +/// `placeHolder` mechanism renders instead — nothing for solo Lux/Nox, the +/// existing static logo mark for the Duo — so every call site below is safe +/// to ship today, before any real Rive asset has been authored. +class MascotView extends ConsumerWidget { + final MascotCharacter character; + final double size; + + /// Only set this when the mascot is the *sole* carrier of a state change + /// not already reflected in surrounding text/UI — per the accessibility + /// rule in docs/design/MASCOT_MOTION_BIBLE.md, that should be rare. + final String? semanticLabel; + + /// What to show before a real `.riv` asset exists for [character]. Defaults + /// to nothing for solo Lux/Nox (the mascot simply isn't visible yet) — but + /// pass the widget being *replaced* at a call site (e.g. an existing empty + /// -state icon) so today's UI doesn't lose content while waiting on real + /// mascot art. + final Widget? fallback; + + const MascotView({ + super.key, + required this.character, + this.size = 48, + this.semanticLabel, + this.fallback, + }); + + Widget get _placeholder { + if (fallback != null) return fallback!; + if (character == MascotCharacter.duo) { + return Image.asset('assets/icon/LuxandNox.png', fit: BoxFit.contain); + } + return const SizedBox.shrink(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reducedMotion = MediaQuery.of(context).disableAnimations; + final notifier = ref.read(mascotProvider(character).notifier); + notifier.setReducedMotion(reducedMotion); + + return Semantics( + excludeSemantics: semanticLabel == null, + label: semanticLabel, + child: SizedBox( + width: size, + height: size, + child: FutureBuilder( + future: _loadRiveFile(character), + builder: (context, snapshot) { + final file = snapshot.data; + if (file == null) return _placeholder; + return rive.RiveAnimation.direct( + file, + stateMachines: const ['SM'], + fit: BoxFit.contain, + onInit: (artboard) => notifier.attach(artboard), + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/core/providers/theme_provider.dart b/app/lib/core/providers/theme_provider.dart new file mode 100644 index 0000000..1d4346a --- /dev/null +++ b/app/lib/core/providers/theme_provider.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Provider exposing the pre-initialized SharedPreferences instance. +/// Overridden in the root ProviderScope at app boot. +final sharedPreferencesProvider = Provider((ref) { + throw UnimplementedError('SharedPreferences has not been pre-initialized'); +}); + +/// Global theme mode provider — replaces the `setState`-based `_themeMode` in MyApp. +/// +/// Reads SharedPreferences synchronously from the provider to prevent startup flicker. +class ThemeModeNotifier extends Notifier { + static const _key = 'nosus_theme_mode'; + + @override + ThemeMode build() { + try { + final prefs = ref.watch(sharedPreferencesProvider); + final val = prefs.getString(_key); + return val == 'light' ? ThemeMode.light : ThemeMode.dark; + } catch (_) { + return ThemeMode.dark; // Fallback default + } + } + + Future toggle() async { + final newMode = state == ThemeMode.light ? ThemeMode.dark : ThemeMode.light; + state = newMode; + try { + final prefs = ref.read(sharedPreferencesProvider); + await prefs.setString(_key, newMode == ThemeMode.light ? 'light' : 'dark'); + } catch (_) {} + } + + Future setMode(ThemeMode mode) async { + state = mode; + try { + final prefs = ref.read(sharedPreferencesProvider); + await prefs.setString(_key, mode == ThemeMode.light ? 'light' : 'dark'); + } catch (_) {} + } +} + +final themeModeProvider = NotifierProvider(ThemeModeNotifier.new); diff --git a/app/lib/core/supabase/supabase_bootstrap.dart b/app/lib/core/supabase/supabase_bootstrap.dart new file mode 100644 index 0000000..c9c993a --- /dev/null +++ b/app/lib/core/supabase/supabase_bootstrap.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../config/supabase_credentials.dart'; +import '../auth/session_recovery.dart'; + +/// Owns the one-time Supabase SDK initialization for the application. +final class SupabaseBootstrap { + const SupabaseBootstrap._(); + + static bool get isConfigured => + SupabaseCredentials.url.isNotEmpty && + SupabaseCredentials.anonKey.isNotEmpty; + + static Future initialize() async { + if (!isConfigured) { + throw StateError('Supabase credentials are not configured.'); + } + + await Supabase.initialize( + url: SupabaseCredentials.url, + publishableKey: SupabaseCredentials.anonKey, + authOptions: const FlutterAuthClientOptions( + authFlowType: AuthFlowType.pkce, + autoRefreshToken: true, + detectSessionInUri: true, + ), + ); + // A persisted session is usable immediately. If its access token is stale, + // refresh without making the first Flutter frame wait for the network. + unawaited(SessionRecovery.recoverIfNeeded(Supabase.instance.client.auth)); + } +} diff --git a/app/lib/core/supabase/supabase_providers.dart b/app/lib/core/supabase/supabase_providers.dart new file mode 100644 index 0000000..445e93b --- /dev/null +++ b/app/lib/core/supabase/supabase_providers.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../services/supabase_service.dart'; + +final supabaseClientProvider = Provider((ref) { + if (!SupabaseService.instance.isReachable) { + throw StateError('Supabase is offline'); + } + return Supabase.instance.client; +}); diff --git a/app/lib/core/utils/debug_logger.dart b/app/lib/core/utils/debug_logger.dart new file mode 100644 index 0000000..b9d2102 --- /dev/null +++ b/app/lib/core/utils/debug_logger.dart @@ -0,0 +1,7 @@ +import 'package:flutter/foundation.dart'; + +void debugLog(String message) { + if (kDebugMode) { + debugPrint(message); + } +} diff --git a/app/lib/core/utils/web_links.dart b/app/lib/core/utils/web_links.dart new file mode 100644 index 0000000..4633942 --- /dev/null +++ b/app/lib/core/utils/web_links.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; + +/// Where the Flutter web app lives in production. The marketing landing page +/// owns the bare `nosus.foo` root; the app is served from this subdomain. +/// The landing page carries a forwarding shim, so legacy `nosus.foo/#/...` +/// links (shared before the split) still resolve into the app here. +/// Override at build time for the APK, e.g. +/// --dart-define=WEB_APP_ORIGIN=https://https-shubhamsahu.github.io +const String kWebAppOrigin = String.fromEnvironment( + 'WEB_APP_ORIGIN', + defaultValue: 'http://shubham-sahu.me', +); + +/// The path prefix the Flutter web app is deployed under on [kWebAppOrigin]. +/// Empty — the app sits at the subdomain root. +/// Override at build time, e.g. --dart-define=WEB_APP_BASE_PATH=/nosus-canary +/// (leading slash, no trailing slash). +const String kWebAppBasePath = String.fromEnvironment('WEB_APP_BASE_PATH'); + +/// Origin + base path to prefix any deep link shared out of the app with. +/// +/// On web, [Uri.base] already reflects wherever this session is actually +/// running (a local dev server, a PR preview, production) so the base path +/// is derived from the current URL rather than hardcoded. On native, there +/// is no "current URL": every generated link must point at the fixed +/// production location instead, via [kWebAppOrigin]/[kWebAppBasePath]. +({String origin, String basePath}) webShareLinkBase() { + if (kIsWeb) { + final origin = Uri.base.origin; + final basePath = Uri.base.path + .replaceAll('index.html', '') + .replaceAll(RegExp(r'/$'), ''); + return (origin: origin, basePath: basePath); + } + return (origin: kWebAppOrigin, basePath: kWebAppBasePath); +} diff --git a/app/lib/features/admin/presentation/screens/admin_dashboard_screen.dart b/app/lib/features/admin/presentation/screens/admin_dashboard_screen.dart new file mode 100644 index 0000000..b9200be --- /dev/null +++ b/app/lib/features/admin/presentation/screens/admin_dashboard_screen.dart @@ -0,0 +1,754 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../../../theme.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../../config/presentation/providers/config_provider.dart'; +import '../../../../core/utils/debug_logger.dart'; + +/// The Super Admin Console for Platform Operations. +/// Accessible only to authenticated users with 'admin' or 'super_admin' roles. +class AdminDashboardScreen extends ConsumerStatefulWidget { + const AdminDashboardScreen({super.key}); + + @override + ConsumerState createState() => _AdminDashboardScreenState(); +} + +class _AdminDashboardScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + final _supabase = Supabase.instance.client; + + // State for feature flags + List> _flags = []; + // State for remote configs + List> _configs = []; + // State for system metrics + Map _metrics = { + 'total_users': 0, + 'total_files': 0, + 'total_links': 0, + 'total_views': 0, + 'storage_bytes': 0, + }; + // State for users list + List> _users = []; + String _userSearchQuery = ''; + // State for admin audit logs + List> _auditLogs = []; + + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 5, vsync: this); + _loadAllData(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _loadAllData() async { + if (!mounted) return; + setState(() => _isLoading = true); + + try { + await Future.wait([ + _loadFeatureFlags(), + _loadRemoteConfigs(), + _loadSystemMetrics(), + _loadUsers(), + _loadAuditLogs(), + ]); + } catch (e) { + _showSnackBar('Error loading admin data: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _loadFeatureFlags() async { + final data = await _supabase.from('feature_flags').select().order('flag_key'); + setState(() => _flags = List>.from(data)); + } + + Future _loadRemoteConfigs() async { + final data = await _supabase.from('remote_configs').select().order('config_key'); + setState(() => _configs = List>.from(data)); + } + + Future _loadSystemMetrics() async { + try { + final userCountRes = await _supabase.from('profiles').select('id'); + final fileCountRes = await _supabase.from('secure_files').select('id'); + final linkCountRes = await _supabase.from('share_links').select('id'); + final viewCountRes = await _supabase.from('share_link_views').select('id'); + + // Sum storage bytes + final filesData = await _supabase.from('secure_files').select('size'); + final totalSize = filesData.fold(0, (sum, f) => sum + ((f['size'] as num?)?.toInt() ?? 0)); + + setState(() { + _metrics = { + 'total_users': userCountRes.length, + 'total_files': fileCountRes.length, + 'total_links': linkCountRes.length, + 'total_views': viewCountRes.length, + 'storage_bytes': totalSize, + }; + }); + } catch (e) { + debugLog('Metrics loading error: $e'); + } + } + + + Future _loadUsers() async { + // Queries profiles and left joins their roles + var query = _supabase.from('profiles').select('id, display_name, email, created_at, user_roles(role)'); + if (_userSearchQuery.trim().isNotEmpty) { + query = query.or('display_name.ilike.%$_userSearchQuery%,email.ilike.%$_userSearchQuery%'); + } + final data = await query.limit(20); + setState(() => _users = List>.from(data)); + } + + Future _loadAuditLogs() async { + final data = await _supabase + .from('admin_audit_logs') + .select('*, profiles!admin_audit_logs_admin_id_fkey(display_name, email)') + .order('created_at', ascending: false) + .limit(30); + setState(() => _auditLogs = List>.from(data)); + } + + void _showSnackBar(String msg) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: Colors.white, + behavior: SnackBarBehavior.floating, + ), + ); + } + + Future _promptJustification(String actionName) async { + final controller = TextEditingController(); + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + final fg = Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black; + return AlertDialog( + backgroundColor: Theme.of(context).cardColor, + title: Text( + 'LOG ADMINISTRATIVE ACTION', + style: TextStyle(color: fg, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Action: $actionName\n\nAdministrative regulations require a justification for audit logs. Please specify a reason:', + style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 11, height: 1.4), + ), + const SizedBox(height: 12), + TextField( + controller: controller, + autofocus: true, + style: TextStyle(color: fg, fontSize: 12), + decoration: InputDecoration( + hintText: 'e.g. Disabling watermark for beta performance testing', + hintStyle: TextStyle(color: fg.withValues(alpha: 0.3)), + border: const OutlineInputBorder(), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + TextButton( + onPressed: () { + final reason = controller.text.trim(); + if (reason.isNotEmpty) { + Navigator.pop(context, reason); + } + }, + child: Text('SUBMIT LOG', style: TextStyle(color: fg, fontWeight: FontWeight.bold, fontSize: 11)), + ), + ], + ); + }, + ); + } + + Future _updateFlag(String flagKey, bool currentActive, bool newActive, int percentage) async { + final reason = await _promptJustification('Modify Feature Flag: $flagKey -> ${newActive ? "ENABLED" : "DISABLED"} ($percentage% rollout)'); + if (reason == null) return; + + final adminUser = ref.read(authStateProvider).value; + if (adminUser == null) return; + + setState(() => _isLoading = true); + + try { + final oldRecord = _flags.firstWhere((element) => element['flag_key'] == flagKey); + + // Update DB + await _supabase.from('feature_flags').update({ + 'is_active': newActive, + 'rollout_percentage': percentage, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }).eq('flag_key', flagKey); + + // Create Admin Audit Log + await _supabase.from('admin_audit_logs').insert({ + 'admin_id': adminUser.id, + 'action': 'TOGGLE_FEATURE_FLAG', + 'target_table': 'feature_flags', + 'target_key': flagKey, + 'previous_value': oldRecord, + 'new_value': {'is_active': newActive, 'rollout_percentage': percentage}, + 'reason': reason, + }); + + _showSnackBar('Feature flag "$flagKey" successfully updated.'); + await _loadFeatureFlags(); + await _loadAuditLogs(); + + // Refresh local cache via Riverpod invalidate + ref.invalidate(remoteConfigServiceProvider); + } catch (e) { + _showSnackBar('Update failed: $e'); + } finally { + setState(() => _isLoading = false); + } + } + + Future _updateConfig(String key, dynamic newValue) async { + final reason = await _promptJustification('Modify Config parameter: $key -> $newValue'); + if (reason == null) return; + + final adminUser = ref.read(authStateProvider).value; + if (adminUser == null) return; + + setState(() => _isLoading = true); + + try { + final oldRecord = _configs.firstWhere((element) => element['config_key'] == key); + + // Update DB + await _supabase.from('remote_configs').update({ + 'config_value': newValue, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }).eq('config_key', key); + + // Create Admin Audit Log + await _supabase.from('admin_audit_logs').insert({ + 'admin_id': adminUser.id, + 'action': 'UPDATE_REMOTE_CONFIG', + 'target_table': 'remote_configs', + 'target_key': key, + 'previous_value': oldRecord, + 'new_value': {'config_value': newValue}, + 'reason': reason, + }); + + _showSnackBar('Remote configuration "$key" successfully updated.'); + await _loadRemoteConfigs(); + await _loadAuditLogs(); + + // Refresh local cache + ref.invalidate(remoteConfigServiceProvider); + } catch (e) { + _showSnackBar('Update failed: $e'); + } finally { + setState(() => _isLoading = false); + } + } + + Future _changeUserRole(String userId, String email, String oldRole, String newRole) async { + final reason = await _promptJustification('Change user role: $email ($oldRole -> $newRole)'); + if (reason == null) return; + + final adminUser = ref.read(authStateProvider).value; + if (adminUser == null) return; + + setState(() => _isLoading = true); + + try { + // Update DB role + await _supabase.from('user_roles').upsert({ + 'user_id': userId, + 'role': newRole.toLowerCase(), + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }); + + // Log action + await _supabase.from('admin_audit_logs').insert({ + 'admin_id': adminUser.id, + 'action': 'CHANGE_USER_ROLE', + 'target_table': 'user_roles', + 'target_key': userId, + 'previous_value': {'role': oldRole}, + 'new_value': {'role': newRole}, + 'reason': reason, + }); + + _showSnackBar('Successfully elevated $email to $newRole.'); + await _loadUsers(); + await _loadAuditLogs(); + } catch (e) { + _showSnackBar('Role elevation failed: $e'); + } finally { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final fg = isDark ? Colors.white : Colors.black; + + return Scaffold( + appBar: AppBar( + title: Text( + 'PLATFORM SUPER ADMIN CONSOLE', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1.5, color: fg), + ), + backgroundColor: Colors.transparent, + elevation: 0, + actions: [ + IconButton( + icon: Icon(Icons.refresh, color: fg), + onPressed: _loadAllData, + ) + ], + bottom: TabBar( + controller: _tabController, + indicatorColor: fg, + labelColor: fg, + unselectedLabelColor: fg.withValues(alpha: 0.4), + labelStyle: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 1.0), + tabs: const [ + Tab(text: 'METRICS'), + Tab(text: 'FEATURE FLAGS'), + Tab(text: 'REMOTE CONFIG'), + Tab(text: 'USERS'), + Tab(text: 'AUDIT LOGS'), + ], + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator(strokeWidth: 1.5)) + : TabBarView( + controller: _tabController, + children: [ + _buildMetricsTab(fg), + _buildFlagsTab(fg), + _buildConfigsTab(fg), + _buildUsersTab(fg), + _buildAuditLogsTab(fg), + ], + ), + ); + } + + Widget _buildMetricsTab(Color fg) { + final double storageMB = _metrics['storage_bytes'] / (1024 * 1024); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SECURESEND SYSTEM TELEMETRY', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: fg.withValues(alpha: 0.5), letterSpacing: 1.0), + ), + const SizedBox(height: 16), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 1.3, + children: [ + _buildMetricCard('TOTAL ENROLLED USERS', '${_metrics['total_users']}', fg), + _buildMetricCard('SECURE FILES UPLOADED', '${_metrics['total_files']}', fg), + _buildMetricCard('ACTIVE SHARE LINKS', '${_metrics['total_links']}', fg), + _buildMetricCard('TOTAL SECURE VIEWS LOGGED', '${_metrics['total_views']}', fg), + ], + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TOTAL STORAGE SIZE', + style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: fg.withValues(alpha: 0.4), letterSpacing: 0.5), + ), + const SizedBox(height: 4), + Text( + '${storageMB.toStringAsFixed(2)} MB', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: fg), + ), + ], + ), + Icon(Icons.cloud_queue, size: 28, color: fg.withValues(alpha: 0.3)), + ], + ), + ), + ], + ), + ); + } + + Widget _buildMetricCard(String title, String val, Color fg) { + return Container( + padding: const EdgeInsets.all(16), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + val, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w900, color: fg), + ), + const SizedBox(height: 6), + Text( + title, + style: TextStyle(fontSize: 8.5, fontWeight: FontWeight.bold, color: fg.withValues(alpha: 0.4), letterSpacing: 0.5), + ), + ], + ), + ); + } + + Widget _buildFlagsTab(Color fg) { + return ListView.separated( + padding: const EdgeInsets.all(24), + itemCount: _flags.length, + separatorBuilder: (context, index) => const SizedBox(height: 16), + itemBuilder: (context, idx) { + final flag = _flags[idx]; + final key = flag['flag_key'] as String; + final isActive = flag['is_active'] as bool? ?? false; + final rollout = flag['rollout_percentage'] as int? ?? 100; + final desc = flag['description'] as String? ?? ''; + + return Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + key, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: fg), + ), + ), + Switch( + value: isActive, + activeTrackColor: Colors.green, + onChanged: (val) { + _updateFlag(key, isActive, val, rollout); + }, + ) + ], + ), + const SizedBox(height: 6), + Text( + desc, + style: TextStyle(fontSize: 11, color: fg.withValues(alpha: 0.6), height: 1.4), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'ROLLOUT STAGE: $rollout%', + style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: fg.withValues(alpha: 0.4)), + ), + SizedBox( + width: 140, + child: SliderTheme( + data: SliderThemeData( + overlayShape: SliderComponentShape.noOverlay, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + ), + child: Slider( + value: rollout.toDouble(), + min: 0, + max: 100, + divisions: 10, + activeColor: fg, + inactiveColor: fg.withValues(alpha: 0.15), + onChanged: (val) { + // update via state first to show slide locally, then confirm update + }, + onChangeEnd: (val) { + _updateFlag(key, isActive, isActive, val.toInt()); + }, + ), + ), + ) + ], + ) + ], + ), + ); + }, + ); + } + + Widget _buildConfigsTab(Color fg) { + return ListView.separated( + padding: const EdgeInsets.all(24), + itemCount: _configs.length, + separatorBuilder: (context, index) => const SizedBox(height: 16), + itemBuilder: (context, idx) { + final config = _configs[idx]; + final key = config['config_key'] as String; + final rawVal = config['config_value']; + final desc = config['description'] as String? ?? ''; + + return Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + key, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: fg), + ), + const SizedBox(height: 6), + Text( + desc, + style: TextStyle(fontSize: 11, color: fg.withValues(alpha: 0.6), height: 1.4), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'VALUE: ${jsonEncode(rawVal)}', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: fg), + ), + IconButton( + icon: Icon(Icons.edit, size: 16, color: fg), + onPressed: () async { + // Prompt for new value + final textController = TextEditingController(text: rawVal.toString()); + final newVal = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: Theme.of(context).cardColor, + title: const Text('EDIT CONFIG VALUE', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + content: TextField( + controller: textController, + style: TextStyle(color: fg, fontSize: 13), + decoration: const InputDecoration(border: OutlineInputBorder()), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('CANCEL')), + TextButton( + onPressed: () { + final input = textController.text.trim(); + // Attempt parsing to dynamic int / double / bool or keep string + if (input == 'true') { + Navigator.pop(context, true); + } else if (input == 'false') { + Navigator.pop(context, false); + } else if (int.tryParse(input) != null) { + Navigator.pop(context, int.parse(input)); + } else if (double.tryParse(input) != null) { + Navigator.pop(context, double.parse(input)); + } else { + Navigator.pop(context, input); + } + }, + child: const Text('SAVE'), + ), + ], + ); + }, + ); + if (newVal != null) { + _updateConfig(key, newVal); + } + }, + ) + ], + ) + ], + ), + ); + }, + ); + } + + Widget _buildUsersTab(Color fg) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: TextField( + style: TextStyle(color: fg, fontSize: 13), + decoration: InputDecoration( + hintText: 'Search display name or email...', + hintStyle: TextStyle(color: fg.withValues(alpha: 0.3)), + prefixIcon: Icon(Icons.search, size: 16, color: fg.withValues(alpha: 0.5)), + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + ), + onChanged: (val) { + setState(() => _userSearchQuery = val); + _loadUsers(); + }, + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 24), + itemCount: _users.length, + separatorBuilder: (context, index) => Divider(height: 1, color: fg.withValues(alpha: 0.08)), + itemBuilder: (context, idx) { + final user = _users[idx]; + final id = user['id'] as String; + final email = user['email'] as String? ?? 'No Email'; + final name = user['display_name'] as String? ?? 'No Name'; + final rolesData = user['user_roles'] as Map?; + final role = (rolesData?['role'] as String? ?? 'user').toUpperCase(); + + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + title: Text(name, style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: fg)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text(email, style: TextStyle(fontSize: 10, color: fg.withValues(alpha: 0.5))), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: role == 'SUPER_ADMIN' ? Colors.redAccent.withValues(alpha: 0.1) : fg.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + role, + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.bold, + color: role == 'SUPER_ADMIN' ? Colors.redAccent : fg.withValues(alpha: 0.6), + ), + ), + ) + ], + ), + trailing: PopupMenuButton( + icon: Icon(Icons.more_vert, size: 16, color: fg), + onSelected: (val) { + _changeUserRole(id, email, role, val); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'user', child: Text('DEMOTE TO USER', style: TextStyle(fontSize: 10))), + const PopupMenuItem(value: 'operator', child: Text('ELEVATE TO OPERATOR', style: TextStyle(fontSize: 10))), + const PopupMenuItem(value: 'admin', child: Text('ELEVATE TO ADMIN', style: TextStyle(fontSize: 10))), + const PopupMenuItem(value: 'super_admin', child: Text('ELEVATE TO SUPER ADMIN', style: TextStyle(fontSize: 10))), + ], + ), + ); + }, + ), + ) + ], + ); + } + + Widget _buildAuditLogsTab(Color fg) { + return ListView.separated( + padding: const EdgeInsets.all(24), + itemCount: _auditLogs.length, + separatorBuilder: (context, index) => Divider(height: 1, color: fg.withValues(alpha: 0.08)), + itemBuilder: (context, idx) { + final log = _auditLogs[idx]; + final action = log['action'] as String; + final key = log['target_key'] as String; + final profilesData = log['profiles'] as Map?; + final adminEmail = profilesData?['email'] as String? ?? 'Unknown Admin'; + final reason = log['reason'] as String; + final dateStr = DateTime.parse(log['created_at'] as String).toLocal().toString().substring(5, 16); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + action, + style: TextStyle(fontSize: 8.5, fontWeight: FontWeight.bold, color: fg), + ), + ), + Text( + dateStr, + style: TextStyle(fontSize: 9, color: fg.withValues(alpha: 0.4)), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Key: $key', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: fg), + ), + const SizedBox(height: 4), + Text( + 'Justification: "$reason"', + style: TextStyle(fontSize: 11, fontStyle: FontStyle.italic, color: fg.withValues(alpha: 0.8)), + ), + const SizedBox(height: 6), + Text( + 'By: $adminEmail', + style: TextStyle(fontSize: 9, color: fg.withValues(alpha: 0.5)), + ) + ], + ), + ); + }, + ); + } +} diff --git a/app/lib/features/analytics/data/analytics_service.dart b/app/lib/features/analytics/data/analytics_service.dart new file mode 100644 index 0000000..28563b4 --- /dev/null +++ b/app/lib/features/analytics/data/analytics_service.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../core/utils/debug_logger.dart'; +import '../../../services/supabase_service.dart'; + +/// The complete set of events this app is allowed to record. +/// +/// Mirrors the CHECK constraint in 20260730112308_analytics_events.sql — a name +/// that is not in both places is rejected by the database, on purpose. Add to +/// the migration first. +enum AnalyticsEvent { + appOpened('app_opened'), + welcomeViewed('welcome_viewed'), + guestToolOpened('guest_tool_opened'), + authWallHit('auth_wall_hit'), + signupStarted('signup_started'), + signupCompleted('signup_completed'), + signinCompleted('signin_completed'), + onboardingStarted('onboarding_started'), + onboardingSkipped('onboarding_skipped'), + onboardingCompleted('onboarding_completed'), + intentResumed('intent_resumed'), + groupCreateStarted('group_create_started'), + groupCreateCompleted('group_create_completed'), + groupJoinStarted('group_join_started'), + groupJoinCompleted('group_join_completed'), + firstDocumentUploaded('first_document_uploaded'), + firstDocumentViewed('first_document_viewed'), + burnNoteCreated('burn_note_created'), + burnFileCreated('burn_file_created'), + notificationPermissionPrompted('notification_permission_prompted'), + notificationPermissionGranted('notification_permission_granted'), + notificationPermissionDenied('notification_permission_denied'), + tourStepShown('tour_step_shown'), + tourSkipped('tour_skipped'), + helpTopicOpened('help_topic_opened'); + + final String wireName; + const AnalyticsEvent(this.wireName); +} + +/// Records activation-funnel events so friction can be measured. +/// +/// Rules this service enforces so callers cannot get them wrong: +/// +/// * **Fire and forget.** Every call returns immediately and swallows its +/// errors. Analytics must never delay a screen or surface a failure — a +/// user whose upload fails because a metric could not be written would be +/// an absurd trade. +/// * **No free-form strings.** [log] takes an enum and a small map of +/// primitives. Callers cannot pass a document name, a group name, an invite +/// code or a share token, because there is nowhere to put one — the +/// database caps and type-checks `properties` as a backstop. +/// * **Once-only events stay once-only.** [logOnce] is backed by +/// SharedPreferences, so `first_document_uploaded` means the user's first, +/// not the first this session. +/// +/// See store_listing/data_safety_answers.md — this is the only thing in the app +/// that reports usage to the server. +class AnalyticsService { + AnalyticsService._(); + static final AnalyticsService instance = AnalyticsService._(); + + static const _oncePrefix = 'nosus_analytics_once_'; + + SharedPreferences? _prefs; + String? _appVersion; + bool _versionResolved = false; + + /// Injected at boot so [logOnce] does not have to await a plugin channel on + /// every call. Safe to skip — the service just degrades to always logging. + void attachPreferences(SharedPreferences prefs) => _prefs = prefs; + + String get _platform { + if (kIsWeb) return 'web'; + return defaultTargetPlatform.name; + } + + Future _resolveVersion() async { + if (_versionResolved) return _appVersion; + _versionResolved = true; + try { + _appVersion = (await PackageInfo.fromPlatform()).version; + } catch (_) { + _appVersion = null; + } + return _appVersion; + } + + /// Records [event]. Never throws, never blocks the caller. + /// + /// [properties] must contain only primitives (String/num/bool) and should be + /// low-cardinality — "which tab", not "which file". + void log(AnalyticsEvent event, {Map properties = const {}}) { + unawaited(_send(event, properties)); + } + + /// Records [event] at most once per install. + /// + /// Returns true if it was recorded, false if it had already fired. Used for + /// the "first ever" milestones, which are meaningless if they repeat. + Future logOnce( + AnalyticsEvent event, { + Map properties = const {}, + }) async { + final prefs = _prefs ?? await _loadPrefs(); + final key = '$_oncePrefix${event.wireName}'; + try { + if (prefs?.getBool(key) == true) return false; + await prefs?.setBool(key, true); + } catch (_) { + // Could not read the marker — log anyway. An occasional duplicate is a + // better failure than losing the milestone entirely. + } + unawaited(_send(event, properties)); + return true; + } + + Future _loadPrefs() async { + try { + _prefs = await SharedPreferences.getInstance(); + } catch (_) { + return null; + } + return _prefs; + } + + Future _send( + AnalyticsEvent event, + Map properties, + ) async { + // Mock fallback mode has no backend by design; unreachable means the DNS + // pre-flight failed. Either way there is nothing to write to. + if (!SupabaseService.instance.isConfigured || + !SupabaseService.instance.isReachable) { + return; + } + + try { + final client = Supabase.instance.client; + await client.from('analytics_events').insert({ + // Explicit rather than relying on a default: the RLS policies split on + // exactly this value (authenticated must match auth.uid(); anon must be + // null), so it has to be stated. + 'user_id': client.auth.currentUser?.id, + 'event': event.wireName, + 'properties': _sanitize(properties), + 'app_version': await _resolveVersion(), + 'platform': _platform, + }); + } catch (e) { + debugLog('NO SUS: analytics "${event.wireName}" not recorded: $e'); + } + } + + /// Drops anything that is not a primitive and truncates long strings. + /// + /// Defence in depth against a future caller passing a whole object through: + /// the database constraints would reject an oversized payload, but silently + /// losing the event is worse than sending a trimmed one. + Map _sanitize(Map properties) { + final out = {}; + for (final entry in properties.entries) { + final value = entry.value; + if (value is num || value is bool) { + out[entry.key] = value; + } else if (value is String) { + out[entry.key] = value.length > 64 ? value.substring(0, 64) : value; + } + } + return out; + } +} diff --git a/app/lib/features/audit/presentation/pages/audit_tab.dart b/app/lib/features/audit/presentation/pages/audit_tab.dart new file mode 100644 index 0000000..d9278a7 --- /dev/null +++ b/app/lib/features/audit/presentation/pages/audit_tab.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../../../../theme.dart'; +import '../../../../components/shimmer_box.dart'; +import '../../../../components/async_state_view.dart'; +import '../../providers/audit_provider.dart'; +import '../../../../core/mascot/mascot_controller.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; +import '../widgets/security_alerts_banner.dart'; + +class AuditTab extends ConsumerStatefulWidget { + const AuditTab({super.key}); + + @override + ConsumerState createState() => _AuditTabState(); +} + +class _AuditTabState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + // Nox keeps watch over the ledger — an ongoing presence, not a claim + // that a cryptographic check ran (this screen doesn't call + // verify_audit_chain yet; don't imply verification that isn't there). + if (mounted) ref.read(noxMascotProvider.notifier).play(MascotMood.observe); + }); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final auditLogsAsync = ref.watch(auditLogsProvider); + + return RefreshIndicator( + key: const ValueKey('audit_tab'), + onRefresh: () async { + ref.invalidate(auditLogsProvider); + await ref.read(auditLogsProvider.future); + }, + color: theme.colorScheme.onSurface, + backgroundColor: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'SECURITY LEDGER & AUDIT LOGS', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ), + const MascotView(character: MascotCharacter.nox, size: 22), + ], + ), + const SizedBox(height: NoSusTheme.s24), + + const SecurityAlertsBanner(), + + // List of audit records + Expanded( + child: Container( + width: double.infinity, + decoration: NoSusTheme.cardDecoration(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(NoSusTheme.r16), + child: AsyncStateView>>( + value: auditLogsAsync, + onRetry: () => ref.invalidate(auditLogsProvider), + errorMessage: 'Failed to load the audit ledger', + loading: (context) => Padding( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: ShimmerListSkeleton( + spacing: NoSusTheme.s24, + itemBuilder: (context) => const _AuditLogRowSkeleton(), + ), + ), + isEmpty: (logs) => logs.isEmpty, + empty: (context) => _buildEmptyState(theme), + data: (context, auditLogs) => ListView.separated( + padding: const EdgeInsets.all(NoSusTheme.s24), + itemCount: auditLogs.length, + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + separatorBuilder: (context, index) => Divider( + color: theme.colorScheme.outline.withValues(alpha: 0.15), + height: NoSusTheme.s24, + ), + itemBuilder: (context, index) { + final log = auditLogs[index]; + + Color statusColor; + switch (log['status']) { + case 'SUCCESS': + statusColor = const Color(0xFF10B981); + break; + case 'SECURITY': + statusColor = Colors.amber; + break; + default: + statusColor = theme.colorScheme.onSurface + .withValues(alpha: 0.4); + } + + return Row( + children: [ + // Status dot + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: NoSusTheme.s16), + // Time + Text( + log['time'] ?? '', + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'Courier', + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + fontSize: 13, + ), + ), + const SizedBox(width: NoSusTheme.s24), + // Event description + Expanded( + child: Text( + log['event'] ?? '', + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 14, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.85, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), + ).animate().fadeIn(duration: 300.ms), + ), + ], + ), + ); + } + + Widget _buildEmptyState(ThemeData theme) { + final subtle = theme.colorScheme.onSurface.withValues(alpha: 0.4); + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Padding( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.history_toggle_off_outlined, size: 48, color: subtle), + const SizedBox(height: 16), + Text( + 'NO ACTIVITY YET', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 12, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 8), + Text( + 'Security and access events will appear here.', + style: TextStyle(color: subtle, fontSize: 13), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Shimmer placeholder matching one audit-log row: status dot + time + event. +class _AuditLogRowSkeleton extends StatelessWidget { + const _AuditLogRowSkeleton(); + + @override + Widget build(BuildContext context) { + return const Row( + children: [ + ShimmerBox(width: 8, height: 8, radius: 4), + SizedBox(width: NoSusTheme.s16), + ShimmerBox(width: 60, height: 13), + SizedBox(width: NoSusTheme.s24), + Expanded(child: ShimmerBox(width: double.infinity, height: 14)), + ], + ); + } +} diff --git a/app/lib/features/audit/presentation/widgets/security_alerts_banner.dart b/app/lib/features/audit/presentation/widgets/security_alerts_banner.dart new file mode 100644 index 0000000..9ceee3b --- /dev/null +++ b/app/lib/features/audit/presentation/widgets/security_alerts_banner.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../theme.dart'; +import '../../../../services/supabase_service.dart'; +import '../../providers/security_alerts_provider.dart'; + +/// Shows escalated security_alerts (tier 'high'/'critical' — see +/// recompute_user_risk in 20260710030000_risk_engine.sql) to whoever RLS +/// says can see them: group admins for their groups, super-admins for +/// everything, everyone for their own account. Renders nothing when the +/// list is empty, which is the common case for the vast majority of users — +/// this is a "surface the exception" panel, not a permanent fixture. +class SecurityAlertsBanner extends ConsumerWidget { + const SecurityAlertsBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final alertsAsync = ref.watch(securityAlertsProvider); + + return alertsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (alerts) { + if (alerts.isEmpty) return const SizedBox.shrink(); + + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.only(bottom: NoSusTheme.s24), + width: double.infinity, + padding: const EdgeInsets.all(NoSusTheme.s16), + decoration: BoxDecoration( + color: Colors.redAccent.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(NoSusTheme.r16), + border: Border.all(color: Colors.redAccent.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.warning_amber_rounded, size: 18, color: Colors.redAccent), + const SizedBox(width: NoSusTheme.s8), + Text( + 'SECURITY ALERTS (${alerts.length})', + style: theme.textTheme.labelLarge?.copyWith( + color: Colors.redAccent, + letterSpacing: 1.2, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: NoSusTheme.s12), + for (final alert in alerts) _SecurityAlertRow(alert: alert), + ], + ), + ); + }, + ); + } +} + +class _SecurityAlertRow extends ConsumerWidget { + final Map alert; + const _SecurityAlertRow({required this.alert}); + + static String _relativeTime(String? iso) { + if (iso == null) return ''; + final dt = DateTime.tryParse(iso); + if (dt == null) return ''; + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final tier = alert['tier'] as String? ?? 'high'; + final score = alert['score']?.toString() ?? '?'; + final groupId = alert['group_id'] as String?; + final relTime = _relativeTime(alert['created_at'] as String?); + final id = alert['id'] as String; + final isCritical = tier == 'critical'; + final tierColor = isCritical ? Colors.redAccent : Colors.orangeAccent; + + final scopeLabel = groupId == null + ? 'cross-group' + : 'group ${groupId.length > 8 ? groupId.substring(0, 8) : groupId}'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: NoSusTheme.s8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: tierColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + tier.toUpperCase(), + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: tierColor), + ), + ), + const SizedBox(width: NoSusTheme.s12), + Expanded( + child: Text( + 'Score $score · $scopeLabel · $relTime', + style: theme.textTheme.bodyMedium?.copyWith(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 16), + tooltip: 'Acknowledge', + onPressed: () async { + await SupabaseService.instance.acknowledgeSecurityAlert(id); + ref.invalidate(securityAlertsProvider); + }, + ), + ], + ), + ); + } +} diff --git a/app/lib/features/audit/providers/audit_provider.dart b/app/lib/features/audit/providers/audit_provider.dart new file mode 100644 index 0000000..5009211 --- /dev/null +++ b/app/lib/features/audit/providers/audit_provider.dart @@ -0,0 +1,39 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../services/audit_service.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; + +class AuditLogsNotifier extends AsyncNotifier>> { + StreamSubscription? _sub; + + @override + Future>> build() async { + final user = ref.watch(authStateProvider).value; + + _sub?.cancel(); + ref.onDispose(() => _sub?.cancel()); + + if (user == null) { + AuditService.instance.reset(); + return const []; + } + + // Re-initialize AuditService with the new user session + AuditService.instance.init(); + + _sub = AuditService.instance.watchAuditLogs().listen((data) { + state = AsyncValue.data(data); + }); + + return AuditService.instance.auditLogs; + } + + void addLog(String event, String status) { + AuditService.instance.logEvent(event, status); + } +} + +final auditLogsProvider = + AsyncNotifierProvider>>( + AuditLogsNotifier.new, + ); diff --git a/app/lib/features/audit/providers/security_alerts_provider.dart b/app/lib/features/audit/providers/security_alerts_provider.dart new file mode 100644 index 0000000..c503a7d --- /dev/null +++ b/app/lib/features/audit/providers/security_alerts_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../services/supabase_service.dart'; + +/// Unacknowledged security_alerts visible to the current user under RLS — +/// group admins see alerts for their groups, super-admins see all, everyone +/// sees their own (see 20260710030000_risk_engine.sql). Empty for the vast +/// majority of users, which is why the UI hides itself entirely when empty +/// rather than showing an empty-state card. +final securityAlertsProvider = + FutureProvider.autoDispose>>((ref) async { + return SupabaseService.instance.fetchSecurityAlerts(); +}); diff --git a/app/lib/features/auth/data/repositories/mock_auth_repository.dart b/app/lib/features/auth/data/repositories/mock_auth_repository.dart new file mode 100644 index 0000000..f5205c0 --- /dev/null +++ b/app/lib/features/auth/data/repositories/mock_auth_repository.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import '../../domain/entities/authenticated_user.dart'; +import '../../domain/repositories/auth_repository.dart'; + +class MockAuthRepository implements AuthRepository { + static AuthenticatedUser? _staticUser; + static final StreamController _authStateController = + StreamController.broadcast(); + + const MockAuthRepository(); + + @override + AuthenticatedUser? get currentUser => _staticUser; + + @override + Stream watchAuthState() async* { + yield _staticUser; + yield* _authStateController.stream; + } + + @override + Future signIn({ + required String email, + required String password, + }) async { + await Future.delayed(const Duration(milliseconds: 500)); + _staticUser = AuthenticatedUser( + id: 'me', + email: email, + ); + _authStateController.add(_staticUser); + return _staticUser!; + } + + @override + Future signUp({ + required String email, + required String password, + }) async { + await Future.delayed(const Duration(milliseconds: 500)); + _staticUser = AuthenticatedUser( + id: 'me', + email: email, + ); + _authStateController.add(_staticUser); + return _staticUser!; + } + + @override + Future signInWithGoogle() async { + await Future.delayed(const Duration(milliseconds: 800)); + _staticUser = const AuthenticatedUser( + id: 'mock-google-user', + email: 'student.google@nosus.app', + ); + _authStateController.add(_staticUser); + } + + @override + Future signInWithGitHub() async { + await Future.delayed(const Duration(milliseconds: 800)); + _staticUser = const AuthenticatedUser( + id: 'mock-github-user', + email: 'student.github@nosus.app', + ); + _authStateController.add(_staticUser); + } + + @override + Future signInWithPhone(String phone) async { + await Future.delayed(const Duration(milliseconds: 500)); + } + + @override + Future verifyPhoneOtp(String phone, String otp) async { + await Future.delayed(const Duration(milliseconds: 800)); + _staticUser = AuthenticatedUser( + id: 'mock-phone-user', + email: phone.isNotEmpty ? '$phone@nosus.app' : 'student.phone@nosus.app', + ); + _authStateController.add(_staticUser); + return _staticUser!; + } + + @override + Future requestPasswordReset(String email) async { + await Future.delayed(const Duration(milliseconds: 300)); + } + + @override + Future updatePassword(String newPassword) async { + await Future.delayed(const Duration(milliseconds: 300)); + } + + @override + Future signOut() async { + await Future.delayed(const Duration(milliseconds: 300)); + _staticUser = null; + _authStateController.add(null); + } +} diff --git a/app/lib/features/auth/data/repositories/supabase_auth_repository.dart b/app/lib/features/auth/data/repositories/supabase_auth_repository.dart new file mode 100644 index 0000000..40f3984 --- /dev/null +++ b/app/lib/features/auth/data/repositories/supabase_auth_repository.dart @@ -0,0 +1,74 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../domain/entities/authenticated_user.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../services/supabase_auth_service.dart'; +import '../../../../core/utils/debug_logger.dart'; + +class SupabaseAuthRepository implements AuthRepository { + final SupabaseAuthService _service; + + const SupabaseAuthRepository(this._service); + + @override + AuthenticatedUser? get currentUser { + final user = _service.currentUser; + return user == null ? null : _mapUser(user); + } + + @override + Stream watchAuthState() { + return _service.watchUser().handleError((error, stackTrace) { + // Log the error for diagnostic visibility + debugLog('NO SUS Auth Stream Error: $error'); + // Swallow the error to prevent authStateProvider from terminating/entering + // an AsyncValue.error state, which displays the blocking AuthGate error screen. + }).map( + (user) => user == null ? null : _mapUser(user), + ); + } + + @override + Future signIn({ + required String email, + required String password, + }) async { + return _mapUser(await _service.signIn(email: email, password: password)); + } + + @override + Future signUp({ + required String email, + required String password, + }) async { + return _mapUser(await _service.signUp(email: email, password: password)); + } + + @override + Future signInWithGoogle() => _service.signInWithGoogle(); + + @override + Future signInWithGitHub() => _service.signInWithGitHub(); + + @override + Future signInWithPhone(String phone) => _service.signInWithPhone(phone); + + @override + Future verifyPhoneOtp(String phone, String otp) async { + final user = await _service.verifyPhoneOtp(phone, otp); + return _mapUser(user); + } + + @override + Future requestPasswordReset(String email) => _service.requestPasswordReset(email); + + @override + Future updatePassword(String newPassword) => _service.updatePassword(newPassword); + + @override + Future signOut() => _service.signOut(); + + AuthenticatedUser _mapUser(User user) { + return AuthenticatedUser(id: user.id, email: user.email); + } +} diff --git a/app/lib/features/auth/data/services/supabase_auth_service.dart b/app/lib/features/auth/data/services/supabase_auth_service.dart new file mode 100644 index 0000000..f224052 --- /dev/null +++ b/app/lib/features/auth/data/services/supabase_auth_service.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../../../core/utils/debug_logger.dart'; + +class SupabaseAuthService { + final SupabaseClient _client; + + const SupabaseAuthService(this._client); + + User? get currentUser => _client.auth.currentUser; + + Stream watchUser() { + late final StreamController controller; + StreamSubscription? subscription; + + controller = StreamController( + onListen: () { + // Subscribe before emitting the cached session so an auth event that + // lands during restore cannot be missed. `currentUser` comes from the + // SDK's persistent storage and gives AuthGate an immediate answer. + subscription = _client.auth.onAuthStateChange.listen( + (state) { + debugLog( + 'NO SUS Auth State Changed: event=${state.event}, ' + 'hasSession=${state.session != null}', + ); + controller.add(state.session?.user); + }, + onError: (Object error, StackTrace stackTrace) { + // Auth refresh can fail while the device is offline. Keep the + // cached session rather than turning a transient network problem + // into a forced logout or an unhandled zone exception. + debugLog('NO SUS Auth State Error: $error'); + }, + ); + controller.add(currentUser); + }, + onCancel: () => subscription?.cancel(), + ); + + return controller.stream; + } + + Future signIn({required String email, required String password}) async { + try { + final response = await _client.auth.signInWithPassword( + email: email.trim(), + password: password, + ); + return _requireUser(response); + } on AuthException catch (e) { + if (e.message.contains('Invalid login credentials')) { + throw const AuthException( + 'You are not a registered user, try registration instead, or check your password.', + ); + } + rethrow; + } + } + + Future signUp({required String email, required String password}) async { + final response = await _client.auth.signUp( + email: email.trim(), + password: password, + ); + return _requireUser(response); + } + + Future signInWithGoogle() async { + await _client.auth.signInWithOAuth( + OAuthProvider.google, + redirectTo: kIsWeb ? null : 'io.supabase.nosus://login-callback', + authScreenLaunchMode: kIsWeb + ? LaunchMode.platformDefault + : LaunchMode.externalApplication, + ); + } + + Future signInWithGitHub() async { + await _client.auth.signInWithOAuth( + OAuthProvider.github, + redirectTo: kIsWeb ? null : 'io.supabase.nosus://login-callback', + authScreenLaunchMode: kIsWeb + ? LaunchMode.platformDefault + : LaunchMode.externalApplication, + ); + } + + Future signInWithPhone(String phone) async { + await _client.auth.signInWithOtp(phone: phone.trim()); + } + + Future verifyPhoneOtp(String phone, String otp) async { + final response = await _client.auth.verifyOTP( + phone: phone.trim(), + token: otp.trim(), + type: OtpType.sms, + ); + return _requireUser(response); + } + + Future requestPasswordReset(String email) async { + await _client.auth.resetPasswordForEmail( + email.trim(), + redirectTo: kIsWeb ? null : 'io.supabase.nosus://login-callback', + ); + } + + Future updatePassword(String newPassword) async { + await _client.auth.updateUser(UserAttributes(password: newPassword)); + } + + Future signOut() => _client.auth.signOut(); + + User _requireUser(AuthResponse response) { + final user = response.user; + if (user == null) { + throw StateError('Supabase authentication returned no user.'); + } + return user; + } +} diff --git a/app/lib/features/auth/domain/entities/authenticated_user.dart b/app/lib/features/auth/domain/entities/authenticated_user.dart new file mode 100644 index 0000000..5079d86 --- /dev/null +++ b/app/lib/features/auth/domain/entities/authenticated_user.dart @@ -0,0 +1,6 @@ +class AuthenticatedUser { + final String id; + final String? email; + + const AuthenticatedUser({required this.id, required this.email}); +} diff --git a/app/lib/features/auth/domain/repositories/auth_repository.dart b/app/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..9f7872c --- /dev/null +++ b/app/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,33 @@ +import '../entities/authenticated_user.dart'; + +abstract interface class AuthRepository { + AuthenticatedUser? get currentUser; + + Stream watchAuthState(); + + Future signIn({ + required String email, + required String password, + }); + + Future signUp({ + required String email, + required String password, + }); + + Future signInWithGoogle(); + Future signInWithGitHub(); + Future signInWithPhone(String phone); + Future verifyPhoneOtp(String phone, String otp); + + /// Sends a password-reset email. Never throws for "no such account" — the + /// caller should show the same generic confirmation either way so this + /// can't be used to enumerate registered emails. + Future requestPasswordReset(String email); + + /// Sets a new password. Only valid while the user holds a temporary + /// recovery session (see [AuthGate]'s password-recovery gate). + Future updatePassword(String newPassword); + + Future signOut(); +} diff --git a/app/lib/features/auth/domain/usecases/sign_in_use_case.dart b/app/lib/features/auth/domain/usecases/sign_in_use_case.dart new file mode 100644 index 0000000..532226e --- /dev/null +++ b/app/lib/features/auth/domain/usecases/sign_in_use_case.dart @@ -0,0 +1,31 @@ +import '../repositories/auth_repository.dart'; +import '../entities/authenticated_user.dart'; + +class SignInUseCase { + final AuthRepository _repository; + + const SignInUseCase(this._repository); + + Future call({ + required String email, + required String password, + }) async { + return await _repository.signIn(email: email, password: password); + } + + Future signInWithGoogle() async { + return await _repository.signInWithGoogle(); + } + + Future signInWithGitHub() async { + return await _repository.signInWithGitHub(); + } + + Future signInWithPhone(String phone) async { + return await _repository.signInWithPhone(phone); + } + + Future verifyPhoneOtp(String phone, String otp) async { + return await _repository.verifyPhoneOtp(phone, otp); + } +} diff --git a/app/lib/features/auth/domain/usecases/sign_out_use_case.dart b/app/lib/features/auth/domain/usecases/sign_out_use_case.dart new file mode 100644 index 0000000..7e4f827 --- /dev/null +++ b/app/lib/features/auth/domain/usecases/sign_out_use_case.dart @@ -0,0 +1,11 @@ +import '../repositories/auth_repository.dart'; + +class SignOutUseCase { + final AuthRepository _repository; + + const SignOutUseCase(this._repository); + + Future call() async { + return await _repository.signOut(); + } +} diff --git a/app/lib/features/auth/domain/usecases/sign_up_use_case.dart b/app/lib/features/auth/domain/usecases/sign_up_use_case.dart new file mode 100644 index 0000000..768bf64 --- /dev/null +++ b/app/lib/features/auth/domain/usecases/sign_up_use_case.dart @@ -0,0 +1,15 @@ +import '../repositories/auth_repository.dart'; +import '../entities/authenticated_user.dart'; + +class SignUpUseCase { + final AuthRepository _repository; + + const SignUpUseCase(this._repository); + + Future call({ + required String email, + required String password, + }) async { + return await _repository.signUp(email: email, password: password); + } +} diff --git a/app/lib/features/auth/presentation/controllers/auth_controller.dart b/app/lib/features/auth/presentation/controllers/auth_controller.dart new file mode 100644 index 0000000..788ae19 --- /dev/null +++ b/app/lib/features/auth/presentation/controllers/auth_controller.dart @@ -0,0 +1,132 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/auth_providers.dart'; +import '../../../analytics/data/analytics_service.dart'; +import '../../../notifications/data/push_service.dart'; + +class AuthController extends Notifier> { + @override + AsyncValue build() { + return const AsyncValue.data(null); + } + + Future signIn(String email, String password) async { + state = const AsyncValue.loading(); + try { + await ref + .read(authRepositoryProvider) + .signIn(email: email, password: password); + AnalyticsService.instance.log(AnalyticsEvent.signinCompleted); + state = const AsyncValue.data(null); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + } + } + + Future signUp(String email, String password) async { + state = const AsyncValue.loading(); + // Logged before the call, not after: the gap between "started" and + // "completed" is exactly the drop-off this funnel exists to measure, and a + // signup that fails would otherwise be invisible. + AnalyticsService.instance.log(AnalyticsEvent.signupStarted); + try { + await ref + .read(authRepositoryProvider) + .signUp(email: email, password: password); + AnalyticsService.instance.log(AnalyticsEvent.signupCompleted); + state = const AsyncValue.data(null); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + } + } + + Future signInWithGoogle() async { + state = const AsyncValue.loading(); + try { + await ref.read(authRepositoryProvider).signInWithGoogle(); + AnalyticsService.instance.log(AnalyticsEvent.signinCompleted); + state = const AsyncValue.data(null); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + } + } + + Future signInWithGitHub() async { + state = const AsyncValue.loading(); + try { + await ref.read(authRepositoryProvider).signInWithGitHub(); + AnalyticsService.instance.log(AnalyticsEvent.signinCompleted); + state = const AsyncValue.data(null); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + } + } + + Future signInWithPhone(String phone) async { + state = const AsyncValue.loading(); + try { + await ref.read(authRepositoryProvider).signInWithPhone(phone); + state = const AsyncValue.data(null); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + } + } + + Future verifyPhoneOtp(String phone, String otp) async { + state = const AsyncValue.loading(); + try { + await ref.read(authRepositoryProvider).verifyPhoneOtp(phone, otp); + AnalyticsService.instance.log(AnalyticsEvent.signinCompleted); + state = const AsyncValue.data(null); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + } + } + + Future signOut() async { + state = const AsyncValue.loading(); + try { + // Before the session is torn down, not after: unregister_device_token is + // scoped by auth.uid(), so once signed out the delete matches nothing and + // this handset would keep receiving the previous account's notifications + // until someone else's registration happened to overwrite the row. + await PushService.instance.releaseCurrentDevice(); + + await ref.read(authRepositoryProvider).signOut(); + // Onboarding completion is no longer reset here. It is now recorded + // against the account that completed it (see OnboardingNotifier), so a + // different account signing in on this device still gets first-run setup, + // and this one does not repeat it — which is what the old unconditional + // reset() got wrong once the flag became durable. + state = const AsyncValue.data(null); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + } + } + + /// Always resolves to loading->data on success — callers should show the + /// same generic confirmation regardless of whether the account exists, so + /// this can't be used to enumerate registered emails. Real failures (e.g. + /// network down) still surface as an error state. + Future requestPasswordReset(String email) async { + state = const AsyncValue.loading(); + try { + await ref.read(authRepositoryProvider).requestPasswordReset(email); + state = const AsyncValue.data(null); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + } + } + + Future updatePassword(String newPassword) async { + state = const AsyncValue.loading(); + try { + await ref.read(authRepositoryProvider).updatePassword(newPassword); + state = const AsyncValue.data(null); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + } + } +} + +final authControllerProvider = + NotifierProvider>(AuthController.new); diff --git a/app/lib/features/auth/presentation/providers/auth_providers.dart b/app/lib/features/auth/presentation/providers/auth_providers.dart new file mode 100644 index 0000000..df33565 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/auth_providers.dart @@ -0,0 +1,84 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../../core/supabase/supabase_bootstrap.dart'; +import '../../../../core/supabase/supabase_providers.dart'; +import '../../../../services/supabase_service.dart'; + +import '../../data/repositories/supabase_auth_repository.dart'; +import '../../data/repositories/mock_auth_repository.dart'; +import '../../data/services/supabase_auth_service.dart'; +import '../../domain/entities/authenticated_user.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../../domain/usecases/sign_in_use_case.dart'; +import '../../domain/usecases/sign_up_use_case.dart'; +import '../../domain/usecases/sign_out_use_case.dart'; + +final supabaseAuthServiceProvider = Provider((ref) { + return SupabaseAuthService(ref.watch(supabaseClientProvider)); +}); + +final authRepositoryProvider = Provider((ref) { + if (SupabaseBootstrap.isConfigured && SupabaseService.instance.isReachable) { + return SupabaseAuthRepository(ref.watch(supabaseAuthServiceProvider)); + } else { + return const MockAuthRepository(); + } +}); + +final authStateProvider = StreamProvider((ref) { + return ref.watch(authRepositoryProvider).watchAuthState(); +}); + +/// Profile data is loaded once for the active session rather than each time +/// [AuthGate] rebuilds for a theme, risk, or auth event. An empty result means +/// onboarding is needed; it must never be treated as a reason to sign out. +final sessionProfileProvider = FutureProvider>(( + ref, +) async { + final user = await ref.watch(authStateProvider.future); + if (user == null) return {}; + return SupabaseService.instance.fetchProfile(user.id); +}); + +final signInUseCaseProvider = Provider((ref) { + return SignInUseCase(ref.watch(authRepositoryProvider)); +}); + +final signUpUseCaseProvider = Provider((ref) { + return SignUpUseCase(ref.watch(authRepositoryProvider)); +}); + +final signOutUseCaseProvider = Provider((ref) { + return SignOutUseCase(ref.watch(authRepositoryProvider)); +}); + +/// True once the user has opened a password-recovery link and is holding a +/// temporary recovery session. [AuthGate] checks this before its normal +/// signed-in check so recovery lands on "set a new password" instead of +/// straight into the app with an unchanged password. +class PasswordRecoveryNotifier extends Notifier { + @override + bool build() { + if (!SupabaseBootstrap.isConfigured || + !SupabaseService.instance.isReachable) { + return false; + } + final sub = ref.watch(supabaseClientProvider).auth.onAuthStateChange.listen( + (state) { + if (state.event == AuthChangeEvent.passwordRecovery) { + this.state = true; + } + }, + ); + ref.onDispose(sub.cancel); + return false; + } + + void clear() => state = false; +} + +final passwordRecoveryProvider = + NotifierProvider( + PasswordRecoveryNotifier.new, + ); diff --git a/app/lib/features/auth/presentation/providers/pending_intent_provider.dart b/app/lib/features/auth/presentation/providers/pending_intent_provider.dart new file mode 100644 index 0000000..3e8a31f --- /dev/null +++ b/app/lib/features/auth/presentation/providers/pending_intent_provider.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/providers/theme_provider.dart'; +import '../../../../core/utils/debug_logger.dart'; + +/// What the user was trying to do when an authentication wall interrupted them. +/// +/// Persisted to SharedPreferences rather than held in memory: sign-up can round +/// trip through an email confirmation link or an OAuth browser hand-off, either +/// of which can take the process down. An intent that does not survive that is +/// an intent that gets dropped exactly when it matters most. +enum PendingIntentKind { + /// Open a group invite landing page for [PendingIntent.payload] (invite code). + joinGroup, + + /// Open a SecureSend share for [PendingIntent.payload] (share token). + openShare, + + /// Land on the Groups tab — "I wanted to make/join a group". + browseGroups, + + /// Land on the Vault tab — "I wanted to share a document". + shareDocument; + + static PendingIntentKind? fromName(String? name) { + for (final k in PendingIntentKind.values) { + if (k.name == name) return k; + } + return null; + } +} + +class PendingIntent { + final PendingIntentKind kind; + final String? payload; + + const PendingIntent(this.kind, {this.payload}); + + Map toJson() => {'kind': kind.name, 'payload': payload}; + + static PendingIntent? fromJson(Map json) { + final kind = PendingIntentKind.fromName(json['kind'] as String?); + if (kind == null) return null; + return PendingIntent(kind, payload: json['payload'] as String?); + } + + @override + bool operator ==(Object other) => + other is PendingIntent && other.kind == kind && other.payload == payload; + + @override + int get hashCode => Object.hash(kind, payload); +} + +/// Stores the action a signed-out user tried to take, so it can be replayed +/// once they have an account instead of dumping them on Home to find it again. +/// +/// Deliberately a one-shot: [take] clears the stored value as it returns it, so +/// a resumed intent can never fire twice (e.g. a rebuild of the shell after the +/// join already happened). +class PendingIntentNotifier extends Notifier { + static const _key = 'nosus_pending_intent'; + + /// The key the pre-existing invite flow wrote. Still read (never written) so + /// an app updated mid-flow — invite saved by the old build, consumed by the + /// new one — does not silently lose the user's invite. + static const _legacyInviteKey = 'pending_invite_code'; + + @override + PendingIntent? build() { + try { + final prefs = ref.watch(sharedPreferencesProvider); + + final legacy = prefs.getString(_legacyInviteKey); + if (legacy != null && legacy.isNotEmpty) { + return PendingIntent(PendingIntentKind.joinGroup, payload: legacy); + } + + final raw = prefs.getString(_key); + if (raw == null) return null; + return PendingIntent.fromJson(jsonDecode(raw) as Map); + } catch (e) { + debugLog('NO SUS: Could not read pending intent: $e'); + return null; + } + } + + Future set(PendingIntent intent) async { + state = intent; + try { + await ref + .read(sharedPreferencesProvider) + .setString(_key, jsonEncode(intent.toJson())); + } catch (e) { + debugLog('NO SUS: Could not persist pending intent: $e'); + } + } + + /// Returns the stored intent and clears it in the same step. + PendingIntent? take() { + final current = state; + if (current == null) return null; + clear(); + return current; + } + + void clear() { + state = null; + try { + final prefs = ref.read(sharedPreferencesProvider); + prefs.remove(_key); + prefs.remove(_legacyInviteKey); + } catch (_) {} + } +} + +final pendingIntentProvider = + NotifierProvider( + PendingIntentNotifier.new, + ); diff --git a/app/lib/features/auth/presentation/providers/risk_state_provider.dart b/app/lib/features/auth/presentation/providers/risk_state_provider.dart new file mode 100644 index 0000000..b0732e1 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/risk_state_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../../core/supabase/supabase_providers.dart'; +import '../../../../services/supabase_service.dart'; +import '../../../../services/risk_engine_service.dart'; +import 'auth_providers.dart'; + +/// Live risk state for the current user (score/tier/watermark +/// intensity/lock flags), recomputed server-side by +/// recompute_user_risk() every time a scored security event lands in +/// audit_logs or device_integrity_events — see +/// supabase/migrations/20260710030000_risk_engine.sql. Streams via +/// Supabase Realtime so a mid-session escalation (e.g. repeated +/// screenshot attempts) reaches [AuthGate] without waiting for a restart. +final riskStateProvider = StreamProvider((ref) { + final user = ref.watch(authStateProvider).value; + if (user == null) return Stream.value(RiskState.low); + return RiskEngineService.instance.watch(user.id); +}); + +/// Clears a require_reauth/session_locked gate the moment the user +/// completes a genuine interactive sign-in — not on a restored session at +/// cold start, which is why this listens for [AuthChangeEvent.signedIn] +/// specifically rather than watching [authStateProvider] (whose +/// AuthenticatedUser abstraction doesn't carry the event type). Mirrors +/// [PasswordRecoveryNotifier]'s listener pattern in auth_providers.dart. +/// +/// Without this, a locked user who signs back out and back in would see +/// the exact same lock screen again forever — recompute_user_risk() only +/// re-runs on a NEW security event, so the flags never clear on their own. +final riskReauthAcknowledgerProvider = Provider((ref) { + // Mock fallback mode: supabaseClientProvider throws by design (no backend + // to hold a real session), and there's no server-side risk state to + // acknowledge against anyway. + if (!SupabaseService.instance.isConfigured || !SupabaseService.instance.isReachable) { + return; + } + final sub = ref + .watch(supabaseClientProvider) + .auth + .onAuthStateChange + .listen((state) { + if (state.event == AuthChangeEvent.signedIn) { + RiskEngineService.instance.acknowledgeReauth(); + } + }); + ref.onDispose(sub.cancel); +}); diff --git a/app/lib/features/auth/presentation/screens/auth_screen.dart b/app/lib/features/auth/presentation/screens/auth_screen.dart new file mode 100644 index 0000000..ec31a88 --- /dev/null +++ b/app/lib/features/auth/presentation/screens/auth_screen.dart @@ -0,0 +1,866 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../theme.dart'; +import '../../../onboarding/presentation/screens/welcome_screen.dart'; +import '../controllers/auth_controller.dart'; +import '../providers/auth_providers.dart'; +import '../providers/pending_intent_provider.dart'; + + +class AuthScreen extends ConsumerStatefulWidget { + final String? pendingInviteCode; + + /// Open on the sign-up form rather than sign-in. + /// + /// Someone arriving from "Create a free account" has already said which one + /// they want; making them find the toggle is a needless tap on the single + /// highest-drop-off screen in the product. + final bool startOnSignUp; + + const AuthScreen({ + super.key, + this.pendingInviteCode, + this.startOnSignUp = false, + }); + + @override + ConsumerState createState() => _AuthScreenState(); +} + +class _AuthScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _phoneController = TextEditingController(); + final _otpController = TextEditingController(); + final _emailFocus = FocusNode(); + final _passwordFocus = FocusNode(); + final _phoneFocus = FocusNode(); + final _otpFocus = FocusNode(); + + late bool _isSignUp = widget.startOnSignUp; + bool _obscurePassword = true; + bool _isPhoneAuth = false; + bool _otpSent = false; + int _resendCooldown = 0; + Timer? _resendTimer; + + @override + void initState() { + super.initState(); + if (widget.pendingInviteCode != null) { + _savePendingInvite(widget.pendingInviteCode!); + } + } + + void _startResendTimer() { + setState(() { + _resendCooldown = 60; + }); + _resendTimer?.cancel(); + _resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_resendCooldown == 0) { + timer.cancel(); + } else { + setState(() { + _resendCooldown--; + }); + } + }); + } + + /// Park the invite so it survives the round trip through email confirmation + /// or an OAuth browser hand-off — either of which can take the process down — + /// and gets replayed once there is a session. See [PendingIntentNotifier]. + void _savePendingInvite(String code) { + ref + .read(pendingIntentProvider.notifier) + .set(PendingIntent(PendingIntentKind.joinGroup, payload: code)); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _phoneController.dispose(); + _otpController.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + _phoneFocus.dispose(); + _otpFocus.dispose(); + _resendTimer?.cancel(); + super.dispose(); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + HapticFeedback.mediumImpact(); + + if (_isPhoneAuth) { + final phone = _phoneController.text.trim(); + if (!_otpSent) { + if (phone.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: Colors.redAccent, + content: Text('Phone number is required.'), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + if (!phone.startsWith('+')) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: Colors.redAccent, + content: Text('Phone number must start with country code (e.g. +1 or +91).'), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + if (phone.length < 8) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: Colors.redAccent, + content: Text('Please enter a valid phone number.'), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + ref.read(authControllerProvider.notifier).signInWithPhone(phone).then((_) { + if (mounted) { + final controllerState = ref.read(authControllerProvider); + if (!controllerState.hasError) { + setState(() => _otpSent = true); + _startResendTimer(); + } + } + }); + } else { + final otp = _otpController.text.trim(); + ref.read(authControllerProvider.notifier).verifyPhoneOtp(phone, otp); + } + } else { + final email = _emailController.text.trim(); + final password = _passwordController.text; + + if (_isSignUp) { + ref.read(authControllerProvider.notifier).signUp(email, password); + } else { + ref.read(authControllerProvider.notifier).signIn(email, password); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark ? NoSusTheme.dTextSecondary : NoSusTheme.lTextSecondary; + + final authState = ref.watch(authControllerProvider); + + ref.listen>(authControllerProvider, (previous, next) { + if (next is AsyncData && previous is AsyncLoading) { + if (_isSignUp) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: Color(0xFF10B981), + content: Text( + 'Registration successful! Please check your email for a confirmation link.', + style: TextStyle(color: Colors.white), + ), + ), + ); + if (mounted) { + setState(() { + _isSignUp = false; + _passwordController.clear(); + }); + } + } else if (ref.read(authRepositoryProvider).currentUser != null && + Navigator.of(context).canPop()) { + // This screen is only swapped out automatically when AuthGate is the + // one rendering it. When it was *pushed* — from the group-invite + // landing page, or from the signed-out welcome surface — a successful + // sign-in left the user staring at the form they just completed, with + // no way forward but the system back button. Pop so the route + // underneath (which is what they were trying to reach) comes back. + // + // Gated on a real session because AsyncData also lands after + // signInWithPhone merely *sends* an OTP — popping there would abandon + // the half-finished flow. + Navigator.of(context).pop(); + } + } + next.whenOrNull( + error: (error, _) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.redAccent, + content: Text( + error.toString().replaceAll('Exception: ', '').replaceAll('StateError: ', '').replaceAll('AuthException: ', ''), + style: const TextStyle(color: Colors.white), + ), + ), + ); + }, + ); + }); + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + physics: NoSusTheme.getScrollPhysics(context), + padding: const EdgeInsets.all(NoSusTheme.s24), + // Sign-in card width on desktop/web instead of a full-bleed form. + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo / Branding Header + Center( + child: Column( + children: [ + Text( + 'NO SUS', + style: theme.textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: NoSusTheme.s8), + Text( + AppConstants.appTagline, + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 10, + color: fg.withValues(alpha: 0.5), + letterSpacing: 1.5, + ), + ), + ], + ), + ).animate().fadeIn(duration: 400.ms).slideY(begin: -0.1, end: 0), + const SizedBox(height: NoSusTheme.s48), + + // Auth Card Container + Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isPhoneAuth + ? (_otpSent ? 'VERIFY OTP' : 'PHONE AUTHENTICATION') + : (_isSignUp ? 'CREATE ACCOUNT' : 'AUTHENTICATE'), + style: TextStyle( + color: subtle, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: NoSusTheme.s16), + + if (_isPhoneAuth) ...[ + if (!_otpSent) ...[ + // Phone Number Field + TextFormField( + controller: _phoneController, + focusNode: _phoneFocus, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => authState.isLoading ? null : _submit(), + cursorColor: fg, + style: TextStyle(color: fg, fontSize: 14), + decoration: InputDecoration( + labelText: 'PHONE NUMBER', + hintText: '+1234567890', + hintStyle: TextStyle(color: fg.withValues(alpha: 0.3)), + labelStyle: TextStyle(color: subtle, fontSize: 10, letterSpacing: 1.0), + floatingLabelStyle: TextStyle(color: fg, fontSize: 11, letterSpacing: 1.0), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg.withValues(alpha: 0.15)), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Phone number is required'; + } + return null; + }, + ), + const SizedBox(height: NoSusTheme.s32), + // Send OTP Button + Semantics( + button: true, + enabled: !authState.isLoading, + label: 'Send OTP', + child: GestureDetector( + onTap: authState.isLoading ? null : _submit, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: authState.isLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.black : Colors.white, + ), + ) + : Text( + 'SEND OTP', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ), + ] else ...[ + // Disabled Phone Number field + TextFormField( + controller: _phoneController, + enabled: false, + style: TextStyle(color: fg.withValues(alpha: 0.5), fontSize: 14), + decoration: InputDecoration( + labelText: 'PHONE NUMBER', + labelStyle: TextStyle(color: subtle, fontSize: 10, letterSpacing: 1.0), + floatingLabelStyle: TextStyle(color: fg.withValues(alpha: 0.5), fontSize: 11, letterSpacing: 1.0), + disabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg.withValues(alpha: 0.15)), + ), + ), + ), + const SizedBox(height: NoSusTheme.s16), + // OTP Verification Field + TextFormField( + controller: _otpController, + focusNode: _otpFocus, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => authState.isLoading ? null : _submit(), + cursorColor: fg, + style: TextStyle(color: fg, fontSize: 14), + decoration: InputDecoration( + labelText: 'VERIFICATION CODE', + labelStyle: TextStyle(color: subtle, fontSize: 10, letterSpacing: 1.0), + floatingLabelStyle: TextStyle(color: fg, fontSize: 11, letterSpacing: 1.0), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg.withValues(alpha: 0.15)), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Verification code is required'; + } + return null; + }, + ), + const SizedBox(height: NoSusTheme.s32), + // Verify OTP Button + Semantics( + button: true, + enabled: !authState.isLoading, + label: 'Verify OTP', + child: GestureDetector( + onTap: authState.isLoading ? null : _submit, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: authState.isLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.black : Colors.white, + ), + ) + : Text( + 'VERIFY OTP', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Center( + child: TextButton( + onPressed: _resendCooldown > 0 + ? null + : () { + final phone = _phoneController.text.trim(); + ref.read(authControllerProvider.notifier).signInWithPhone(phone); + _startResendTimer(); + }, + child: Text( + _resendCooldown > 0 + ? 'RESEND CODE IN ${_resendCooldown}S' + : 'RESEND CODE', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: _resendCooldown > 0 + ? fg.withValues(alpha: 0.3) + : fg.withValues(alpha: 0.6), + ), + ), + ), + ), + Center( + child: TextButton( + onPressed: () => setState(() { + _otpSent = false; + _resendCooldown = 0; + _resendTimer?.cancel(); + }), + child: Text( + 'CHANGE NUMBER', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: fg.withValues(alpha: 0.6), + ), + ), + ), + ), + ], + const SizedBox(height: 12), + Center( + child: TextButton( + onPressed: () => setState(() { + _isPhoneAuth = false; + _otpSent = false; + }), + child: Text( + 'USE EMAIL / PASSWORD', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: fg.withValues(alpha: 0.6), + ), + ), + ), + ), + ] else ...[ + // Email Field + TextFormField( + controller: _emailController, + focusNode: _emailFocus, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _passwordFocus.requestFocus(), + cursorColor: fg, + style: TextStyle(color: fg, fontSize: 14), + decoration: InputDecoration( + labelText: 'EMAIL ADDRESS', + labelStyle: TextStyle(color: subtle, fontSize: 10, letterSpacing: 1.0), + floatingLabelStyle: TextStyle(color: fg, fontSize: 11, letterSpacing: 1.0), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg.withValues(alpha: 0.15)), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Email is required'; + } + if (!value.contains('@')) { + return 'Invalid email format'; + } + return null; + }, + ), + const SizedBox(height: NoSusTheme.s16), + + // Password Field + TextFormField( + controller: _passwordController, + focusNode: _passwordFocus, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => authState.isLoading ? null : _submit(), + cursorColor: fg, + style: TextStyle(color: fg, fontSize: 14), + decoration: InputDecoration( + labelText: 'PASSWORD', + labelStyle: TextStyle(color: subtle, fontSize: 10, letterSpacing: 1.0), + floatingLabelStyle: TextStyle(color: fg, fontSize: 11, letterSpacing: 1.0), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg.withValues(alpha: 0.15)), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg), + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + color: subtle, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password is required'; + } + if (value.length < 6) { + return 'Must be at least 6 characters'; + } + return null; + }, + ), + if (!_isSignUp) ...[ + const SizedBox(height: 6), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _showForgotPasswordDialog, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Forgot password?', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: fg.withValues(alpha: 0.6), + ), + ), + ), + ), + ], + const SizedBox(height: NoSusTheme.s32), + + // Action Button + Semantics( + button: true, + enabled: !authState.isLoading, + label: _isSignUp ? 'Register' : 'Enter workspace', + child: GestureDetector( + onTap: authState.isLoading ? null : _submit, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: authState.isLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.black : Colors.white, + ), + ) + : Text( + _isSignUp ? 'REGISTER' : 'ENTER WORKSPACE', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ), + + const SizedBox(height: NoSusTheme.s24), + Row( + children: [ + Expanded(child: Divider(color: fg.withValues(alpha: 0.1))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'OR CONTINUE WITH', + style: TextStyle( + color: subtle, + fontSize: 9, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + ), + Expanded(child: Divider(color: fg.withValues(alpha: 0.1))), + ], + ), + const SizedBox(height: NoSusTheme.s24), + + // OAuth Buttons Row + Row( + children: [ + Expanded( + child: _buildOAuthIconButton( + icon: Icons.g_mobiledata, + label: 'GOOGLE', + onPressed: () { + HapticFeedback.mediumImpact(); + ref.read(authControllerProvider.notifier).signInWithGoogle(); + }, + fg: fg, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildOAuthIconButton( + icon: Icons.code, + label: 'GITHUB', + onPressed: () { + HapticFeedback.mediumImpact(); + ref.read(authControllerProvider.notifier).signInWithGitHub(); + }, + fg: fg, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildOAuthIconButton( + icon: Icons.phone_android, + label: 'CONTINUE WITH PHONE', + onPressed: () { + setState(() { + _isPhoneAuth = true; + }); + }, + fg: fg, + ), + + ], + ], + ), + ).animate().fadeIn(delay: 200.ms, duration: 400.ms).slideY(begin: 0.05, end: 0), + const SizedBox(height: NoSusTheme.s24), + + // Switch between Sign In / Sign Up + Center( + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: fg, + ), + onPressed: () { + setState(() { + _isSignUp = !_isSignUp; + }); + }, + child: Text( + _isSignUp + ? 'ALREADY REGISTERED? SIGN IN' + : 'NEW TO WORKSPACE? SIGN UP', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + color: fg.withValues(alpha: 0.6), + ), + ), + ), + ).animate().fadeIn(delay: 350.ms, duration: 300.ms), + + // Escape hatch back to the guest surface. Only when this is + // the root route — if it was pushed, the thing underneath is + // already where "back" goes, and a second exit would be + // confusing. Without this, signing out strands a returning + // user on a form with no route to Burn Notes, Burn Files, + // code redemption or Help, all of which need no account. + if (!Navigator.of(context).canPop()) + Center( + child: TextButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const WelcomeScreen(), + ), + ), + icon: Icon( + Icons.explore_outlined, + size: 14, + color: fg.withValues(alpha: 0.5), + ), + label: Text( + 'Explore without an account', + style: TextStyle( + fontSize: 12, + color: fg.withValues(alpha: 0.5), + ), + ), + ), + ).animate().fadeIn(delay: 400.ms, duration: 300.ms), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Future _showForgotPasswordDialog() async { + final emailCtrl = TextEditingController(text: _emailController.text.trim()); + bool isSending = false; + + await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + title: const Text('RESET PASSWORD', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + content: TextField( + controller: emailCtrl, + autofocus: true, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email address', + hintText: 'you@example.com', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('CANCEL', style: TextStyle(fontSize: 11, color: Colors.grey)), + ), + FilledButton( + onPressed: isSending + ? null + : () async { + final email = emailCtrl.text.trim(); + if (email.isEmpty || !email.contains('@')) return; + setDialogState(() => isSending = true); + // Same outcome shown regardless of success/failure — never + // reveal whether an account exists for this email. + try { + await ref.read(authRepositoryProvider).requestPasswordReset(email); + } catch (_) { + // intentionally swallowed, see above + } + if (dialogContext.mounted) Navigator.pop(dialogContext); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'If an account exists for that email, a reset link has been sent.', + ), + ), + ); + } + }, + child: isSending + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('SEND LINK', style: TextStyle(fontSize: 11)), + ), + ], + ), + ), + ); + } + + Widget _buildOAuthIconButton({ + required IconData icon, + required String label, + required VoidCallback? onPressed, + required Color fg, + bool enabled = true, + }) { + final displayFg = enabled ? fg : fg.withValues(alpha: 0.35); + return Semantics( + button: true, + enabled: enabled, + label: label, + child: GestureDetector( + onTap: enabled ? onPressed : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: displayFg.withValues(alpha: 0.15), width: 0.75), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: displayFg), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: displayFg, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/auth/presentation/screens/reset_password_screen.dart b/app/lib/features/auth/presentation/screens/reset_password_screen.dart new file mode 100644 index 0000000..2415a5c --- /dev/null +++ b/app/lib/features/auth/presentation/screens/reset_password_screen.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../theme.dart'; +import '../controllers/auth_controller.dart'; +import '../providers/auth_providers.dart'; + +/// Shown by [AuthGate] in place of the normal app whenever +/// [passwordRecoveryProvider] is true — the user opened a password-recovery +/// link and holds a temporary session that should only be used to set a new +/// password, never to browse the app with the old one still active. +class ResetPasswordScreen extends ConsumerStatefulWidget { + const ResetPasswordScreen({super.key}); + + @override + ConsumerState createState() => _ResetPasswordScreenState(); +} + +class _ResetPasswordScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); + bool _obscure = true; + + @override + void dispose() { + _passwordController.dispose(); + _confirmController.dispose(); + super.dispose(); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + ref.read(authControllerProvider.notifier).updatePassword(_passwordController.text); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark ? NoSusTheme.dTextSecondary : NoSusTheme.lTextSecondary; + final authState = ref.watch(authControllerProvider); + + ref.listen>(authControllerProvider, (previous, next) { + if (next is AsyncData && previous is AsyncLoading) { + ref.read(passwordRecoveryProvider.notifier).clear(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: Color(0xFF10B981), + content: Text('Password updated.', style: TextStyle(color: Colors.white)), + ), + ); + } + next.whenOrNull( + error: (error, _) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.redAccent, + content: Text( + error.toString().replaceAll('Exception: ', '').replaceAll('AuthException: ', ''), + style: const TextStyle(color: Colors.white), + ), + ), + ); + }, + ); + }); + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'SET A NEW PASSWORD', + textAlign: TextAlign.center, + style: theme.textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.w900, + fontSize: 20, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: NoSusTheme.s8), + Text( + 'Choose a new password for your account.', + textAlign: TextAlign.center, + style: TextStyle(color: subtle, fontSize: 13), + ), + const SizedBox(height: NoSusTheme.s32), + Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _passwordController, + obscureText: _obscure, + autofocus: true, + cursorColor: fg, + style: TextStyle(color: fg, fontSize: 14), + decoration: InputDecoration( + labelText: 'NEW PASSWORD', + labelStyle: TextStyle(color: subtle, fontSize: 10, letterSpacing: 1.0), + floatingLabelStyle: TextStyle(color: fg, fontSize: 11, letterSpacing: 1.0), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg.withValues(alpha: 0.15)), + ), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: fg)), + suffixIcon: IconButton( + icon: Icon( + _obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined, + size: 18, + color: subtle, + ), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Password is required'; + if (value.length < 6) return 'Must be at least 6 characters'; + return null; + }, + ), + const SizedBox(height: NoSusTheme.s16), + TextFormField( + controller: _confirmController, + obscureText: _obscure, + cursorColor: fg, + style: TextStyle(color: fg, fontSize: 14), + decoration: InputDecoration( + labelText: 'CONFIRM PASSWORD', + labelStyle: TextStyle(color: subtle, fontSize: 10, letterSpacing: 1.0), + floatingLabelStyle: TextStyle(color: fg, fontSize: 11, letterSpacing: 1.0), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: fg.withValues(alpha: 0.15)), + ), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: fg)), + ), + validator: (value) { + if (value != _passwordController.text) return 'Passwords do not match'; + return null; + }, + ), + const SizedBox(height: NoSusTheme.s32), + Semantics( + button: true, + enabled: !authState.isLoading, + label: 'Update password', + child: GestureDetector( + onTap: authState.isLoading ? null : _submit, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration(color: fg, borderRadius: BorderRadius.circular(14)), + child: Center( + child: authState.isLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.black : Colors.white, + ), + ) + : Text( + 'UPDATE PASSWORD', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/auth/presentation/widgets/auth_gate.dart b/app/lib/features/auth/presentation/widgets/auth_gate.dart new file mode 100644 index 0000000..c88cc87 --- /dev/null +++ b/app/lib/features/auth/presentation/widgets/auth_gate.dart @@ -0,0 +1,528 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:no_sus/theme.dart'; +import '../providers/auth_providers.dart'; +import '../providers/risk_state_provider.dart'; +import '../screens/auth_screen.dart'; +import '../screens/reset_password_screen.dart'; +import '../../../../services/supabase_service.dart'; +import '../../../../services/risk_engine_service.dart'; +import '../../../../services/device_integrity_service.dart'; +import '../../../onboarding/presentation/screens/get_started_screen.dart'; +import '../../../onboarding/presentation/screens/welcome_screen.dart'; +import '../../../onboarding/presentation/providers/onboarding_providers.dart'; +import '../../../../screens/splash_screen.dart'; + +class AuthGate extends ConsumerWidget { + final Widget child; + + const AuthGate({super.key, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authStateProvider); + + // ── 1. Auth state ────────────────────────────────────────────────────────── + return authState.when( + data: (user) { + // A password-recovery link grants a real (non-null) session so it + // can call updateUser — but that session must only ever be used to + // set a new password, never to browse in as the account holder. + if (ref.watch(passwordRecoveryProvider)) { + return const ResetPasswordScreen(); + } + + // Signed out. This used to render AuthScreen unconditionally, which + // made an email field the first thing anyone ever saw — no explanation + // of the product, and no way to try any part of it. WelcomeScreen + // explains what this is and hands over the features that genuinely + // need no account (Burn Notes, Burn Files, code redemption); it pushes + // AuthScreen when the user asks for it. + // + // A returning user who has already seen the pitch and signed out goes + // straight back to the form. + if (user == null) { + return ref.watch(welcomeSeenProvider) + ? const AuthScreen() + : const WelcomeScreen(); + } + + // ── 2a. Email confirmation gate ──────────────────────────────────────── + if (SupabaseService.instance.isReachable) { + final rawUser = Supabase.instance.client.auth.currentUser; + if (rawUser != null && rawUser.emailConfirmedAt == null) { + return _EmailConfirmationScreen(email: user.email ?? ''); + } + } + + // ── 2b. Risk gate (session_locked / require_reauth) ──────────────────── + // Keeps the auth-state listener that clears these flags after a + // fresh sign-in alive for the app's lifetime — see + // risk_reauth_acknowledger_provider's doc comment for why a plain + // authStateProvider watch can't detect "this was a real sign-in". + ref.watch(riskReauthAcknowledgerProvider); + final riskState = ref.watch(riskStateProvider).value ?? RiskState.low; + if (riskState.sessionLocked || riskState.requireReauth) { + return _SecurityGateScreen(critical: riskState.sessionLocked); + } + + // Fire-and-forget, deduped internally to once per app session — see + // DeviceIntegrityService.registerDeviceSeen's doc comment for why + // this can't run any earlier than "user is confirmed signed in". + DeviceIntegrityService.instance.registerDeviceSeen(); + + // ── 2c. Profile / Onboarding Gate ────────────────────────────────────── + // Mock fallback mode (no credentials configured — see + // SupabaseCredentials) has no backend to reach by design, so it must + // not trip this gate. Only a *configured* backend that's momentarily + // unreachable should block here. + if (SupabaseService.instance.isConfigured && + !SupabaseService.instance.isReachable) { + return _OfflineGateScreen( + onRetry: () async { + await SupabaseService.instance.initialize(); + ref.invalidate(authStateProvider); + }, + ); + } + + final localCompleted = ref.watch(onboardingCompletedProvider); + if (localCompleted) return child; + + final profileAsync = ref.watch(sessionProfileProvider); + return profileAsync.when( + data: (profileData) { + if (profileData['onboarding_completed'] == true) return child; + return const GetStartedScreen(); + }, + loading: () => const BrandSplash(), + // A profile request can fail while a valid persisted session is + // offline. Keep that user in the app rather than creating a login + // flicker or signing them out. + error: (_, _) => child, + ); + }, + loading: () => const BrandSplash(), + error: (error, _) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: Container( + padding: const EdgeInsets.all(NoSusTheme.s32), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center( + child: Icon( + Icons.report_gmailerrorred_outlined, + color: Colors.redAccent, + size: 40, + ), + ), + const SizedBox(height: NoSusTheme.s24), + Text( + 'AUTHENTICATION ERROR', + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge?.copyWith( + color: Colors.redAccent, + letterSpacing: 2.0, + fontSize: 11, + ), + ), + const SizedBox(height: NoSusTheme.s16), + Text( + error + .toString() + .replaceAll('Exception: ', '') + .replaceAll('AuthApiException: ', ''), + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + fontSize: 13, + height: 1.5, + ), + ), + const SizedBox(height: NoSusTheme.s32), + Semantics( + button: true, + label: 'Retry', + child: GestureDetector( + onTap: () => ref.invalidate(authStateProvider), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + 'RETRY', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +// ─── Offline Gate (no reachable Supabase host at startup) ───────────────────── + +/// Shown when [SupabaseService.isReachable] is false — replaces the old +/// unconditional dead-end block with a retry action that re-runs +/// [SupabaseService.initialize] and invalidates [authStateProvider] so +/// [AuthGate] re-evaluates the reachability check. +class _OfflineGateScreen extends StatefulWidget { + final Future Function() onRetry; + const _OfflineGateScreen({required this.onRetry}); + + @override + State<_OfflineGateScreen> createState() => _OfflineGateScreenState(); +} + +class _OfflineGateScreenState extends State<_OfflineGateScreen> { + bool _isRetrying = false; + + Future _retry() async { + if (_isRetrying) return; + setState(() => _isRetrying = true); + await widget.onRetry(); + if (mounted) setState(() => _isRetrying = false); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? NoSusTheme.dBackground : NoSusTheme.lBackground; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return Scaffold( + backgroundColor: bg, + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: Container( + padding: const EdgeInsets.all(NoSusTheme.s32), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center( + child: Icon( + Icons.wifi_off_outlined, + color: Colors.amber, + size: 40, + ), + ), + const SizedBox(height: NoSusTheme.s24), + Text( + 'NETWORK CONNECTION REQUIRED', + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge?.copyWith( + color: Colors.amber, + letterSpacing: 2.0, + fontSize: 11, + ), + ), + const SizedBox(height: NoSusTheme.s16), + Text( + 'The secure enclave needs a live connection to continue. Check your connection and try again.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + fontSize: 13, + height: 1.5, + ), + ), + const SizedBox(height: NoSusTheme.s32), + Semantics( + button: true, + enabled: !_isRetrying, + label: 'Retry', + child: GestureDetector( + onTap: _isRetrying ? null : _retry, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: _isRetrying + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.black : Colors.white, + ), + ) + : Text( + 'RETRY', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +// ─── Security Gate (session_locked / require_reauth) ────────────────────────── + +/// Shown when the risk engine (recompute_user_risk in +/// 20260710030000_risk_engine.sql) has flagged this account as 'high' or +/// 'critical' risk. [critical] distinguishes a full lock (multiple/severe +/// signals — e.g. active instrumentation detected) from a softer +/// require-reauth prompt (fewer/lower-severity signals). Either way the +/// remedy is the same: sign out and sign back in, which +/// riskReauthAcknowledgerProvider detects and clears server-side. +class _SecurityGateScreen extends ConsumerWidget { + final bool critical; + const _SecurityGateScreen({required this.critical}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? NoSusTheme.dBackground : NoSusTheme.lBackground; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return Scaffold( + backgroundColor: bg, + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + critical ? Icons.gpp_bad_outlined : Icons.security_outlined, + size: 56, + color: critical ? Colors.redAccent : Colors.orangeAccent, + ), + const SizedBox(height: 24), + Text( + critical + ? 'ACCESS TEMPORARILY RESTRICTED' + : 'SECURITY CHECK REQUIRED', + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 13, + letterSpacing: 2.0, + color: critical ? Colors.redAccent : Colors.orangeAccent, + ), + ), + const SizedBox(height: 16), + Text( + critical + ? 'We detected unusual security signals on this account (e.g. tampering or repeated screenshot attempts). Sign back in to continue — this refreshes your session and clears the flag if the risk has passed.' + : 'We noticed some security signals on this account recently. For your protection, please sign in again to continue.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + height: 1.6, + ), + ), + const SizedBox(height: 36), + SizedBox( + width: double.infinity, + child: Semantics( + button: true, + label: 'Sign out and re-verify', + child: GestureDetector( + onTap: () => Supabase.instance.client.auth.signOut(), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + 'SIGN OUT & RE-VERIFY', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ─── Email Confirmation Holding Screen ──────────────────────────────────────── + +class _EmailConfirmationScreen extends ConsumerWidget { + final String email; + const _EmailConfirmationScreen({required this.email}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? NoSusTheme.dBackground : NoSusTheme.lBackground; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return Scaffold( + backgroundColor: bg, + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Animated envelope icon + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: fg.withValues(alpha: 0.1)), + ), + child: Icon( + Icons.mark_email_unread_outlined, + size: 40, + color: fg.withValues(alpha: 0.85), + ), + ) + .animate(onPlay: (c) => c.repeat(reverse: true)) + .scale( + begin: const Offset(1, 1), + end: const Offset(1.05, 1.05), + duration: 2000.ms, + ), + const SizedBox(height: 32), + Text( + 'CONFIRM YOUR EMAIL', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 13, + letterSpacing: 2.5, + color: fg, + ), + ), + const SizedBox(height: 16), + Text( + 'We sent a confirmation link to\n$email\n\nCheck your inbox and click the link to activate your account.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + height: 1.6, + ), + ), + const SizedBox(height: 40), + // Check status button + Semantics( + button: true, + label: "I've confirmed — continue", + child: GestureDetector( + onTap: () => ref.invalidate(authStateProvider), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 32, + ), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + "I'VE CONFIRMED — CONTINUE", + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ), + ), + ), + const SizedBox(height: 20), + // Sign out and try another account + TextButton( + onPressed: () async { + await Supabase.instance.client.auth.signOut(); + ref.invalidate(authStateProvider); + }, + child: Text( + 'USE A DIFFERENT ACCOUNT', + style: TextStyle( + color: subtle, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.0, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/canary/data/canary_api.dart b/app/lib/features/canary/data/canary_api.dart new file mode 100644 index 0000000..10ca767 --- /dev/null +++ b/app/lib/features/canary/data/canary_api.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../../config/supabase_credentials.dart'; +import '../domain/canary_models.dart'; + +/// Thrown for every failed call. [message] is safe to show to the person. +class CanaryApiException implements Exception { + const CanaryApiException(this.message, {this.retryable = false}); + final String message; + final bool retryable; + + @override + String toString() => message; +} + +/// Talks to the single `canary` Supabase Edge Function. Same calling pattern +/// as RedemptionCodeClient: plain HTTPS, publishable key, no session. +class CanaryApi { + CanaryApi({http.Client? client}) : _client = client ?? http.Client(); + + final http.Client _client; + + static final Uri endpoint = Uri.parse( + '${SupabaseCredentials.url}/functions/v1/canary', + ); + + Future> _post( + Map body, { + Duration timeout = const Duration(seconds: 30), + }) async { + final http.Response response; + try { + response = await _client + .post( + endpoint, + headers: { + 'Content-Type': 'application/json', + 'apikey': SupabaseCredentials.anonKey, + }, + body: jsonEncode(body), + ) + .timeout(timeout); + } catch (_) { + throw const CanaryApiException( + 'Could not reach NO SUS. Check your connection and try again.', + retryable: true, + ); + } + + final Map decoded; + try { + decoded = jsonDecode(response.body) as Map; + } catch (_) { + throw CanaryApiException( + 'Unexpected reply from NO SUS (${response.statusCode}).', + retryable: response.statusCode >= 500, + ); + } + if (response.statusCode != 200) { + throw CanaryApiException( + decoded['error'] as String? ?? + 'Something went wrong (${response.statusCode}).', + retryable: decoded['retry'] == true || response.statusCode >= 500, + ); + } + return decoded; + } + + /// Uploads every encrypted copy and seals the note: one memo on Solana + /// devnet ([sealSolanaSig]) and, only while the server has the Monad + /// prototype switched on, one Monad testnet transaction ([sealTxHash]). + /// The server answers 200 only once at least one of them exists. + Future<({String? sealTxHash, String? sealSolanaSig, DateTime expiresAt})> + create({ + required String noteId, + required String ownerHash, + required int copyCount, + required String copiesHash, + required int expiresInHours, + required List<({int index, String ciphertext, String iv})> copies, + }) async { + final result = await _post({ + 'action': 'create', + 'note_id': noteId, + 'owner_hash': ownerHash, + 'copy_count': copyCount, + 'copies_hash': copiesHash, + 'expires_in_hours': expiresInHours, + 'copies': [ + for (final c in copies) + {'index': c.index, 'ciphertext': c.ciphertext, 'iv': c.iv}, + ], + }, timeout: const Duration(seconds: 60)); + final sealTxHash = result['seal_tx_hash'] as String?; + final sealSolanaSig = result['seal_solana_sig'] as String?; + if (sealTxHash == null && sealSolanaSig == null) { + throw const CanaryApiException( + 'The note was not sealed. Try again.', + retryable: true, + ); + } + return ( + sealTxHash: sealTxHash, + sealSolanaSig: sealSolanaSig, + expiresAt: DateTime.parse(result['expires_at'] as String), + ); + } + + /// Gives this device its own copy (the same one again on retries). + Future< + ({ + int copyIndex, + int copyCount, + String ciphertext, + String iv, + String? openTxHash, + String? openSolanaSig, + DateTime openedAt, + }) + > + open({ + required String noteId, + required String deviceId, + required String readerName, + }) async { + final r = await _post({ + 'action': 'open', + 'note_id': noteId, + 'device_id': deviceId, + 'reader_name': readerName, + }); + return ( + copyIndex: r['copy_index'] as int, + copyCount: r['copy_count'] as int, + ciphertext: r['ciphertext'] as String, + iv: r['iv'] as String, + openTxHash: r['open_tx_hash'] as String?, + openSolanaSig: r['open_solana_sig'] as String?, + openedAt: DateTime.parse(r['opened_at'] as String), + ); + } + + /// Owner-only: who opened which copy. Proven with the owner secret. + Future status({ + required String noteId, + required String ownerSecret, + }) async { + final r = await _post({ + 'action': 'status', + 'note_id': noteId, + 'owner_secret': ownerSecret, + }); + return CanaryNoteStatus( + copyCount: r['copy_count'] as int, + sealTxHash: r['seal_tx_hash'] as String?, + sealSolanaSig: r['seal_solana_sig'] as String?, + expiresAt: DateTime.parse(r['expires_at'] as String), + copies: [ + for (final c in r['copies'] as List) + CanaryCopyStatus( + copyIndex: (c as Map)['copy_index'] as int, + readerName: c['reader_name'] as String, + openedAt: DateTime.parse(c['opened_at'] as String), + openTxHash: c['open_tx_hash'] as String?, + openSolanaSig: c['open_solana_sig'] as String?, + ), + ], + ); + } +} diff --git a/app/lib/features/canary/data/canary_crypto.dart b/app/lib/features/canary/data/canary_crypto.dart new file mode 100644 index 0000000..83eb68b --- /dev/null +++ b/app/lib/features/canary/data/canary_crypto.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:encrypt/encrypt.dart' as enc; + +import '../../../services/burn_file_crypto.dart' show bytesToHex, hexToBytes; + +/// Crypto helpers for NO SUS Canary. Uses exactly the same AES setup as Burn +/// Notes (package:encrypt, AES-256, default mode, random 16-byte IV) so there +/// is no new primitive to review. +class CanaryCrypto { + const CanaryCrypto._(); + + static String randomHex(int byteCount) { + final random = Random.secure(); + return bytesToHex( + Uint8List.fromList( + List.generate(byteCount, (_) => random.nextInt(256)), + ), + ); + } + + static String sha256Hex(String text) => + sha256.convert(utf8.encode(text)).toString(); + + static ({String ciphertextB64, String ivHex}) encrypt( + String plaintext, + String keyHex, + ) { + final key = enc.Key(hexToBytes(keyHex)); + final iv = enc.IV.fromSecureRandom(16); + final encrypted = enc.Encrypter(enc.AES(key)).encrypt(plaintext, iv: iv); + return (ciphertextB64: encrypted.base64, ivHex: bytesToHex(iv.bytes)); + } + + /// Throws if the key is wrong or the data is damaged. + static String decrypt(String ciphertextB64, String ivHex, String keyHex) { + final key = enc.Key(hexToBytes(keyHex)); + final iv = enc.IV(hexToBytes(ivHex)); + return enc.Encrypter(enc.AES(key)).decrypt64(ciphertextB64, iv: iv); + } + + /// Commitment to one copy: `sha256("::")`. + static String copyDigest(String saltHex, int index, String copyText) => + sha256Hex('$saltHex:$index:$copyText'); + + /// Commitment to every copy, written on chain: sha256 of the digests + /// concatenated in copy order, as 0x-prefixed hex. + static String copiesHash(List copyDigests) => + '0x${sha256Hex(copyDigests.join())}'; +} diff --git a/app/lib/features/canary/data/canary_repository.dart b/app/lib/features/canary/data/canary_repository.dart new file mode 100644 index 0000000..698c597 --- /dev/null +++ b/app/lib/features/canary/data/canary_repository.dart @@ -0,0 +1,200 @@ +import 'package:uuid/uuid.dart'; + +import '../../../config/app_mode.dart'; +import '../../../core/utils/web_links.dart'; +import '../domain/canary_fingerprint.dart'; +import '../domain/canary_link.dart'; +import '../domain/canary_models.dart'; +import 'canary_api.dart'; +import 'canary_crypto.dart'; +import 'canary_store.dart'; + +/// The only entry point the Canary screens use (no widget talks to the API, +/// the store or the crypto helpers directly). +class CanaryRepository { + CanaryRepository(this._store, {CanaryApi? api}) : _api = api ?? CanaryApi(); + + final CanaryStore _store; + final CanaryApi _api; + + /// Characters allowed in the composer. Each copy carries an invisible + /// marker every 10 words, so a copy is about 1.75x the note in bytes; with + /// the server's per-copy cap below, English notes fit up to ~5,170 + /// characters at 100 readers. [createNote] also checks every copy exactly. + static const int maxNoteLength = 5000; + + /// Must match MAX_CIPHERTEXT in the canary edge function and the + /// canary_copies.ciphertext check (base64 of AES-CBC, about 8,990 bytes + /// of copy text). + static const int maxCiphertextLength = 12000; + static const int maxReaderNameLength = 40; + + List listNotes() => _store.loadNotes(); + + CanaryOwnerRecord? findNote(String noteId) => _store.findNote(noteId); + + /// Sender: builds every copy on this device, uploads only ciphertext, and + /// seals the note on Solana devnet (plus Monad testnet when the server has + /// the prototype on). + /// [onStage] receives short progress messages. + Future createNote({ + required String text, + required int copyCount, + required int expiresInHours, + void Function(String stage)? onStage, + }) async { + final trimmed = text.trim(); + if (trimmed.isEmpty) { + throw const CanaryUserException('Write the note first.'); + } + if (trimmed.length > maxNoteLength) { + throw const CanaryUserException( + 'Keep the note under $maxNoteLength characters.', + ); + } + final plan = buildCanaryPlan(trimmed); + final required = canaryRequiredSlots(copyCount); + if (plan.bitCount < required) { + throw CanaryUserException( + 'Canary found ${plan.bitCount} of the $required swappable words it ' + 'needs for $copyCount readers. Add a sentence or two, or choose fewer readers.', + ); + } + + onStage?.call('Making $copyCount unique copies…'); + final List> codewords; + try { + codewords = generateCanaryCodewords(copyCount, plan.bitCount); + } on StateError catch (e) { + throw CanaryUserException(e.message); + } + final noteId = const Uuid().v4(); + final keyHex = CanaryCrypto.randomHex(32); + final saltHex = CanaryCrypto.randomHex(32); + final ownerSecret = CanaryCrypto.randomHex(32); + + final encrypted = <({int index, String ciphertext, String iv})>[]; + final digests = []; + for (var i = 0; i < copyCount; i++) { + final copyText = renderCanaryCopy(plan, codewords[i], copyIndex: i); + final sealed = CanaryCrypto.encrypt(copyText, keyHex); + final over = sealed.ciphertextB64.length - maxCiphertextLength; + if (over > 0) { + // Base64 is 4 chars per 3 bytes; round up and add a small margin. + final trim = (over * 3 / 4).ceil() + 20; + throw CanaryUserException( + 'This note is a little too long for the server. Remove about ' + '$trim characters and try again.', + ); + } + encrypted.add(( + index: i, + ciphertext: sealed.ciphertextB64, + iv: sealed.ivHex, + )); + digests.add(CanaryCrypto.copyDigest(saltHex, i, copyText)); + } + + final (:origin, :basePath) = webShareLinkBase(); + final firstLine = trimmed.split('\n').first; + final draft = CanaryOwnerRecord( + noteId: noteId, + keyHex: keyHex, + ownerSecret: ownerSecret, + saltHex: saltHex, + copyCount: copyCount, + createdAt: DateTime.now(), + expiresAt: DateTime.now().add(Duration(hours: expiresInHours)), + readerLink: buildCanaryReaderLink( + origin: origin, + basePath: basePath, + noteId: noteId, + keyHex: keyHex, + ), + preview: firstLine.length > 80 + ? '${firstLine.substring(0, 77)}…' + : firstLine, + plan: plan, + codewords: codewords, + ); + // Save first so a crash after the upload never loses the only copy of + // the codewords. Removed again below if the upload fails. + await _store.saveNote(draft); + + onStage?.call( + kCanaryMonad ? 'Sealing on Monad + Solana…' : 'Sealing on Solana devnet…', + ); + try { + final result = await _api.create( + noteId: noteId, + ownerHash: CanaryCrypto.sha256Hex(ownerSecret), + copyCount: copyCount, + copiesHash: CanaryCrypto.copiesHash(digests), + expiresInHours: expiresInHours, + copies: encrypted, + ); + final sealed = draft.copyWith( + sealTxHash: result.sealTxHash, + sealSolanaSig: result.sealSolanaSig, + expiresAt: result.expiresAt, + ); + await _store.saveNote(sealed); + return sealed; + } catch (_) { + await _store.deleteNote(noteId); + rethrow; + } + } + + /// Reader: gets (or re-gets) this device's copy and decrypts it locally. + Future openCopy({ + required String noteId, + required String keyHex, + required String readerName, + }) async { + final name = readerName.trim(); + if (name.isEmpty) { + throw const CanaryUserException('Type your name first.'); + } + if (name.length > maxReaderNameLength) { + throw const CanaryUserException( + 'Use a shorter name ($maxReaderNameLength characters max).', + ); + } + final deviceId = await _store.deviceId(); + final result = await _api.open( + noteId: noteId, + deviceId: deviceId, + readerName: name, + ); + final String text; + try { + text = CanaryCrypto.decrypt(result.ciphertext, result.iv, keyHex); + } catch (_) { + throw const CanaryUserException( + 'This link is incomplete or damaged. Ask for the link again.', + ); + } + await _store.rememberReaderName(name); + return CanaryOpenedCopy( + text: text, + copyIndex: result.copyIndex, + copyCount: result.copyCount, + readerName: name, + openedAt: result.openedAt, + openTxHash: result.openTxHash, + openSolanaSig: result.openSolanaSig, + ); + } + + String? lastReaderName() => _store.lastReaderName(); + + Future fetchStatus(CanaryOwnerRecord record) => + _api.status(noteId: record.noteId, ownerSecret: record.ownerSecret); + + /// Runs entirely on this device; the leaked text is never uploaded. + CanaryMatch checkLeak(CanaryOwnerRecord record, String leakText) => + matchCanaryLeak(leakText, record.plan, record.codewords); + + Future forgetNote(String noteId) => _store.deleteNote(noteId); +} diff --git a/app/lib/features/canary/data/canary_store.dart b/app/lib/features/canary/data/canary_store.dart new file mode 100644 index 0000000..b0a4b9f --- /dev/null +++ b/app/lib/features/canary/data/canary_store.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import '../domain/canary_models.dart'; + +/// Local-only storage for Canary (SharedPreferences = localStorage on web). +class CanaryStore { + CanaryStore(this._prefs); + + final SharedPreferences _prefs; + + static const _notesKey = 'nosus_canary_notes_v1'; + static const _deviceKey = 'nosus_canary_device_id_v1'; + static const _readerNameKey = 'nosus_canary_reader_name_v1'; + + /// Newest first. Entries that cannot be read are skipped, never thrown. + List loadNotes() { + final raw = _prefs.getString(_notesKey); + if (raw == null) return []; + final List list; + try { + list = jsonDecode(raw) as List; + } catch (_) { + return []; + } + final records = []; + for (final entry in list) { + try { + records.add( + CanaryOwnerRecord.fromJson((entry as Map).cast()), + ); + } catch (_) { + // Skip a damaged entry rather than losing every note. + } + } + records.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return records; + } + + CanaryOwnerRecord? findNote(String noteId) { + for (final r in loadNotes()) { + if (r.noteId == noteId) return r; + } + return null; + } + + Future saveNote(CanaryOwnerRecord record) async { + final others = loadNotes().where((r) => r.noteId != record.noteId); + final all = [record, ...others]; + await _prefs.setString( + _notesKey, + jsonEncode([for (final r in all) r.toJson()]), + ); + } + + Future deleteNote(String noteId) async { + final remaining = loadNotes().where((r) => r.noteId != noteId).toList(); + await _prefs.setString( + _notesKey, + jsonEncode([for (final r in remaining) r.toJson()]), + ); + } + + /// Random id for this browser/app install. Lets a reader reopen the same + /// copy instead of consuming a new one. + Future deviceId() async { + final existing = _prefs.getString(_deviceKey); + if (existing != null && existing.length == 36) return existing; + final created = const Uuid().v4(); + await _prefs.setString(_deviceKey, created); + return created; + } + + String? lastReaderName() => _prefs.getString(_readerNameKey); + + Future rememberReaderName(String name) => + _prefs.setString(_readerNameKey, name); +} diff --git a/app/lib/features/canary/domain/canary_fingerprint.dart b/app/lib/features/canary/domain/canary_fingerprint.dart new file mode 100644 index 0000000..1ed20db --- /dev/null +++ b/app/lib/features/canary/domain/canary_fingerprint.dart @@ -0,0 +1,639 @@ +// NO SUS Canary — fingerprint engine. +// +// Pure Dart (no Flutter imports) so it runs in unit tests and on every +// platform. It turns one note into many copies that differ only in small, +// meaning-preserving word choices ("slots"), and later works out which copy a +// leaked text came from. +// +// Vocabulary used everywhere in this feature: +// * slot — one word in the original note that has a safe alternate. +// * codeword — one 0/1 choice per slot. 0 keeps the original word, 1 uses the +// alternate. Every copy gets a different codeword. +// * copy — the note rendered with one codeword (+ an invisible marker). +// +// The all-zero codeword is the sender's original text and is never given to a +// reader, so a leak of the sender's own original is reported as such. + +import 'dart:math'; + +/// Single-word key (lowercase, straight apostrophe) -> alternate wording. +/// Only pairs that stay correct in (almost) every sentence are listed. +/// Do not add pairs whose meaning or grammar changes with context +/// (e.g. soon/shortly, help/assist, start/begin, happy/glad). +const Map kCanaryAlternates = { + // Contractions -> expanded forms. + "don't": 'do not', + "doesn't": 'does not', + "didn't": 'did not', + "can't": 'cannot', + 'cannot': "can't", + "won't": 'will not', + "isn't": 'is not', + "aren't": 'are not', + "wasn't": 'was not', + "weren't": 'were not', + "haven't": 'have not', + "hasn't": 'has not', + "hadn't": 'had not', + "shouldn't": 'should not', + "wouldn't": 'would not', + "couldn't": 'could not', + "it's": 'it is', + "that's": 'that is', + "there's": 'there is', + "what's": 'what is', + "here's": 'here is', + "i'm": 'i am', + "you're": 'you are', + "we're": 'we are', + "they're": 'they are', + "i'll": 'i will', + "we'll": 'we will', + "you'll": 'you will', + "they'll": 'they will', + "i've": 'i have', + "we've": 'we have', + "you've": 'you have', + "they've": 'they have', + "let's": 'let us', + // Number words -> digits and back ("one" is excluded on purpose: "no one"). + 'two': '2', 'three': '3', 'four': '4', 'five': '5', 'six': '6', + 'seven': '7', 'eight': '8', 'nine': '9', 'ten': '10', + '2': 'two', '3': 'three', '4': 'four', '5': 'five', '6': 'six', + '7': 'seven', '8': 'eight', '9': 'nine', '10': 'ten', + // Everyday synonyms that are safe in any position. + 'okay': 'ok', + 'ok': 'okay', + 'hi': 'hello', + 'hello': 'hi', + 'hey': 'hi', + 'please': 'kindly', + 'kindly': 'please', + 'maybe': 'perhaps', + 'perhaps': 'maybe', + 'till': 'until', + 'until': 'till', + 'almost': 'nearly', + 'nearly': 'almost', + 'buy': 'purchase', + 'purchase': 'buy', + 'everyone': 'everybody', + 'everybody': 'everyone', + 'someone': 'somebody', + 'somebody': 'someone', + 'anyone': 'anybody', + 'anybody': 'anyone', + 'nobody': 'no one', + 'toward': 'towards', + 'towards': 'toward', + 'afterward': 'afterwards', + 'afterwards': 'afterward', + 'among': 'amongst', + 'amongst': 'among', + 'whilst': 'while', + 'learned': 'learnt', + 'learnt': 'learned', + 'spelled': 'spelt', + 'spelt': 'spelled', + 'burned': 'burnt', + 'burnt': 'burned', + 'email': 'e-mail', + 'gray': 'grey', + 'grey': 'gray', + // US <-> UK spellings (both are normal in Indian English). + 'organize': 'organise', 'organise': 'organize', + 'organized': 'organised', 'organised': 'organized', + 'organizing': 'organising', 'organising': 'organizing', + 'organization': 'organisation', 'organisation': 'organization', + 'realize': 'realise', 'realise': 'realize', + 'realized': 'realised', 'realised': 'realized', + 'recognize': 'recognise', 'recognise': 'recognize', + 'apologize': 'apologise', 'apologise': 'apologize', + 'prioritize': 'prioritise', 'prioritise': 'prioritize', + 'finalize': 'finalise', 'finalise': 'finalize', + 'finalized': 'finalised', 'finalised': 'finalized', + 'summarize': 'summarise', 'summarise': 'summarize', + 'analyze': 'analyse', 'analyse': 'analyze', + 'color': 'colour', 'colour': 'color', + 'favorite': 'favourite', 'favourite': 'favorite', + 'behavior': 'behaviour', 'behaviour': 'behavior', + 'center': 'centre', 'centre': 'center', + 'canceled': 'cancelled', 'cancelled': 'canceled', + 'traveling': 'travelling', 'travelling': 'traveling', + 'traveled': 'travelled', 'travelled': 'traveled', + 'labeled': 'labelled', 'labelled': 'labeled', + 'modeling': 'modelling', 'modelling': 'modeling', + 'enroll': 'enrol', 'enrol': 'enroll', + 'fulfill': 'fulfil', 'fulfil': 'fulfill', + 'judgment': 'judgement', 'judgement': 'judgment', + 'acknowledgment': 'acknowledgement', + 'acknowledgement': 'acknowledgment', + 'catalog': 'catalogue', 'catalogue': 'catalog', + 'defense': 'defence', 'defence': 'defense', + 'offense': 'offence', 'offence': 'offense', +}; + +/// Words that must not precede "please"/"kindly" (verb use: "to please"). +const Set _kPleaseVerbGuards = { + 'to', + 'will', + 'would', + 'can', + 'could', + 'should', + 'might', + 'must', + 'not', +}; + +/// Words after an "'s" contraction where "is" would be wrong ("it's been"). +const Set _kHasGuards = {'been', 'got', 'gotten', 'had'}; + +/// Hard limits. Keep in sync with the UI and the edge function. +const int kCanaryMaxSlots = 24; +const int kCanaryMaxCopies = 100; +const int kCanaryMinCopies = 2; + +/// Invisible marker: WORD JOINER, 12 bits as ZERO WIDTH SPACE (0) / +/// ZERO WIDTH NON-JOINER (1), WORD JOINER. 10 data bits hold copyIndex + 1, +/// 2 check bits hold (number of 1-bits in the data) % 4. +final String _kZwEdge = String.fromCharCode(0x2060); // WORD JOINER +final String _kZwZero = String.fromCharCode(0x200B); // ZERO WIDTH SPACE +final String _kZwOne = String.fromCharCode(0x200C); // ZERO WIDTH NON-JOINER +/// Typographic apostrophe, as typed by phone keyboards. +final String _kCurlyQuote = String.fromCharCode(0x2019); + +final RegExp _kZwMarker = RegExp('$_kZwEdge([$_kZwZero$_kZwOne]{12})$_kZwEdge'); + +final RegExp _kTokenPattern = RegExp( + "[A-Za-z0-9'$_kCurlyQuote]+|[^A-Za-z0-9'$_kCurlyQuote]+", +); +final RegExp _kWordPattern = RegExp( + "^[A-Za-z0-9'$_kCurlyQuote]+" + r'$', +); +final RegExp _kWhitespaceOnly = RegExp(r'^\s+$'); +final RegExp _kSafeAfter = RegExp(r'^(\s+|[,.!?;:]\s+|[,.!?;:]+$)'); +final RegExp _kNotWordChar = RegExp(r"[^a-z0-9']+"); +final RegExp _kSpaceRun = RegExp(r' +'); + +/// One swappable word inside the original note. +class CanarySlot { + const CanarySlot({ + required this.tokenIndex, + required this.original, + required this.alternate, + required this.left, + required this.right, + }); + + /// Index into [CanaryPlan.tokens]. + final int tokenIndex; + + /// Lowercase original word (bit 0). + final String original; + + /// Lowercase alternate wording (bit 1). May contain a space ("do not"). + final String alternate; + + /// Up to two normalized words before / after the slot (nearest first). + final List left; + final List right; + + Map toJson() => { + 'i': tokenIndex, + 'o': original, + 'a': alternate, + 'l': left, + 'r': right, + }; + + factory CanarySlot.fromJson(Map json) => CanarySlot( + tokenIndex: json['i'] as int, + original: json['o'] as String, + alternate: json['a'] as String, + left: (json['l'] as List).cast(), + right: (json['r'] as List).cast(), + ); +} + +/// The tokenized note plus its slots. Everything needed to render copies and +/// to match leaks. Stored only on the sender's device. +class CanaryPlan { + const CanaryPlan({required this.tokens, required this.slots}); + + final List tokens; + final List slots; + + int get bitCount => slots.length; + + Map toJson() => { + 't': tokens, + 's': [for (final s in slots) s.toJson()], + }; + + factory CanaryPlan.fromJson(Map json) => CanaryPlan( + tokens: (json['t'] as List).cast(), + slots: [ + for (final s in json['s'] as List) + CanarySlot.fromJson((s as Map).cast()), + ], + ); +} + +String _canonicalWord(String word) => + word.replaceAll(_kCurlyQuote, "'").toLowerCase(); + +/// Lowercases, maps curly apostrophes to straight ones, turns every other +/// non [a-z0-9'] character (including zero-width characters) into a space, +/// and collapses runs of spaces. Used on both leaks and slot options. +String canaryNormalize(String input) { + final lower = input.replaceAll(_kCurlyQuote, "'").toLowerCase(); + final spaced = lower.replaceAll(_kNotWordChar, ' '); + return spaced.trim().replaceAll(_kSpaceRun, ' '); +} + +/// Finds the swappable words in [text]. Slots are at least three words apart +/// so that their context words are never slots themselves. +CanaryPlan buildCanaryPlan(String text) { + final tokens = [for (final m in _kTokenPattern.allMatches(text)) m.group(0)!]; + final wordIndexes = [ + for (var i = 0; i < tokens.length; i++) + if (_kWordPattern.hasMatch(tokens[i])) i, + ]; + + final slots = []; + var lastSlotWordPos = -1000; + for (var w = 0; w < wordIndexes.length; w++) { + if (slots.length >= kCanaryMaxSlots) break; + if (w - lastSlotWordPos < 3) continue; + + final tokenIndex = wordIndexes[w]; + final key = _canonicalWord(tokens[tokenIndex]); + final alternate = kCanaryAlternates[key]; + if (alternate == null) continue; + + // Separator guards: the word must stand alone, not be glued to symbols + // ("2.5", "10:30", "(please", "ok.com"). + final before = tokenIndex > 0 ? tokens[tokenIndex - 1] : null; + final after = tokenIndex + 1 < tokens.length + ? tokens[tokenIndex + 1] + : null; + if (before != null && !_kWhitespaceOnly.hasMatch(before)) continue; + if (after != null && !_kSafeAfter.hasMatch(after)) continue; + + final prevWord = w > 0 ? _canonicalWord(tokens[wordIndexes[w - 1]]) : ''; + final nextWord = w + 1 < wordIndexes.length + ? _canonicalWord(tokens[wordIndexes[w + 1]]) + : ''; + if ((key == 'please' || key == 'kindly') && + _kPleaseVerbGuards.contains(prevWord)) { + continue; + } + if (key.endsWith("'s") && _kHasGuards.contains(nextWord)) continue; + + final left = [ + for (var k = w - 1; k >= 0 && k >= w - 2; k--) + _canonicalWord(tokens[wordIndexes[k]]), + ]; + final right = [ + for (var k = w + 1; k < wordIndexes.length && k <= w + 2; k++) + _canonicalWord(tokens[wordIndexes[k]]), + ]; + slots.add( + CanarySlot( + tokenIndex: tokenIndex, + original: key, + alternate: alternate, + left: left, + right: right, + ), + ); + lastSlotWordPos = w; + } + return CanaryPlan(tokens: List.unmodifiable(tokens), slots: slots); +} + +/// Smallest number of slots a note needs for [copyCount] copies. +/// +/// Measured with the test harness: with 9 slots, a text mixed from two copies +/// named an innocent third reader in ~11% of trials; with 15 slots, ~0.2%. +/// So never go below 10, and add 5 on top of the bits needed to count copies. +int canaryRequiredSlots(int copyCount) { + var bits = 0; + while ((1 << bits) < copyCount + 1) { + bits++; + } + return max(10, bits + 5); +} + +/// Largest number of reader copies a plan supports at [canaryRequiredSlots]. +int canaryMaxCopiesFor(CanaryPlan plan) { + var best = 0; + for (var copies = kCanaryMinCopies; copies <= kCanaryMaxCopies; copies++) { + if (canaryRequiredSlots(copies) <= plan.bitCount) best = copies; + } + return best; +} + +String _applyCase(String template, String replacement) { + final letters = template.replaceAll(RegExp(r'[^A-Za-z]'), ''); + if (letters.length >= 3 && letters == letters.toUpperCase()) { + return replacement.toUpperCase(); + } + if (letters.isNotEmpty && + letters[0] == letters[0].toUpperCase() && + letters[0] != letters[0].toLowerCase()) { + return replacement.isEmpty + ? replacement + : replacement[0].toUpperCase() + replacement.substring(1); + } + return replacement; +} + +String _zwMarker(int copyIndex) { + final value = copyIndex + 1; + final data = List.generate(10, (b) => (value >> (9 - b)) & 1); + final ones = data.where((b) => b == 1).length; + final check = [(ones % 4) >> 1 & 1, (ones % 4) & 1]; + final bits = [...data, ...check]; + return _kZwEdge + + bits.map((b) => b == 1 ? _kZwOne : _kZwZero).join() + + _kZwEdge; +} + +/// Returns the copy index hidden in [text] by [renderCanaryCopy], or null. +/// When several markers disagree, the most frequent valid value wins. +int? decodeCanaryMarker(String text) { + final counts = {}; + for (final m in _kZwMarker.allMatches(text)) { + final bits = [ + for (final ch in m.group(1)!.split('')) ch == _kZwOne ? 1 : 0, + ]; + var value = 0; + for (var b = 0; b < 10; b++) { + value = (value << 1) | bits[b]; + } + final ones = bits.take(10).where((b) => b == 1).length; + final check = (bits[10] << 1) | bits[11]; + if (check != ones % 4 || value == 0) continue; + counts[value - 1] = (counts[value - 1] ?? 0) + 1; + } + if (counts.isEmpty) return null; + final sorted = counts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + return sorted.first.key; +} + +/// The note's tokens with [codeword] applied (no invisible marker). Used by +/// [renderCanaryCopy] and by the "see the magic" view to highlight changes. +List renderCanaryTokens(CanaryPlan plan, List codeword) { + if (codeword.length != plan.bitCount) { + throw ArgumentError('codeword length must equal the number of slots'); + } + final tokens = List.of(plan.tokens); + for (var k = 0; k < plan.slots.length; k++) { + final slot = plan.slots[k]; + final chosen = codeword[k] == 0 ? slot.original : slot.alternate; + tokens[slot.tokenIndex] = _applyCase(plan.tokens[slot.tokenIndex], chosen); + } + return tokens; +} + +/// Renders the copy for [codeword]. When [copyIndex] is given, an invisible +/// marker carrying it is inserted after the 1st, 11th, 21st... word. +String renderCanaryCopy(CanaryPlan plan, List codeword, {int? copyIndex}) { + final tokens = renderCanaryTokens(plan, codeword); + if (copyIndex == null) return tokens.join(); + + final marker = _zwMarker(copyIndex); + final out = StringBuffer(); + var wordCount = 0; + for (final token in tokens) { + out.write(token); + if (_kWordPattern.hasMatch(token)) { + wordCount++; + if (wordCount % 10 == 1) out.write(marker); + } + } + return out.toString(); +} + +/// Number of 1-bits in [value] (Hamming weight). +int _bitCountOf(int value) { + var n = 0; + for (var v = value; v != 0; v &= v - 1) { + n++; + } + return n; +} + +/// Random, distinct, non-zero codewords, spread apart where the slot count +/// allows (minimum distance 4, relaxed to 3, 2, then 1 if needed). +/// Throws [StateError] if the note has too few slots for [copyCount]. +List> generateCanaryCodewords( + int copyCount, + int bitCount, { + Random? random, +}) { + if (copyCount < kCanaryMinCopies || copyCount > kCanaryMaxCopies) { + throw ArgumentError( + 'copyCount must be $kCanaryMinCopies..$kCanaryMaxCopies', + ); + } + if (bitCount > kCanaryMaxSlots) { + throw ArgumentError('bitCount must be at most $kCanaryMaxSlots'); + } + if (bitCount < canaryRequiredSlots(copyCount)) { + throw StateError('Not enough swappable words for $copyCount copies.'); + } + final rng = random ?? Random.secure(); + // Codewords are packed into ints (slot 0 = most significant bit) so the + // distance check is one XOR and a bit count. Bits are drawn one at a time, + // exactly as before, so a seeded Random still gives the same codewords. + for (var minDistance = 4; minDistance >= 1; minDistance--) { + final chosen = []; + var attempts = 0; + while (chosen.length < copyCount && attempts < copyCount * 4000) { + attempts++; + var candidate = 0; + for (var b = 0; b < bitCount; b++) { + candidate = (candidate << 1) | rng.nextInt(2); + } + if (_bitCountOf(candidate) < minDistance) continue; + if (chosen.every((c) => _bitCountOf(c ^ candidate) >= minDistance)) { + chosen.add(candidate); + } + } + if (chosen.length == copyCount) { + return [ + for (final c in chosen) + List.generate(bitCount, (k) => (c >> (bitCount - 1 - k)) & 1), + ]; + } + } + throw StateError('Could not spread copies apart. Add a few more words.'); +} + +enum CanaryMatchKind { + /// The invisible marker named the copy exactly. + exactMarker, + + /// Exactly one copy fits every readable fingerprint (5 or more readable). + confident, + + /// One copy fits best, but either few fingerprints survived (3–4) or one of + /// them disagrees (OCR/typing noise). Shown as "most likely". + likely, + + /// The text matches the sender's own original, not any reader copy. + senderOriginal, + + /// Several copies fit equally well: more of the leak is needed. + ambiguous, + + /// Too little of the note survived to decide. + notEnough, + + /// No single copy fits: edited, or combined from several copies. Never name + /// anyone in this case. + noMatch, +} + +class CanaryMatch { + const CanaryMatch({ + required this.kind, + this.copyIndex, + this.candidates = const [], + this.agreeing = 0, + this.known = 0, + }); + + final CanaryMatchKind kind; + + /// Set only for [CanaryMatchKind.exactMarker], [CanaryMatchKind.confident] + /// and [CanaryMatchKind.likely]. The UI must not name a reader otherwise. + final int? copyIndex; + + /// For [CanaryMatchKind.ambiguous]: the copies that fit equally well. + final List candidates; + + /// Fingerprints that agree with [copyIndex] out of [known] readable ones. + final int agreeing; + final int known; +} + +/// Reads 0/1/null for every slot from a leaked text. +List readCanaryBits(String leakText, CanaryPlan plan) { + final leak = ' ${canaryNormalize(leakText)} '; + final result = []; + for (final slot in plan.slots) { + final a = canaryNormalize(slot.original); + final b = canaryNormalize(slot.alternate); + final l1 = slot.left.isNotEmpty ? slot.left[0] : null; + final l2 = slot.left.length > 1 ? slot.left[1] : null; + final r1 = slot.right.isNotEmpty ? slot.right[0] : null; + final r2 = slot.right.length > 1 ? slot.right[1] : null; + + // Most specific context first. Each entry: words before, words after. + final levels = <(List, List)>[ + if (l1 != null && l2 != null && r1 != null && r2 != null) + ([l2, l1], [r1, r2]), + if (l1 != null && r1 != null) ([l1], [r1]), + if (l1 != null && l2 != null) ([l2, l1], const []), + if (r1 != null && r2 != null) (const [], [r1, r2]), + ]; + + int? bit; + for (final (before, after) in levels) { + String pattern(String option) => + ' ${[...before, option, ...after].join(' ')} '; + final hasA = leak.contains(pattern(a)); + final hasB = leak.contains(pattern(b)); + if (hasA != hasB) { + bit = hasA ? 0 : 1; + break; + } + if (hasA && hasB) break; // Ambiguous at this level: give up on slot. + } + result.add(bit); + } + return result; +} + +/// Works out which copy [leakText] came from. +CanaryMatch matchCanaryLeak( + String leakText, + CanaryPlan plan, + List> codewords, +) { + final marker = decodeCanaryMarker(leakText); + if (marker != null && marker >= 0 && marker < codewords.length) { + return CanaryMatch( + kind: CanaryMatchKind.exactMarker, + copyIndex: marker, + agreeing: plan.bitCount, + known: plan.bitCount, + ); + } + + final bits = readCanaryBits(leakText, plan); + final known = bits.where((b) => b != null).length; + if (known < 3) { + return CanaryMatch(kind: CanaryMatchKind.notEnough, known: known); + } + + int disagreeWith(List code) { + var n = 0; + for (var k = 0; k < bits.length; k++) { + if (bits[k] != null && bits[k] != code[k]) n++; + } + return n; + } + + final perfect = [ + for (var i = 0; i < codewords.length; i++) + if (disagreeWith(codewords[i]) == 0) i, + ]; + final zeroFits = disagreeWith(List.filled(plan.bitCount, 0)) == 0; + + if (zeroFits && perfect.isEmpty) { + return CanaryMatch( + kind: CanaryMatchKind.senderOriginal, + agreeing: known, + known: known, + ); + } + if (perfect.length == 1) { + return CanaryMatch( + kind: known >= 5 ? CanaryMatchKind.confident : CanaryMatchKind.likely, + copyIndex: perfect.single, + agreeing: known, + known: known, + ); + } + if (perfect.length > 1) { + return CanaryMatch( + kind: CanaryMatchKind.ambiguous, + candidates: perfect, + known: known, + ); + } + + // No copy fits perfectly. Allow exactly one disagreement (a misread word), + // but only when enough fingerprints survived and exactly one copy is that + // close — otherwise refuse to name anyone. + final nearlyPerfect = [ + for (var i = 0; i < codewords.length; i++) + if (disagreeWith(codewords[i]) == 1) i, + ]; + if (known >= 8 && nearlyPerfect.length == 1 && !zeroFits) { + return CanaryMatch( + kind: CanaryMatchKind.likely, + copyIndex: nearlyPerfect.single, + agreeing: known - 1, + known: known, + ); + } + return CanaryMatch(kind: CanaryMatchKind.noMatch, known: known); +} diff --git a/app/lib/features/canary/domain/canary_link.dart b/app/lib/features/canary/domain/canary_link.dart new file mode 100644 index 0000000..8b6067b --- /dev/null +++ b/app/lib/features/canary/domain/canary_link.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +/// What a reader link carries. The key lives in the URL fragment, which +/// browsers never send to a server. +class CanaryLinkToken { + const CanaryLinkToken({required this.noteId, required this.keyHex}); + + /// Lowercase UUID v4, e.g. 1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed. + final String noteId; + + /// 64 lowercase hex characters (AES-256 key). + final String keyHex; +} + +final RegExp _uuidPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', +); +final RegExp _keyPattern = RegExp(r'^[0-9a-f]{64}$'); + +/// Reader link: `/#/canary/?k=`. +/// Same shape as Burn links (query inside the fragment). +String buildCanaryReaderLink({ + required String origin, + required String basePath, + required String noteId, + required String keyHex, +}) => '$origin$basePath/#/canary/$noteId?k=$keyHex'; + +/// Parses a full URL (including its fragment). Returns null for anything that +/// is not exactly a Canary reader link. +CanaryLinkToken? parseCanaryReaderLink(String fullUrl) { + final hashIndex = fullUrl.indexOf('#'); + if (hashIndex == -1) return null; + final fragment = fullUrl.substring(hashIndex + 1); + final queryIndex = fragment.indexOf('?'); + if (queryIndex == -1) return null; + + final path = fragment.substring(0, queryIndex).toLowerCase(); + final params = Uri.splitQueryString(fragment.substring(queryIndex + 1)); + final match = RegExp(r'^/?canary/([0-9a-f\-]{36})$').firstMatch(path); + final key = params['k']?.toLowerCase(); + if (match == null || key == null) return null; + + final noteId = match.group(1)!; + if (!_uuidPattern.hasMatch(noteId) || !_keyPattern.hasMatch(key)) { + return null; + } + return CanaryLinkToken(noteId: noteId, keyHex: key); +} + +/// The bytes32 note id used on chain (Solana memo, Monad contract): +/// sha256("nosus-canary:" + noteId). +/// The edge function computes the same value; keep them identical. +String canaryChainNoteId(String noteId) => + '0x${sha256.convert(utf8.encode('nosus-canary:$noteId'))}'; + +/// Public block explorer page for a Monad testnet transaction. +String canaryExplorerTxUrl(String txHash) => + 'https://testnet.monadscan.com/tx/$txHash'; + +/// Public block explorer page for a Solana devnet transaction signature. +String canarySolanaExplorerTxUrl(String signature) => + 'https://explorer.solana.com/tx/$signature?cluster=devnet'; + +/// Which public chain a Canary proof lives on. +enum CanaryChain { + monad, + solana; + + /// Short brand name for chips: 'Monad' / 'Solana'. + String get label => this == CanaryChain.monad ? 'Monad' : 'Solana'; + + /// Network name for sentences: 'Monad testnet' / 'Solana devnet'. + String get network => + this == CanaryChain.monad ? 'Monad testnet' : 'Solana devnet'; + + /// Public explorer page for a transaction on this chain. + String txUrl(String hash) => this == CanaryChain.monad + ? canaryExplorerTxUrl(hash) + : canarySolanaExplorerTxUrl(hash); +} diff --git a/app/lib/features/canary/domain/canary_models.dart b/app/lib/features/canary/domain/canary_models.dart new file mode 100644 index 0000000..f7300a5 --- /dev/null +++ b/app/lib/features/canary/domain/canary_models.dart @@ -0,0 +1,215 @@ +import 'canary_fingerprint.dart'; + +/// A problem the person can fix or should read (shown as-is in the UI). +class CanaryUserException implements Exception { + const CanaryUserException(this.message); + final String message; + + @override + String toString() => message; +} + +/// Everything the sender's device keeps about one Canary note. +/// +/// Stored only on the device that created the note (SharedPreferences). It +/// holds the key, the salt and every codeword, so it must never be uploaded. +class CanaryOwnerRecord { + const CanaryOwnerRecord({ + required this.noteId, + required this.keyHex, + required this.ownerSecret, + required this.saltHex, + required this.copyCount, + required this.createdAt, + required this.expiresAt, + required this.readerLink, + required this.preview, + required this.plan, + required this.codewords, + this.sealTxHash, + this.sealSolanaSig, + }); + + final String noteId; + final String keyHex; + + /// 64 hex chars. The server only stores sha256(ownerSecret). + final String ownerSecret; + + /// 64 hex chars mixed into every copy digest (never leaves the device). + final String saltHex; + final int copyCount; + final DateTime createdAt; + final DateTime expiresAt; + final String readerLink; + + /// First line of the note, max 80 characters, for the list screen. + final String preview; + final CanaryPlan plan; + + /// codewords[i] is the 0/1 choice per slot for copy i. + final List> codewords; + + /// Monad testnet transaction that sealed the note (earlier prototype). + /// Null while the server has Monad off, and for unsealed drafts. + final String? sealTxHash; + + /// Solana devnet memo signature that sealed the note: the product's + /// receipt. Null for unsealed drafts and for records saved before Solana + /// proofs existed. + final String? sealSolanaSig; + + /// Sealed once either chain holds the seal. + bool get isSealed => sealTxHash != null || sealSolanaSig != null; + + CanaryOwnerRecord copyWith({ + String? sealTxHash, + String? sealSolanaSig, + DateTime? expiresAt, + }) => CanaryOwnerRecord( + noteId: noteId, + keyHex: keyHex, + ownerSecret: ownerSecret, + saltHex: saltHex, + copyCount: copyCount, + createdAt: createdAt, + expiresAt: expiresAt ?? this.expiresAt, + readerLink: readerLink, + preview: preview, + plan: plan, + codewords: codewords, + sealTxHash: sealTxHash ?? this.sealTxHash, + sealSolanaSig: sealSolanaSig ?? this.sealSolanaSig, + ); + + Map toJson() => { + 'v': 1, + 'noteId': noteId, + 'keyHex': keyHex, + 'ownerSecret': ownerSecret, + 'saltHex': saltHex, + 'copyCount': copyCount, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'expiresAt': expiresAt.toUtc().toIso8601String(), + 'readerLink': readerLink, + 'preview': preview, + 'plan': plan.toJson(), + 'codewords': [for (final c in codewords) c.join()], + 'sealTxHash': sealTxHash, + 'sealSolanaSig': sealSolanaSig, + }; + + factory CanaryOwnerRecord.fromJson(Map json) { + if (json['v'] != 1) throw const FormatException('Unknown record version'); + return CanaryOwnerRecord( + noteId: json['noteId'] as String, + keyHex: json['keyHex'] as String, + ownerSecret: json['ownerSecret'] as String, + saltHex: json['saltHex'] as String, + copyCount: json['copyCount'] as int, + createdAt: DateTime.parse(json['createdAt'] as String), + expiresAt: DateTime.parse(json['expiresAt'] as String), + readerLink: json['readerLink'] as String, + preview: json['preview'] as String, + plan: CanaryPlan.fromJson((json['plan'] as Map).cast()), + codewords: [ + for (final c in json['codewords'] as List) + [for (final ch in (c as String).split('')) ch == '1' ? 1 : 0], + ], + sealTxHash: json['sealTxHash'] as String?, + // Records saved before Solana proofs existed have no key here. + sealSolanaSig: json['sealSolanaSig'] as String?, + ); + } +} + +/// One reader copy as the owner dashboard sees it. +class CanaryCopyStatus { + const CanaryCopyStatus({ + required this.copyIndex, + required this.readerName, + required this.openedAt, + required this.openTxHash, + required this.openSolanaSig, + }); + + final int copyIndex; + final String readerName; + final DateTime openedAt; + + /// Monad testnet transaction for this open. Null while it is being + /// written, and always null while the server has Monad off. + final String? openTxHash; + + /// Solana devnet memo signature for this open. Null while it is being + /// written (a failed memo is sent again on the reader's next open). + final String? openSolanaSig; + + /// The open has a public receipt on at least one chain. + bool get isRecorded => openTxHash != null || openSolanaSig != null; +} + +class CanaryNoteStatus { + const CanaryNoteStatus({ + required this.copyCount, + required this.sealTxHash, + required this.sealSolanaSig, + required this.expiresAt, + required this.copies, + }); + + final int copyCount; + + /// Monad testnet transaction that sealed the note. Null while the server + /// has Monad off. + final String? sealTxHash; + + /// Solana devnet memo signature that sealed the note. + final String? sealSolanaSig; + final DateTime expiresAt; + + /// Only copies that have been opened, ordered by copy index. + final List copies; + + /// Nothing more can change: every copy is opened and recorded on chain, + /// or the link has expired. Screens stop polling at this point. + bool get isSettled => + !expiresAt.isAfter(DateTime.now()) || + (copies.length >= copyCount && copies.every((c) => c.isRecorded)); + + CanaryCopyStatus? copy(int index) { + for (final c in copies) { + if (c.copyIndex == index) return c; + } + return null; + } +} + +/// What a reader receives after opening their copy. +class CanaryOpenedCopy { + const CanaryOpenedCopy({ + required this.text, + required this.copyIndex, + required this.copyCount, + required this.readerName, + required this.openedAt, + required this.openTxHash, + required this.openSolanaSig, + }); + + /// The copy text, including its invisible marker. + final String text; + final int copyIndex; + final int copyCount; + final String readerName; + final DateTime openedAt; + + /// Monad testnet transaction for this open (earlier prototype). Null + /// while the server has Monad off. + final String? openTxHash; + + /// Solana devnet memo signature for this open. Null when the memo has not + /// landed yet (it is sent again on the next open of this copy); the UI + /// then shows the time without a proof link. + final String? openSolanaSig; +} diff --git a/app/lib/features/canary/ocr/canary_ocr.dart b/app/lib/features/canary/ocr/canary_ocr.dart new file mode 100644 index 0000000..8e9f862 --- /dev/null +++ b/app/lib/features/canary/ocr/canary_ocr.dart @@ -0,0 +1,4 @@ +// Platform-safe OCR entry point, same pattern as services/measure_reporting.dart. +// google_mlkit_text_recognition is Android/iOS-only, so the web build must +// never import it: the conditional export keeps it out of the web program. +export 'canary_ocr_stub.dart' if (dart.library.io) 'canary_ocr_mlkit.dart'; diff --git a/app/lib/features/canary/ocr/canary_ocr_mlkit.dart b/app/lib/features/canary/ocr/canary_ocr_mlkit.dart new file mode 100644 index 0000000..f56d9cf --- /dev/null +++ b/app/lib/features/canary/ocr/canary_ocr_mlkit.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; +import 'package:image_picker/image_picker.dart'; + +/// On-device OCR (ML Kit, Latin script). Nothing is uploaded. +bool get canaryOcrAvailable => Platform.isAndroid || Platform.isIOS; + +/// Opens the system photo picker (no storage permission needed on Android) +/// and returns the text found in the chosen screenshot, or null if the person +/// cancelled. +Future canaryPickScreenshotText() async { + final picked = await ImagePicker().pickImage(source: ImageSource.gallery); + if (picked == null) return null; + final recognizer = TextRecognizer(script: TextRecognitionScript.latin); + try { + final result = await recognizer.processImage( + InputImage.fromFilePath(picked.path), + ); + return result.text; + } finally { + await recognizer.close(); + } +} diff --git a/app/lib/features/canary/ocr/canary_ocr_stub.dart b/app/lib/features/canary/ocr/canary_ocr_stub.dart new file mode 100644 index 0000000..90304ad --- /dev/null +++ b/app/lib/features/canary/ocr/canary_ocr_stub.dart @@ -0,0 +1,5 @@ +/// Web (and any platform without dart:io): no OCR. The leak screen then only +/// offers paste. +bool get canaryOcrAvailable => false; + +Future canaryPickScreenshotText() async => null; diff --git a/app/lib/features/canary/presentation/canary_app.dart b/app/lib/features/canary/presentation/canary_app.dart new file mode 100644 index 0000000..66d5379 --- /dev/null +++ b/app/lib/features/canary/presentation/canary_app.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import '../../../theme.dart'; +import 'canary_home_screen.dart'; +import 'ui/canary_strip.dart'; + +/// The standalone NO SUS Canary web app: Canary home is the root page. +/// No AuthGate, no workspace. Reader links (`#/canary/?k=...`) are still +/// handled earlier in main() by CanaryReaderApp. +class CanaryApp extends StatelessWidget { + const CanaryApp({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: 'NO SUS Canary', + debugShowCheckedModeBanner: false, + theme: CanaryTokens.theme(), + darkTheme: CanaryTokens.theme(), + themeMode: ThemeMode.dark, + builder: (context, child) => + CanaryStrip(child: child ?? const SizedBox.shrink()), + home: const CanaryHomeScreen(), + // Old links to #/canary keep working and land on the same home page. + onGenerateRoute: (settings) => MaterialPageRoute( + settings: settings, + builder: (_) => const CanaryHomeScreen(), + ), + ); +} diff --git a/app/lib/features/canary/presentation/canary_composer_screen.dart b/app/lib/features/canary/presentation/canary_composer_screen.dart new file mode 100644 index 0000000..5ea9154 --- /dev/null +++ b/app/lib/features/canary/presentation/canary_composer_screen.dart @@ -0,0 +1,478 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../config/app_mode.dart'; +import '../../../core/layout/app_breakpoints.dart'; +import '../../../theme.dart'; +import '../data/canary_api.dart'; +import '../data/canary_repository.dart'; +import '../domain/canary_fingerprint.dart'; +import '../domain/canary_models.dart'; +import 'canary_created_screen.dart'; +import 'canary_providers.dart'; +import 'canary_ui.dart'; +import 'ui/fingerprint_meter.dart'; +import 'ui/glow_card.dart'; +import 'ui/liquid_carve_button.dart'; +import 'ui/pixel_canary.dart'; +import 'ui/quest_steps.dart'; + +/// A demo note that has 15 swappable words (checked by the test harness), so +/// it supports up to 100 readers. +const String kCanaryDemoNote = + "Hi everyone, please don't forward this. We're finalizing the budget and it's nearly done. " + 'The first ten people who reply can join the review, so kindly keep it quiet until Monday. ' + "I'll organize a short call afterwards for anybody who can't read the whole email. " + 'Someone from finance will share the favorite options, okay? ' + "Let's make sure nobody posts screenshots, and maybe we can wrap up by five. " + "There's a lot riding on this, so don't be late."; + +class CanaryComposerScreen extends ConsumerStatefulWidget { + const CanaryComposerScreen({super.key}); + + @override + ConsumerState createState() => + _CanaryComposerScreenState(); +} + +class _CanaryComposerScreenState extends ConsumerState { + final _text = TextEditingController(); + int _copies = 50; + int _hours = 24; + + /// Plan for the current text, rebuilt once per edit (null while empty). + CanaryPlan? _plan; + bool _busy = false; + String? _stage; + String? _error; + + static const _copyChoices = [10, 20, 50, 100]; + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + int get _slots => _plan?.bitCount ?? 0; + + void _onTextChanged(String value) { + final trimmed = value.trim(); + final plan = trimmed.isEmpty ? null : buildCanaryPlan(trimmed); + final slots = plan?.bitCount ?? 0; + setState(() { + _plan = plan; + _error = null; + // Drop to the largest reader count this text supports. + if (canaryRequiredSlots(_copies) > slots) { + _copies = _copyChoices.lastWhere( + (c) => canaryRequiredSlots(c) <= slots, + orElse: () => _copies, + ); + } + }); + } + + Future _create() async { + setState(() { + _busy = true; + _error = null; + _stage = 'Starting…'; + }); + try { + final record = await ref + .read(canaryRepositoryProvider) + .createNote( + text: _text.text, + copyCount: _copies, + expiresInHours: _hours, + onStage: (stage) { + if (mounted) setState(() => _stage = stage); + }, + ); + if (!mounted) return; + await Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => CanaryCreatedScreen(noteId: record.noteId), + ), + ); + } on CanaryUserException catch (e) { + if (mounted) setState(() => _error = e.message); + } on CanaryApiException catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) { + setState(() => _error = 'Could not create the note. Try again.'); + } + } finally { + if (mounted) { + setState(() { + _busy = false; + _stage = null; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final required = canaryRequiredSlots(_copies); + final strongEnough = _slots >= required; + final length = _text.text.trim().length; + + return CanaryUi.frame( + title: 'New Canary note', + body: (context) { + final theme = Theme.of(context); + final expanded = AppBreakpoints.isExpanded(context); + final panel = _sidePanel(theme, required, strongEnough, length, _plan); + + return LayoutBuilder( + builder: (context, constraints) { + // Desktop with enough height: the editor stretches to fill the + // screen. Otherwise everything scrolls as one column. + final fill = expanded && constraints.maxHeight >= 700; + final pad = expanded ? 48.0 : 20.0; + + final header = _header(expanded); + + if (!fill) { + return ListView( + padding: EdgeInsets.all(pad), + children: [ + header, + const SizedBox(height: 24), + const QuestSteps(current: 0), + const SizedBox(height: 24), + _editorCard(theme, expand: false), + const SizedBox(height: 24), + panel, + ], + ); + } + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1360), + child: Padding( + padding: EdgeInsets.fromLTRB(pad, 32, pad, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + const SizedBox(height: 24), + const QuestSteps(current: 0), + const SizedBox(height: 28), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _editorCard(theme, expand: true)), + const SizedBox(width: 32), + SizedBox( + width: 460, + child: SingleChildScrollView( + padding: const EdgeInsets.only( + right: 8, + bottom: 8, + ), + child: panel, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } + + Widget _header(bool expanded) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Write it once.', + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: expanded ? 52 : 34, + fontWeight: FontWeight.w700, + height: 1.05, + letterSpacing: expanded ? -1.5 : -0.5, + color: CanaryTokens.text, + ), + ), + const SizedBox(height: 10), + Text( + 'Canary swaps tiny everyday words, so every reader gets a copy ' + 'that reads the same but is secretly unique.', + style: TextStyle( + fontSize: expanded ? 18 : 16, + height: 1.55, + color: CanaryTokens.textDim, + ), + ), + ], + ), + ), + if (expanded) ...[ + const SizedBox(width: 24), + const PixelCanary(size: 96, sing: true), + const SizedBox(width: 24), + ], + ], + ); + } + + Widget _editorCard(ThemeData theme, {required bool expand}) { + final field = TextField( + controller: _text, + enabled: !_busy, + minLines: expand ? null : 10, + maxLines: expand ? null : 18, + expands: expand, + textAlignVertical: TextAlignVertical.top, + maxLength: CanaryRepository.maxNoteLength, + onChanged: _onTextChanged, + style: const TextStyle( + fontFamily: CanaryTokens.bodyFont, + fontSize: 19, + color: CanaryTokens.text, + height: 1.65, + ), + cursorColor: CanaryTokens.text, + cursorWidth: 3, + decoration: const InputDecoration( + hintText: 'Write your note the way you normally would…', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + filled: false, + contentPadding: EdgeInsets.zero, + ), + ); + + return GlowCard( + padding: const EdgeInsets.fromLTRB(28, 22, 28, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const RetroLabel('Your note'), + const Spacer(), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: CanaryTokens.text, + backgroundColor: CanaryTokens.canary, + side: const BorderSide(color: CanaryTokens.text, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + textStyle: const TextStyle(fontWeight: FontWeight.w700), + ), + onPressed: _busy + ? null + : () { + _text.text = kCanaryDemoNote; + _onTextChanged(kCanaryDemoNote); + }, + icon: const Icon(Icons.auto_awesome, size: 18), + label: const Text('Use the demo note'), + ), + ], + ), + const SizedBox(height: 14), + Container(height: 2, color: CanaryTokens.text), + const SizedBox(height: 16), + if (expand) Expanded(child: field) else field, + ], + ), + ); + } + + Widget _sidePanel( + ThemeData theme, + int required, + bool strongEnough, + int length, + CanaryPlan? plan, + ) { + final ready = length > 0 && strongEnough; + return GlowCard( + padding: const EdgeInsets.all(26), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const RetroLabel('Fingerprint'), + const Spacer(), + Container( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 2), + decoration: BoxDecoration( + color: ready ? CanaryTokens.canary : CanaryTokens.surfaceHi, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: CanaryTokens.text, width: 1.5), + ), + child: Text( + ready ? 'READY' : '$_slots / $required', + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 20, + height: 1, + color: CanaryTokens.text, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + FingerprintMeter(found: _slots, requiredSlots: required), + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + length == 0 + ? 'Canary needs at least $required everyday words it can swap ' + '(like "don\'t", "okay", "until", numbers).' + : strongEnough + ? '$_slots swappable words found. Enough for $_copies readers.' + : 'Found $_slots of the $required swappable words needed for ' + '$_copies readers. Add a sentence or two.', + style: TextStyle( + fontSize: 15, + height: 1.5, + color: length > 0 && !strongEnough + ? theme.colorScheme.error + : CanaryTokens.textDim, + ), + ), + ), + if (plan != null && plan.slots.isNotEmpty) ...[ + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final slot in plan.slots.take(8)) + Container( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 2), + decoration: BoxDecoration( + color: CanaryTokens.surfaceHi, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: CanaryTokens.text, width: 1.5), + ), + child: Text( + '${slot.original} / ${slot.alternate}', + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + color: CanaryTokens.text, + fontSize: 19, + height: 1, + ), + ), + ), + ], + ), + ], + const SizedBox(height: 28), + const RetroLabel('How many readers?'), + const SizedBox(height: 12), + SegmentedButton( + showSelectedIcon: false, + segments: [ + for (final c in _copyChoices) + ButtonSegment( + value: c, + label: Text('$c'), + enabled: canaryRequiredSlots(c) <= _slots || length == 0, + tooltip: canaryRequiredSlots(c) > _slots && length > 0 + ? 'Needs ${canaryRequiredSlots(c)} swappable words' + : null, + ), + ], + selected: {_copies}, + onSelectionChanged: _busy + ? null + : (value) => setState(() => _copies = value.first), + ), + const SizedBox(height: 24), + const RetroLabel('Link works for'), + const SizedBox(height: 12), + SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: 1, label: Text('1 hour')), + ButtonSegment(value: 24, label: Text('1 day')), + ButtonSegment(value: 168, label: Text('7 days')), + ], + selected: {_hours}, + onSelectionChanged: _busy + ? null + : (value) => setState(() => _hours = value.first), + ), + const SizedBox(height: 32), + if (_busy) ...[ + RetroLabel(_stage ?? 'Working…'), + const SizedBox(height: 12), + ], + LiquidCarveButton( + label: _busy ? (_stage ?? 'Working…') : 'Create Canary link', + icon: Icons.link, + busy: _busy, + onPressed: _busy || !strongEnough ? null : _create, + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics( + liveRegion: true, + child: Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ), + const SizedBox(height: 24), + Container( + height: 2, + color: CanaryTokens.text.withValues(alpha: 0.15), + ), + const SizedBox(height: 16), + const Text( + kCanaryMonad + ? 'Your note is encrypted on this device. NO SUS stores only the ' + 'encrypted copies; the key travels inside the link. Monad testnet ' + 'and Solana devnet receive only a hash of the copies and an ' + 'anonymous tag per reader, never the text or names.' + : 'Your note is encrypted on this device. NO SUS stores only the ' + 'encrypted copies; the key travels inside the link. Solana ' + 'devnet receives only a hash of the copies and an anonymous ' + 'tag per reader, never the text or names.', + style: TextStyle( + fontSize: 13, + height: 1.55, + color: CanaryTokens.textDim, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/canary/presentation/canary_created_screen.dart b/app/lib/features/canary/presentation/canary_created_screen.dart new file mode 100644 index 0000000..325d9de --- /dev/null +++ b/app/lib/features/canary/presentation/canary_created_screen.dart @@ -0,0 +1,466 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../core/layout/app_breakpoints.dart'; +import '../../../theme.dart'; +import '../domain/canary_models.dart'; +import 'canary_note_screen.dart'; +import 'canary_providers.dart'; +import 'canary_ui.dart'; +import 'ui/glow_card.dart'; +import 'ui/proof_chips.dart'; +import 'ui/swap_word_text.dart'; + +/// Shown right after creating a note, and from the dashboard's "Show QR". +class CanaryCreatedScreen extends ConsumerStatefulWidget { + const CanaryCreatedScreen({super.key, required this.noteId}); + + final String noteId; + + @override + ConsumerState createState() => + _CanaryCreatedScreenState(); +} + +class _CanaryCreatedScreenState extends ConsumerState { + late final CanaryOwnerRecord? _record = ref + .read(canaryRepositoryProvider) + .findNote(widget.noteId); + Timer? _poll; + CanaryNoteStatus? _status; + bool _copied = false; + bool _present = false; + + @override + void initState() { + super.initState(); + if (_record != null) { + _refresh(); + _poll = Timer.periodic(const Duration(seconds: 3), (_) => _refresh()); + } + } + + @override + void dispose() { + _poll?.cancel(); + super.dispose(); + } + + Future _refresh() async { + final record = _record; + if (record == null) return; + try { + final status = await ref + .read(canaryRepositoryProvider) + .fetchStatus(record); + if (!mounted) return; + setState(() => _status = status); + if (status.isSettled) _poll?.cancel(); + } catch (_) { + // The QR still works without a live open count. + } + } + + Future _copyLink(String link) async { + await Clipboard.setData(ClipboardData(text: link)); + if (!mounted) return; + setState(() => _copied = true); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Canary link copied'))); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + Widget build(BuildContext context) { + final record = _record; + if (record == null) { + return CanaryUi.missing(message: 'This note is not on this device.'); + } + + if (_present) { + return _PresentMode( + record: record, + opened: _status?.copies.length ?? 0, + onClose: () => setState(() => _present = false), + ); + } + + return CanaryUi.frame( + title: 'Your Canary link', + actions: [ + IconButton( + tooltip: 'Present', + icon: const Icon(Icons.present_to_all), + onPressed: () => setState(() => _present = true), + ), + ], + body: (context) { + final expanded = AppBreakpoints.isExpanded(context); + final pad = expanded ? 32.0 : 20.0; + return SingleChildScrollView( + padding: EdgeInsets.all(pad), + child: expanded ? _desktop(context, record) : _phone(context, record), + ); + }, + ); + } + + Widget _desktop(BuildContext context, CanaryOwnerRecord record) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _FloatingQr(data: record.readerLink, size: 320), + const SizedBox(width: 28), + Expanded(child: _middle(context, record)), + const SizedBox(width: 28), + Expanded(child: _magic(context, record, open: true)), + ], + ); + } + + Widget _phone(BuildContext context, CanaryOwnerRecord record) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Share this one link with the group.', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Each person who opens it gets their own copy ' + '(${record.copyCount} copies available).', + ), + const SizedBox(height: 20), + Center(child: _FloatingQr(data: record.readerLink, size: 260)), + const SizedBox(height: 20), + _middle(context, record), + const SizedBox(height: 24), + _magic(context, record, open: false), + ], + ); + } + + Widget _middle(BuildContext context, CanaryOwnerRecord record) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (AppBreakpoints.isExpanded(context)) ...[ + Text( + 'Share this one link with the group.', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Each person who opens it gets their own copy ' + '(${record.copyCount} copies available).', + ), + const SizedBox(height: 16), + ], + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: CanaryTokens.surfaceHi, + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + border: Border.all(color: CanaryTokens.border), + ), + child: SelectableText( + record.readerLink, + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + color: CanaryTokens.textDim, + fontSize: 20, + ), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + icon: Icon(_copied ? Icons.check : Icons.copy), + label: Text(_copied ? 'Copied' : 'Copy link'), + onPressed: () => _copyLink(record.readerLink), + ), + OutlinedButton.icon( + icon: const Icon(Icons.share), + label: const Text('Share'), + onPressed: () => SharePlus.instance.share( + ShareParams( + text: + 'A private note for the group (your copy is yours ' + 'only): ${record.readerLink}', + ), + ), + ), + OutlinedButton.icon( + icon: const Icon(Icons.visibility_outlined), + label: const Text('Who opened it'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CanaryNoteScreen(noteId: record.noteId), + ), + ), + ), + OutlinedButton.icon( + icon: const Icon(Icons.present_to_all), + label: const Text('Present'), + onPressed: () => setState(() => _present = true), + ), + ], + ), + if (record.sealTxHash != null || record.sealSolanaSig != null) ...[ + const SizedBox(height: 12), + ProofChips( + monadTxHash: record.sealTxHash, + solanaSig: record.sealSolanaSig, + labelPrefix: 'Sealed on', + ), + ], + ], + ); + } + + Widget _magic( + BuildContext context, + CanaryOwnerRecord record, { + required bool open, + }) { + final n = record.copyCount < 3 ? record.copyCount : 3; + final words = [for (var i = 0; i < n; i++) record.codewords[i]]; + final labels = [for (var i = 0; i < n; i++) 'Copy ${i + 1}']; + final body = SwapWordText( + plan: record.plan, + codewords: words, + labels: labels, + ); + + if (open) { + return GlowCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'See the magic', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + const Text('The same note, as different readers get it'), + const SizedBox(height: 12), + body, + ], + ), + ); + } + + return GlowCard( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: ExpansionTile( + initiallyExpanded: false, + tilePadding: EdgeInsets.zero, + title: Text( + 'See the magic', + style: Theme.of(context).textTheme.titleMedium, + ), + subtitle: const Text( + 'The same note, as three different readers get it', + ), + children: [body], + ), + ); + } +} + +class _FloatingQr extends StatefulWidget { + const _FloatingQr({required this.data, required this.size}); + + final String data; + final double size; + + @override + State<_FloatingQr> createState() => _FloatingQrState(); +} + +class _FloatingQrState extends State<_FloatingQr> + with SingleTickerProviderStateMixin { + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController(vsync: this, duration: const Duration(seconds: 4)); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context)) { + _c.stop(); + _c.value = 0.5; + } else if (!_c.isAnimating) { + _c.repeat(reverse: true); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _c, + builder: (context, child) { + final y = CanaryTokens.reduceMotion(context) + ? 0.0 + : math.sin(_c.value * math.pi * 2) * 4; + return Transform.translate(offset: Offset(0, y), child: child); + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(CanaryTokens.rCard), + border: Border.all(color: CanaryTokens.canary, width: 3), + boxShadow: CanaryTokens.glow, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: QrImageView( + data: widget.data, + version: QrVersions.auto, + size: widget.size, + backgroundColor: Colors.white, + ), + ), + ), + ); + } +} + +class _PresentMode extends StatelessWidget { + const _PresentMode({ + required this.record, + required this.opened, + required this.onClose, + }); + + final CanaryOwnerRecord record; + final int opened; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Theme( + data: CanaryTokens.theme(), + child: CallbackShortcuts( + bindings: {const SingleActivator(LogicalKeyboardKey.escape): onClose}, + child: Focus( + autofocus: true, + child: Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) { + final qr = math.min( + math.min( + constraints.maxWidth, + constraints.maxHeight, + ) * + 0.62, + 480.0, + ); + return Column( + children: [ + const SizedBox(height: 24), + Text( + record.preview.isEmpty + ? 'NO SUS Canary' + : record.preview, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: 28, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Text( + '$opened / ${record.copyCount} opened', + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 22, + color: CanaryTokens.canary, + ), + ), + const Spacer(), + Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular( + CanaryTokens.rCard, + ), + border: Border.all( + color: CanaryTokens.canary, + width: 3, + ), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: QrImageView( + data: record.readerLink, + version: QrVersions.auto, + size: qr, + backgroundColor: Colors.white, + ), + ), + ), + ), + const Spacer(), + const SizedBox(height: 72), + ], + ); + }, + ), + ), + Positioned( + top: 8, + right: 8, + child: IconButton( + tooltip: 'Close present mode', + iconSize: 28, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + foregroundColor: Colors.white, + ), + icon: const Icon(Icons.close), + onPressed: onClose, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/canary/presentation/canary_entry_card.dart b/app/lib/features/canary/presentation/canary_entry_card.dart new file mode 100644 index 0000000..a2b0835 --- /dev/null +++ b/app/lib/features/canary/presentation/canary_entry_card.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; + +import '../../../theme.dart'; +import 'canary_home_screen.dart'; +import 'ui/canary_mark.dart'; + +/// Entry point shown on Welcome (no account needed) and in Workspace. +class CanaryEntryCard extends StatelessWidget { + const CanaryEntryCard({super.key, this.onOpen}); + + /// When set, used instead of pushing [CanaryHomeScreen] (Welcome analytics). + final VoidCallback? onOpen; + + void _open(BuildContext context) { + if (onOpen != null) { + onOpen!(); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CanaryHomeScreen(), + settings: const RouteSettings(name: '/canary'), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Semantics( + button: true, + label: + 'NO SUS Canary. Every reader gets their own copy. If it leaks, see whose copy it was.', + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Container( + decoration: BoxDecoration( + gradient: CanaryTokens.brand, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(1.4), + child: Material( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(14.6), + child: InkWell( + borderRadius: BorderRadius.circular(14.6), + onTap: () => _open(context), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const CanaryMark(size: 28), + const SizedBox(width: 10), + Text( + 'NO SUS Canary', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: CanaryTokens.canary, + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'NEW', + style: TextStyle( + color: CanaryTokens.onCanary, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'Every reader gets their own copy. If it leaks, see whose copy it was.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + 'Open Canary →', + style: TextStyle( + color: CanaryTokens.text, + decoration: TextDecoration.underline, + decorationColor: CanaryTokens.canary, + decorationThickness: 3, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/canary/presentation/canary_home_screen.dart b/app/lib/features/canary/presentation/canary_home_screen.dart new file mode 100644 index 0000000..b162e23 --- /dev/null +++ b/app/lib/features/canary/presentation/canary_home_screen.dart @@ -0,0 +1,740 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../config/app_mode.dart'; +import '../../../core/layout/app_breakpoints.dart'; +import '../../../theme.dart'; +import '../domain/canary_fingerprint.dart'; +import '../domain/canary_models.dart'; +import 'canary_composer_screen.dart'; +import 'canary_note_screen.dart'; +import 'canary_providers.dart'; +import 'canary_ui.dart'; +import 'ui/canary_nav_bar.dart'; +import 'ui/chain_chip.dart'; +import 'ui/glow_card.dart'; +import 'ui/liquid_carve_button.dart'; +import 'ui/pixel_canary.dart'; +import 'ui/swap_word_text.dart'; + +class CanaryHomeScreen extends ConsumerStatefulWidget { + const CanaryHomeScreen({super.key}); + + @override + ConsumerState createState() => _CanaryHomeScreenState(); +} + +class _CanaryHomeScreenState extends ConsumerState { + List _notes = const []; + late final CanaryPlan _demoPlan; + late final List> _demoWords; + + final _howItWorksKey = GlobalKey(); + final _notesKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _demoPlan = buildCanaryPlan(kCanaryDemoNote); + _demoWords = [ + List.filled(_demoPlan.bitCount, 0), + List.filled(_demoPlan.bitCount, 1), + ]; + _reload(); + } + + void _reload() { + setState(() => _notes = ref.read(canaryRepositoryProvider).listNotes()); + } + + Future _openComposer() async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CanaryComposerScreen()), + ); + if (mounted) _reload(); + } + + void _scrollTo(GlobalKey key) { + final ctx = key.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + ); + } + } + + void _onCheckLeak() { + if (_notes.isNotEmpty) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CanaryNoteScreen(noteId: _notes.first.noteId), + ), + ); + } else { + _scrollTo(_notesKey); + } + } + + Future _openUrl(String url) async { + final ok = await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + webOnlyWindowName: '_blank', + ); + if (!ok && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Could not open $url'))); + } + } + + static const double _maxWide = 1360; + + static const _tickerItems = kCanaryMonad + ? [ + 'EVERY OPEN = 1 TX ON MONAD + 1 ON SOLANA', + '~0.015 MON PER READER', + 'FINAL IN UNDER 1 SECOND', + 'NO WALLET NEEDED TO READ', + 'LEAK MATCHING STAYS ON YOUR DEVICE', + 'IT CANNOT STOP SCREENSHOTS. IT MAKES THEM TRACEABLE.', + ] + : [ + 'EVERY OPEN = 1 MEMO ON SOLANA DEVNET', + '~0.000005 SOL PER READER', + 'ONLY HASHES GO ON CHAIN, NEVER TEXT', + 'NO WALLET NEEDED TO READ', + 'LEAK MATCHING STAYS ON YOUR DEVICE', + 'IT CANNOT STOP SCREENSHOTS. IT MAKES THEM TRACEABLE.', + ]; + + Widget _sectionTitle(String title, String subtitle) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: 34, + fontWeight: FontWeight.w700, + color: CanaryTokens.text, + height: 1.1, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: const TextStyle( + color: CanaryTokens.textDim, + fontSize: 16, + height: 1.5, + ), + ), + ], + ); + + @override + Widget build(BuildContext context) { + return CanaryUi.frame( + title: CanaryUi.featureName, + customAppBar: CanaryTopBar( + onHowItWorks: () => _scrollTo(_howItWorksKey), + onNotes: () => _scrollTo(_notesKey), + onCheckLeak: _onCheckLeak, + standalone: kCanaryOnly, + ), + body: (context) { + final expanded = AppBreakpoints.isExpanded(context); + final pad = expanded ? 48.0 : 20.0; + final maxW = expanded ? _maxWide : AppBreakpoints.contentMaxCompact; + + Widget contained(Widget child) => Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxW), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: pad), + child: child, + ), + ), + ); + + return LayoutBuilder( + builder: (context, constraints) { + // The hero fills the first screen; the ticker sits on the fold. + final heroMin = expanded + ? (constraints.maxHeight - 60).clamp(520.0, 1400.0) + : 0.0; + final hero = expanded + ? Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(flex: 11, child: _heroCopy(context)), + const SizedBox(width: 72), + Expanded(flex: 10, child: _heroStage(context, true)), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _heroCopy(context), + const SizedBox(height: 36), + _heroStage(context, false), + ], + ); + + return SingleChildScrollView( + child: Column( + children: [ + contained( + Container( + constraints: BoxConstraints(minHeight: heroMin), + alignment: Alignment.center, + padding: EdgeInsets.symmetric( + vertical: expanded ? 40 : 28, + ), + child: hero, + ), + ), + const TickerBand(items: _tickerItems), + contained( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: expanded ? 88 : 48), + Container( + key: _howItWorksKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle( + 'How it works', + 'Three steps. No wallet, no install, no sign-up.', + ), + if (expanded) ...[ + const SizedBox(height: 20), + const HowItWorksConnector(), + const SizedBox(height: 20), + ] else + const SizedBox(height: 20), + _howItWorks(expanded), + ], + ), + ), + SizedBox(height: expanded ? 88 : 48), + Container( + key: _notesKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle( + 'Your Canary notes', + 'Kept only on this device. Clearing your browser data deletes them.', + ), + const SizedBox(height: 24), + if (_notes.isEmpty) + _emptyNotesCard(context) + else + for (final note in _notes) + _noteCard(context, note), + ], + ), + ), + const SizedBox(height: 40), + const Text( + CanaryUi.honestyNote, + style: TextStyle( + color: CanaryTokens.textDim, + fontSize: 14, + height: 1.6, + ), + ), + if (kCanaryOnly) ...[ + const SizedBox(height: 48), + const CanaryFooter(), + ], + const SizedBox(height: 32), + ], + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + /// The demo card with the pixel canary perched on its top edge. + Widget _heroStage(BuildContext context, bool expanded) { + final bird = expanded ? 150.0 : 104.0; + final birdH = bird * 14 / 16; + // Feet (last 2 of 14 rows) rest on the card's top border. + final cardTop = birdH - bird * 2 / 16; + final reduce = CanaryTokens.reduceMotion(context); + Widget bubble = Container( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 6), + decoration: BoxDecoration( + color: CanaryTokens.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CanaryTokens.text, width: 2), + boxShadow: const [ + BoxShadow(color: CanaryTokens.text, offset: Offset(4, 4)), + ], + ), + child: Text( + 'TWEET! I KNOW WHOSE COPY IT IS.', + style: TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: expanded ? 24 : 19, + color: CanaryTokens.text, + height: 1, + ), + ), + ); + if (!reduce) { + bubble = bubble + .animate(delay: 700.ms) + .fadeIn(duration: 300.ms) + .scale( + begin: const Offset(0.85, 0.85), + duration: 300.ms, + curve: Curves.easeOutBack, + ); + } + return Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: EdgeInsets.only(top: cardTop), + child: _demoCard(context), + ), + Positioned( + right: expanded ? 36 : 16, + top: 0, + child: PixelCanary(size: bird, sing: true), + ), + Positioned( + right: (expanded ? 36 : 16) + bird + 20, + top: birdH * 0.18, + child: bubble, + ), + ], + ); + } + + Widget _emptyNotesCard(BuildContext context) { + return GlowCard( + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const PixelCanary(size: 72, sing: true), + const SizedBox(height: 16), + const Text( + 'No Canary notes yet', + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: 24, + fontWeight: FontWeight.w700, + color: CanaryTokens.text, + ), + ), + const SizedBox(height: 6), + const Text( + 'Make one with the demo note. It takes 20 seconds.', + style: TextStyle(color: CanaryTokens.textDim, fontSize: 14), + ), + const SizedBox(height: 16), + OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: CanaryTokens.text, + side: const BorderSide(color: CanaryTokens.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + minimumSize: const Size(48, 48), + ), + onPressed: _openComposer, + child: const Text('Create a Canary link'), + ), + ], + ), + ), + ); + } + + Widget _noteCard(BuildContext context, CanaryOwnerRecord note) { + return GlowCard( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CanaryNoteScreen(noteId: note.noteId), + ), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: DateTime.now().isAfter(note.expiresAt) + ? CanaryTokens.textDim + : CanaryTokens.canary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + note.preview.isEmpty ? 'Untitled note' : note.preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: CanaryTokens.displayFont, + fontWeight: FontWeight.w600, + fontSize: 16, + color: CanaryTokens.text, + ), + ), + const SizedBox(height: 4), + Text( + '${note.copyCount} copies · created ${CanaryUi.clock(note.createdAt)}', + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + color: CanaryTokens.textDim, + fontSize: 18, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: CanaryTokens.textDim), + ], + ), + ); + } + + Widget _heroCopy(BuildContext context) { + final reduce = CanaryTokens.reduceMotion(context); + final expanded = AppBreakpoints.isExpanded(context); + final wide = MediaQuery.sizeOf(context).width >= 1500; + + Widget item(Widget child, int index) { + if (reduce) return child; + return child + .animate(delay: Duration(milliseconds: 70 * index)) + .fadeIn(duration: 420.ms, curve: Curves.easeOutCubic) + .slideY( + begin: 0.08, + end: 0, + duration: 420.ms, + curve: Curves.easeOutCubic, + ); + } + + const stat = TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 22, + color: CanaryTokens.textDim, + height: 1, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + item( + Container( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 4), + decoration: BoxDecoration( + color: CanaryTokens.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: CanaryTokens.text, width: 2), + ), + // One line on wide screens; on a phone the three names of the + // Monad build would not fit in 320 px, so Solana takes a second + // line in the stamp. + child: Text( + !kCanaryMonad + ? 'NO SUS x SOLANA' + : expanded + ? 'NO SUS x MONAD x SOLANA' + : 'NO SUS x MONAD\nx SOLANA', + style: const TextStyle( + color: CanaryTokens.text, + fontFamily: CanaryTokens.monoFont, + fontSize: 22, + letterSpacing: 2, + height: 1.1, + ), + ), + ), + 0, + ), + SizedBox(height: expanded ? 28 : 20), + item( + Text( + 'Every reader gets their own copy.', + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: expanded ? (wide ? 76 : 62) : 38, + fontWeight: FontWeight.w700, + color: CanaryTokens.text, + height: 1.02, + letterSpacing: expanded ? -2 : -1, + ), + ), + 1, + ), + SizedBox(height: expanded ? 22 : 16), + item( + Text( + ' If it leaks, the canary sings. ', + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: expanded ? 30 : 22, + fontWeight: FontWeight.w700, + color: CanaryTokens.text, + backgroundColor: CanaryTokens.canary, + height: 1.3, + ), + ), + 2, + ), + SizedBox(height: expanded ? 24 : 16), + item( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 580), + child: Text( + 'Share one link in your group like any other link. Each person ' + 'who opens it gets a copy worded a tiny bit differently. If a ' + 'screenshot or pasted copy turns up somewhere it should not, ' + 'NO SUS tells you whose copy it was.', + style: TextStyle( + color: CanaryTokens.textDim, + fontSize: expanded ? 19 : 16, + height: 1.6, + ), + ), + ), + 3, + ), + SizedBox(height: expanded ? 36 : 28), + item( + Wrap( + spacing: 18, + runSpacing: 14, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + LiquidCarveButton( + label: 'Create a Canary link', + icon: Icons.add, + onPressed: _openComposer, + ), + OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: CanaryTokens.text, + backgroundColor: CanaryTokens.surface, + side: const BorderSide(color: CanaryTokens.text, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + ), + padding: const EdgeInsets.symmetric( + horizontal: 22, + vertical: 16, + ), + minimumSize: const Size(48, 52), + textStyle: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + ), + ), + onPressed: () => _scrollTo(_howItWorksKey), + child: const Text('See how it works ↓'), + ), + ], + ), + 4, + ), + SizedBox(height: expanded ? 32 : 24), + item( + Wrap( + spacing: 18, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (kCanaryMonad) ...[ + if (kCanaryContract.isNotEmpty) + const ChainChip( + label: 'Contract on Monad', + txHash: kCanaryContract, + compact: true, + ), + const Text('~0.015 MON / READER', style: stat), + const Text('FINAL < 1 S', style: stat), + ] else ...[ + const Text('SOLANA DEVNET', style: stat), + const Text('~0.000005 SOL / READER', style: stat), + ], + InkWell( + onTap: () => _openUrl(kCanaryRepoUrl), + child: const Text( + 'OPEN SOURCE ↗', + style: TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 22, + height: 1, + color: CanaryTokens.text, + decoration: TextDecoration.underline, + decorationColor: CanaryTokens.canary, + decorationThickness: 3, + ), + ), + ), + ], + ), + 5, + ), + ], + ); + } + + Widget _demoCard(BuildContext context) { + return GlowCard( + padding: const EdgeInsets.fromLTRB(28, 26, 28, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Same note, different copies', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 12), + SwapWordText( + plan: _demoPlan, + codewords: _demoWords, + labels: const ['Copy 03 · Asha', 'Copy 17 · Ben'], + ), + ], + ), + ); + } + + Widget _howItWorks(bool expanded) { + final reduce = CanaryTokens.reduceMotion(context); + final cards = [ + ( + '1', + Icons.edit_outlined, + 'Write once', + 'One note. One link for the group. Encrypted locally with AES-256 before upload.', + ), + ( + '2', + Icons.copy_outlined, + 'Everyone gets a unique copy', + 'Tiny wording changes identify each reader without changing meaning.', + ), + ( + '3', + Icons.search, + 'Paste a leak, find the copy', + 'Matching stays 100% on this device. Local fingerprint engine identifies who leaked.', + ), + ]; + + final children = [ + for (var i = 0; i < cards.length; i++) + GlowCard( + margin: EdgeInsets.only(bottom: expanded ? 0 : 16, right: 0), + padding: const EdgeInsets.all(26), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: CanaryTokens.canary, + border: Border.all(color: CanaryTokens.text, width: 2), + ), + alignment: Alignment.center, + child: Text( + cards[i].$1, + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + color: CanaryTokens.onCanary, + fontSize: 26, + height: 1, + ), + ), + ), + Icon(cards[i].$2, color: CanaryTokens.text, size: 26), + ], + ), + const SizedBox(height: 22), + Text( + cards[i].$3, + style: const TextStyle( + fontFamily: CanaryTokens.displayFont, + fontWeight: FontWeight.w700, + fontSize: 22, + color: CanaryTokens.text, + height: 1.2, + ), + ), + const SizedBox(height: 10), + Text( + cards[i].$4, + style: const TextStyle( + color: CanaryTokens.textDim, + fontSize: 16, + height: 1.55, + ), + ), + ], + ), + ), + ]; + + if (!expanded) { + return Column(children: children); + } + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(width: 24), + Expanded( + child: reduce + ? children[i] + : children[i] + .animate(delay: Duration(milliseconds: 60 * i)) + .fadeIn(duration: 400.ms, curve: Curves.easeOutCubic) + .slideY(begin: 0.05, end: 0, duration: 400.ms), + ), + ], + ], + ), + ); + } +} diff --git a/app/lib/features/canary/presentation/canary_leak_check_screen.dart b/app/lib/features/canary/presentation/canary_leak_check_screen.dart new file mode 100644 index 0000000..bb1715e --- /dev/null +++ b/app/lib/features/canary/presentation/canary_leak_check_screen.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../theme.dart'; +import '../domain/canary_fingerprint.dart'; +import '../domain/canary_models.dart'; +import '../ocr/canary_ocr.dart'; +import 'canary_providers.dart'; +import 'canary_ui.dart'; +import 'ui/canary_confetti.dart'; +import 'ui/canary_reveal.dart'; +import 'ui/glow_card.dart'; +import 'ui/liquid_carve_button.dart'; + +/// Paste a leaked text (or read a screenshot on Android) and find the copy. +/// Matching runs on this device; the leak is never uploaded. +class CanaryLeakCheckScreen extends StatelessWidget { + const CanaryLeakCheckScreen({super.key, required this.noteId}); + + final String noteId; + + @override + Widget build(BuildContext context) { + return CanaryUi.scaffold( + title: 'Check a leak', + builder: (context) => [ + CanaryLeakPanel(noteId: noteId, showHonesty: true), + ], + ); + } +} + +/// Leak-check controls. Used as a full page and as the desktop dashboard panel. +class CanaryLeakPanel extends ConsumerStatefulWidget { + const CanaryLeakPanel({ + super.key, + required this.noteId, + this.showHonesty = false, + }); + + final String noteId; + final bool showHonesty; + + @override + ConsumerState createState() => _CanaryLeakPanelState(); +} + +class _CanaryLeakPanelState extends ConsumerState { + final _leak = TextEditingController(); + + /// Read once: the dashboard rebuilds this panel on every status poll. + late final CanaryOwnerRecord? _record = ref + .read(canaryRepositoryProvider) + .findNote(widget.noteId); + CanaryMatch? _match; + CanaryNoteStatus? _status; + bool _busy = false; + bool _scanning = false; + String? _error; + + @override + void dispose() { + _leak.dispose(); + super.dispose(); + } + + Future _readScreenshot() async { + setState(() => _error = null); + final String? text; + try { + text = await canaryPickScreenshotText(); + } catch (_) { + if (mounted) setState(() => _error = 'Could not read that picture.'); + return; + } + if (!mounted || text == null) return; + if (text.trim().isEmpty) { + setState(() => _error = 'No text found in that picture.'); + return; + } + _leak.text = text; + await _check(); + } + + Future _paste() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (mounted && text != null) _leak.text = text; + } + + Future _check() async { + final repository = ref.read(canaryRepositoryProvider); + final record = _record; + if (record == null) { + setState(() => _error = 'This note is not on this device.'); + return; + } + final match = repository.checkLeak(record, _leak.text); + setState(() { + _busy = true; + _scanning = true; + _error = null; + _match = null; + }); + // Started before the scan animation and awaited after it. Names are a + // bonus (the copy number alone is useful), so a failure becomes null + // here and can never surface as an unhandled error. + final statusFuture = repository + .fetchStatus(record) + .then((s) => s, onError: (Object _) => null); + if (!CanaryTokens.reduceMotion(context)) { + await Future.delayed(const Duration(milliseconds: 900)); + } + final status = await statusFuture; + if (!mounted) return; + setState(() { + _match = match; + _status = status; + _busy = false; + _scanning = false; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final record = _record; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Paste the text that leaked — from a forward, a post, or a ' + 'screenshot. Even part of it helps.', + ), + const SizedBox(height: 12), + LeakScanOverlay( + active: _scanning, + child: TextField( + controller: _leak, + minLines: 5, + maxLines: 12, + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 21, + color: CanaryTokens.text, + ), + decoration: const InputDecoration( + labelText: 'Leaked text', + alignLabelWithHint: true, + ), + ), + ), + if (record != null && (_scanning || _match != null)) ...[ + const SizedBox(height: 12), + ScanHitChips(plan: record.plan, leak: _leak.text, active: _scanning), + ], + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + LiquidCarveButton( + label: 'Find the copy', + icon: Icons.search, + busy: _busy, + onPressed: _busy ? null : _check, + ), + OutlinedButton.icon( + icon: const Icon(Icons.content_paste), + label: const Text('Paste'), + onPressed: _paste, + ), + if (canaryOcrAvailable) + OutlinedButton.icon( + icon: const Icon(Icons.image_search), + label: const Text('Read a screenshot'), + onPressed: _busy ? null : _readScreenshot, + ), + ], + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + if (_match != null) ...[ + const SizedBox(height: 20), + _ResultCard(match: _match!, status: _status), + ], + if (widget.showHonesty) ...[ + const SizedBox(height: 24), + const Text(CanaryUi.honestyNote), + ], + ], + ); + } +} + +class _ResultCard extends StatelessWidget { + const _ResultCard({required this.match, required this.status}); + + final CanaryMatch match; + final CanaryNoteStatus? status; + + @override + Widget build(BuildContext context) { + final reveal = + match.kind == CanaryMatchKind.exactMarker || + match.kind == CanaryMatchKind.confident || + match.kind == CanaryMatchKind.likely; + final celebrate = + match.kind == CanaryMatchKind.exactMarker || + match.kind == CanaryMatchKind.confident; + + if (reveal) { + final revealCard = CanaryReveal( + match: match, + status: status, + likely: match.kind == CanaryMatchKind.likely, + ); + if (!celebrate) return revealCard; + return Stack( + clipBehavior: Clip.none, + children: [ + revealCard, + const Positioned.fill(child: CanaryConfetti()), + ], + ); + } + + final (String headline, String detail) = switch (match.kind) { + CanaryMatchKind.senderOriginal => ( + 'This is your own original text', + 'It matches the version on this device, not any reader\'s copy.', + ), + CanaryMatchKind.ambiguous => ( + 'Could be copies ${match.candidates.map((i) => '#${i + 1}').join(', ')}', + 'Paste more of the leak to narrow it down.', + ), + CanaryMatchKind.notEnough => ( + 'Not enough of the note to tell', + 'Paste more of the leaked text.', + ), + CanaryMatchKind.noMatch => ( + 'No single copy matches', + 'The text may have been edited, or mixed from more than one copy. ' + 'Canary will not name anyone in this case.', + ), + _ => ('', ''), + }; + + return GlowCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( + liveRegion: true, + child: Text( + headline, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const SizedBox(height: 8), + Text(detail), + ], + ), + ); + } +} diff --git a/app/lib/features/canary/presentation/canary_note_screen.dart b/app/lib/features/canary/presentation/canary_note_screen.dart new file mode 100644 index 0000000..6ee6431 --- /dev/null +++ b/app/lib/features/canary/presentation/canary_note_screen.dart @@ -0,0 +1,288 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../config/app_mode.dart'; +import '../../../core/layout/app_breakpoints.dart'; +import '../../../theme.dart'; +import '../data/canary_api.dart'; +import '../domain/canary_link.dart'; +import '../domain/canary_models.dart'; +import 'canary_created_screen.dart'; +import 'canary_leak_check_screen.dart'; +import 'canary_providers.dart'; +import 'canary_ui.dart'; +import 'ui/glow_card.dart'; +import 'ui/proof_chips.dart'; +import 'ui/wallet_button.dart'; + +/// Owner dashboard: live "Seen by" list plus the leak check. +class CanaryNoteScreen extends ConsumerStatefulWidget { + const CanaryNoteScreen({super.key, required this.noteId}); + + final String noteId; + + @override + ConsumerState createState() => _CanaryNoteScreenState(); +} + +class _CanaryNoteScreenState extends ConsumerState { + CanaryOwnerRecord? _record; + CanaryNoteStatus? _status; + String? _error; + Timer? _poll; + bool _loading = false; + + @override + void initState() { + super.initState(); + _record = ref.read(canaryRepositoryProvider).findNote(widget.noteId); + if (_record != null) { + _refresh(); + // Polling (not Realtime) on purpose: simplest thing that works on + // every network, and a 3 s delay is fine for a "Seen by" list. + _poll = Timer.periodic(const Duration(seconds: 3), (_) => _refresh()); + } + } + + @override + void dispose() { + _poll?.cancel(); + super.dispose(); + } + + Future _refresh() async { + final record = _record; + if (record == null || _loading) return; + _loading = true; + try { + final status = await ref + .read(canaryRepositoryProvider) + .fetchStatus(record); + if (!mounted) return; + setState(() { + _status = status; + _error = null; + }); + if (status.isSettled) _poll?.cancel(); + } on CanaryApiException catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) setState(() => _error = 'Could not load who opened it.'); + } finally { + _loading = false; + } + } + + Future _openSolanaSeal(String signature) async { + final ok = await launchUrl( + Uri.parse(canarySolanaExplorerTxUrl(signature)), + mode: LaunchMode.externalApplication, + ); + if (!ok && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open the explorer.')), + ); + } + } + + /// Solana is on for this note when its seal landed there; an open with no + /// signature yet is then "still being written" for a short while. + bool _solanaPending(CanaryNoteStatus status, CanaryCopyStatus copy) => + status.sealSolanaSig != null && + copy.openSolanaSig == null && + DateTime.now().difference(copy.openedAt) < const Duration(minutes: 2); + + @override + Widget build(BuildContext context) { + final record = _record; + if (record == null) { + return CanaryUi.missing(message: 'This note is not on this device.'); + } + final status = _status; + final opened = status?.copies.length ?? 0; + + return CanaryUi.frame( + title: 'Canary note', + body: (context) { + final theme = Theme.of(context); + final expanded = AppBreakpoints.isExpanded(context); + final pad = expanded ? 32.0 : 20.0; + final left = _leftColumn( + context, + theme, + record, + status, + opened, + expanded, + ); + if (!expanded) { + return ListView( + padding: EdgeInsets.all(pad), + children: [ + ...left, + const SizedBox(height: 24), + const Text(CanaryUi.honestyNote), + ], + ); + } + return Padding( + padding: EdgeInsets.all(pad), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 5, + child: ListView( + children: [ + ...left, + const SizedBox(height: 24), + const Text(CanaryUi.honestyNote), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + flex: 4, + child: ListView( + children: [ + Text('Check a leak', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + GlowCard(child: CanaryLeakPanel(noteId: record.noteId)), + ], + ), + ), + ], + ), + ); + }, + ); + } + + List _leftColumn( + BuildContext context, + ThemeData theme, + CanaryOwnerRecord record, + CanaryNoteStatus? status, + int opened, + bool expanded, + ) { + return [ + Text(record.preview, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Semantics( + liveRegion: true, + child: Text( + status == null + ? 'Checking who opened it…' + : '$opened of ${record.copyCount} copies opened', + style: theme.textTheme.headlineSmall, + ), + ), + const SizedBox(height: 4), + Text( + 'Link works until ${CanaryUi.clock(record.expiresAt)} ' + '(${record.expiresAt.toLocal().day}/${record.expiresAt.toLocal().month})', + style: const TextStyle(fontFamily: CanaryTokens.monoFont), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + if (!expanded) + FilledButton.icon( + icon: const Icon(Icons.search), + label: const Text('Check a leak'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CanaryLeakCheckScreen(noteId: record.noteId), + ), + ), + ), + if (kCanaryMonad) + VerifyOnMonadButton( + noteId: record.noteId, + localOpened: status?.copies.length, + ), + if (status?.sealSolanaSig case final sig?) + OutlinedButton.icon( + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text( + 'Verify on Solana ↗', + semanticsLabel: 'Verify on Solana. Opens the seal on ' + 'Solana devnet in the explorer.', + ), + onPressed: () => _openSolanaSeal(sig), + ), + OutlinedButton.icon( + icon: const Icon(Icons.qr_code), + label: const Text('Show link & QR'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CanaryCreatedScreen(noteId: record.noteId), + ), + ), + ), + ], + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + const Divider(height: 36), + Text('Seen by', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + if (status != null && status.copies.isEmpty) + const Text('Nobody has opened it yet.'), + if (status != null) + for (final copy in status.copies) + GlowCard( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + backgroundColor: CanaryTokens.surfaceHi, + foregroundColor: CanaryTokens.canary, + child: Text('${copy.copyIndex + 1}'), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(copy.readerName, style: theme.textTheme.titleSmall), + Text( + 'Copy ${copy.copyIndex + 1} · opened ${CanaryUi.clock(copy.openedAt)}', + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + color: CanaryTokens.textDim, + ), + ), + const SizedBox(height: 8), + // Below the name, not beside it: two chips need the + // card's full width to wrap on a phone. + ProofChips( + monadTxHash: copy.openTxHash, + solanaSig: copy.openSolanaSig, + monadPending: copy.openTxHash == null, + solanaPending: _solanaPending(status, copy), + compact: true, + ), + ], + ), + ), + ], + ), + ), + ]; + } +} diff --git a/app/lib/features/canary/presentation/canary_providers.dart b/app/lib/features/canary/presentation/canary_providers.dart new file mode 100644 index 0000000..5917566 --- /dev/null +++ b/app/lib/features/canary/presentation/canary_providers.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/providers/theme_provider.dart' + show sharedPreferencesProvider; +import '../data/canary_repository.dart'; +import '../data/canary_store.dart'; + +/// sharedPreferencesProvider is overridden with a warm instance in main.dart +/// (both the normal app and every standalone runApp branch). +final canaryRepositoryProvider = Provider((ref) { + return CanaryRepository(CanaryStore(ref.watch(sharedPreferencesProvider))); +}); diff --git a/app/lib/features/canary/presentation/canary_reader_screen.dart b/app/lib/features/canary/presentation/canary_reader_screen.dart new file mode 100644 index 0000000..3f615a2 --- /dev/null +++ b/app/lib/features/canary/presentation/canary_reader_screen.dart @@ -0,0 +1,426 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../config/app_mode.dart'; +import '../../../core/layout/app_breakpoints.dart'; +import '../../../theme.dart'; +import '../data/canary_api.dart'; +import '../domain/canary_models.dart'; +import 'canary_providers.dart'; +import 'canary_ui.dart'; +import 'ui/canary_envelope.dart'; +import 'ui/copy_badge.dart'; +import 'ui/glow_card.dart'; +import 'ui/liquid_carve_button.dart'; +import 'ui/pixel_canary.dart'; +import 'ui/proof_chips.dart'; +import 'ui/canary_strip.dart'; + +/// Standalone app for the web reader path (main.dart runs it directly for +/// `#/canary/?k=...` links, like the Burn viewers). +class CanaryReaderApp extends StatelessWidget { + const CanaryReaderApp({ + super.key, + required this.noteId, + required this.keyHex, + }); + + final String noteId; + final String keyHex; + + @override + Widget build(BuildContext context) => MaterialApp( + title: 'NO SUS', + debugShowCheckedModeBanner: false, + theme: CanaryTokens.theme(), + darkTheme: CanaryTokens.theme(), + themeMode: ThemeMode.dark, + builder: (context, child) => + CanaryStrip(child: child ?? const SizedBox.shrink()), + home: CanaryReaderScreen(noteId: noteId, keyHex: keyHex), + ); +} + +/// What a reader sees: type a name, get their own numbered copy. +class CanaryReaderScreen extends ConsumerStatefulWidget { + const CanaryReaderScreen({ + super.key, + required this.noteId, + required this.keyHex, + }); + + final String noteId; + final String keyHex; + + @override + ConsumerState createState() => _CanaryReaderScreenState(); +} + +class _CanaryReaderScreenState extends ConsumerState { + final _name = TextEditingController(); + final _nameFocus = FocusNode(); + CanaryOpenedCopy? _copy; + bool _busy = false; + String? _error; + + @override + void initState() { + super.initState(); + _name.text = ref.read(canaryRepositoryProvider).lastReaderName() ?? ''; + _nameFocus.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _name.dispose(); + _nameFocus.dispose(); + super.dispose(); + } + + Future _open() async { + setState(() { + _busy = true; + _error = null; + }); + try { + final copy = await ref + .read(canaryRepositoryProvider) + .openCopy( + noteId: widget.noteId, + keyHex: widget.keyHex, + readerName: _name.text, + ); + if (mounted) setState(() => _copy = copy); + } on CanaryUserException catch (e) { + if (mounted) setState(() => _error = e.message); + } on CanaryApiException catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) { + setState(() => _error = 'Could not open the note. Try again.'); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return CanaryUi.frame( + title: CanaryUi.featureName, + body: (context) => _layout(context, _card(context)), + ); + } + + Widget _layout(BuildContext context, Widget card) { + final expanded = AppBreakpoints.isExpanded(context); + if (!expanded) { + return ListView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 32), + children: [ + const Center(child: PixelCanary(size: 96, sing: true)), + const SizedBox(height: 20), + card, + ], + ); + } + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1360), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 48, + vertical: 40, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Expanded(flex: 9, child: _BrandSide()), + const SizedBox(width: 72), + Expanded( + flex: 10, + child: Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: card, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _card(BuildContext context) { + final copy = _copy; + if (copy == null && !_busy) return _gate(context); + return CanaryFlipCard( + showBack: copy != null, + front: const CanaryEnvelope(), + back: copy == null ? const CanaryEnvelope() : _copyView(context, copy), + ); + } + + Widget _gate(BuildContext context) { + final theme = Theme.of(context); + final focused = _nameFocus.hasFocus; + final expanded = AppBreakpoints.isExpanded(context); + return GlowCard( + padding: EdgeInsets.all(expanded ? 36 : 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.fromLTRB(10, 5, 10, 3), + decoration: BoxDecoration( + color: CanaryTokens.canary, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: CanaryTokens.text, width: 2), + ), + child: const Text( + 'PRIVATE NOTE', + style: TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 22, + letterSpacing: 2, + height: 1, + color: CanaryTokens.text, + ), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'Someone shared a private note with you.', + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: expanded ? 38 : 28, + fontWeight: FontWeight.w700, + height: 1.1, + letterSpacing: -0.8, + color: CanaryTokens.text, + ), + ), + const SizedBox(height: 14), + const Text( + 'Every reader gets their own numbered copy. Type your name to open ' + 'yours.', + style: TextStyle( + fontSize: 17, + height: 1.55, + color: CanaryTokens.textDim, + ), + ), + const SizedBox(height: 28), + AnimatedContainer( + duration: CanaryTokens.reduceMotion(context) + ? Duration.zero + : const Duration(milliseconds: 180), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(CanaryTokens.rCard), + boxShadow: focused ? CanaryTokens.glow : null, + ), + child: TextField( + controller: _name, + focusNode: _nameFocus, + enabled: !_busy, + maxLength: 40, + textInputAction: TextInputAction.go, + onSubmitted: (_) => _busy ? null : _open(), + style: const TextStyle(fontSize: 18, color: CanaryTokens.text), + decoration: const InputDecoration(labelText: 'Your name'), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: LiquidCarveButton( + label: 'Open my copy', + icon: Icons.lock_open, + busy: _busy, + onPressed: _busy ? null : _open, + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics( + liveRegion: true, + child: Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ), + const SizedBox(height: 28), + Container( + height: 2, + color: CanaryTokens.text.withValues(alpha: 0.15), + ), + const SizedBox(height: 16), + const Text( + kCanaryMonad + ? 'The sender sees your name and when you opened your copy. Monad ' + 'testnet and Solana devnet each record that a copy was opened, ' + 'without your name.' + : 'The sender sees your name and when you opened your copy. ' + 'Solana devnet records that a copy was opened, without ' + 'your name.', + style: TextStyle( + fontSize: 14, + height: 1.55, + color: CanaryTokens.textDim, + ), + ), + ], + ), + ); + } + + Widget _copyView(BuildContext context, CanaryOpenedCopy copy) { + final theme = Theme.of(context); + return GlowCard( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CopyBadge( + copyIndex: copy.copyIndex, + copyCount: copy.copyCount, + readerName: copy.readerName, + ), + const SizedBox(height: 24), + SelectableText( + copy.text, + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 19, + height: 1.7, + ), + ), + const SizedBox(height: 24), + Text( + 'This copy is unique to you. If it gets shared, NO SUS can tell it was ' + 'this copy.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + DelayedFadeIn( + delay: const Duration(milliseconds: 450), + // No polling here, so no pending chip: show the proofs that + // exist, or the time alone when no shown proof was stored. + child: + copy.openSolanaSig == null && + (!kCanaryMonad || copy.openTxHash == null) + ? Text( + kCanaryMonad + ? 'Recorded on Monad testnet at ${CanaryUi.clock(copy.openedAt)}' + : 'Opened at ${CanaryUi.clock(copy.openedAt)}', + style: const TextStyle(fontFamily: CanaryTokens.monoFont), + ) + : ProofChips( + monadTxHash: copy.openTxHash, + solanaSig: copy.openSolanaSig, + labelPrefix: 'Recorded on', + ), + ), + ], + ), + ); + } +} + +class _BrandSide extends StatelessWidget { + const _BrandSide(); + + static const _facts = [ + 'YOUR COPY IS UNIQUE TO YOU', + kCanaryMonad + ? 'OPENING IT = 1 TX ON MONAD + 1 ON SOLANA' + : 'OPENING IT = 1 MEMO ON SOLANA DEVNET', + 'NO WALLET, NO APP, NO SIGN-UP', + ]; + + @override + Widget build(BuildContext context) { + final wide = MediaQuery.sizeOf(context).width >= 1500; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + PixelCanary(size: wide ? 220 : 180, sing: true), + const SizedBox(height: 32), + Text( + 'NO SUS', + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: wide ? 88 : 72, + fontWeight: FontWeight.w700, + height: 0.95, + letterSpacing: -2, + color: CanaryTokens.text, + ), + ), + const SizedBox(height: 14), + const Text( + kCanaryMonad + ? ' Your own copy. Recorded on Monad + Solana. ' + : ' Your own copy. Recorded on Solana. ', + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: 26, + fontWeight: FontWeight.w700, + height: 1.3, + color: CanaryTokens.text, + backgroundColor: CanaryTokens.canary, + ), + ), + const SizedBox(height: 32), + for (final fact in _facts) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: CanaryTokens.canary, + border: Border.all(color: CanaryTokens.text, width: 2), + ), + ), + const SizedBox(width: 14), + Flexible( + child: Text( + fact, + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 24, + letterSpacing: 1, + height: 1.1, + color: CanaryTokens.text, + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/features/canary/presentation/canary_ui.dart b/app/lib/features/canary/presentation/canary_ui.dart new file mode 100644 index 0000000..b09ab51 --- /dev/null +++ b/app/lib/features/canary/presentation/canary_ui.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../config/app_mode.dart'; +import '../../../core/layout/app_breakpoints.dart'; +import '../../../theme.dart'; +import '../domain/canary_link.dart'; +import 'ui/canary_backdrop.dart'; +import 'ui/canary_mark.dart'; +import 'ui/wallet_button.dart'; + +/// Small shared pieces for the Canary screens. +class CanaryUi { + const CanaryUi._(); + + static const String featureName = 'NO SUS Canary'; + + /// Always visible on Canary screens. Keep it honest and short. + static const String honestyNote = + 'Canary shows whose copy leaked, not who shared it. Phones get lost and ' + 'screens get shown to friends. If two readers compare and mix their ' + 'copies, the result can point at the wrong copy.'; + + static String clock(DateTime time) { + final t = time.toLocal(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'; + } + + static Future openTx(BuildContext context, String txHash) async { + final ok = await launchUrl( + Uri.parse(canaryExplorerTxUrl(txHash)), + mode: LaunchMode.externalApplication, + ); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open the explorer.')), + ); + } + } + + static Widget scaffold({ + required String title, + required List Function(BuildContext context) builder, + List? actions, + }) { + return Theme( + data: CanaryTokens.theme(), + child: Builder( + builder: (context) { + return Scaffold( + backgroundColor: CanaryTokens.bg, + appBar: AppBar( + title: Row( + children: [ + const CanaryMark(size: 28), + const SizedBox(width: 12), + Flexible(child: Text(title, overflow: TextOverflow.ellipsis)), + ], + ), + actions: actions, + ), + body: CanaryBackdrop(child: page(children: builder(context))), + ); + }, + ), + ); + } + + static Widget frame({ + required String title, + required Widget Function(BuildContext context) body, + List? actions, + PreferredSizeWidget? customAppBar, + bool showAppBar = true, + }) { + return Theme( + data: CanaryTokens.theme(), + child: Builder( + builder: (context) { + return Scaffold( + backgroundColor: CanaryTokens.bg, + appBar: !showAppBar + ? null + : customAppBar ?? + AppBar( + title: Row( + children: [ + const CanaryMark(size: 28), + const SizedBox(width: 12), + Flexible( + child: Text( + title, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + actions: [ + ...?actions, + if (kCanaryMonad) + const WalletButton(compact: true), + const SizedBox(width: 8), + ], + ), + body: CanaryBackdrop(child: body(context)), + ); + }, + ), + ); + } + + static Widget missing({required String message}) { + return Theme( + data: CanaryTokens.theme(), + child: Scaffold( + backgroundColor: CanaryTokens.bg, + appBar: AppBar(), + body: CanaryBackdrop( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(message, textAlign: TextAlign.center), + ), + ), + ), + ), + ); + } + + static Widget page({required List children}) => LayoutBuilder( + builder: (context, constraints) { + final expanded = constraints.maxWidth >= AppBreakpoints.expanded; + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: expanded + ? AppBreakpoints.contentMaxExpanded + : AppBreakpoints.contentMaxCompact, + ), + child: ListView( + padding: EdgeInsets.all(expanded ? 32 : 20), + children: children, + ), + ), + ); + }, + ); +} diff --git a/app/lib/features/canary/presentation/ui/canary_backdrop.dart b/app/lib/features/canary/presentation/ui/canary_backdrop.dart new file mode 100644 index 0000000..1196a3c --- /dev/null +++ b/app/lib/features/canary/presentation/ui/canary_backdrop.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; + +/// Plain paper page background. No grid, no gradients. +class CanaryBackdrop extends StatelessWidget { + const CanaryBackdrop({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => + ColoredBox(color: CanaryTokens.bg, child: child); +} diff --git a/app/lib/features/canary/presentation/ui/canary_confetti.dart b/app/lib/features/canary/presentation/ui/canary_confetti.dart new file mode 100644 index 0000000..19ca804 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/canary_confetti.dart @@ -0,0 +1,140 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import '../../../../theme.dart'; + +/// Lightweight orange/purple/white burst. Ticker-driven CustomPaint. +class CanaryConfetti extends StatefulWidget { + const CanaryConfetti({super.key, this.play = true}); + + final bool play; + + @override + State createState() => _CanaryConfettiState(); +} + +class _Particle { + _Particle(this.angle, this.speed, this.spin, this.color, this.size); + final double angle; + final double speed; + final double spin; + final Color color; + final double size; +} + +class _CanaryConfettiState extends State + with SingleTickerProviderStateMixin { + late final Ticker _ticker; + final ValueNotifier _t = ValueNotifier(1); + late final List<_Particle> _particles; + Duration _start = Duration.zero; + bool _started = false; + + static const _colors = [ + CanaryTokens.canary, + CanaryTokens.canaryHi, + CanaryTokens.monad, + Colors.white, + ]; + + @override + void initState() { + super.initState(); + final rng = math.Random(7); + _particles = [ + for (var i = 0; i < 40; i++) + _Particle( + rng.nextDouble() * math.pi * 2, + 80 + rng.nextDouble() * 140, + (rng.nextDouble() - 0.5) * 8, + _colors[i % _colors.length], + 3 + rng.nextDouble() * 4, + ), + ]; + _ticker = createTicker(_onTick); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final reduce = CanaryTokens.reduceMotion(context); + if (reduce || !widget.play) { + if (_ticker.isActive) _ticker.stop(); + _t.value = 1; + return; + } + if (!_started) { + _started = true; + _ticker.start(); + } + } + + void _onTick(Duration elapsed) { + if (!_started) { + _start = elapsed; + _started = true; + } + final t = (elapsed - _start).inMilliseconds / 1200.0; + if (t >= 1) { + _t.value = 1; + _ticker.stop(); + return; + } + _t.value = t; + } + + @override + void dispose() { + _ticker.dispose(); + _t.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: CustomPaint( + painter: _ConfettiPainter(progress: _t, particles: _particles), + child: const SizedBox.expand(), + ), + ); + } +} + +class _ConfettiPainter extends CustomPainter { + _ConfettiPainter({required this.progress, required this.particles}) + : super(repaint: progress); + + final ValueNotifier progress; + final List<_Particle> particles; + + @override + void paint(Canvas canvas, Size size) { + final t = progress.value; + if (t <= 0 || t >= 1) return; + final origin = Offset(size.width / 2, size.height / 2); + final fade = (1 - t).clamp(0.0, 1.0); + for (final p in particles) { + final dist = p.speed * t; + final pos = origin + Offset(math.cos(p.angle), math.sin(p.angle)) * dist; + final paint = Paint()..color = p.color.withValues(alpha: fade); + canvas.save(); + canvas.translate(pos.dx, pos.dy); + canvas.rotate(p.spin * t); + canvas.drawRect( + Rect.fromCenter( + center: Offset.zero, + width: p.size, + height: p.size * 0.45, + ), + paint, + ); + canvas.restore(); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/app/lib/features/canary/presentation/ui/canary_envelope.dart b/app/lib/features/canary/presentation/ui/canary_envelope.dart new file mode 100644 index 0000000..3e995b5 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/canary_envelope.dart @@ -0,0 +1,262 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../../config/app_mode.dart'; +import '../../../../theme.dart'; +import 'canary_mark.dart'; +import 'glow_card.dart'; +import 'pixel_canary.dart'; + +/// The animated pixel canary (bobs, flaps, blinks). Still when animations +/// are disabled. +class BobbingCanaryMark extends StatelessWidget { + const BobbingCanaryMark({super.key, this.size = 64}); + + final double size; + + @override + Widget build(BuildContext context) => PixelCanary(size: size); +} + +/// Sealed envelope shown while the copy is assigned and recorded on chain. +class CanaryEnvelope extends StatefulWidget { + const CanaryEnvelope({super.key}); + + @override + State createState() => _CanaryEnvelopeState(); +} + +class _CanaryEnvelopeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context)) { + _c.stop(); + _c.value = 1; + } else if (!_c.isAnimating) { + _c.repeat(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GlowCard( + child: CustomPaint( + painter: _ShimmerPainter(_c), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CanaryMark(size: 56), + const SizedBox(height: 16), + Text( + kCanaryMonad + ? 'Making your copy… recording it on Monad' + : 'Making your copy… recording it on Solana', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + AnimatedBuilder( + animation: _c, + builder: (context, _) { + final n = CanaryTokens.reduceMotion(context) + ? 3 + : ((_c.value * 3).floor() % 3) + 1; + return Text( + List.filled(n, '·').join(' '), + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 28, + color: CanaryTokens.text, + height: 1, + ), + ); + }, + ), + ], + ), + ), + ); + } +} + +class _ShimmerPainter extends CustomPainter { + _ShimmerPainter(this.progress) : super(repaint: progress); + + final Animation progress; + + @override + void paint(Canvas canvas, Size size) { + final t = progress.value; + if (t <= 0 || t >= 1) return; + final x = size.width * (t * 1.4 - 0.2); + final rect = Rect.fromLTWH(x, 0, size.width * 0.12, size.height); + canvas.drawRect( + rect, + Paint()..color = CanaryTokens.canary.withValues(alpha: 0.25), + ); + } + + @override + bool shouldRepaint(covariant _ShimmerPainter oldDelegate) => + oldDelegate.progress != progress; +} + +/// 3D Y-flip from [front] to [back] in 500 ms. +class CanaryFlipCard extends StatefulWidget { + const CanaryFlipCard({ + super.key, + required this.front, + required this.back, + required this.showBack, + }); + + final Widget front; + final Widget back; + final bool showBack; + + @override + State createState() => _CanaryFlipCardState(); +} + +class _CanaryFlipCardState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + if (widget.showBack) _c.value = 1; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context) && widget.showBack) { + _c.value = 1; + } + } + + @override + void didUpdateWidget(covariant CanaryFlipCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.showBack == oldWidget.showBack) return; + if (CanaryTokens.reduceMotion(context)) { + _c.value = widget.showBack ? 1 : 0; + return; + } + if (widget.showBack) { + _c.forward(); + } else { + _c.reverse(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _c, + builder: (context, _) { + final t = Curves.easeOutCubic.transform(_c.value); + final back = t >= 0.5; + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(math.pi * t), + child: back + ? Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY(math.pi), + child: widget.back, + ) + : widget.front, + ); + }, + ); + } +} + +/// Fades [child] in after [delay]. Instant when animations are disabled. +class DelayedFadeIn extends StatefulWidget { + const DelayedFadeIn({ + super.key, + required this.child, + this.delay = const Duration(milliseconds: 400), + }); + + final Widget child; + final Duration delay; + + @override + State createState() => _DelayedFadeInState(); +} + +class _DelayedFadeInState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c; + bool _scheduled = false; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context)) { + _c.value = 1; + return; + } + if (_scheduled || _c.isCompleted || _c.isAnimating) return; + _scheduled = true; + Future.delayed(widget.delay, () { + if (mounted) _c.forward(); + }); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition(opacity: _c, child: widget.child); + } +} diff --git a/app/lib/features/canary/presentation/ui/canary_mark.dart b/app/lib/features/canary/presentation/ui/canary_mark.dart new file mode 100644 index 0000000..20ff2f2 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/canary_mark.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; + +/// The NO SUS pixel canary (same sprite as the README and the hub page). +/// Never use an emoji glyph for this mark. +class CanaryMark extends StatelessWidget { + const CanaryMark({super.key, this.size = 32}); + + final double size; + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'NO SUS Canary', + image: true, + child: SizedBox( + width: size, + height: size * 14 / 16, + child: const CustomPaint(painter: CanaryMarkPainter()), + ), + ); + } +} + +class CanaryMarkPainter extends CustomPainter { + const CanaryMarkPainter(); + + static const _rows = [ + '.....KKKKK......', + '...KKYYYYYKK....', + '..KYYYYYYYWWK...', + '..KYYYYYYYWKK...', + '.KYYYYYYYYYYKOO.', + '.KYYYYYYYYYYKOOO', + '.KYyyyyYYYYYK...', + 'KYYyyyyyYYYYK...', + 'KYYYyyyyYYYK....', + '.KYYYyyYYYYK....', + '..KYYYYYYYK.....', + '...KKKKKKK......', + '....K...K.......', + '...KK..KK.......', + ]; + + @override + void paint(Canvas canvas, Size size) { + final cell = (size.width / 16 < size.height / 14) + ? size.width / 16 + : size.height / 14; + final colors = { + 'K': CanaryTokens.text, + 'O': CanaryTokens.text, + 'Y': CanaryTokens.canary, + 'y': CanaryTokens.bg, + 'W': CanaryTokens.bg, + }; + for (var y = 0; y < _rows.length; y++) { + final row = _rows[y]; + var x = 0; + while (x < row.length) { + final c = row[x]; + if (c == '.') { + x++; + continue; + } + var end = x; + while (end < row.length && row[end] == c) { + end++; + } + canvas.drawRect( + Rect.fromLTWH(x * cell, y * cell, (end - x) * cell + 0.5, cell + 0.5), + Paint()..color = colors[c]!, + ); + x = end; + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/app/lib/features/canary/presentation/ui/canary_nav_bar.dart b/app/lib/features/canary/presentation/ui/canary_nav_bar.dart new file mode 100644 index 0000000..d9095f9 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/canary_nav_bar.dart @@ -0,0 +1,497 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../config/app_mode.dart'; +import '../../../../core/layout/app_breakpoints.dart'; +import '../../../../theme.dart'; +import 'canary_mark.dart'; +import 'wallet_button.dart'; + +/// Desktop & mobile top navigation bar for the Canary site. +class CanaryTopBar extends StatelessWidget implements PreferredSizeWidget { + const CanaryTopBar({ + super.key, + required this.onHowItWorks, + required this.onNotes, + required this.onCheckLeak, + this.standalone = true, + }); + + final VoidCallback onHowItWorks; + final VoidCallback onNotes; + final VoidCallback onCheckLeak; + final bool standalone; + + @override + Size get preferredSize => const Size.fromHeight(72); + + Future _openNoSusApp(BuildContext context) async { + final ok = await launchUrl( + Uri.parse('https://nosus.foo'), + mode: LaunchMode.externalApplication, + webOnlyWindowName: '_blank', + ); + if (!ok && context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not open nosus.foo'))); + } + } + + @override + Widget build(BuildContext context) { + final expanded = AppBreakpoints.isExpanded(context); + final width = MediaQuery.sizeOf(context).width; + // Keep the bar on one line: the centre links need ~1440 px next to a + // connected wallet pill; the testnet badge needs ~1180 px. + final showNav = expanded && width >= 1440; + final showPill = expanded && width >= 1180; + final pad = expanded ? 32.0 : 16.0; + + return Container( + height: expanded ? 72 : 64, + decoration: BoxDecoration( + color: CanaryTokens.bg.withValues(alpha: 0.95), + border: const Border(bottom: BorderSide(color: CanaryTokens.border)), + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: AppBreakpoints.contentMaxExpanded, + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: pad), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Brand + const Row( + mainAxisSize: MainAxisSize.min, + children: [ + CanaryMark(size: 28), + SizedBox(width: 10), + Text( + 'NO SUS Canary', + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: 20, + fontWeight: FontWeight.w700, + color: CanaryTokens.text, + ), + ), + ], + ), + + // Center navigation (wide desktop only) + if (showNav) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _NavLink(label: 'How it works', onTap: onHowItWorks), + const _NavDivider(), + _NavLink(label: 'My notes', onTap: onNotes), + const _NavDivider(), + _NavLink(label: 'Check a leak', onTap: onCheckLeak), + ], + ), + + // Right side: network pulse pill (+ Monad wallet when the + // CANARY_MONAD build flag is on) + NO SUS app link + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showPill) ...[ + const NetworkPill(), + const SizedBox(width: 14), + ], + if (kCanaryMonad) ...[ + WalletButton(compact: !expanded), + const SizedBox(width: 12), + ], + if (standalone) + expanded + ? ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: CanaryTokens.text, + side: const BorderSide( + color: CanaryTokens.border, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + CanaryTokens.rChip, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onPressed: () => _openNoSusApp(context), + child: const Text( + 'NO SUS app ↗', + style: TextStyle( + fontFamily: CanaryTokens.bodyFont, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ) + : Semantics( + button: true, + label: 'Open the NO SUS app', + child: IconButton( + tooltip: 'Open the NO SUS app', + icon: const Icon( + Icons.open_in_new, + color: CanaryTokens.text, + size: 20, + ), + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + ), + onPressed: () => _openNoSusApp(context), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _NavDivider extends StatelessWidget { + const _NavDivider(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 14), + child: Text( + '·', + style: TextStyle( + color: CanaryTokens.textDim, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} + +class _NavLink extends StatefulWidget { + const _NavLink({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + State<_NavLink> createState() => _NavLinkState(); +} + +class _NavLinkState extends State<_NavLink> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label.toUpperCase(), + style: TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 22, + letterSpacing: 1, + color: _hovered ? CanaryTokens.text : CanaryTokens.textDim, + ), + ), + const SizedBox(height: 2), + AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + height: 3, + width: _hovered ? 40 : 0, + decoration: BoxDecoration( + color: CanaryTokens.canary, + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Name of the network Canary proofs land on, for pills and strips. +/// "SOLANA DEVNET", or "MONAD TESTNET" in a CANARY_MONAD build. +const String kCanaryNetworkLabel = kCanaryMonad + ? 'MONAD TESTNET' + : 'SOLANA DEVNET'; + +/// Pulsing network indicator pill ([kCanaryNetworkLabel]). +class NetworkPill extends StatefulWidget { + const NetworkPill({super.key, this.label = kCanaryNetworkLabel}); + + final String label; + + @override + State createState() => _NetworkPillState(); +} + +class _NetworkPillState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1600), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context)) { + _controller.stop(); + _controller.value = 1.0; + } else if (!_controller.isAnimating) { + _controller.repeat(reverse: true); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: CanaryTokens.surface, + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + border: Border.all(color: CanaryTokens.text, width: 2), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final opacity = CanaryTokens.reduceMotion(context) + ? 1.0 + : 0.4 + 0.6 * _controller.value; + return Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: CanaryTokens.canary.withValues(alpha: opacity), + border: Border.all(color: CanaryTokens.text, width: 1.5), + ), + ); + }, + ), + const SizedBox(width: 8), + Text( + widget.label, + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 21, + letterSpacing: 1, + color: CanaryTokens.text, + height: 1.1, + ), + ), + ], + ), + ); + } +} + +/// Thin dashed connector line with an orange dot traveling every 4 seconds. +class HowItWorksConnector extends StatefulWidget { + const HowItWorksConnector({super.key}); + + @override + State createState() => _HowItWorksConnectorState(); +} + +class _HowItWorksConnectorState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context)) { + _controller.stop(); + _controller.value = 0.5; + } else if (!_controller.isAnimating) { + _controller.repeat(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (CanaryTokens.reduceMotion(context)) { + return const SizedBox.shrink(); + } + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + size: const Size(double.infinity, 16), + painter: _DashedLinePainter(_controller.value), + ); + }, + ); + } +} + +class _DashedLinePainter extends CustomPainter { + const _DashedLinePainter(this.progress); + final double progress; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = CanaryTokens.border + ..strokeWidth = 1; + + const dashWidth = 6.0; + const dashSpace = 4.0; + var startX = 0.0; + final y = size.height / 2; + + while (startX < size.width) { + canvas.drawLine( + Offset(startX, y), + Offset(math.min(startX + dashWidth, size.width), y), + paint, + ); + startX += dashWidth + dashSpace; + } + + final dotX = size.width * progress; + final dotPaint = Paint()..color = CanaryTokens.canary; + canvas.drawCircle(Offset(dotX, y), 3.5, dotPaint); + } + + @override + bool shouldRepaint(covariant _DashedLinePainter oldDelegate) => + oldDelegate.progress != progress; +} + +/// Standalone Canary footer. +class CanaryFooter extends StatelessWidget { + const CanaryFooter({super.key}); + + Future _openUrl(BuildContext context, String url) async { + final ok = await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + webOnlyWindowName: '_blank', + ); + if (!ok && context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Could not open $url'))); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: CanaryTokens.border)), + ), + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, + children: [ + if (kCanaryMonad) ...[ + const Text( + 'Built at Monad Blitz Mumbai V4', + style: TextStyle(color: CanaryTokens.textDim, fontSize: 13), + ), + const Text('·', style: TextStyle(color: CanaryTokens.textDim)), + ], + InkWell( + onTap: () => _openUrl(context, 'https://nosus.foo'), + child: const Text( + 'Part of NO SUS ↗', + style: TextStyle( + color: CanaryTokens.textDim, + fontSize: 13, + decoration: TextDecoration.underline, + ), + ), + ), + const Text('·', style: TextStyle(color: CanaryTokens.textDim)), + InkWell( + onTap: () => _openUrl(context, kCanaryRepoUrl), + child: const Text( + 'GitHub ↗', + style: TextStyle( + color: CanaryTokens.textDim, + fontSize: 13, + decoration: TextDecoration.underline, + ), + ), + ), + const Text('·', style: TextStyle(color: CanaryTokens.textDim)), + const Text( + 'Use harmless test notes only', + style: TextStyle(color: CanaryTokens.textDim, fontSize: 13), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/canary/presentation/ui/canary_reveal.dart b/app/lib/features/canary/presentation/ui/canary_reveal.dart new file mode 100644 index 0000000..a2ac73d --- /dev/null +++ b/app/lib/features/canary/presentation/ui/canary_reveal.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; + +import '../../../../config/app_mode.dart'; +import '../../../../theme.dart'; +import '../../domain/canary_fingerprint.dart'; +import '../../domain/canary_link.dart'; +import '../../domain/canary_models.dart'; +import '../canary_ui.dart'; +import 'canary_mark.dart'; +import 'chain_chip.dart'; +import 'copy_badge.dart'; +import 'glow_card.dart'; + +/// Hero result for confident / exact / likely matches. +class CanaryReveal extends StatefulWidget { + const CanaryReveal({ + super.key, + required this.match, + required this.status, + this.likely = false, + }); + + final CanaryMatch match; + final CanaryNoteStatus? status; + final bool likely; + + @override + State createState() => _CanaryRevealState(); +} + +class _CanaryRevealState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c; + late final Animation _pop; + late final Animation _type; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _pop = CurvedAnimation( + parent: _c, + curve: const Interval(0, 0.55, curve: Curves.elasticOut), + ); + _type = CurvedAnimation( + parent: _c, + curve: const Interval(0.4, 1, curve: Curves.linear), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context)) { + _c.value = 1; + } else if (!_c.isCompleted && !_c.isAnimating) { + _c.forward(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final index = widget.match.copyIndex; + final copy = index == null ? null : widget.status?.copy(index); + final who = copy?.readerName; + final accent = widget.likely ? CanaryTokens.warn : CanaryTokens.canary; + + return GlowCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ScaleTransition(scale: _pop, child: const CanaryMark(size: 72)), + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + widget.likely ? 'MOST LIKELY' : 'THE CANARY SANG', + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: 28, + fontWeight: FontWeight.w700, + color: accent, + letterSpacing: 0.6, + ), + ), + ), + if (who != null && who.isNotEmpty) ...[ + const SizedBox(height: 8), + AnimatedBuilder( + animation: _type, + builder: (context, _) { + final n = (who.length * _type.value).ceil().clamp( + 0, + who.length, + ); + return Text( + who.substring(0, n), + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: 22, + fontWeight: FontWeight.w600, + color: accent, + ), + ); + }, + ), + ], + const SizedBox(height: 12), + if (index != null) + CopyBadge( + copyIndex: index, + copyCount: widget.status?.copyCount ?? (index + 1), + readerName: who, + fill: accent, + ), + if (copy != null) ...[ + const SizedBox(height: 12), + Text( + 'Opened ${CanaryUi.clock(copy.openedAt)}', + style: const TextStyle(fontFamily: CanaryTokens.monoFont), + ), + if (kCanaryMonad && copy.openTxHash != null) ...[ + const SizedBox(height: 8), + ChainChip(label: 'Proof on Monad', txHash: copy.openTxHash), + ] else if (!kCanaryMonad && copy.openSolanaSig != null) ...[ + const SizedBox(height: 8), + ChainChip( + chain: CanaryChain.solana, + label: 'Proof on Solana', + txHash: copy.openSolanaSig, + ), + ], + ], + const SizedBox(height: 8), + Text( + widget.likely + ? '${widget.match.agreeing} of ${widget.match.known} fingerprints match. Treat this as a strong hint, not proof.' + : widget.match.kind == CanaryMatchKind.exactMarker + ? 'Matched by the hidden marker in the text.' + : '${widget.match.agreeing} of ${widget.match.known} fingerprints match.', + style: const TextStyle(color: CanaryTokens.textDim), + ), + ], + ), + ); + } +} + +/// Orange scan line over pasted text. Completes in 900 ms. +class LeakScanOverlay extends StatefulWidget { + const LeakScanOverlay({super.key, required this.child, required this.active}); + + final Widget child; + final bool active; + + @override + State createState() => _LeakScanOverlayState(); +} + +class _LeakScanOverlayState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + } + + @override + void didUpdateWidget(covariant LeakScanOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.active && !oldWidget.active) { + if (CanaryTokens.reduceMotion(context)) { + _c.value = 1; + } else { + _c.forward(from: 0); + } + } else if (!widget.active && oldWidget.active) { + _c.value = 0; + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CustomPaint( + foregroundPainter: _ScanPainter(_c), + child: widget.child, + ); + } +} + +class _ScanPainter extends CustomPainter { + _ScanPainter(this.progress) : super(repaint: progress); + + final Animation progress; + + @override + void paint(Canvas canvas, Size size) { + final t = progress.value; + if (t <= 0 || t >= 1) return; + final y = size.height * t; + canvas.drawRect( + Rect.fromLTWH(0, y - 8, size.width, 16), + Paint()..color = CanaryTokens.canary.withValues(alpha: 0.45), + ); + canvas.drawRect( + Rect.fromLTWH(0, y - 1.5, size.width, 3), + Paint()..color = CanaryTokens.text, + ); + } + + @override + bool shouldRepaint(covariant _ScanPainter oldDelegate) => + oldDelegate.progress != progress; +} + +/// Lights fingerprint words as the scan runs (orange = found, grey = unknown). +class ScanHitChips extends StatefulWidget { + const ScanHitChips({ + super.key, + required this.plan, + required this.leak, + required this.active, + }); + + final CanaryPlan plan; + final String leak; + final bool active; + + @override + State createState() => _ScanHitChipsState(); +} + +class _ScanHitChipsState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + if (!widget.active) _c.value = 1; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context)) { + _c.value = 1; + } + } + + @override + void didUpdateWidget(covariant ScanHitChips oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.active && !oldWidget.active) { + if (CanaryTokens.reduceMotion(context)) { + _c.value = 1; + } else { + _c.forward(from: 0); + } + } else if (!widget.active) { + _c.value = 1; + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bits = readCanaryBits(widget.leak, widget.plan); + final n = bits.length; + if (n == 0) return const SizedBox.shrink(); + final shown = n > 12 ? 12 : n; + + return AnimatedBuilder( + animation: _c, + builder: (context, _) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < shown; i++) + Opacity( + opacity: _c.value >= (i + 1) / shown ? 1 : 0.2, + child: _hitChip(widget.plan.slots[i], bits[i]), + ), + ], + ); + }, + ); + } + + Widget _hitChip(CanarySlot slot, int? bit) { + final label = switch (bit) { + 0 => slot.original, + 1 => slot.alternate, + _ => slot.original, + }; + final found = bit != null; + return DecoratedBox( + decoration: BoxDecoration( + color: found + ? CanaryTokens.canary.withValues(alpha: 0.18) + : CanaryTokens.surfaceHi, + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + border: Border.all( + color: found ? CanaryTokens.canary : CanaryTokens.border, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Text( + label, + style: TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 18, + color: found ? CanaryTokens.canaryHi : CanaryTokens.textDim, + ), + ), + ), + ); + } +} diff --git a/app/lib/features/canary/presentation/ui/canary_strip.dart b/app/lib/features/canary/presentation/ui/canary_strip.dart new file mode 100644 index 0000000..930c535 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/canary_strip.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; +import 'canary_nav_bar.dart' show kCanaryNetworkLabel; + +/// Orange "testnet" strip across the top of the Canary web app. +class CanaryStrip extends StatelessWidget { + const CanaryStrip({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => Column( + children: [ + Material( + color: CanaryTokens.canary, + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: CanaryTokens.text, width: 2), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: const SafeArea( + bottom: false, + child: Text( + 'NO SUS · $kCanaryNetworkLabel · USE HARMLESS TEST NOTES ONLY', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 20, + letterSpacing: 1, + color: CanaryTokens.text, + ), + ), + ), + ), + ), + Expanded(child: child), + ], + ); +} diff --git a/app/lib/features/canary/presentation/ui/chain_chip.dart b/app/lib/features/canary/presentation/ui/chain_chip.dart new file mode 100644 index 0000000..a1b76d8 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/chain_chip.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../theme.dart'; +import '../../domain/canary_link.dart'; + +/// Ink and tint for one chain's chip. Monad keeps the orange paper tint with +/// an ink border; Solana gets a single restrained teal ink (5.7:1 on the +/// paper background) so the two proofs read apart without a brand gradient. +class _ChainStyle { + const _ChainStyle({required this.ink, required this.tint}); + + final Color ink; + final Color tint; + + static const Color _solanaInk = Color(0xFF0F6E63); + + static _ChainStyle of(CanaryChain chain) => switch (chain) { + CanaryChain.monad => const _ChainStyle( + ink: CanaryTokens.monad, + tint: Color(0x40F28C28), + ), + CanaryChain.solana => const _ChainStyle( + ink: _solanaInk, + tint: Color(0x1F0F6E63), + ), + }; +} + +const TextStyle _chipText = TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 20, + color: CanaryTokens.text, + height: 1.2, +); + +/// On-chain status pill. Border and icon take the chain's ink; hash is VT323. +class ChainChip extends StatelessWidget { + const ChainChip({ + super.key, + required this.label, + this.txHash, + this.chain = CanaryChain.monad, + this.compact = false, + }); + + final String label; + final String? txHash; + final CanaryChain chain; + final bool compact; + + /// `0x1234…abcd` for hex hashes, `5VfY…Qm2p` for base58 signatures. + static String shortHash(String hash) { + final hex = hash.startsWith('0x'); + final h = hex ? hash.substring(2) : hash; + if (h.length < 10) return hash; + final short = '${h.substring(0, 4)}…${h.substring(h.length - 4)}'; + return hex ? '0x$short' : short; + } + + Future _open(BuildContext context) async { + final hash = txHash; + if (hash == null) return; + // A 42-char 0x value is a Monad address (the contract), not a tx. + final isMonadAddress = chain == CanaryChain.monad && hash.length == 42; + final ok = await launchUrl( + Uri.parse( + isMonadAddress + ? 'https://testnet.monadscan.com/address/$hash' + : chain.txUrl(hash), + ), + mode: LaunchMode.externalApplication, + ); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open the explorer.')), + ); + } + } + + @override + Widget build(BuildContext context) { + final hash = txHash; + final style = _ChainStyle.of(chain); + final text = hash == null ? label : '$label · ${shortHash(hash)}'; + final chip = ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: DecoratedBox( + decoration: BoxDecoration( + color: style.tint, + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + border: Border.all(color: style.ink), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: compact ? 12 : 16, + vertical: 10, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.link, size: 18, color: style.ink), + const SizedBox(width: 8), + Text(text, overflow: TextOverflow.ellipsis, style: _chipText), + ], + ), + ), + ), + ); + + if (hash == null) { + return Semantics(label: label, child: chip); + } + return Semantics( + button: true, + label: '$label. View proof on ${chain.network}.', + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + onTap: () => _open(context), + child: chip, + ), + ), + ); + } +} + +/// Same silhouette as [ChainChip] for a proof that is still being written: +/// a 16 px progress ring plus "Monad…" / "Solana…". Not tappable. +class PendingChainChip extends StatelessWidget { + const PendingChainChip({ + super.key, + required this.chain, + this.compact = false, + }); + + final CanaryChain chain; + final bool compact; + + @override + Widget build(BuildContext context) { + final style = _ChainStyle.of(chain); + final message = 'Recording on ${chain.network}…'; + return Semantics( + label: message, + excludeSemantics: true, + child: Tooltip( + message: message, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + border: Border.all(color: style.ink.withValues(alpha: 0.6)), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: compact ? 12 : 16, + vertical: 10, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: style.ink, + ), + ), + const SizedBox(width: 8), + Text('${chain.label}…', style: _chipText), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/canary/presentation/ui/copy_badge.dart b/app/lib/features/canary/presentation/ui/copy_badge.dart new file mode 100644 index 0000000..b81b7f5 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/copy_badge.dart @@ -0,0 +1,133 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; + +/// Perforated ticket: "COPY 07 / 50 · made for Priya". +class CopyBadge extends StatefulWidget { + const CopyBadge({ + super.key, + required this.copyIndex, + required this.copyCount, + this.readerName, + this.fill = CanaryTokens.canary, + }); + + final int copyIndex; + final int copyCount; + final String? readerName; + final Color fill; + + @override + State createState() => _CopyBadgeState(); +} + +class _CopyBadgeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c; + late final Animation _scale; + late final Animation _rotate; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _scale = Tween( + begin: 1.3, + end: 1, + ).animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic)); + _rotate = Tween( + begin: -6 * math.pi / 180, + end: 0, + ).animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic)); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context)) { + _c.value = 1; + } else if (!_c.isCompleted && !_c.isAnimating) { + _c.forward(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final name = widget.readerName; + final label = name == null || name.isEmpty + ? 'COPY ${widget.copyIndex + 1} / ${widget.copyCount}' + : 'COPY ${widget.copyIndex + 1} / ${widget.copyCount} · made for $name'; + + return AnimatedBuilder( + animation: _c, + builder: (context, child) { + return Transform.rotate( + angle: _rotate.value, + child: Transform.scale(scale: _scale.value, child: child), + ); + }, + child: Semantics( + label: label, + child: CustomPaint( + painter: _TicketPainter(fill: widget.fill), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Text( + label, + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 21, + color: CanaryTokens.onCanary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } +} + +class _TicketPainter extends CustomPainter { + const _TicketPainter({required this.fill}); + + final Color fill; + + @override + void paint(Canvas canvas, Size size) { + const r = 8.0; + const notch = 7.0; + final rect = Offset.zero & size; + final path = Path() + ..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(r))); + final holes = Path(); + for (var y = notch * 2; y < size.height - notch; y += notch * 2) { + holes.addOval(Rect.fromCircle(center: Offset(0, y), radius: notch)); + holes.addOval( + Rect.fromCircle(center: Offset(size.width, y), radius: notch), + ); + } + final ticket = Path.combine(PathOperation.difference, path, holes); + canvas.drawPath( + ticket, + Paint() + ..color = fill + ..style = PaintingStyle.fill, + ); + } + + @override + bool shouldRepaint(covariant _TicketPainter oldDelegate) => + oldDelegate.fill != fill; +} diff --git a/app/lib/features/canary/presentation/ui/fingerprint_meter.dart b/app/lib/features/canary/presentation/ui/fingerprint_meter.dart new file mode 100644 index 0000000..cf06f46 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/fingerprint_meter.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; + +/// Segmented bar: one tick per required slot, orange until enough, then green. +class FingerprintMeter extends StatelessWidget { + const FingerprintMeter({ + super.key, + required this.found, + required this.requiredSlots, + }); + + final int found; + final int requiredSlots; + + @override + Widget build(BuildContext context) { + final n = requiredSlots.clamp(1, 24); + final filled = found.clamp(0, n); + final enough = found >= requiredSlots && found > 0; + final reduce = CanaryTokens.reduceMotion(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + for (var i = 0; i < n; i++) + Expanded( + child: Padding( + padding: EdgeInsets.only(left: i == 0 ? 0 : 3), + child: AnimatedContainer( + duration: reduce + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + height: 10, + decoration: BoxDecoration( + color: i < filled + ? (enough ? CanaryTokens.ok : CanaryTokens.canary) + : CanaryTokens.surfaceHi, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + found == 0 + ? 'No swappable words yet · need $requiredSlots' + : enough + ? '$found swappable words · ready for this reader count' + : '$found of $requiredSlots swappable words', + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + color: CanaryTokens.textDim, + fontSize: 20, + ), + ), + ], + ); + } +} diff --git a/app/lib/features/canary/presentation/ui/glow_card.dart b/app/lib/features/canary/presentation/ui/glow_card.dart new file mode 100644 index 0000000..9bfd9ec --- /dev/null +++ b/app/lib/features/canary/presentation/ui/glow_card.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; + +/// Paper card with an ink outline and a hard ink shadow. Hover lifts it and +/// turns the shadow orange. +class GlowCard extends StatefulWidget { + const GlowCard({ + super.key, + required this.child, + this.onTap, + this.padding = const EdgeInsets.all(20), + this.margin, + }); + + final Widget child; + final VoidCallback? onTap; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry? margin; + + @override + State createState() => _GlowCardState(); +} + +class _GlowCardState extends State { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final reduce = CanaryTokens.reduceMotion(context); + final hovered = _hover && !reduce; + final duration = reduce ? Duration.zero : const Duration(milliseconds: 180); + + final inner = AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + transform: Matrix4.translationValues( + hovered ? -3 : 0, + hovered ? -3 : 0, + 0, + ), + padding: widget.padding, + decoration: BoxDecoration( + color: CanaryTokens.surface, + borderRadius: BorderRadius.circular(CanaryTokens.rCard), + border: Border.all(color: CanaryTokens.text, width: 2), + boxShadow: [ + BoxShadow( + color: hovered ? CanaryTokens.canary : CanaryTokens.text, + offset: hovered ? const Offset(8, 8) : const Offset(5, 5), + ), + ], + ), + child: widget.child, + ); + + Widget card = MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: inner, + ); + + if (widget.onTap != null) { + card = Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(CanaryTokens.rCard), + child: card, + ), + ); + } + + if (widget.margin != null) { + card = Padding(padding: widget.margin!, child: card); + } + return card; + } +} diff --git a/app/lib/features/canary/presentation/ui/liquid_carve_button.dart b/app/lib/features/canary/presentation/ui/liquid_carve_button.dart new file mode 100644 index 0000000..b8df585 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/liquid_carve_button.dart @@ -0,0 +1,554 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +import '../../../../theme.dart'; + +/// Originkit-style liquid carve CTA. Paint is ticker-driven; pointer +/// events only update targets, they do not call setState. +class LiquidCarveButton extends StatefulWidget { + const LiquidCarveButton({ + super.key, + required this.label, + required this.onPressed, + this.icon, + this.fill = CanaryTokens.canary, + this.blobColor = CanaryTokens.canaryHi, + this.textColor = CanaryTokens.onCanary, + this.blobSize = 80, + this.smoothness = 55, + this.busy = false, + this.enableGoo = true, + }); + + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final Color fill; + final Color blobColor; + final Color textColor; + final double blobSize; + final double smoothness; + final bool busy; + final bool enableGoo; + + @override + State createState() => _LiquidCarveButtonState(); +} + +class _BitePaint extends ChangeNotifier { + Offset pos = Offset.zero; + double squash = 1; + double angle = 0; + double scale = 0; + + void set({Offset? pos, double? squash, double? angle, double? scale}) { + var changed = false; + if (pos != null && pos != this.pos) { + this.pos = pos; + changed = true; + } + if (squash != null && squash != this.squash) { + this.squash = squash; + changed = true; + } + if (angle != null && angle != this.angle) { + this.angle = angle; + changed = true; + } + if (scale != null && scale != this.scale) { + this.scale = scale; + changed = true; + } + if (changed) notifyListeners(); + } +} + +class _LiquidCarveButtonState extends State + with SingleTickerProviderStateMixin { + late final Ticker _ticker; + final _paint = _BitePaint(); + final _focus = FocusNode(); + + Offset _target = Offset.zero; + double _x = 0; + double _y = 0; + double _squash = 1; + double _angle = 0; + double _scale = 0; + double _scaleVel = 0; + SpringSimulation? _scaleSim; + Duration _simStart = Duration.zero; + Duration _lastElapsed = Duration.zero; + Duration? _tapBiteStart; + bool _hovering = false; + bool _focused = false; + bool _pressedFallback = false; + + bool get _enabled => widget.onPressed != null && !widget.busy; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_onTick); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final reduce = CanaryTokens.reduceMotion(context); + if (reduce) { + if (_ticker.isActive) _ticker.stop(); + } else if (!_ticker.isActive) { + _ticker.start(); + } + } + + @override + void dispose() { + _ticker.dispose(); + _paint.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _onTick(Duration elapsed) { + final rawDt = (elapsed - _lastElapsed).inMicroseconds / 1e6; + _lastElapsed = elapsed; + final dt = rawDt.clamp(0.0, 0.05); + if (dt <= 0) return; + + if (_tapBiteStart != null) { + final t = (elapsed - _tapBiteStart!).inMilliseconds / 350.0; + if (t >= 1) { + _tapBiteStart = null; + _scale = 0; + _scaleVel = 0; + } else if (t < 0.5) { + _scale = t * 2; + } else { + _scale = 1 - (t - 0.5) * 2; + } + } else if (_scaleSim != null) { + final t = (elapsed - _simStart).inMicroseconds / 1e6; + _scale = _scaleSim!.x(t); + _scaleVel = _scaleSim!.dx(t); + if (_scaleSim!.isDone(t)) { + _scaleSim = null; + _scale = _hovering ? 1 : 0; + _scaleVel = 0; + } + } + + final tau = 0.02 + (widget.smoothness / 100) * (0.4 - 0.02); + final k = 1 - math.exp(-dt / tau); + final nx = _x + (_target.dx - _x) * k; + final ny = _y + (_target.dy - _y) * k; + final dist = math.sqrt(math.pow(nx - _x, 2) + math.pow(ny - _y, 2)); + final speed = dist / dt; + _x = nx; + _y = ny; + if (speed > 8) { + _angle = math.atan2(_target.dy - _y, _target.dx - _x); + } + final targetSquash = math.min(1.6, 1 + speed * 0.0011); + _squash += (targetSquash - _squash) * (1 - math.exp(-dt / 0.09)); + + _paint.set( + pos: Offset(_x, _y), + squash: _squash, + angle: _angle, + scale: _scale.clamp(0.0, 1.0), + ); + } + + void _springTo(double target) { + _tapBiteStart = null; + _scaleSim = SpringSimulation( + const SpringDescription(mass: 1.4, stiffness: 110, damping: 13), + _scale, + target, + _scaleVel, + ); + _simStart = _lastElapsed; + } + + void _enter(Offset local) { + if (!_enabled) return; + _hovering = true; + _target = local; + _x = local.dx; + _y = local.dy; + _springTo(1); + } + + void _leave() { + _hovering = false; + _springTo(0); + } + + void _tapBite(Offset local) { + if (!_enabled) return; + _target = local; + _x = local.dx; + _y = local.dy; + _scaleSim = null; + _tapBiteStart = _lastElapsed; + } + + void _activate() { + if (!_enabled) return; + widget.onPressed?.call(); + } + + @override + Widget build(BuildContext context) { + final reduce = CanaryTokens.reduceMotion(context); + if (reduce) return _fallback(); + + final label = _Label( + icon: widget.icon, + label: widget.label, + busy: widget.busy, + color: widget.textColor, + ); + + return Semantics( + button: true, + enabled: _enabled, + label: widget.label, + child: FocusableActionDetector( + focusNode: _focus, + enabled: _enabled, + mouseCursor: _enabled + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + onShowFocusHighlight: (v) { + if (_focused != v) setState(() => _focused = v); + }, + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + _activate(); + return null; + }, + ), + }, + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + }, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48, minWidth: 48), + child: Listener( + onPointerHover: (e) { + if (e.kind != PointerDeviceKind.mouse) return; + if (!_hovering) { + _enter(e.localPosition); + } else { + _target = e.localPosition; + } + }, + onPointerDown: (e) { + if (e.kind == PointerDeviceKind.mouse) { + _target = e.localPosition; + return; + } + _tapBite(e.localPosition); + }, + onPointerUp: (_) => _activate(), + onPointerCancel: (_) => _leave(), + child: MouseRegion( + onExit: (_) => _leave(), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + transform: Matrix4.translationValues( + _hovering ? -2 : 0, + _hovering ? -2 : 0, + 0, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + border: Border.all( + color: CanaryTokens.text, + width: _focused ? 3.5 : 2.5, + ), + boxShadow: [ + BoxShadow( + color: CanaryTokens.text, + offset: _hovering + ? const Offset(6, 6) + : const Offset(4, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + child: RepaintBoundary( + child: IntrinsicWidth( + child: IntrinsicHeight( + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill(child: _blobLayer()), + Positioned.fill( + child: CustomPaint( + painter: _FillBitePainter( + paintState: _paint, + fill: widget.fill, + blobSize: widget.blobSize, + radius: CanaryTokens.rChip, + ), + ), + ), + IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 22, + vertical: 14, + ), + child: label, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _blobLayer() { + final blob = CustomPaint( + painter: _BlobPainter( + paintState: _paint, + color: widget.blobColor, + radius: CanaryTokens.rChip, + ), + ); + if (!widget.enableGoo) return blob; + return ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 19, + -2295, + ]), + child: ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: 8, + sigmaY: 8, + tileMode: TileMode.decal, + ), + child: blob, + ), + ); + } + + Widget _fallback() { + final bg = _pressedFallback ? CanaryTokens.canaryDeep : widget.fill; + return Semantics( + button: true, + enabled: _enabled, + label: widget.label, + child: FocusableActionDetector( + focusNode: _focus, + enabled: _enabled, + mouseCursor: _enabled + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + onShowFocusHighlight: (v) { + if (_focused != v) setState(() => _focused = v); + }, + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + _activate(); + return null; + }, + ), + }, + child: GestureDetector( + onTapDown: _enabled + ? (_) => setState(() => _pressedFallback = true) + : null, + onTapUp: _enabled + ? (_) { + setState(() => _pressedFallback = false); + _activate(); + } + : null, + onTapCancel: () => setState(() => _pressedFallback = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + constraints: const BoxConstraints(minHeight: 48, minWidth: 48), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: _enabled ? bg : bg.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + border: Border.all( + color: CanaryTokens.text, + width: _focused ? 3.5 : 2.5, + ), + boxShadow: [ + BoxShadow( + color: CanaryTokens.text, + offset: _pressedFallback ? Offset.zero : const Offset(4, 4), + ), + ], + ), + child: _Label( + icon: widget.icon, + label: widget.label, + busy: widget.busy, + color: widget.textColor, + ), + ), + ), + ), + ); + } +} + +class _Label extends StatelessWidget { + const _Label({ + required this.label, + required this.busy, + required this.color, + this.icon, + }); + + final String label; + final bool busy; + final Color color; + final IconData? icon; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (busy) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: color), + ) + else if (icon != null) + Icon(icon, size: 18, color: color), + if (busy || icon != null) const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontFamily: CanaryTokens.displayFont, + fontWeight: FontWeight.w600, + fontSize: 16, + color: color, + ), + ), + ], + ); + } +} + +class _BlobPainter extends CustomPainter { + _BlobPainter({ + required this.paintState, + required this.color, + required this.radius, + }) : super(repaint: paintState); + + final _BitePaint paintState; + final Color color; + final double radius; + + @override + void paint(Canvas canvas, Size size) { + canvas.drawRRect( + RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)), + Paint()..color = color, + ); + } + + @override + bool shouldRepaint(covariant _BlobPainter oldDelegate) => + oldDelegate.color != color || oldDelegate.radius != radius; +} + +class _FillBitePainter extends CustomPainter { + _FillBitePainter({ + required this.paintState, + required this.fill, + required this.blobSize, + required this.radius, + }) : super(repaint: paintState); + + final _BitePaint paintState; + final Color fill; + final double blobSize; + final double radius; + + @override + void paint(Canvas canvas, Size size) { + final rrect = Path() + ..addRRect( + RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)), + ); + final scale = paintState.scale; + if (scale <= 0.001) { + canvas.drawPath(rrect, Paint()..color = fill); + return; + } + final squash = paintState.squash.clamp(0.6, 1.6); + final d = blobSize * scale; + final ellipse = Path() + ..addOval( + Rect.fromCenter( + center: Offset.zero, + width: d * squash, + height: d / squash, + ), + ); + final matrix = Matrix4.identity() + ..translateByDouble(paintState.pos.dx, paintState.pos.dy, 0, 1) + ..rotateZ(paintState.angle); + final hole = ellipse.transform(matrix.storage); + final bitten = Path.combine(PathOperation.difference, rrect, hole); + canvas.drawPath(bitten, Paint()..color = fill); + } + + @override + bool shouldRepaint(covariant _FillBitePainter oldDelegate) => + oldDelegate.fill != fill || + oldDelegate.blobSize != blobSize || + oldDelegate.radius != radius; +} diff --git a/app/lib/features/canary/presentation/ui/pixel_canary.dart b/app/lib/features/canary/presentation/ui/pixel_canary.dart new file mode 100644 index 0000000..1dcce71 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/pixel_canary.dart @@ -0,0 +1,309 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; + +/// The NO SUS pixel canary, alive: it bobs, flaps in short bursts, blinks, +/// and (with [sing]) lets pixel music notes float up from its beak. +/// Shows a still bird when animations are disabled. +class PixelCanary extends StatefulWidget { + const PixelCanary({super.key, this.size = 64, this.sing = false}); + + /// Width in logical pixels; height is size * 14 / 16. + final double size; + final bool sing; + + @override + State createState() => _PixelCanaryState(); +} + +class _PixelCanaryState extends State + with SingleTickerProviderStateMixin { + static const _cycleMs = 4000; + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: _cycleMs), + ); + bool _still = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _still = CanaryTokens.reduceMotion(context); + if (_still) { + _c.stop(); + _c.value = 0; + } else if (!_c.isAnimating) { + _c.repeat(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final w = widget.size; + final h = w * 14 / 16; + return Semantics( + label: 'NO SUS canary', + image: true, + child: SizedBox( + width: w, + height: h, + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final t = _c.value * _cycleMs; + // Flap in bursts: wings beat for the first 900 ms of every 2 s. + final flap = + !_still && (t % 2000) < 900 && ((t / 150).floor() % 2 == 1); + final blink = !_still && t > 3650 && t < 3800; + final bob = _still + ? 0.0 + : -math.sin(_c.value * 4 * math.pi).abs() * w * 0.06; + return Stack( + clipBehavior: Clip.none, + children: [ + Transform.translate( + offset: Offset(0, bob), + child: CustomPaint( + size: Size(w, h), + painter: _BirdPainter(flap: flap, blink: blink), + ), + ), + if (widget.sing && !_still) + for (var i = 0; i < 2; i++) _note(t, i, w), + ], + ); + }, + ), + ), + ); + } + + Widget _note(double t, int i, double w) { + // Each note lives 2 s, the second starts 1 s after the first. + final p = ((t + i * 1000) % 2000) / 2000; + final opacity = p < 0.15 ? p / 0.15 : (1 - p).clamp(0.0, 1.0); + return Positioned( + left: w * (0.92 + 0.18 * p) + i * w * 0.08, + top: w * (0.05 - 0.45 * p), + child: Opacity( + opacity: opacity, + child: CustomPaint( + size: Size(w * 0.16, w * 0.19), + painter: const _NotePainter(), + ), + ), + ); + } +} + +const _frameA = [ + '.....KKKKK......', + '...KKYYYYYKK....', + '..KYYYYYYYWWK...', + '..KYYYYYYYWKK...', + '.KYYYYYYYYYYKOO.', + '.KYYYYYYYYYYKOOO', + '.KYyyyyYYYYYK...', + 'KYYyyyyyYYYYK...', + 'KYYYyyyyYYYK....', + '.KYYYyyYYYYK....', + '..KYYYYYYYK.....', + '...KKKKKKK......', + '....K...K.......', + '...KK..KK.......', +]; +const _frameB = [ + '.....KKKKK......', + '...KKYYYYYKK....', + '..KYYYYYYYWWK...', + '..KYYYYYYYWKK...', + '.KYYyyYYYYYYKOO.', + '.KYyyyyYYYYYKOOO', + '.KYYyyyYYYYYK...', + 'KYYYYYYYYYYYK...', + 'KYYYYYYYYYYK....', + '.KYYYYYYYYYK....', + '..KYYYYYYYK.....', + '...KKKKKKK......', + '....K...K.......', + '...KK..KK.......', +]; + +void _paintSprite( + Canvas canvas, + List rows, + double cell, + Map colors, +) { + for (var y = 0; y < rows.length; y++) { + final row = rows[y]; + var x = 0; + while (x < row.length) { + final c = row[x]; + final color = colors[c]; + if (color == null) { + x++; + continue; + } + var end = x; + while (end < row.length && row[end] == c) { + end++; + } + canvas.drawRect( + Rect.fromLTWH(x * cell, y * cell, (end - x) * cell + 0.5, cell + 0.5), + Paint()..color = color, + ); + x = end; + } + } +} + +class _BirdPainter extends CustomPainter { + const _BirdPainter({required this.flap, required this.blink}); + + final bool flap; + final bool blink; + + @override + void paint(Canvas canvas, Size size) { + final cell = size.width / 16; + _paintSprite(canvas, flap ? _frameB : _frameA, cell, { + 'K': CanaryTokens.text, + 'O': CanaryTokens.text, + 'Y': CanaryTokens.canary, + 'y': CanaryTokens.bg, + 'W': blink ? CanaryTokens.text : CanaryTokens.bg, + }); + } + + @override + bool shouldRepaint(covariant _BirdPainter old) => + old.flap != flap || old.blink != blink; +} + +class _NotePainter extends CustomPainter { + const _NotePainter(); + + static const _rows = [ + '...##.', + '...#.#', + '...#..', + '...#..', + '.###..', + '####..', + '.##...', + ]; + + @override + void paint(Canvas canvas, Size size) { + _paintSprite(canvas, _rows, size.width / 6, {'#': CanaryTokens.text}); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Full-width ink band with facts scrolling past, retro ticker style. +class TickerBand extends StatefulWidget { + const TickerBand({super.key, required this.items}); + + final List items; + + @override + State createState() => _TickerBandState(); +} + +class _TickerBandState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(seconds: 28), + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (CanaryTokens.reduceMotion(context)) { + _c.stop(); + _c.value = 0; + } else if (!_c.isAnimating) { + _c.repeat(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const style = TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 26, + letterSpacing: 1.5, + color: CanaryTokens.bg, + height: 1, + ); + final spans = [ + for (final item in widget.items) ...[ + TextSpan(text: item), + const TextSpan( + text: ' * ', + style: TextStyle(color: CanaryTokens.canary), + ), + ], + ]; + final segment = TextSpan(style: style, children: spans); + final painter = TextPainter( + text: segment, + textDirection: TextDirection.ltr, + maxLines: 1, + )..layout(); + final segW = painter.width; + + return Container( + height: 60, + decoration: const BoxDecoration( + color: CanaryTokens.text, + border: Border.symmetric( + horizontal: BorderSide(color: CanaryTokens.canary, width: 4), + ), + ), + child: ClipRect( + child: LayoutBuilder( + builder: (context, constraints) { + final copies = (constraints.maxWidth / segW).ceil() + 2; + return AnimatedBuilder( + animation: _c, + builder: (context, child) => Transform.translate( + offset: Offset(-_c.value * segW, 0), + child: child, + ), + child: OverflowBox( + alignment: Alignment.centerLeft, + maxWidth: double.infinity, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < copies; i++) + Text.rich(segment, maxLines: 1, softWrap: false), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/features/canary/presentation/ui/proof_chips.dart b/app/lib/features/canary/presentation/ui/proof_chips.dart new file mode 100644 index 0000000..025793a --- /dev/null +++ b/app/lib/features/canary/presentation/ui/proof_chips.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import '../../../../config/app_mode.dart'; +import '../../domain/canary_link.dart'; +import 'chain_chip.dart'; + +/// The on-chain proofs for one seal or one open. +/// +/// Solana devnet is the receipt: its chip shows whenever a signature exists. +/// The Monad testnet chip (earlier prototype) shows only when [showMonad] is +/// true, which defaults to the CANARY_MONAD build flag ([kCanaryMonad]). +/// +/// A [ChainChip] is shown for every hash that exists. A null hash shows a +/// [PendingChainChip] only when the matching `pending` flag is true, so a +/// chain that is switched off simply does not appear. Chips wrap onto new +/// rows instead of clipping on narrow screens. +class ProofChips extends StatelessWidget { + const ProofChips({ + super.key, + this.monadTxHash, + this.solanaSig, + this.monadPending = false, + this.solanaPending = false, + this.labelPrefix = '', + this.compact = false, + bool? showMonad, + }) : showMonad = showMonad ?? kCanaryMonad; + + final String? monadTxHash; + final String? solanaSig; + + /// Show a progress chip while the Monad record is still being written. + final bool monadPending; + + /// Show a progress chip while the Solana record is still being written. + final bool solanaPending; + + /// 'Sealed on' / 'Recorded on' / '' (bare chain name). + final String labelPrefix; + final bool compact; + + /// Whether the Monad chip may appear at all. + final bool showMonad; + + String _label(CanaryChain chain) => + labelPrefix.isEmpty ? chain.label : '$labelPrefix ${chain.label}'; + + Widget? _chip(CanaryChain chain, String? hash, bool pending) { + if (hash != null) { + return ChainChip( + chain: chain, + label: _label(chain), + txHash: hash, + compact: compact, + ); + } + if (pending) return PendingChainChip(chain: chain, compact: compact); + return null; + } + + @override + Widget build(BuildContext context) { + final chips = [ + if (showMonad) _chip(CanaryChain.monad, monadTxHash, monadPending), + _chip(CanaryChain.solana, solanaSig, solanaPending), + ].nonNulls.toList(); + if (chips.isEmpty) return const SizedBox.shrink(); + return Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: chips, + ); + } +} diff --git a/app/lib/features/canary/presentation/ui/quest_steps.dart b/app/lib/features/canary/presentation/ui/quest_steps.dart new file mode 100644 index 0000000..e809167 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/quest_steps.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; + +/// Retro "quest" progress: 1 WRITE -> 2 SHARE -> 3 CATCH. +class QuestSteps extends StatelessWidget { + const QuestSteps({super.key, required this.current}); + + /// 0 = write, 1 = share, 2 = catch. + final int current; + + static const _steps = ['WRITE', 'SHARE', 'CATCH']; + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'Step ${current + 1} of 3: ${_steps[current].toLowerCase()}', + child: Row( + children: [ + for (var i = 0; i < _steps.length; i++) ...[ + if (i > 0) + Expanded( + child: Container( + height: 3, + margin: const EdgeInsets.symmetric(horizontal: 10), + color: i <= current + ? CanaryTokens.text + : CanaryTokens.text.withValues(alpha: 0.2), + ), + ), + _pill(i), + ], + ], + ), + ); + } + + Widget _pill(int i) { + final active = i == current; + final done = i < current; + return Container( + padding: const EdgeInsets.fromLTRB(12, 7, 14, 5), + decoration: BoxDecoration( + color: active || done ? CanaryTokens.canary : CanaryTokens.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: i <= current + ? CanaryTokens.text + : CanaryTokens.text.withValues(alpha: 0.3), + width: 2, + ), + boxShadow: active + ? const [BoxShadow(color: CanaryTokens.text, offset: Offset(3, 3))] + : null, + ), + child: Text( + '${i + 1} ${_steps[i]}${done ? ' OK' : ''}', + style: TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 22, + letterSpacing: 1.5, + height: 1, + color: i <= current + ? CanaryTokens.text + : CanaryTokens.text.withValues(alpha: 0.45), + ), + ), + ); + } +} + +/// Small pixel-caps label used above form groups. +class RetroLabel extends StatelessWidget { + const RetroLabel(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) => Text( + text.toUpperCase(), + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 22, + letterSpacing: 1.5, + height: 1, + color: CanaryTokens.text, + ), + ); +} diff --git a/app/lib/features/canary/presentation/ui/swap_word_text.dart b/app/lib/features/canary/presentation/ui/swap_word_text.dart new file mode 100644 index 0000000..d0fe003 --- /dev/null +++ b/app/lib/features/canary/presentation/ui/swap_word_text.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; +import '../../domain/canary_fingerprint.dart'; + +/// Renders a copy with swapped words flipping between variants. +class SwapWordText extends StatefulWidget { + const SwapWordText({ + super.key, + required this.plan, + required this.codewords, + this.labels = const [], + this.interval = const Duration(milliseconds: 1800), + }); + + final CanaryPlan plan; + final List> codewords; + final List labels; + final Duration interval; + + @override + State createState() => _SwapWordTextState(); +} + +class _SwapWordTextState extends State { + int _index = 0; + Timer? _timer; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _arm(); + } + + @override + void didUpdateWidget(covariant SwapWordText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.codewords.length != widget.codewords.length) { + _index = 0; + } + _arm(); + } + + void _arm() { + _timer?.cancel(); + _timer = null; + if (CanaryTokens.reduceMotion(context) || widget.codewords.length < 2) { + return; + } + _timer = Timer.periodic(widget.interval, (_) { + if (!mounted) return; + setState(() => _index = (_index + 1) % widget.codewords.length); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.codewords.isEmpty) return const SizedBox.shrink(); + final i = _index.clamp(0, widget.codewords.length - 1); + final tokens = renderCanaryTokens(widget.plan, widget.codewords[i]); + final original = widget.plan.tokens; + final label = i < widget.labels.length ? widget.labels[i] : 'Copy ${i + 1}'; + final reduce = CanaryTokens.reduceMotion(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + color: CanaryTokens.text, + backgroundColor: CanaryTokens.canary, + fontSize: 21, + ), + ), + const SizedBox(height: 8), + Text.rich( + TextSpan( + children: [ + for (var t = 0; t < tokens.length; t++) + if (tokens[t] == original[t]) + TextSpan( + text: tokens[t], + style: const TextStyle( + fontFamily: CanaryTokens.bodyFont, + fontSize: 16, + height: 1.5, + color: CanaryTokens.text, + ), + ) + else + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: AnimatedSwitcher( + duration: reduce + ? Duration.zero + : const Duration(milliseconds: 280), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, anim) { + if (reduce) return child; + return FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.35), + end: Offset.zero, + ).animate(anim), + child: child, + ), + ); + }, + child: Text( + tokens[t], + key: ValueKey('$i-$t-${tokens[t]}'), + style: const TextStyle( + fontFamily: CanaryTokens.displayFont, + fontSize: 16, + fontWeight: FontWeight.w700, + height: 1.5, + color: CanaryTokens.text, + backgroundColor: Color(0x80F28C28), + ), + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/features/canary/presentation/ui/wallet_button.dart b/app/lib/features/canary/presentation/ui/wallet_button.dart new file mode 100644 index 0000000..777768c --- /dev/null +++ b/app/lib/features/canary/presentation/ui/wallet_button.dart @@ -0,0 +1,525 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../theme.dart'; +import '../../wallet/monad_wallet.dart'; +import '../canary_ui.dart'; + +Future _open(String url) => + launchUrl(Uri.parse(url), webOnlyWindowName: '_blank'); + +void _toast(BuildContext context, String message) { + ScaffoldMessenger.maybeOf(context) + ?..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); +} + +String _friendly(Object e) { + if (e is WalletError) { + if (e.code == 4001) return 'Request rejected in your wallet.'; + if (e.code == -32002) { + return 'Your wallet already has a request open. Check the wallet window.'; + } + return e.message; + } + if (e is TimeoutException) return 'Monad did not answer in time. Try again.'; + if (e is StateError) return e.message; + return 'Something went wrong. Try again.'; +} + +/// "Connect wallet" for Monad testnet: connect, switch or add the network, +/// then show the address and MON balance with a small menu. +class WalletButton extends StatelessWidget { + const WalletButton({super.key, this.compact = false}); + + final bool compact; + + Future _connect(BuildContext context) async { + final wallet = MonadWallet.instance; + if (!wallet.available) { + await _noWalletDialog(context); + return; + } + try { + await wallet.connect(); + if (context.mounted && wallet.value.onMonad) { + _toast(context, 'Wallet connected on Monad testnet.'); + } + } catch (e) { + if (context.mounted) _toast(context, _friendly(e)); + } + } + + Future _switch(BuildContext context) async { + try { + await MonadWallet.instance.switchToMonad(); + } catch (e) { + if (context.mounted) _toast(context, _friendly(e)); + } + } + + Future _noWalletDialog(BuildContext context) => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('No wallet found'), + content: const Text( + 'Install a browser wallet such as MetaMask, Rabby or Phantom, then ' + 'reload this page.\n\nYou do not need one to use Canary: the NO SUS ' + 'relayer pays for every Monad transaction. A wallet lets you check ' + 'the seal on Monad yourself.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + FilledButton( + onPressed: () { + _open('https://metamask.io/download/'); + Navigator.of(context).pop(); + }, + child: const Text('Get MetaMask'), + ), + ], + ), + ); + + @override + Widget build(BuildContext context) { + if (!kIsWeb) return const SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: MonadWallet.instance, + builder: (context, state, _) { + if (state.busy) { + return _shell( + icon: const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + label: 'Connecting…', + onPressed: null, + ); + } + if (!state.connected) { + return _shell( + icon: const Icon(Icons.account_balance_wallet_outlined, size: 18), + label: 'Connect wallet', + onPressed: () => _connect(context), + ); + } + if (!state.onMonad) { + return _shell( + icon: const Icon(Icons.swap_horiz, size: 18), + label: 'Switch to Monad', + filled: true, + onPressed: () => _switch(context), + ); + } + return _ConnectedMenu(state: state, compact: compact); + }, + ); + } + + Widget _shell({ + required Widget icon, + required String label, + required VoidCallback? onPressed, + bool filled = false, + }) { + if (compact) { + return IconButton( + tooltip: label, + onPressed: onPressed, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + backgroundColor: filled ? CanaryTokens.canary : null, + ), + icon: icon, + ); + } + final style = OutlinedButton.styleFrom( + foregroundColor: CanaryTokens.text, + backgroundColor: filled ? CanaryTokens.canary : CanaryTokens.surface, + side: const BorderSide(color: CanaryTokens.text, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + minimumSize: const Size(48, 48), + textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13), + ); + return OutlinedButton.icon( + style: style, + onPressed: onPressed, + icon: icon, + label: Text(label), + ); + } +} + +class _ConnectedMenu extends StatelessWidget { + const _ConnectedMenu({required this.state, required this.compact}); + + final WalletState state; + final bool compact; + + @override + Widget build(BuildContext context) { + final pill = Container( + constraints: const BoxConstraints(minHeight: 44), + padding: EdgeInsets.symmetric(horizontal: compact ? 10 : 14, vertical: 8), + decoration: BoxDecoration( + color: CanaryTokens.surface, + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + border: Border.all(color: CanaryTokens.text, width: 2), + boxShadow: const [ + BoxShadow(color: CanaryTokens.text, offset: Offset(3, 3)), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: CanaryTokens.canary, + shape: BoxShape.circle, + border: Border.all(color: CanaryTokens.text, width: 1.5), + ), + ), + const SizedBox(width: 8), + Text( + compact + ? state.shortAddress + : '${state.shortAddress} · ${state.balanceMon} MON', + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 23, + color: CanaryTokens.text, + ), + ), + const Icon(Icons.arrow_drop_down, color: CanaryTokens.text), + ], + ), + ); + + return Tooltip( + message: 'Wallet', + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(CanaryTokens.rChip), + onTap: () => showDialog( + context: context, + builder: (_) => const _WalletPanel(), + ), + child: pill, + ), + ), + ); + } +} + +class _WalletPanel extends StatelessWidget { + const _WalletPanel(); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: MonadWallet.instance, + builder: (context, state, _) { + final wallet = MonadWallet.instance; + final address = state.address; + Widget action( + IconData icon, + String label, + Future Function() run, + ) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + alignment: Alignment.centerLeft, + minimumSize: const Size.fromHeight(48), + ), + icon: Icon(icon, size: 18), + label: Text(label), + onPressed: run, + ), + ); + return AlertDialog( + title: const Text('Your wallet'), + content: SizedBox( + width: 400, + child: address == null + ? const Text('Not connected.') + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CanaryTokens.canary, + borderRadius: BorderRadius.circular( + CanaryTokens.rChip, + ), + border: Border.all( + color: CanaryTokens.text, + width: 2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.onMonad ? 'MONAD TESTNET' : 'WRONG NETWORK', + style: const TextStyle( + fontWeight: FontWeight.w900, + letterSpacing: 1, + color: CanaryTokens.text, + ), + ), + const SizedBox(height: 6), + Text( + '${state.balanceMon} MON', + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 34, + color: CanaryTokens.text, + ), + ), + SelectableText( + address, + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 21, + color: CanaryTokens.text, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + if (!state.onMonad) + action( + Icons.swap_horiz, + 'Switch to Monad testnet', + () async { + try { + await wallet.switchToMonad(); + } catch (e) { + if (context.mounted) { + _toast(context, _friendly(e)); + } + } + }, + ), + action(Icons.copy, 'Copy address', () async { + await Clipboard.setData(ClipboardData(text: address)); + if (context.mounted) _toast(context, 'Address copied.'); + }), + action( + Icons.open_in_new, + 'My wallet on Monadscan', + () => _open(monadAddressUrl(address)), + ), + if (canaryContractAddress.isNotEmpty) + action( + Icons.description_outlined, + 'Canary contract on Monadscan', + () => _open(monadAddressUrl(canaryContractAddress)), + ), + action( + Icons.water_drop_outlined, + 'Get testnet MON', + () => _open('https://faucet.monad.xyz/'), + ), + action(Icons.refresh, 'Refresh balance', wallet.refresh), + ], + ), + ), + actions: [ + TextButton( + onPressed: () async { + await wallet.disconnect(); + if (context.mounted) { + Navigator.of(context).pop(); + _toast(context, 'Wallet disconnected.'); + } + }, + child: const Text('Disconnect'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Done'), + ), + ], + ); + }, + ); + } +} + +/// Reads this note's seal straight from the NoSusCanary contract and shows it. +class VerifyOnMonadButton extends StatelessWidget { + const VerifyOnMonadButton({ + super.key, + required this.noteId, + this.localOpened, + }); + + final String noteId; + + /// How many opens the NO SUS server reports, to compare with the chain. + final int? localOpened; + + @override + Widget build(BuildContext context) { + if (canaryContractAddress.isEmpty) return const SizedBox.shrink(); + return OutlinedButton.icon( + icon: const Icon(Icons.verified_outlined), + label: const Text('Verify on Monad'), + onPressed: () => showDialog( + context: context, + builder: (context) => + _VerifyDialog(noteId: noteId, localOpened: localOpened), + ), + ); + } +} + +class _VerifyDialog extends StatefulWidget { + const _VerifyDialog({required this.noteId, this.localOpened}); + + final String noteId; + final int? localOpened; + + @override + State<_VerifyDialog> createState() => _VerifyDialogState(); +} + +class _VerifyDialogState extends State<_VerifyDialog> { + // Read once: a resize or keyboard rebuild must not re-query Monad. + late final Future _note = readCanaryNoteOnChain( + widget.noteId, + ); + + @override + Widget build(BuildContext context) { + final localOpened = widget.localOpened; + return AlertDialog( + title: const Text('On-chain seal'), + content: SizedBox( + width: 440, + child: FutureBuilder( + future: _note, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + SizedBox(width: 14), + Text('Reading NoSusCanary on Monad…'), + ], + ), + ); + } + if (snap.hasError) { + return Text('Could not read Monad: ${_friendly(snap.error!)}'); + } + final n = snap.data!; + if (!n.exists) { + return const Text( + 'This note is not sealed on Monad yet. Sealing takes a few ' + 'seconds after the link is created; try again shortly.', + ); + } + Widget row(String k, String v) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: Text( + k, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + Expanded( + child: SelectableText( + v, + style: const TextStyle( + fontFamily: CanaryTokens.monoFont, + fontSize: 23, + ), + ), + ), + ], + ), + ); + final hash = n.copiesHash; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + color: CanaryTokens.canary, + child: const Text( + 'SEALED ON MONAD', + style: TextStyle( + fontWeight: FontWeight.w900, + letterSpacing: 1, + ), + ), + ), + const SizedBox(height: 12), + row('Sealed', '${n.sealedAt!.toLocal()}'), + row('Copies', '${n.copyCount}'), + row( + 'Opened', + localOpened == null + ? '${n.openedCount}' + : '${n.openedCount} on-chain · $localOpened on NO SUS', + ), + if (n.expiresAt != null) + row('Expires', CanaryUi.clock(n.expiresAt!)), + row( + 'Copies hash', + '${hash.substring(0, 10)}…${hash.substring(hash.length - 6)}', + ), + row('Read via', n.readVia), + ], + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => _open(monadAddressUrl(canaryContractAddress)), + child: const Text('Contract on Monadscan ↗'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Done'), + ), + ], + ); + } +} diff --git a/app/lib/features/canary/wallet/eth_bridge.dart b/app/lib/features/canary/wallet/eth_bridge.dart new file mode 100644 index 0000000..daf6ab2 --- /dev/null +++ b/app/lib/features/canary/wallet/eth_bridge.dart @@ -0,0 +1,25 @@ +/// Minimal EIP-1193 bridge to the browser wallet (MetaMask, Rabby, Phantom, +/// OKX...). The web build talks to `window.ethereum`; other platforms get a +/// stub that reports no wallet. +abstract class EthBridge { + /// True when an injected wallet exists in this browser. + bool get available; + + /// `ethereum.request({method, params})`, with the result converted to Dart. + Future request(String method, [List params = const []]); + + /// `ethereum.on(event, cb)`. + void on(String event, void Function(Object? data) callback); +} + +/// A wallet error with its EIP-1193 code (4001 = the user rejected, +/// 4902 = the chain is not added to the wallet yet). +class WalletError implements Exception { + const WalletError(this.code, this.message); + + final int code; + final String message; + + @override + String toString() => message; +} diff --git a/app/lib/features/canary/wallet/eth_bridge_stub.dart b/app/lib/features/canary/wallet/eth_bridge_stub.dart new file mode 100644 index 0000000..567a6f5 --- /dev/null +++ b/app/lib/features/canary/wallet/eth_bridge_stub.dart @@ -0,0 +1,17 @@ +import 'eth_bridge.dart'; + +EthBridge createEthBridge() => _StubEthBridge(); + +class _StubEthBridge implements EthBridge { + @override + bool get available => false; + + @override + Future request(String method, [List params = const []]) => + Future.error( + const WalletError(-1, 'Wallet connect works in the web app.'), + ); + + @override + void on(String event, void Function(Object? data) callback) {} +} diff --git a/app/lib/features/canary/wallet/eth_bridge_web.dart b/app/lib/features/canary/wallet/eth_bridge_web.dart new file mode 100644 index 0000000..5c4cc99 --- /dev/null +++ b/app/lib/features/canary/wallet/eth_bridge_web.dart @@ -0,0 +1,70 @@ +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'eth_bridge.dart'; + +EthBridge createEthBridge() => _WebEthBridge(); + +class _WebEthBridge implements EthBridge { + JSObject? get _eth { + final value = globalContext['ethereum']; + if (value == null) return null; + return value as JSObject; + } + + @override + bool get available => _eth != null; + + @override + Future request( + String method, [ + List params = const [], + ]) async { + final eth = _eth; + if (eth == null) { + throw const WalletError(-1, 'No wallet found in this browser.'); + } + final args = JSObject(); + args['method'] = method.toJS; + args['params'] = params.jsify(); + try { + final result = await eth + .callMethod>('request'.toJS, args) + .toDart; + return result.dartify(); + } catch (e) { + throw _walletError(e); + } + } + + @override + void on(String event, void Function(Object? data) callback) { + final eth = _eth; + if (eth == null) return; + void handler(JSAny? data) { + callback(data.dartify()); + } + + eth.callMethod('on'.toJS, event.toJS, handler.toJS); + } + + WalletError _walletError(Object error) { + try { + final raw = (error as JSAny).dartify(); + if (raw is Map) { + var code = raw['code'] is num ? (raw['code'] as num).toInt() : -1; + // Some wallets wrap "unknown chain" (4902) inside data.originalError. + final data = raw['data']; + if (data is Map && data['originalError'] is Map) { + final inner = (data['originalError'] as Map)['code']; + if (inner is num) code = inner.toInt(); + } + final message = raw['message']?.toString() ?? 'Wallet error'; + return WalletError(code, message); + } + } catch (_) { + // Fall through to the generic error below. + } + return WalletError(-1, error.toString()); + } +} diff --git a/app/lib/features/canary/wallet/monad_wallet.dart b/app/lib/features/canary/wallet/monad_wallet.dart new file mode 100644 index 0000000..f393d29 --- /dev/null +++ b/app/lib/features/canary/wallet/monad_wallet.dart @@ -0,0 +1,303 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../../../config/app_mode.dart'; +import '../domain/canary_link.dart'; +import 'eth_bridge.dart'; +import 'eth_bridge_stub.dart' + if (dart.library.js_interop) 'eth_bridge_web.dart'; + +export 'eth_bridge.dart' show WalletError; + +const int kMonadTestnetChainId = 10143; +const String kMonadTestnetChainHex = '0x279f'; +const String kMonadRpcUrl = 'https://testnet-rpc.monad.xyz'; +const String kMonadExplorer = 'https://testnet.monadscan.com'; + +/// The deployed NoSusCanary contract on Monad testnet. +String get canaryContractAddress => kCanaryContract; + +String monadAddressUrl(String address) => '$kMonadExplorer/address/$address'; + +class WalletState { + const WalletState({ + this.address, + this.chainId, + this.balanceWei, + this.busy = false, + }); + + final String? address; + final int? chainId; + final BigInt? balanceWei; + final bool busy; + + bool get connected => address != null; + bool get onMonad => chainId == kMonadTestnetChainId; + + String get shortAddress { + final a = address; + if (a == null || a.length < 10) return a ?? ''; + return '${a.substring(0, 6)}…${a.substring(a.length - 4)}'; + } + + /// Balance in MON with 3 decimals, e.g. "12.345". + String get balanceMon { + final wei = balanceWei; + if (wei == null) return '…'; + final milli = wei ~/ BigInt.from(10).pow(15); + final whole = milli ~/ BigInt.from(1000); + final frac = (milli % BigInt.from(1000)).toString().padLeft(3, '0'); + return '$whole.$frac'; + } + + WalletState copyWith({ + String? address, + int? chainId, + BigInt? balanceWei, + bool? busy, + }) => WalletState( + address: address ?? this.address, + chainId: chainId ?? this.chainId, + balanceWei: balanceWei ?? this.balanceWei, + busy: busy ?? this.busy, + ); +} + +/// The browser wallet connected to Monad testnet. One instance for the app. +class MonadWallet extends ValueNotifier { + MonadWallet._(this._bridge) : super(const WalletState()) { + if (_bridge.available) { + _listen(); + _restore(); + } + } + + static final MonadWallet instance = MonadWallet._(createEthBridge()); + + final EthBridge _bridge; + + bool get available => _bridge.available; + + /// Connects (asks the wallet for an account), then switches to or adds + /// Monad testnet and reads the balance. Throws [WalletError] on failure. + Future connect() async { + value = value.copyWith(busy: true); + try { + final accounts = await _bridge.request('eth_requestAccounts'); + final address = _firstAccount(accounts); + if (address == null) { + throw const WalletError(-1, 'The wallet returned no account.'); + } + value = WalletState(address: address, busy: true); + await switchToMonad(); + await refresh(); + } finally { + value = value.copyWith(busy: false); + } + } + + /// Switches the wallet to Monad testnet, adding the network first if the + /// wallet does not know it yet. + Future switchToMonad() async { + try { + await _bridge.request('wallet_switchEthereumChain', [ + {'chainId': kMonadTestnetChainHex}, + ]); + } on WalletError catch (e) { + if (e.code == 4001) rethrow; + await _bridge.request('wallet_addEthereumChain', [ + { + 'chainId': kMonadTestnetChainHex, + 'chainName': 'Monad Testnet', + 'nativeCurrency': {'name': 'Monad', 'symbol': 'MON', 'decimals': 18}, + 'rpcUrls': [kMonadRpcUrl], + 'blockExplorerUrls': [kMonadExplorer], + }, + ]); + } + await refresh(); + } + + /// Re-reads the chain id and the MON balance. + Future refresh() async { + final address = value.address; + if (address == null) return; + final chain = _hexToInt(await _bridge.request('eth_chainId')); + BigInt? balance; + try { + final raw = await _bridge.request('eth_getBalance', [address, 'latest']); + balance = _hexToBigInt(raw); + } catch (_) { + balance = null; + } + value = WalletState( + address: address, + chainId: chain, + balanceWei: balance, + busy: value.busy, + ); + } + + /// Forgets the account in this app (and asks the wallet to revoke access + /// where it supports that). + Future disconnect() async { + try { + await _bridge.request('wallet_revokePermissions', [ + {'eth_accounts': {}}, + ]); + } catch (_) { + // Not every wallet supports revoking; forgetting locally is enough. + } + value = const WalletState(); + } + + /// `eth_call` through the connected wallet. + Future ethCall(String to, String data) async { + final result = await _bridge.request('eth_call', [ + {'to': to, 'data': data}, + 'latest', + ]); + return result?.toString() ?? '0x'; + } + + Future _restore() async { + try { + final accounts = await _bridge.request('eth_accounts'); + final address = _firstAccount(accounts); + if (address == null) return; + value = WalletState(address: address); + await refresh(); + } catch (_) { + // Not connected before; stay disconnected. + } + } + + void _listen() { + _bridge.on('accountsChanged', (data) { + final address = _firstAccount(data); + if (address == null) { + value = const WalletState(); + } else { + value = WalletState(address: address, chainId: value.chainId); + refresh(); + } + }); + _bridge.on('chainChanged', (data) { + if (value.connected) { + value = WalletState(address: value.address, chainId: _hexToInt(data)); + refresh(); + } + }); + } + + static String? _firstAccount(Object? accounts) { + if (accounts is List && accounts.isNotEmpty) { + return accounts.first?.toString(); + } + return null; + } + + static int? _hexToInt(Object? hex) { + final s = hex?.toString(); + if (s == null || !s.startsWith('0x')) return null; + return int.tryParse(s.substring(2), radix: 16); + } + + static BigInt? _hexToBigInt(Object? hex) { + final s = hex?.toString(); + if (s == null || !s.startsWith('0x')) return null; + return BigInt.tryParse(s.substring(2), radix: 16); + } +} + +/// What NoSusCanary.noteOf returns for one note. +class CanaryChainNote { + const CanaryChainNote({ + required this.sealedAt, + required this.expiresAt, + required this.copyCount, + required this.openedCount, + required this.copiesHash, + required this.readVia, + }); + + final DateTime? sealedAt; + final DateTime? expiresAt; + final int copyCount; + final int openedCount; + final String copiesHash; + + /// "your wallet" or "the public Monad RPC". + final String readVia; + + bool get exists => sealedAt != null; +} + +/// Reads a note's seal straight from the NoSusCanary contract. Uses the +/// connected wallet when it is on Monad testnet, else the public RPC. +Future readCanaryNoteOnChain(String noteId) async { + final contract = canaryContractAddress; + if (contract.isEmpty) { + throw StateError('No contract address is configured.'); + } + final id = canaryChainNoteId(noteId).substring(2); + // noteOf(bytes32) selector. + final data = '0x3a989573$id'; + + final wallet = MonadWallet.instance; + String hex; + String via; + if (wallet.value.connected && wallet.value.onMonad) { + hex = await wallet.ethCall(contract, data); + via = 'your wallet'; + } else { + final response = await http + .post( + Uri.parse(kMonadRpcUrl), + headers: {'content-type': 'application/json'}, + body: jsonEncode({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'eth_call', + 'params': [ + {'to': contract, 'data': data}, + 'latest', + ], + }), + ) + .timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw StateError( + 'The Monad RPC is busy (${response.statusCode}). Try again.', + ); + } + final body = jsonDecode(response.body) as Map; + if (body['error'] != null) { + throw StateError('Monad RPC error: ${body['error']}'); + } + hex = body['result'] as String; + via = 'the public Monad RPC'; + } + + final words = hex.startsWith('0x') ? hex.substring(2) : hex; + String word(int i) => words.length >= (i + 1) * 64 + ? words.substring(i * 64, (i + 1) * 64) + : '0'; + int num(int i) => int.parse(word(i), radix: 16); + DateTime? time(int i) { + final s = num(i); + return s == 0 ? null : DateTime.fromMillisecondsSinceEpoch(s * 1000); + } + + return CanaryChainNote( + sealedAt: time(0), + expiresAt: time(1), + copyCount: num(2), + openedCount: num(3), + copiesHash: '0x${word(4)}', + readVia: via, + ); +} diff --git a/app/lib/features/config/data/remote_config_service.dart b/app/lib/features/config/data/remote_config_service.dart new file mode 100644 index 0000000..ca2610b --- /dev/null +++ b/app/lib/features/config/data/remote_config_service.dart @@ -0,0 +1,161 @@ +import 'dart:async'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../../core/utils/debug_logger.dart'; +import '../domain/feature_flag.dart'; + +/// Service responsible for managing dynamic feature flags and remote configs. +/// It caches the values locally and keeps them updated in real-time. +/// +/// A null [SupabaseClient] means mock fallback mode (no backend): every +/// lookup returns its caller-provided default and [initialize] is a no-op — +/// constructing this must never touch `Supabase.instance`, which throws when +/// the SDK was never initialized. +class RemoteConfigService { + final SupabaseClient? _supabase; + final Map _flags = {}; + final Map _configs = {}; + RealtimeChannel? _flagsChannel; + RealtimeChannel? _configsChannel; + Future? _initFuture; + + RemoteConfigService(this._supabase); + + /// Memoized [initialize] — safe to await from multiple call sites without + /// re-fetching or double-subscribing the realtime channels. + Future ensureInitialized() => _initFuture ??= initialize(); + + /// Loads all flags and configurations from the database and starts real-time subscriptions. + Future initialize() async { + final supabase = _supabase; + if (supabase == null) { + debugLog('Config: No backend (mock fallback mode). Using defaults.'); + return; + } + try { + debugLog('Config: Initializing remote configuration...'); + + // Fetch initial feature flags + final flagRows = await supabase.from('feature_flags').select(); + _flags.clear(); + for (final row in flagRows) { + final flag = FeatureFlag.fromJson(row); + _flags[flag.key] = flag; + } + debugLog('Config: Loaded ${_flags.length} feature flags.'); + + // Fetch initial remote configurations + final configRows = await supabase.from('remote_configs').select(); + _configs.clear(); + for (final row in configRows) { + _configs[row['config_key'] as String] = row['config_value']; + } + debugLog('Config: Loaded ${_configs.length} remote configurations.'); + + // Start real-time sync + _subscribeToRealtime(); + } catch (e) { + debugLog('Config: Initialization failed (offline or unauthenticated). Using defaults. Error: $e'); + } + } + + /// Checks if a feature flag is enabled for the current user. + /// Falls back to [defaultValue] if the flag does not exist or fetch failed. + bool isFeatureEnabled(String flagKey, String userId, {bool defaultValue = false}) { + final flag = _flags[flagKey]; + if (flag == null) { + return defaultValue; + } + return flag.isEnabledFor(userId); + } + + /// Gets a remote configuration value cast to [T]. + /// Falls back to [defaultValue] if the configuration key is missing. + T getConfigValue(String key, T defaultValue) { + final value = _configs[key]; + if (value == null) { + return defaultValue; + } + try { + if (T == int && value is num) { + return value.toInt() as T; + } + if (T == double && value is num) { + return value.toDouble() as T; + } + return value as T; + } catch (e) { + debugLog('Config: Failed to cast config value for key "$key" to $T: $e'); + return defaultValue; + } + } + + /// Subscribes to real-time database modifications to support instant remote toggles. + void _subscribeToRealtime() { + final supabase = _supabase; + if (supabase == null) return; + try { + _flagsChannel = supabase + .channel('public:feature_flags') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'feature_flags', + callback: (payload) { + final newRecord = payload.newRecord; + final oldRecord = payload.oldRecord; + + if (payload.eventType == PostgresChangeEvent.delete && oldRecord.isNotEmpty) { + final key = oldRecord['flag_key'] as String?; + if (key != null) { + _flags.remove(key); + debugLog('Config: Feature flag "$key" deleted via remote.'); + } + } else if (newRecord.isNotEmpty) { + final flag = FeatureFlag.fromJson(newRecord); + _flags[flag.key] = flag; + debugLog('Config: Feature flag "${flag.key}" updated via remote. Active: ${flag.isActive}, Rollout: ${flag.rolloutPercentage}%'); + } + }, + ) + .subscribe(); + + _configsChannel = supabase + .channel('public:remote_configs') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'remote_configs', + callback: (payload) { + final newRecord = payload.newRecord; + final oldRecord = payload.oldRecord; + + if (payload.eventType == PostgresChangeEvent.delete && oldRecord.isNotEmpty) { + final key = oldRecord['config_key'] as String?; + if (key != null) { + _configs.remove(key); + debugLog('Config: Remote config "$key" deleted via remote.'); + } + } else if (newRecord.isNotEmpty) { + final key = newRecord['config_key'] as String; + final value = newRecord['config_value']; + _configs[key] = value; + debugLog('Config: Remote config "$key" updated via remote to: $value'); + } + }, + ) + .subscribe(); + } catch (e) { + debugLog('Config: Realtime subscription failed: $e'); + } + } + + /// Closes real-time channels when disposing. + void dispose() { + if (_flagsChannel != null) { + _supabase?.removeChannel(_flagsChannel!); + } + if (_configsChannel != null) { + _supabase?.removeChannel(_configsChannel!); + } + } +} diff --git a/app/lib/features/config/domain/app_update.dart b/app/lib/features/config/domain/app_update.dart new file mode 100644 index 0000000..aa65c75 --- /dev/null +++ b/app/lib/features/config/domain/app_update.dart @@ -0,0 +1,53 @@ +// Domain model for the in-app update check. +// +// The latest released version is published to the `app_latest_version` row +// in `remote_configs` as part of the release flow (see CLAUDE.md, CI +// section) — the app never calls out to GitHub or any other third party to +// learn about updates, consistent with the product's privacy stance. + +/// Compares two dotted version strings numerically, segment by segment. +/// +/// Returns a negative number if [a] < [b], zero if equal, positive if +/// [a] > [b]. Tolerates a leading `v`/`V` and unequal segment counts +/// ("1.2" == "1.2.0"). Non-numeric segments are treated as 0. +int compareVersions(String a, String b) { + List parse(String v) => v + .trim() + .replaceFirst(RegExp(r'^[vV]'), '') + .split('.') + .map((s) => int.tryParse(s) ?? 0) + .toList(); + + final pa = parse(a); + final pb = parse(b); + final len = pa.length > pb.length ? pa.length : pb.length; + for (var i = 0; i < len; i++) { + final va = i < pa.length ? pa[i] : 0; + final vb = i < pb.length ? pb[i] : 0; + if (va != vb) return va - vb; + } + return 0; +} + +/// Outcome of an update check. +sealed class AppUpdateStatus { + const AppUpdateStatus(); +} + +/// The check doesn't apply on this platform (web always runs the latest +/// deploy; there is no sideload path outside Android). +class AppUpdateNotApplicable extends AppUpdateStatus { + const AppUpdateNotApplicable(); +} + +/// Installed version is current (or the latest version is unknown). +class AppUpToDate extends AppUpdateStatus { + const AppUpToDate(); +} + +/// A newer version exists. +class AppUpdateAvailable extends AppUpdateStatus { + final String latestVersion; + final String downloadUrl; + const AppUpdateAvailable({required this.latestVersion, required this.downloadUrl}); +} diff --git a/app/lib/features/config/domain/feature_flag.dart b/app/lib/features/config/domain/feature_flag.dart new file mode 100644 index 0000000..4439c27 --- /dev/null +++ b/app/lib/features/config/domain/feature_flag.dart @@ -0,0 +1,46 @@ +/// Represents a dynamic feature flag fetched from the database. +class FeatureFlag { + final String key; + final String description; + final bool isActive; + final int rolloutPercentage; + final List targetedUserIds; + final Map metadata; + + FeatureFlag({ + required this.key, + required this.description, + required this.isActive, + required this.rolloutPercentage, + required this.targetedUserIds, + required this.metadata, + }); + + factory FeatureFlag.fromJson(Map json) { + return FeatureFlag( + key: json['flag_key'] as String, + description: json['description'] as String? ?? '', + isActive: json['is_active'] as bool? ?? false, + rolloutPercentage: json['rollout_percentage'] as int? ?? 100, + targetedUserIds: List.from( + (json['targeted_user_ids'] as List?)?.map((e) => e.toString()) ?? [], + ), + metadata: Map.from(json['metadata'] ?? {}), + ); + } + + /// Determines if the feature flag is enabled for a specific [userId]. + bool isEnabledFor(String userId) { + if (!isActive) return false; + + // Explicit targeting overrides percentage rollout + if (targetedUserIds.contains(userId)) return true; + + if (rolloutPercentage == 100) return true; + if (rolloutPercentage == 0) return false; + + // Deterministic hash rollout to keep assignments consistent for a given user + final hash = userId.codeUnits.fold(0, (prev, element) => prev + element); + return (hash % 100) < rolloutPercentage; + } +} diff --git a/app/lib/features/config/presentation/providers/app_update_provider.dart b/app/lib/features/config/presentation/providers/app_update_provider.dart new file mode 100644 index 0000000..6cebe77 --- /dev/null +++ b/app/lib/features/config/presentation/providers/app_update_provider.dart @@ -0,0 +1,45 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../domain/app_update.dart'; +import 'config_provider.dart'; + +/// One-shot update check, evaluated once per app start (same point-in-time +/// contract as OfflineBanner's reachability flag — no polling). +/// +/// The "latest version" comes from the app's own `remote_configs` table +/// (`app_latest_version`), never a third-party API — see +/// lib/features/config/domain/app_update.dart. +final appUpdateProvider = FutureProvider((ref) async { + // Web always runs the latest deploy, and there is no sideload/update path + // on platforms other than Android. + if (kIsWeb || !Platform.isAndroid) { + return const AppUpdateNotApplicable(); + } + + final service = ref.watch(remoteConfigServiceProvider); + await service.ensureInitialized(); + + final latest = service.getConfigValue( + SecureSendConfigs.appLatestVersion.key, + '', + ); + if (latest.trim().isEmpty) { + // Latest version unknown (config row missing or fetch failed) — stay + // quiet rather than guessing. + return const AppUpToDate(); + } + + final info = await PackageInfo.fromPlatform(); + if (compareVersions(latest, info.version) > 0) { + final downloadUrl = service.getConfigValue( + SecureSendConfigs.appDownloadUrl.key, + SecureSendConfigs.appDownloadUrl.defaultValue as String, + ); + return AppUpdateAvailable(latestVersion: latest, downloadUrl: downloadUrl); + } + return const AppUpToDate(); +}); diff --git a/app/lib/features/config/presentation/providers/config_provider.dart b/app/lib/features/config/presentation/providers/config_provider.dart new file mode 100644 index 0000000..7a1c939 --- /dev/null +++ b/app/lib/features/config/presentation/providers/config_provider.dart @@ -0,0 +1,109 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../../../core/utils/debug_logger.dart'; +import '../../../../core/supabase/supabase_bootstrap.dart'; +import '../../data/remote_config_service.dart'; + + +/// Provider for the [RemoteConfigService] singleton. +/// +/// main.dart overrides this with the boot-time instance; this default body +/// only runs in tests or entrypoints without the override, so it must stay +/// safe when Supabase was never initialized (mock fallback mode). +final remoteConfigServiceProvider = Provider((ref) { + final service = RemoteConfigService( + SupabaseBootstrap.isConfigured ? Supabase.instance.client : null, + ); + ref.onDispose(() => service.dispose()); + return service; +}); + +/// FutureProvider to handle initialization of the remote config. +final configInitializerProvider = FutureProvider((ref) async { + final service = ref.watch(remoteConfigServiceProvider); + await service.ensureInitialized(); +}); + +/// Family provider to check if a specific [flagKey] is enabled for the current user. +/// It watches the active authenticated user's ID to perform targeting checks. +final featureFlagProvider = Provider.family((ref, flagKey) { + final user = ref.watch(authStateProvider).value; + final service = ref.watch(remoteConfigServiceProvider); + + if (user == null) { + return false; + } + + return service.isFeatureEnabled(flagKey, user.id); +}); + +/// Family provider to get a remote configuration value. +final remoteConfigValueProvider = Provider.family((ref, param) { + final service = ref.watch(remoteConfigServiceProvider); + return service.getConfigValue(param.key, param.defaultValue); +}); + +/// FutureProvider to fetch the user role dynamically from user_roles table. +final userRoleProvider = FutureProvider((ref) async { + final user = ref.watch(authStateProvider).value; + if (user == null) return 'user'; + + try { + final response = await Supabase.instance.client + .from('user_roles') + .select('role') + .eq('user_id', user.id) + .maybeSingle(); + return response?['role'] as String? ?? 'user'; + } catch (e) { + debugLog('Config: Failed to fetch user role: $e'); + return 'user'; + } +}); + +/// Helper class for defining remote config parameters with defaults. +class _ConfigParam { + final String key; + final dynamic defaultValue; + + const _ConfigParam(this.key, this.defaultValue); +} + +/// Predefined parameters for the SecureSend application configs. +class SecureSendConfigs { + SecureSendConfigs._(); + + static const maxExpiryDays = _ConfigParam( + 'securesend_max_expiry_days', + 30, + ); + + static const maxViewsDefault = _ConfigParam( + 'securesend_max_views_default', + 10, + ); + + static const minSupportedVersion = _ConfigParam( + 'min_supported_client_version', + '1.0.0', + ); + + static const appDownloadUrl = _ConfigParam( + 'app_download_url', + // GitHub Releases page — the play-store workflow attaches an APK to each + // tagged release. (The old nosus.foo/app-release.apk was never actually + // hosted; it 404'd in production.) + 'https://github.com/https-shubhamsahu/NON_SUS/releases/latest', + ); + + static const appLatestVersion = _ConfigParam( + 'app_latest_version', + // Empty default = "latest version unknown" — the update banner stays + // hidden rather than guessing. The row is bumped as part of each release + // (see CLAUDE.md, CI section). + '', + ); +} + + diff --git a/app/lib/features/files/data/repositories/supabase_secure_file_repository.dart b/app/lib/features/files/data/repositories/supabase_secure_file_repository.dart new file mode 100644 index 0000000..c3eb0ba --- /dev/null +++ b/app/lib/features/files/data/repositories/supabase_secure_file_repository.dart @@ -0,0 +1,371 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../../services/supabase_service.dart'; +import '../../../../config/storage_router_config.dart'; +import '../storage_router_client.dart'; +import '../../domain/models/secure_file_metadata.dart'; +import '../../domain/repositories/secure_file_repository.dart'; +import '../../../../core/cache/document_cache.dart'; +import '../../../../core/utils/debug_logger.dart'; + +class SupabaseSecureFileRepository implements SecureFileRepository { + final SupabaseClient _client; + + const SupabaseSecureFileRepository(this._client); + + @override + Stream> watchGroupFiles(String groupId) { + return _client + .from('secure_files') + .stream(primaryKey: ['id']) + .eq('group_id', groupId) + .order('uploaded_at', ascending: false) + .map((rows) => rows.map(_mapFile).toList(growable: false)); + } + + @override + Stream> watchAllFiles() { + return _client + .from('secure_files') + .stream(primaryKey: ['id']) + .order('uploaded_at', ascending: false) + .map((rows) => rows.map(_mapFile).toList(growable: false)); + } + + @override + Future uploadFile({ + required String groupId, + required String name, + required SecureFileType type, + required Uint8List rawBytes, + required String uploaderName, + required String uploaderInitials, + required Function(double) onProgress, + }) async { + // 1. Emulate upload progress + for (int i = 1; i <= 10; i++) { + await Future.delayed(const Duration(milliseconds: 50)); + onProgress(StorageRouterConfig.enableMultiCloudStorage ? i / 20.0 : i / 10.0); + } + + final tempFileId = 'file_${DateTime.now().millisecondsSinceEpoch}'; + final storedName = _displayNameFor(name, type); + final storedType = type.name == 'note' ? 'markdown' : type.name; + + if (StorageRouterConfig.enableMultiCloudStorage) { + try { + await StorageRouterClient.instance.upload( + fileId: tempFileId, + groupId: groupId, + name: storedName, + type: storedType, + bytes: rawBytes, + contentType: _contentTypeFor(type), + ); + onProgress(1.0); + + await SupabaseService.instance.logEvent( + 'file_uploaded', + groupId: groupId, + fileId: tempFileId, + metadata: { + 'file_name': name, + 'size_bytes': rawBytes.length, + 'storage_backend': 'storage-router', + }, + ); + return; + } catch (e, s) { + debugLog( + 'Storage router upload failed; falling back to Supabase Storage: $e\n$s', + ); + } + } + + // 2. Upload raw binary bytes to Supabase Storage. cacheControl: '0' + // means browsers/CDN never cache this response — the signed URL that + // resolves to these bytes already expires in minutes, but a cached + // response would keep serving cached bytes from local disk even after + // that signed URL is no longer valid for new requests. + await _client.storage.from('secure-files').uploadBinary( + tempFileId, + rawBytes, + fileOptions: const FileOptions(cacheControl: '0'), + ); + + // 3. Insert metadata row in secure_files table + await _client.from('secure_files').insert({ + 'id': tempFileId, + 'group_id': groupId, + 'name': storedName, + 'type': storedType, + 'size_bytes': rawBytes.length, + 'is_watermarked': true, + 'is_pinned': false, + 'security_status': 'secured', + }); + + // 4. Log event + await SupabaseService.instance.logEvent( + 'file_uploaded', + groupId: groupId, + fileId: tempFileId, + metadata: { + 'file_name': name, + 'size_bytes': rawBytes.length, + }, + ); + } + + @override + Future deleteFile(String groupId, String fileId) async { + DocumentCache.instance.evict(fileId); + // 1. Delete object from Google Drive (if linked) or Supabase Storage + final isGoogleDrive = !fileId.startsWith('file_') && + !fileId.startsWith('sec_gen_') && + !fileId.startsWith('file_gen_'); + if (isGoogleDrive) { + try { + await SupabaseService.instance.deleteGDriveFile(fileId); + } catch (_) {} + } else { + if (StorageRouterConfig.enableMultiCloudStorage) { + try { + await StorageRouterClient.instance.delete(fileId); + } catch (e) { + debugLog('Storage router delete skipped/fell back: $e'); + } + } + try { + await _client.storage.from('secure-files').remove([fileId]); + } catch (_) {} + } + + // 2. Delete database metadata + await _client.from('secure_files').delete().eq('id', fileId); + + // 3. Log event + await SupabaseService.instance.logEvent( + 'file_deleted', + groupId: groupId, + fileId: fileId, + metadata: { + 'file_id': fileId, + }, + ); + } + + @override + Future togglePin(String groupId, String fileId) async { + final response = await _client + .from('secure_files') + .select('is_pinned') + .eq('id', fileId) + .maybeSingle(); + + if (response != null) { + final currentPin = response['is_pinned'] as bool? ?? false; + await _client + .from('secure_files') + .update({'is_pinned': !currentPin}) + .eq('id', fileId); + } + } + + @override + Future addGoogleDriveLink({ + required String groupId, + required String name, + required SecureFileType type, + required String driveUrl, + required String uploaderName, + required String uploaderInitials, + }) async { + final gDriveId = _extractGoogleDriveFileId(driveUrl); + if (gDriveId == null || gDriveId.isEmpty) { + throw Exception("Invalid Google Drive URL. Could not parse file ID."); + } + + await _client.from('secure_files').insert({ + 'id': gDriveId, + 'group_id': groupId, + 'name': name, + 'type': type.name == 'note' ? 'markdown' : type.name, + 'size_bytes': 0, + 'is_watermarked': true, + 'is_pinned': false, + 'security_status': 'secured', + }); + + await SupabaseService.instance.logEvent( + 'file_uploaded', + groupId: groupId, + fileId: gDriveId, + metadata: { + 'file_name': name, + 'gdrive_file_id': gDriveId, + }, + ); + } + + @override + Future getServiceAccountEmail() async { + return await SupabaseService.instance.getServiceAccountEmail(); + } + + @override + Future downloadFile({ + required String fileId, + required Function(double) onProgress, + }) async { + // Session cache first — re-opening a recently viewed document is instant + // and costs no network. + final cached = DocumentCache.instance.get(fileId); + if (cached != null) { + onProgress(1.0); + return cached; + } + try { + onProgress(0.1); + final isGoogleDrive = !fileId.startsWith('file_') && !fileId.startsWith('sec_gen_'); + if (isGoogleDrive) { + onProgress(0.3); + final gDriveBytes = await SupabaseService.instance.downloadGDriveFile(fileId); + onProgress(1.0); + if (gDriveBytes != null) DocumentCache.instance.put(fileId, gDriveBytes); + return gDriveBytes; + } else { + if (StorageRouterConfig.enableMultiCloudStorage) { + try { + onProgress(0.3); + final bytes = await StorageRouterClient.instance.download(fileId); + onProgress(1.0); + DocumentCache.instance.put(fileId, bytes); + return bytes; + } catch (e) { + debugLog('Storage router download fell back to Supabase Storage: $e'); + } + } + onProgress(0.3); + final bytes = await _client.storage.from('secure-files').download(fileId); + onProgress(1.0); + DocumentCache.instance.put(fileId, bytes); + return bytes; + } + } catch (e, s) { + debugLog("SupabaseSecureFileRepository: downloadFile error: $e\n$s"); + return null; + } + } + + SecureFileMetadata _mapFile(Map row) { + return SecureFileMetadata( + id: row['id'] as String, + groupId: row['group_id'] as String, + name: row['name'] as String, + type: _parseType(row['type']), + sizeBytes: row['size_bytes'] as int? ?? 0, + uploaderName: row['uploaded_by'] as String? ?? 'Unknown', + uploadedAt: + DateTime.tryParse(row['uploaded_at']?.toString() ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + isWatermarked: row['is_watermarked'] as bool? ?? true, + isPinned: row['is_pinned'] as bool? ?? false, + status: _parseStatus(row['security_status']), + ); + } + + SecureFileType _parseType(Object? value) { + final normalized = value?.toString().toLowerCase(); + if (normalized == 'markdown') return SecureFileType.note; + return SecureFileType.values.firstWhere( + (type) => type.name == normalized, + orElse: () => SecureFileType.pdf, + ); + } + + SecureFileStatus _parseStatus(Object? value) { + return SecureFileStatus.values.firstWhere( + (status) => status.name == value?.toString().toLowerCase(), + orElse: () => SecureFileStatus.pending, + ); + } + + String? _extractGoogleDriveFileId(String url) { + final RegExp regExp1 = RegExp(r'/file/d/([a-zA-Z0-9-_]+)'); + final RegExp regExp2 = RegExp(r'[?&]id=([a-zA-Z0-9-_]+)'); + + final match1 = regExp1.firstMatch(url); + if (match1 != null && match1.groupCount >= 1) { + return match1.group(1); + } + + final match2 = regExp2.firstMatch(url); + if (match2 != null && match2.groupCount >= 1) { + return match2.group(1); + } + + if (!url.contains('/') && url.length > 10) { + return url; + } + + return null; + } + + String _displayNameFor(String name, SecureFileType type) { + final extension = switch (type) { + SecureFileType.note => '.md', + SecureFileType.pdf => '.pdf', + SecureFileType.image => '.png', + SecureFileType.scan => '.jpg', + }; + return name.replaceAll(extension, ''); + } + + String _contentTypeFor(SecureFileType type) { + return switch (type) { + SecureFileType.note => 'text/markdown', + SecureFileType.pdf => 'application/pdf', + SecureFileType.image => 'image/png', + SecureFileType.scan => 'image/jpeg', + }; + } + + @override + Future renameFile(String fileId, String newName) async { + final fileRow = await _client + .from('secure_files') + .select('group_id') + .eq('id', fileId) + .maybeSingle(); + final groupId = fileRow?['group_id'] as String?; + + await _client.from('secure_files').update({ + 'name': newName, + }).eq('id', fileId); + + if (groupId != null) { + await SupabaseService.instance.logEvent( + 'file_renamed', + groupId: groupId, + fileId: fileId, + metadata: { + 'new_name': newName, + }, + ); + } + } + + @override + Future> getAllFiles() async { + final response = await _client + .from('secure_files') + .select() + .order('uploaded_at', ascending: false); + return (response as List) + .map((row) => _mapFile(row as Map)) + .toList(); + } +} diff --git a/app/lib/features/files/data/storage_router_client.dart b/app/lib/features/files/data/storage_router_client.dart new file mode 100644 index 0000000..313d92c --- /dev/null +++ b/app/lib/features/files/data/storage_router_client.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../config/storage_router_config.dart'; +import '../../../config/supabase_credentials.dart'; + +class StorageRouterUploadResult { + final String fileId; + final String provider; + final String bucket; + final String objectKey; + final int sizeBytes; + + const StorageRouterUploadResult({ + required this.fileId, + required this.provider, + required this.bucket, + required this.objectKey, + required this.sizeBytes, + }); + + factory StorageRouterUploadResult.fromJson(Map json) { + return StorageRouterUploadResult( + fileId: json['file_id']?.toString() ?? '', + provider: json['provider']?.toString() ?? '', + bucket: json['bucket']?.toString() ?? '', + objectKey: json['object_key']?.toString() ?? '', + sizeBytes: (json['size_bytes'] as num?)?.toInt() ?? 0, + ); + } +} + +class StorageRouterClient { + StorageRouterClient._(); + static final StorageRouterClient instance = StorageRouterClient._(); + + String get _baseUrl => + '${SupabaseCredentials.url}/functions/v1/${StorageRouterConfig.edgeFunctionName}'; + + Future upload({ + required String fileId, + required String groupId, + required String name, + required String type, + required Uint8List bytes, + required String contentType, + }) async { + final uri = Uri.parse('$_baseUrl/upload').replace( + queryParameters: { + 'fileId': fileId, + 'groupId': groupId, + 'name': name, + 'type': type, + 'contentType': contentType, + }, + ); + + final res = await http.post( + uri, + headers: { + 'Authorization': 'Bearer ${_accessToken()}', + 'Content-Type': contentType, + }, + body: bytes, + ); + + if (res.statusCode < 200 || res.statusCode >= 300) { + throw Exception('Storage router upload failed (${res.statusCode}): ${res.body}'); + } + + final data = jsonDecode(res.body) as Map; + return StorageRouterUploadResult.fromJson(data); + } + + Future download(String fileId) async { + final uri = Uri.parse('$_baseUrl/download').replace( + queryParameters: {'fileId': fileId}, + ); + final res = await http.get( + uri, + headers: {'Authorization': 'Bearer ${_accessToken()}'}, + ); + + if (res.statusCode < 200 || res.statusCode >= 300) { + throw Exception('Storage router download failed (${res.statusCode}): ${res.body}'); + } + return res.bodyBytes; + } + + Future delete(String fileId) async { + final uri = Uri.parse('$_baseUrl/delete').replace( + queryParameters: {'fileId': fileId}, + ); + final res = await http.delete( + uri, + headers: {'Authorization': 'Bearer ${_accessToken()}'}, + ); + + if (res.statusCode < 200 || res.statusCode >= 300) { + throw Exception('Storage router delete failed (${res.statusCode}): ${res.body}'); + } + } + + String _accessToken() { + final token = Supabase.instance.client.auth.currentSession?.accessToken; + if (token == null || token.isEmpty) { + throw StateError('Storage router requires an authenticated session.'); + } + return token; + } +} diff --git a/app/lib/features/files/domain/models/secure_file_metadata.dart b/app/lib/features/files/domain/models/secure_file_metadata.dart new file mode 100644 index 0000000..4221af9 --- /dev/null +++ b/app/lib/features/files/domain/models/secure_file_metadata.dart @@ -0,0 +1,29 @@ +enum SecureFileType { pdf, image, note, scan } + +enum SecureFileStatus { secured, processing, pending } + +class SecureFileMetadata { + final String id; + final String groupId; + final String name; + final SecureFileType type; + final int sizeBytes; + final String uploaderName; + final DateTime uploadedAt; + final bool isWatermarked; + final bool isPinned; + final SecureFileStatus status; + + const SecureFileMetadata({ + required this.id, + required this.groupId, + required this.name, + required this.type, + required this.sizeBytes, + required this.uploaderName, + required this.uploadedAt, + this.isWatermarked = true, + this.isPinned = false, + this.status = SecureFileStatus.secured, + }); +} diff --git a/app/lib/features/files/domain/repositories/secure_file_repository.dart b/app/lib/features/files/domain/repositories/secure_file_repository.dart new file mode 100644 index 0000000..ab82180 --- /dev/null +++ b/app/lib/features/files/domain/repositories/secure_file_repository.dart @@ -0,0 +1,42 @@ +import 'dart:typed_data'; +import '../models/secure_file_metadata.dart'; + +abstract interface class SecureFileRepository { + Stream> watchGroupFiles(String groupId); + + Stream> watchAllFiles(); + + Future uploadFile({ + required String groupId, + required String name, + required SecureFileType type, + required Uint8List rawBytes, + required String uploaderName, + required String uploaderInitials, + required Function(double) onProgress, + }); + + Future deleteFile(String groupId, String fileId); + + Future togglePin(String groupId, String fileId); + + Future addGoogleDriveLink({ + required String groupId, + required String name, + required SecureFileType type, + required String driveUrl, + required String uploaderName, + required String uploaderInitials, + }); + + Future getServiceAccountEmail(); + + Future downloadFile({ + required String fileId, + required Function(double) onProgress, + }); + + Future renameFile(String fileId, String newName); + + Future> getAllFiles(); +} diff --git a/app/lib/features/files/presentation/providers/secure_file_providers.dart b/app/lib/features/files/presentation/providers/secure_file_providers.dart new file mode 100644 index 0000000..bc2d007 --- /dev/null +++ b/app/lib/features/files/presentation/providers/secure_file_providers.dart @@ -0,0 +1,16 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/supabase/supabase_providers.dart'; + +import '../../data/repositories/supabase_secure_file_repository.dart'; +import '../../domain/models/secure_file_metadata.dart'; +import '../../domain/repositories/secure_file_repository.dart'; + +final secureFileRepositoryProvider = Provider((ref) { + return SupabaseSecureFileRepository(ref.watch(supabaseClientProvider)); +}); + +final secureFileMetadataProvider = + StreamProvider.family, String>((ref, groupId) { + return ref.watch(secureFileRepositoryProvider).watchGroupFiles(groupId); + }); diff --git a/app/lib/features/files/presentation/providers/upload_provider.dart b/app/lib/features/files/presentation/providers/upload_provider.dart new file mode 100644 index 0000000..b887aeb --- /dev/null +++ b/app/lib/features/files/presentation/providers/upload_provider.dart @@ -0,0 +1,269 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../groups/models/group_file.dart'; +import 'secure_file_providers.dart'; +import '../../domain/models/secure_file_metadata.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../../profile/providers/profile_provider.dart'; +import '../../../../services/supabase_service.dart'; +import '../../../notes/providers/notes_provider.dart'; + +enum UploadStage { idle, picking, processing, complete, error } + +class UploadState { + final UploadStage stage; + final double progress; // 0.0–1.0 + final String? fileName; + final String? errorMessage; + + const UploadState({ + this.stage = UploadStage.idle, + this.progress = 0.0, + this.fileName, + this.errorMessage, + }); + + UploadState copyWith({ + UploadStage? stage, + double? progress, + String? fileName, + String? errorMessage, + }) => UploadState( + stage: stage ?? this.stage, + progress: progress ?? this.progress, + fileName: fileName ?? this.fileName, + errorMessage: errorMessage ?? this.errorMessage, + ); +} + +class UploadNotifier extends Notifier { + bool _isCancelled = false; + + @override + UploadState build() => const UploadState(); + + void cancelUpload() { + _isCancelled = true; + state = const UploadState(stage: UploadStage.idle); + } + + void reset() { + _isCancelled = false; + state = const UploadState(); + } + + Future uploadDocument( + String fileName, + FileType type, + String groupId, + Uint8List rawBytes, + ) async { + _isCancelled = false; + state = state.copyWith( + stage: UploadStage.processing, + fileName: fileName, + progress: 0, + ); + + try { + final repo = ref.read(secureFileRepositoryProvider); + final domainType = SecureFileType.values.firstWhere( + (e) => e.name == type.name, + orElse: () => SecureFileType.pdf, + ); + + final currentUser = ref.read(authRepositoryProvider).currentUser; + final profileVal = ref.read(profileProvider).value; + final uploaderName = profileVal?.displayName ?? currentUser?.email ?? 'Anonymous'; + String uploaderInitials = 'AN'; + if (uploaderName.isNotEmpty) { + if (uploaderName.contains('@')) { + final prefix = uploaderName.split('@').first; + uploaderInitials = prefix.substring(0, prefix.length >= 2 ? 2 : prefix.length).toUpperCase(); + } else { + uploaderInitials = uploaderName.substring(0, uploaderName.length >= 2 ? 2 : uploaderName.length).toUpperCase(); + } + } + + await repo.uploadFile( + groupId: groupId, + name: fileName, + type: domainType, + rawBytes: rawBytes, + uploaderName: uploaderName, + uploaderInitials: uploaderInitials, + onProgress: (prog) { + if (_isCancelled) { + throw Exception('Upload cancelled'); + } + state = state.copyWith(progress: prog); + }, + ); + + if (_isCancelled) return; + + state = state.copyWith(stage: UploadStage.complete, progress: 1.0); + + // Auto-reset after confirmation display + await Future.delayed(const Duration(milliseconds: 1400)); + if (!_isCancelled) { + state = const UploadState(); + } + } catch (e) { + if (_isCancelled) { + state = const UploadState(stage: UploadStage.idle); + return; + } + state = state.copyWith( + stage: UploadStage.error, + errorMessage: e.toString(), + ); + } + } + + Future linkGoogleDriveDocument( + String fileName, + String groupId, + String driveUrl, + ) async { + _isCancelled = false; + state = state.copyWith( + stage: UploadStage.processing, + fileName: fileName, + progress: 0, + ); + + try { + final repo = ref.read(secureFileRepositoryProvider); + final currentUser = ref.read(authRepositoryProvider).currentUser; + final profileVal = ref.read(profileProvider).value; + final uploaderName = profileVal?.displayName ?? currentUser?.email ?? 'Anonymous'; + String uploaderInitials = 'AN'; + if (uploaderName.isNotEmpty) { + if (uploaderName.contains('@')) { + final prefix = uploaderName.split('@').first; + uploaderInitials = prefix.substring(0, prefix.length >= 2 ? 2 : prefix.length).toUpperCase(); + } else { + uploaderInitials = uploaderName.substring(0, uploaderName.length >= 2 ? 2 : uploaderName.length).toUpperCase(); + } + } + + // Simulate some progress for linking + for (int i = 1; i <= 5; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + if (_isCancelled) { + throw Exception('Linking cancelled'); + } + state = state.copyWith(progress: i / 5.0); + } + + await repo.addGoogleDriveLink( + groupId: groupId, + name: fileName, + type: SecureFileType.pdf, + driveUrl: driveUrl, + uploaderName: uploaderName, + uploaderInitials: uploaderInitials, + ); + + if (_isCancelled) return; + + state = state.copyWith(stage: UploadStage.complete, progress: 1.0); + + // Auto-reset after confirmation display + await Future.delayed(const Duration(milliseconds: 1400)); + if (!_isCancelled) { + state = const UploadState(); + } + } catch (e) { + if (_isCancelled) { + state = const UploadState(stage: UploadStage.idle); + return; + } + state = state.copyWith( + stage: UploadStage.error, + errorMessage: e.toString(), + ); + } + } + + Future savePrivateNote({ + required String userId, + required String content, + }) async { + _isCancelled = false; + state = state.copyWith( + stage: UploadStage.processing, + fileName: 'Private Notepad', + progress: 0, + ); + + try { + // Simulate save progress + for (int i = 1; i <= 5; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + if (_isCancelled) { + throw Exception('Save cancelled'); + } + state = state.copyWith(progress: i / 5.0); + } + + await SupabaseService.instance.saveUserNote(userId, content); + + if (_isCancelled) return; + + state = state.copyWith(stage: UploadStage.complete, progress: 1.0); + + // Reload notepad to display the newly imported note immediately + ref.read(userNoteProvider.notifier).loadNote(userId); + + // Auto-reset after confirmation display + await Future.delayed(const Duration(milliseconds: 1400)); + if (!_isCancelled) { + state = const UploadState(); + } + } catch (e) { + if (_isCancelled) { + state = const UploadState(stage: UploadStage.idle); + return; + } + state = state.copyWith( + stage: UploadStage.error, + errorMessage: e.toString(), + ); + } + } + + /// Compatibility wrapper for UI to trigger simulated secure uploads with raw bytes. + Future simulateUpload( + String fileName, + FileType type, + String groupId, + ) async { + final docTitle = fileName.replaceAll(type.extension, ''); + final docText = _generateMockDocumentText(docTitle, type); + final docBytes = Uint8List.fromList(utf8.encode(docText)); + await uploadDocument(fileName, type, groupId, docBytes); + } + + String _generateMockDocumentText(String title, FileType type) { + return '''$title / Study Document +This document contains shared study notes for $title. + +Section 1: Overview +The notes in this document are shared securely via the group workspace. Access is managed through group membership and Supabase Row Level Security. + +Section 2: Guidelines +1. Watermarks are displayed to deter unauthorized sharing. +2. Touch the screen to reveal content when blur is enabled. +3. All document access is logged to the audit trail. + +Section 3: Next Steps +Review the notes and collaborate with your study group. All activity is tracked in the group audit log.'''; + } +} + +final uploadProvider = NotifierProvider( + UploadNotifier.new, +); diff --git a/app/lib/features/focus/providers/focus_provider.dart b/app/lib/features/focus/providers/focus_provider.dart new file mode 100644 index 0000000..445979c --- /dev/null +++ b/app/lib/features/focus/providers/focus_provider.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; +import '../../../services/focus_service.dart'; +import '../../../services/audit_service.dart'; +import '../../../components/study_chart.dart'; +import '../../audit/providers/audit_provider.dart'; + +class FocusSessionNotifier extends Notifier { + Timer? _timer; + + @override + void build() { + _startTimer(); + ref.onDispose(() { + _timer?.cancel(); + }); + } + + void _startTimer() { + _timer = Timer.periodic(const Duration(minutes: 1), (timer) { + final user = ref.read(authRepositoryProvider).currentUser; + if (user != null) { + FocusService.instance.incrementFocusMinutes(user.id, 1).then((_) { + ref.read(studyTimelineProvider.notifier).refresh(); + }); + } + }); + } + + void addFocusMinutes(int minutes) { + final user = ref.read(authRepositoryProvider).currentUser; + if (user != null) { + FocusService.instance.incrementFocusMinutes(user.id, minutes).then((_) { + ref.read(studyTimelineProvider.notifier).refresh(); + }); + } + } +} + +final focusSessionProvider = NotifierProvider( + FocusSessionNotifier.new, +); + +class StudyTimelineNotifier extends AsyncNotifier> { + @override + Future> build() async { + final auth = ref.watch(authStateProvider).value; + ref.watch(auditLogsProvider); + + final now = DateTime.now(); + final dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + if (auth == null) { + return List.generate(7, (i) { + final date = now.subtract(Duration(days: i)); + return StudyDayData( + day: dayNames[date.weekday - 1], + hours: 0.0, + scans: 0, + ); + }).reversed.toList(); + } + + final userId = auth.id; + final focusLogs = await FocusService.instance.fetchFocusLogs(userId); + final auditCounts = await AuditService.instance.fetchAuditLogCounts(); + + final List list = []; + + for (int i = 6; i >= 0; i--) { + final date = now.subtract(Duration(days: i)); + final dayKey = DateTime(date.year, date.month, date.day); + + final dayName = dayNames[date.weekday - 1]; + + final focusMinutes = focusLogs[dayKey] ?? 0; + final hours = focusMinutes / 60.0; + final scans = auditCounts[dayKey] ?? 0; + + list.add(StudyDayData( + day: dayName, + hours: double.parse(hours.toStringAsFixed(1)), + scans: scans, + )); + } + + return list; + } + + Future refresh() async { + ref.invalidateSelf(); + } +} + +final studyTimelineProvider = AsyncNotifierProvider>( + StudyTimelineNotifier.new, +); diff --git a/app/lib/features/groups/data/repositories/supabase_study_group_repository.dart b/app/lib/features/groups/data/repositories/supabase_study_group_repository.dart new file mode 100644 index 0000000..4e85248 --- /dev/null +++ b/app/lib/features/groups/data/repositories/supabase_study_group_repository.dart @@ -0,0 +1,350 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; + +import '../../domain/models/study_group.dart'; +import '../../domain/repositories/study_group_repository.dart'; +import '../../../../core/utils/debug_logger.dart'; + +class SupabaseStudyGroupRepository implements StudyGroupRepository { + final SupabaseClient _client; + + const SupabaseStudyGroupRepository(this._client); + + Future> _fetchGroups(String userId) async { + final members = await _client + .from('study_group_members') + .select('group_id') + .eq('user_id', userId); + final groupIds = members.map((m) => m['group_id'] as String).toSet(); + final groupIdsList = groupIds.toList(); + + if (groupIdsList.isEmpty) { + return []; + } + + final groupsResp = await _client + .from('study_groups') + .select() + .inFilter('id', groupIdsList) + .order('created_at', ascending: false); + + final allMembersRaw = await _client + .from('study_group_members') + .select('group_id, user_id, is_admin, profiles(display_name, email)') + .inFilter('group_id', groupIdsList); + + final membersMap = >{}; + for (final row in allMembersRaw) { + final gId = row['group_id'] as String; + final profile = row['profiles'] ?? {}; + final name = profile['display_name'] as String? ?? (profile['email'] as String? ?? 'User').split('@').first; + final initials = name.isNotEmpty ? name.substring(0, name.length >= 2 ? 2 : name.length).toUpperCase() : 'US'; + membersMap.putIfAbsent(gId, () => []).add(GroupMember( + id: row['user_id'] as String, + name: name, + initials: initials, + isAdmin: row['is_admin'] as bool? ?? false, + )); + } + + return groupsResp + .map((row) => _mapGroup(row, membersMap[row['id'] as String] ?? [])) + .toList(growable: false); + } + + @override + Future> getGroups() async { + final currentUser = _client.auth.currentUser; + if (currentUser == null) return []; + return _fetchGroups(currentUser.id); + } + + @override + Stream> watchGroups() { + final currentUser = _client.auth.currentUser; + if (currentUser == null) return Stream.value([]); + + final controller = StreamController>(); + StreamSubscription? groupsSub; + StreamSubscription? membersSub; + + void updateData() async { + try { + final results = await _fetchGroups(currentUser.id); + if (!controller.isClosed) { + controller.add(results); + } + } catch (e, st) { + debugLog("NO SUS watchGroups updateData error: $e\n$st"); + if (!controller.isClosed) { + controller.addError(e, st); + } + } + } + + groupsSub = _client + .from('study_groups') + .stream(primaryKey: ['id']) + .listen((_) => updateData()); + + membersSub = _client + .from('study_group_members') + .stream(primaryKey: ['group_id', 'user_id']) + .eq('user_id', currentUser.id) + .listen((_) => updateData()); + + // Force an immediate fetch in case realtime stream is delayed or blocked + updateData(); + + controller.onCancel = () { + groupsSub?.cancel(); + membersSub?.cancel(); + }; + + return controller.stream; + } + + @override + Future createGroup(StudyGroup group) async { + await _client.rpc('create_study_group_with_creator', params: { + 'p_group_id': group.id, + 'p_name': group.name, + 'p_description': group.description, + 'p_is_watermark_enabled': group.isWatermarkEnabled, + 'p_invite_code': group.inviteCode, + }); + + final currentUser = _client.auth.currentUser; + if (currentUser != null) { + try { + + // Fetch user profile to get uploader details (or use email split/defaults) + final profile = await _client + .from('profiles') + .select('display_name, email') + .eq('id', currentUser.id) + .maybeSingle(); + + final uploaderName = profile?['display_name'] as String? ?? + (profile?['email'] as String? ?? 'Scholar').split('@').first; + final uploaderInitials = uploaderName.isNotEmpty + ? uploaderName.substring(0, uploaderName.length >= 2 ? 2 : uploaderName.length).toUpperCase() + : 'US'; + + // 1. Auto-generate Welcome Note + final welcomeTitle = 'Welcome to ${group.name}'; + final welcomeContent = '# Welcome to ${group.name}!\n\n' + 'We are excited to have you in this secure enclave workspace. Here, you can share sensitive lecture notes, ' + 'past exams, and research papers with complete confidence.\n\n' + '## Quick Tips for Group Members:\n' + '1. **Confidential Sharing**: All documents uploaded here are stored in our secure repository.\n' + '2. **Access Control**: Only verified group members can access these files via Supabase RLS.\n' + '3. **Spyglass Protection**: Click \'REVEAL\' on any document to open it in the secure Spyglass Viewer, ' + 'which protects it against screenshots, screen recording, and unauthorized inspection.'; + + await _createAutoGeneratedNote( + groupId: group.id, + title: welcomeTitle, + content: welcomeContent, + uploaderName: uploaderName, + uploaderInitials: uploaderInitials, + ); + + // 2. Auto-generate Features Note + final featuresTitle = 'NO SUS Security Features'; + final featuresContent = '# NO SUS — Advanced Security Features\n\n' + 'Here is a quick summary of the security protocols active in your workspace:\n\n' + '### 1. Volatile In-Memory Storage\n' + 'Documents are loaded inside volatile RAM and are never cached to non-volatile local storage. They are ' + 'purged from memory immediately when you leave the viewer.\n\n' + '### 2. Screenshot Protection\n' + 'We block system-level screenshots and screen sharing on mobile devices, and apply a touch-to-reveal ' + 'blur layer that prevents over-the-shoulder inspection.\n\n' + '### 3. Dynamic Watermarking\n' + 'A visible watermark containing your profile email, IP, and timestamp is dynamically overlaid on all pages. ' + 'If someone takes a physical photo of the screen, the leak can be traced back.\n\n' + '### 4. Security Auditing\n' + 'Every document access and download event is logged to an immutable, real-time audit ledger, ' + 'ensuring full team accountability.'; + + await _createAutoGeneratedNote( + groupId: group.id, + title: featuresTitle, + content: featuresContent, + uploaderName: uploaderName, + uploaderInitials: uploaderInitials, + ); + } catch (e, st) { + debugLog("NO SUS createGroup members/notes creation error: $e\n$st"); + } + } + } + + Future _createAutoGeneratedNote({ + required String groupId, + required String title, + required String content, + required String uploaderName, + required String uploaderInitials, + }) async { + try { + final rawBytes = Uint8List.fromList(utf8.encode(content)); + + // 1. Unique ID + final noteId = 'file_gen_${const Uuid().v4()}'; + + // 2. Upload to storage + await _client.storage.from('secure-files').uploadBinary(noteId, rawBytes); + + // 3. Insert metadata in secure_files table + await _client.from('secure_files').insert({ + 'id': noteId, + 'group_id': groupId, + 'name': title, + 'type': 'markdown', + 'size_bytes': rawBytes.length, + 'is_watermarked': true, + 'is_pinned': true, + 'security_status': 'secured', + }); + } catch (e, st) { + debugLog("Error creating auto-generated note: $e\n$st"); + } + } + + @override + Future ensureCommunityExists(String name, String description, bool isPublic) async { + try { + await _client.rpc('ensure_community_exists', params: { + 'p_name': name, + 'p_description': description, + }); + } catch (e) { + debugLog("ensureCommunityExists error: $e"); + } + } + + @override + Future joinGroupByName(String name, String userId) async { + try { + await _client.rpc('join_public_group_by_name', params: { + 'p_group_name': name, + }); + } catch (e) { + debugLog("joinGroupByName error: $e"); + } + } + + @override + Future joinGroupByInviteCode(String inviteCode, String userId) async { + try { + await _client.rpc('join_group_by_invite_code', params: { + 'p_invite_code': inviteCode, + }); + } catch (e) { + debugLog("joinGroupByInviteCode error: $e"); + throw Exception('Invalid invite code'); + } + } + + StudyGroup _mapGroup(Map row, List members) { + return StudyGroup( + id: row['id'] as String, + name: row['name'] as String, + description: row['description'] as String? ?? '', + members: members, + fileCount: row['file_count'] as int? ?? 0, + lastActivity: _parseDate(row['last_activity'] ?? row['created_at']), + isWatermarkEnabled: row['is_watermark_enabled'] as bool? ?? true, + inviteCode: row['invite_code'] as String?, + ); + } + + DateTime _parseDate(Object? value) { + return DateTime.tryParse(value?.toString() ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + } + + @override + Future leaveGroup(String groupId, String userId) async { + await _client + .from('study_group_members') + .delete() + .eq('group_id', groupId) + .eq('user_id', userId); + } + + @override + Future deleteGroup(String groupId) async { + await _client.from('study_groups').delete().eq('id', groupId); + } + + // ── Moderation ───────────────────────────────────────────────────────────── + // All four go through RPCs rather than table writes. The old implementation + // of removeMember was a direct DELETE, which RLS permitted for admins but + // which wrote nothing to the audit ledger — so the one action an admin can + // take against another member was the one the group could not see. The RPC + // performs the check, the delete and the audit entry in one transaction. + // + // Postgres error messages are surfaced as-is: the RPCs raise human-readable + // text ("This is the last admin. Promote someone else first.") precisely so + // the UI does not have to duplicate the rules to explain them. + + @override + Future removeMember(String groupId, String memberId) async { + await _client.rpc('remove_group_member', params: { + 'p_group_id': groupId, + 'p_user_id': memberId, + }); + } + + @override + Future setMemberRole(String groupId, String memberId, bool isAdmin) async { + await _client.rpc('set_group_member_role', params: { + 'p_group_id': groupId, + 'p_user_id': memberId, + 'p_is_admin': isAdmin, + }); + } + + @override + Future banMember(String groupId, String memberId, {String? reason}) async { + await _client.rpc('ban_group_member', params: { + 'p_group_id': groupId, + 'p_user_id': memberId, + 'p_reason': reason, + }); + } + + @override + Future unbanMember(String groupId, String memberId) async { + await _client.rpc('unban_group_member', params: { + 'p_group_id': groupId, + 'p_user_id': memberId, + }); + } + + @override + Future> getBans(String groupId) async { + final rows = await _client + .from('group_bans') + .select('user_id, reason, created_at, profiles!group_bans_user_id_fkey(display_name, email)') + .eq('group_id', groupId) + .order('created_at', ascending: false); + + return (rows as List).map((r) { + final profile = r['profiles'] as Map? ?? const {}; + final name = (profile['display_name'] as String?) ?? + ((profile['email'] as String?) ?? 'User').split('@').first; + return GroupBan( + userId: r['user_id'] as String, + displayName: name, + reason: r['reason'] as String?, + bannedAt: _parseDate(r['created_at']), + ); + }).toList(growable: false); + } +} diff --git a/app/lib/features/groups/domain/models/study_group.dart b/app/lib/features/groups/domain/models/study_group.dart new file mode 100644 index 0000000..700f679 --- /dev/null +++ b/app/lib/features/groups/domain/models/study_group.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; + +// ─── GroupMember ────────────────────────────────────────────────────────────── + +@immutable +class GroupMember { + final String id; + final String name; + final String initials; + final bool isAdmin; + + const GroupMember({ + required this.id, + required this.name, + required this.initials, + this.isAdmin = false, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'initials': initials, + 'isAdmin': isAdmin, + }; + + factory GroupMember.fromJson(Map json) => GroupMember( + id: json['id'] as String, + name: json['name'] as String, + initials: json['initials'] as String? ?? '?', + isAdmin: json['isAdmin'] as bool? ?? false, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupMember && + id == other.id && + name == other.name && + initials == other.initials && + isAdmin == other.isAdmin; + + @override + int get hashCode => Object.hash(id, name, initials, isAdmin); +} + +// ─── StudyGroup ─────────────────────────────────────────────────────────────── + +@immutable +class StudyGroup { + final String id; + final String name; + final String description; + final List members; + final int fileCount; + final DateTime lastActivity; + final bool isWatermarkEnabled; + final String? inviteCode; + + const StudyGroup({ + required this.id, + required this.name, + required this.description, + required this.members, + required this.fileCount, + required this.lastActivity, + this.isWatermarkEnabled = true, + this.inviteCode, + }); + + int get memberCount => members.length; + + String get lastActivityLabel { + final diff = DateTime.now().difference(lastActivity); + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${lastActivity.day}/${lastActivity.month}'; + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + 'members': members.map((m) => m.toJson()).toList(), + 'fileCount': fileCount, + 'lastActivity': lastActivity.toIso8601String(), + 'isWatermarkEnabled': isWatermarkEnabled, + 'inviteCode': inviteCode, + }; + + factory StudyGroup.fromJson(Map json) => StudyGroup( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String? ?? '', + members: (json['members'] as List? ?? const []) + .map((m) => GroupMember.fromJson(m as Map)) + .toList(), + fileCount: json['fileCount'] as int? ?? 0, + lastActivity: DateTime.tryParse(json['lastActivity'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + isWatermarkEnabled: json['isWatermarkEnabled'] as bool? ?? true, + inviteCode: json['inviteCode'] as String?, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StudyGroup && + id == other.id && + name == other.name && + description == other.description && + listEquals(members, other.members) && + fileCount == other.fileCount && + lastActivity == other.lastActivity && + isWatermarkEnabled == other.isWatermarkEnabled && + inviteCode == other.inviteCode; + + @override + int get hashCode => Object.hash( + id, + name, + description, + Object.hashAll(members), + fileCount, + lastActivity, + isWatermarkEnabled, + inviteCode, + ); +} diff --git a/app/lib/features/groups/domain/repositories/study_group_repository.dart b/app/lib/features/groups/domain/repositories/study_group_repository.dart new file mode 100644 index 0000000..b9547b3 --- /dev/null +++ b/app/lib/features/groups/domain/repositories/study_group_repository.dart @@ -0,0 +1,53 @@ +import '../models/study_group.dart'; + +/// A person barred from rejoining a group. +class GroupBan { + final String userId; + final String displayName; + final String? reason; + final DateTime bannedAt; + + const GroupBan({ + required this.userId, + required this.displayName, + required this.reason, + required this.bannedAt, + }); +} + +abstract interface class StudyGroupRepository { + Stream> watchGroups(); + + Future createGroup(StudyGroup group); + + Future ensureCommunityExists(String name, String description, bool isPublic); + Future joinGroupByName(String name, String userId); + Future joinGroupByInviteCode(String inviteCode, String userId); + Future leaveGroup(String groupId, String userId); + Future deleteGroup(String groupId); + + // ── Moderation ───────────────────────────────────────────────────────────── + // Each of these maps to a SECURITY DEFINER RPC that re-checks admin rights + // server-side and writes its own audit entry in the same transaction. The + // client cannot perform any of them by writing to a table directly, and + // hiding the buttons is not what stops a non-admin — the database is. + + /// Removes [memberId] from the group. Throws if the caller is not an admin, + /// or if this would remove the group's last admin. + Future removeMember(String groupId, String memberId); + + /// Promotes or demotes [memberId]. Throws if the caller is not an admin, or + /// if this would demote the group's last admin. + Future setMemberRole(String groupId, String memberId, bool isAdmin); + + /// Removes [memberId] and blocks them from rejoining with any invite. + Future banMember(String groupId, String memberId, {String? reason}); + + /// Restores [memberId]'s eligibility to accept an invite. Does not re-add + /// them to the group. + Future unbanMember(String groupId, String memberId); + + Future> getBans(String groupId); + + Future> getGroups(); +} diff --git a/app/lib/features/groups/models/group_file.dart b/app/lib/features/groups/models/group_file.dart new file mode 100644 index 0000000..4acb7e7 --- /dev/null +++ b/app/lib/features/groups/models/group_file.dart @@ -0,0 +1,112 @@ +import 'package:flutter/foundation.dart'; + +// ─── Enums ──────────────────────────────────────────────────────────────────── + +enum FileType { pdf, image, markdown, scan } + +extension FileTypeExt on FileType { + String get label { + switch (this) { + case FileType.pdf: + return 'PDF'; + case FileType.image: + return 'IMAGE'; + case FileType.markdown: + return 'NOTE'; + case FileType.scan: + return 'SCAN'; + } + } + + String get extension { + switch (this) { + case FileType.pdf: + return '.pdf'; + case FileType.image: + return '.png'; + case FileType.markdown: + return '.md'; + case FileType.scan: + return '.jpg'; + } + } +} + +enum FileSecurityStatus { secured, processing, pending } + +// ─── GroupFile ──────────────────────────────────────────────────────────────── + +@immutable +class GroupFile { + final String id; + final String name; + final FileType type; + final String groupId; + final String uploadedBy; + final String ownerId; + final DateTime uploadedAt; + final int sizeBytes; + final bool isWatermarked; + final bool isPinned; + final FileSecurityStatus securityStatus; + + const GroupFile({ + required this.id, + required this.name, + required this.type, + required this.groupId, + required this.uploadedBy, + required this.ownerId, + required this.uploadedAt, + required this.sizeBytes, + this.isWatermarked = true, + this.isPinned = false, + this.securityStatus = FileSecurityStatus.secured, + }); + + String get sizeLabel { + if (sizeBytes < 1024 * 1024) { + return '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(sizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + String get uploadedAtLabel { + final diff = DateTime.now().difference(uploadedAt); + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${uploadedAt.day}/${uploadedAt.month}/${uploadedAt.year}'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupFile && + id == other.id && + name == other.name && + type == other.type && + groupId == other.groupId && + uploadedBy == other.uploadedBy && + ownerId == other.ownerId && + uploadedAt == other.uploadedAt && + sizeBytes == other.sizeBytes && + isWatermarked == other.isWatermarked && + isPinned == other.isPinned && + securityStatus == other.securityStatus; + + @override + int get hashCode => Object.hash( + id, + name, + type, + groupId, + uploadedBy, + ownerId, + uploadedAt, + sizeBytes, + isWatermarked, + isPinned, + securityStatus, + ); +} diff --git a/app/lib/features/groups/presentation/providers/group_dependencies.dart b/app/lib/features/groups/presentation/providers/group_dependencies.dart new file mode 100644 index 0000000..8b1ead2 --- /dev/null +++ b/app/lib/features/groups/presentation/providers/group_dependencies.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/supabase/supabase_providers.dart'; +import '../../data/repositories/supabase_study_group_repository.dart'; +import '../../domain/repositories/study_group_repository.dart'; + +final studyGroupRepositoryProvider = Provider((ref) { + return SupabaseStudyGroupRepository(ref.watch(supabaseClientProvider)); +}); diff --git a/app/lib/features/groups/providers/groups_provider.dart b/app/lib/features/groups/providers/groups_provider.dart new file mode 100644 index 0000000..143bba1 --- /dev/null +++ b/app/lib/features/groups/providers/groups_provider.dart @@ -0,0 +1,425 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../domain/models/study_group.dart'; +import '../domain/repositories/study_group_repository.dart'; +import '../models/group_file.dart'; +import '../../../services/supabase_service.dart'; +import '../presentation/providers/group_dependencies.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; +import '../../files/domain/models/secure_file_metadata.dart'; +import '../../files/presentation/providers/secure_file_providers.dart'; +import '../../../core/utils/debug_logger.dart'; + +// ─── Search query ───────────────────────────────────────────────────────────── + +class SearchQueryNotifier extends Notifier { + @override + String build() => ''; + + void update(String value) => state = value; +} + +final searchQueryProvider = NotifierProvider( + SearchQueryNotifier.new, +); + +// ─── Groups list ────────────────────────────────────────────────────────────── + +class GroupsNotifier extends AsyncNotifier> { + StreamSubscription? _sub; + static const _cacheKeyPrefix = 'groups_cache_v2_'; + + @override + Future> build() async { + final authState = ref.watch(authStateProvider); + final user = authState.value; + + _sub?.cancel(); + ref.onDispose(() => _sub?.cancel()); + + if (user == null) { + return const []; + } + + final repo = ref.watch(studyGroupRepositoryProvider); + _sub = repo.watchGroups().listen( + (data) { + state = AsyncValue.data(data); + _saveCache(user.id, data); + }, + onError: (err, stack) { + debugLog("GroupsNotifier: Realtime stream error: $err"); + }, + ); + + // Stale-while-revalidate: paint the last-known list immediately (no + // spinner on a warm start), then let the fetch/stream replace it. + final cached = await _loadCache(user.id); + if (cached != null && cached.isNotEmpty) { + state = AsyncValue.data(cached); + } + + try { + final fresh = await repo.getGroups(); + _saveCache(user.id, fresh); + return fresh; + } catch (e) { + debugLog("GroupsNotifier: REST fetch error: $e"); + // Offline with a cache beats an empty screen. + return cached ?? const []; + } + } + + Future?> _loadCache(String userId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString('$_cacheKeyPrefix$userId'); + if (raw == null) return null; + return (jsonDecode(raw) as List) + .map((g) => StudyGroup.fromJson(g as Map)) + .toList(); + } catch (_) { + return null; // corrupt/old-schema cache is simply ignored + } + } + + Future _saveCache(String userId, List groups) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + '$_cacheKeyPrefix$userId', + jsonEncode(groups.map((g) => g.toJson()).toList()), + ); + } catch (_) {} + } + + Future createGroup(StudyGroup group) async { + state = const AsyncValue.loading(); + try { + final repo = ref.read(studyGroupRepositoryProvider); + await repo.createGroup(group); + } catch (e, st) { + // Re-thrown (not just stored in state) so callers that await this can + // actually show the failure instead of treating it as success. + state = AsyncValue.error(e, st); + rethrow; + } + } + + Future joinGroupByName(String name) async { + state = const AsyncValue.loading(); + try { + final repo = ref.read(studyGroupRepositoryProvider); + final userId = SupabaseService.instance.isReachable ? Supabase.instance.client.auth.currentUser?.id : null; + if (userId != null) { + await repo.joinGroupByName(name, userId); + } + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + Future joinByInviteCode(String inviteCode) async { + state = const AsyncValue.loading(); + try { + final repo = ref.read(studyGroupRepositoryProvider); + final userId = SupabaseService.instance.isReachable ? Supabase.instance.client.auth.currentUser?.id : null; + if (userId != null) { + await repo.joinGroupByInviteCode(inviteCode, userId); + } else { + throw Exception("You must be logged in to join a group."); + } + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + Future leaveGroup(String groupId) async { + final repo = ref.read(studyGroupRepositoryProvider); + final userId = Supabase.instance.client.auth.currentUser?.id; + if (userId != null) { + await repo.leaveGroup(groupId, userId); + } + } + + Future deleteGroup(String groupId) async { + final repo = ref.read(studyGroupRepositoryProvider); + await repo.deleteGroup(groupId); + } + + // Moderation. These deliberately do not set `state` to loading/error: the + // groups list is not what changed, and blanking the whole screen because one + // member row is being updated would be a worse experience than the operation + // it is reporting. Failures propagate to the caller, which shows them in + // context. `groupMembersProvider` is invalidated so the member list reflects + // the change immediately rather than waiting on the realtime round trip. + + Future removeMember(String groupId, String memberId) async { + await ref.read(studyGroupRepositoryProvider).removeMember(groupId, memberId); + ref.invalidate(groupMembersProvider(groupId)); + } + + Future setMemberRole(String groupId, String memberId, bool isAdmin) async { + await ref + .read(studyGroupRepositoryProvider) + .setMemberRole(groupId, memberId, isAdmin); + ref.invalidate(groupMembersProvider(groupId)); + } + + Future banMember(String groupId, String memberId, {String? reason}) async { + await ref + .read(studyGroupRepositoryProvider) + .banMember(groupId, memberId, reason: reason); + ref.invalidate(groupMembersProvider(groupId)); + ref.invalidate(groupBansProvider(groupId)); + } + + Future unbanMember(String groupId, String memberId) async { + await ref.read(studyGroupRepositoryProvider).unbanMember(groupId, memberId); + ref.invalidate(groupBansProvider(groupId)); + } + + Future refresh() async { + state = const AsyncValue.loading(); + try { + final repo = ref.read(studyGroupRepositoryProvider); + final groups = await repo.getGroups(); + state = AsyncValue.data(groups); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } +} + +final groupsProvider = AsyncNotifierProvider>( + GroupsNotifier.new, +); + +/// Filtered groups based on the current search query. +final filteredGroupsProvider = Provider>>((ref) { + final query = ref.watch(searchQueryProvider).toLowerCase().trim(); + final groupsAsync = ref.watch(groupsProvider); + + return groupsAsync.whenData((groups) { + if (query.isEmpty) return groups; + return groups + .where( + (g) => + g.name.toLowerCase().contains(query) || + g.description.toLowerCase().contains(query), + ) + .toList(); + }); +}); + +// ─── Group files ────────────────────────────────────────────────────────────── + +class GroupFilesNotifier extends AsyncNotifier>> { + StreamSubscription? _sub; + static const _listEquality = ListEquality(); + + @override + Future>> build() async { + final authState = ref.watch(authStateProvider); + final user = authState.value; + + _sub?.cancel(); + ref.onDispose(() => _sub?.cancel()); + + if (user == null) { + return const {}; + } + + final repo = ref.watch(secureFileRepositoryProvider); + _sub = repo.watchAllFiles().listen( + (data) { + state = AsyncValue.data(_buildFilesMap(data)); + }, + onError: (err, stack) { + // Just log the error (e.g. websocket offline) to keep the REST data visible + debugLog("GroupFilesNotifier: Realtime stream error: $err"); + }, + ); + + try { + final initialData = await repo.getAllFiles(); + return _buildFilesMap(initialData); + } catch (e) { + debugLog("GroupFilesNotifier: REST fetch error: $e"); + return const {}; + } + } + + /// Groups the flat file list by groupId, reusing the previous state's list + /// instance for any group whose files didn't actually change content — + /// this is what lets `groupFilesForGroupProvider`'s `.select()` correctly + /// skip rebuilding widgets for groups that weren't touched by an update + /// (Dart's `List` uses identity equality, so a scoped `.select()` only + /// short-circuits when it gets back the *same* list object). + Map> _buildFilesMap(List data) { + final previous = state.value; + final grouped = >{}; + for (final file in data) { + grouped.putIfAbsent(file.groupId, () => []).add(_mapMetadata(file)); + } + if (previous == null) return grouped; + + final filesMap = >{}; + for (final entry in grouped.entries) { + final oldList = previous[entry.key]; + filesMap[entry.key] = + oldList != null && _listEquality.equals(oldList, entry.value) + ? oldList + : entry.value; + } + return filesMap; + } + + void addFile(String groupId, GroupFile file) { + // Left for direct calls/testing, but upload is now handled via uploadProvider + } + + Future removeFile(String groupId, String fileId) async { + final repo = ref.read(secureFileRepositoryProvider); + await repo.deleteFile(groupId, fileId); + } + + Future togglePin(String groupId, String fileId) async { + final repo = ref.read(secureFileRepositoryProvider); + await repo.togglePin(groupId, fileId); + } + + Future renameFile(String fileId, String newName) async { + final repo = ref.read(secureFileRepositoryProvider); + await repo.renameFile(fileId, newName); + } + + GroupFile _mapMetadata(SecureFileMetadata metadata) { + final fileType = metadata.type == SecureFileType.note + ? FileType.markdown + : FileType.values.firstWhere( + (e) => e.name == metadata.type.name, + orElse: () => FileType.pdf, + ); + + final securityStatus = FileSecurityStatus.values.firstWhere( + (e) => e.name == metadata.status.name, + orElse: () => FileSecurityStatus.secured, + ); + + + return GroupFile( + id: metadata.id, + name: metadata.name, + type: fileType, + groupId: metadata.groupId, + uploadedBy: metadata.uploaderName, + ownerId: metadata.uploaderName, + uploadedAt: metadata.uploadedAt, + sizeBytes: metadata.sizeBytes, + isWatermarked: metadata.isWatermarked, + isPinned: metadata.isPinned, + securityStatus: securityStatus, + ); + } +} + +final groupFilesProvider = + AsyncNotifierProvider>>( + GroupFilesNotifier.new, + ); + +/// Files for a single group, derived from [groupFilesProvider] with a +/// content-aware `.select()` (via [GroupFile]'s value equality) so a +/// single-group screen only rebuilds when *that* group's files actually +/// change, not on every unrelated group's update. +final groupFilesForGroupProvider = + Provider.family>, String>((ref, groupId) { + return ref.watch( + groupFilesProvider.select( + (async) => async.whenData((map) => map[groupId] ?? const []), + ), + ); +}); + +final groupMembersProvider = StreamProvider.family, String>((ref, groupId) { + if (SupabaseService.instance.isConfigured && SupabaseService.instance.isReachable) { + final client = Supabase.instance.client; + final controller = StreamController>(); + StreamSubscription? sub; + + void fetchAndAdd() async { + try { + final rows = await client + .from('study_group_members') + .select('user_id, is_admin, profiles(display_name, email)') + .eq('group_id', groupId); + + final list = []; + for (var r in rows as List) { + final id = r['user_id'] as String; + final profile = r['profiles'] ?? {}; + final name = profile['display_name'] as String? ?? + (profile['email'] as String? ?? 'User').split('@').first; + final initials = name.isNotEmpty + ? name.substring(0, name.length >= 2 ? 2 : name.length).toUpperCase() + : 'US'; + list.add(GroupMember( + id: id, + name: name, + initials: initials, + isAdmin: r['is_admin'] as bool? ?? false, + )); + } + + if (!controller.isClosed) { + controller.add(list); + } + } catch (e) { + if (!controller.isClosed) { + controller.addError(e); + } + } + } + + // Listen to changes in the study_group_members table for this group + sub = client + .from('study_group_members') + .stream(primaryKey: ['group_id', 'user_id']) + .eq('group_id', groupId) + .listen((_) => fetchAndAdd()); + + // Initial fetch + fetchAndAdd(); + + ref.onDispose(() { + sub?.cancel(); + controller.close(); + }); + + return controller.stream; + } else { + return Stream.value([]); + } +}); + +/// People barred from rejoining [groupId]. +/// +/// Visible to every member, not just admins — moderation that only moderators +/// can see is not accountable, which is the same reasoning that makes the audit +/// log group-visible. The RLS policy on `group_bans` matches: members read, +/// only the RPCs write. +final groupBansProvider = + FutureProvider.family, String>((ref, groupId) async { + if (!SupabaseService.instance.isConfigured || + !SupabaseService.instance.isReachable) { + return const []; + } + return ref.read(studyGroupRepositoryProvider).getBans(groupId); +}); diff --git a/app/lib/features/groups/screens/group_detail_screen.dart b/app/lib/features/groups/screens/group_detail_screen.dart new file mode 100644 index 0000000..5ed6b5d --- /dev/null +++ b/app/lib/features/groups/screens/group_detail_screen.dart @@ -0,0 +1,2047 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../domain/models/study_group.dart'; +import '../models/group_file.dart'; +import '../domain/repositories/study_group_repository.dart'; +import '../providers/groups_provider.dart'; +import '../widgets/file_card.dart'; +import '../widgets/member_avatar_stack.dart'; +import '../widgets/upload_modal.dart'; +import '../../share/presentation/widgets/share_link_dialog.dart'; +import '../../share/presentation/providers/share_providers.dart'; +import '../../share/presentation/screens/share_analytics_screen.dart'; +import '../widgets/empty_states.dart'; +import '../../../theme.dart'; +import '../../../components/spyglass_viewer.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; +import '../../audit/providers/audit_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import '../../../services/screenshot_guard.dart'; +import '../../../components/shimmer_box.dart'; +import '../../../components/async_state_view.dart'; +import '../../../core/utils/web_links.dart'; + +/// Full-screen group detail page with tabbed content: +/// Files | Notes | Members | Activity +class GroupDetailScreen extends ConsumerStatefulWidget { + final StudyGroup group; + final String? highlightedFileId; + + const GroupDetailScreen({super.key, required this.group, this.highlightedFileId}); + + @override + ConsumerState createState() => _GroupDetailScreenState(); +} + +class _GroupDetailScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + ScreenshotGuard.instance.activeGroupId = widget.group.id; + + if (widget.highlightedFileId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final filesAsync = ref.read(groupFilesForGroupProvider(widget.group.id)); + filesAsync.whenData((files) { + final matched = files.firstWhere( + (f) => f.id == widget.highlightedFileId, + orElse: () => GroupFile( + id: '', + groupId: '', + name: '', + type: FileType.pdf, + sizeBytes: 0, + uploadedBy: '', + ownerId: '', + securityStatus: FileSecurityStatus.secured, + isWatermarked: true, + isPinned: false, + uploadedAt: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + if (matched.id.isNotEmpty && matched.type == FileType.markdown) { + _tabController.animateTo(1); + } + }); + }); + } + } + + @override + void dispose() { + if (ScreenshotGuard.instance.activeGroupId == widget.group.id) { + ScreenshotGuard.instance.activeGroupId = null; + } + _tabController.dispose(); + super.dispose(); + } + + void _openUploadModal() { + HapticFeedback.lightImpact(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => UploadModal(groupId: widget.group.id), + ); + } + + void _openInviteModal(StudyGroup group) { + HapticFeedback.lightImpact(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _InviteModal(group: group), + ); + } + + void _confirmLeaveGroup(BuildContext context, WidgetRef ref, StudyGroup group) { + showDialog( + context: context, + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return AlertDialog( + backgroundColor: isDark ? const Color(0xFF141414) : Colors.white, + title: const Text('LEAVE GROUP?', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.redAccent)), + content: Text('Are you sure you want to leave "${group.name}"? You will lose access to all files and notes.', style: const TextStyle(fontSize: 12)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + TextButton( + onPressed: () async { + HapticFeedback.heavyImpact(); + final navigator = Navigator.of(context); + final scaffoldMessenger = ScaffoldMessenger.of(context); + Navigator.pop(context); // Close dialog + await ref.read(groupsProvider.notifier).leaveGroup(group.id); + navigator.pop(); // Pop group details screen + scaffoldMessenger.showSnackBar( + SnackBar(content: Text('You left group "${group.name}"'), behavior: SnackBarBehavior.floating), + ); + }, + child: const Text('LEAVE', style: TextStyle(color: Colors.redAccent, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ); + }, + ); + } + + void _confirmDeleteGroup(BuildContext context, WidgetRef ref, StudyGroup group) { + showDialog( + context: context, + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return AlertDialog( + backgroundColor: isDark ? const Color(0xFF141414) : Colors.white, + title: const Text('DELETE GROUP?', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.redAccent)), + content: Text('Are you sure you want to permanently delete "${group.name}"? This will delete all members, notes, and files and cannot be undone.', style: const TextStyle(fontSize: 12)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + TextButton( + onPressed: () async { + HapticFeedback.heavyImpact(); + final navigator = Navigator.of(context); + final scaffoldMessenger = ScaffoldMessenger.of(context); + Navigator.pop(context); // Close dialog + await ref.read(groupsProvider.notifier).deleteGroup(group.id); + navigator.pop(); // Pop group details screen + scaffoldMessenger.showSnackBar( + SnackBar(content: Text('Group "${group.name}" deleted'), behavior: SnackBarBehavior.floating), + ); + }, + child: const Text('DELETE', style: TextStyle(color: Colors.redAccent, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? NoSusTheme.dBackground : NoSusTheme.lBackground; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + final cardBg = isDark ? NoSusTheme.dCard : NoSusTheme.lCard; + + // .select() narrows this widget's rebuild trigger to just THIS group's + // data (now that StudyGroup has value equality) instead of the whole + // groups list — previously this rebuilt the entire scaffold/tabs + // whenever ANY group changed, not just this one. + final group = ref.watch(groupsProvider.select((async) => async.maybeWhen( + data: (list) { + try { + return list.firstWhere((g) => g.id == widget.group.id); + } catch (_) { + return widget.group; + } + }, + orElse: () => widget.group, + ))); + + return Scaffold( + backgroundColor: bg, + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(groupFilesProvider); + ref.invalidate(groupMembersProvider(widget.group.id)); + ref.invalidate(auditLogsProvider); + await Future.wait([ + ref.read(groupFilesProvider.future), + ref.read(groupMembersProvider(widget.group.id).future), + ]); + }, + color: fg, + backgroundColor: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + child: NestedScrollView( + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + headerSliverBuilder: (context, _) => [ + // ── Sliver app bar ────────────────────────────────────────────── + SliverAppBar( + backgroundColor: bg, + // Title + counts must sit above the pinned tab bar. 200px was + // shorter than that stack, so member/file counts painted on the tabs. + expandedHeight: 240, + floating: false, + pinned: true, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: fg, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + // Invite button for admins + if (group.members.any((m) => m.id == ref.watch(authStateProvider).value?.id && m.isAdmin) && group.inviteCode != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: Icon(Icons.group_add_outlined, color: fg), + onPressed: () => _openInviteModal(group), + tooltip: 'Invite Members', + ), + ), + // Group Options + PopupMenuButton( + icon: Icon(Icons.more_vert, color: fg), + onSelected: (val) { + if (val == 'leave') { + _confirmLeaveGroup(context, ref, group); + } else if (val == 'delete') { + _confirmDeleteGroup(context, ref, group); + } + }, + itemBuilder: (context) { + // ref.read, not watch — this callback only runs when the + // menu opens, not during reactive build, so watching here + // registers a listener with no effect. + final isCurrentUserAdmin = group.members.any((m) => m.id == ref.read(authStateProvider).value?.id && m.isAdmin); + return [ + const PopupMenuItem( + value: 'leave', + child: Row( + children: [ + Icon(Icons.exit_to_app, size: 16), + SizedBox(width: 8), + Text('Leave Group', style: TextStyle(fontSize: 12)), + ], + ), + ), + if (isCurrentUserAdmin) + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline, color: Colors.redAccent, size: 16), + SizedBox(width: 8), + Text('Delete Group', style: TextStyle(color: Colors.redAccent, fontSize: 12)), + ], + ), + ), + ]; + }, + ), + // Upload FAB in app bar + Padding( + padding: const EdgeInsets.only(right: 16), + child: Semantics( + button: true, + label: 'Upload file', + child: GestureDetector( + onTap: _openUploadModal, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 14, + color: isDark ? Colors.black : Colors.white, + ), + const SizedBox(width: 5), + Text( + 'UPLOAD', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + color: isDark ? Colors.black : Colors.white, + ), + ), + ], + ), + ), + ), + ), + ), + ], + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.none, + background: Container( + padding: const EdgeInsets.fromLTRB(24, 72, 24, 52), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + group.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg, + fontSize: 26, + fontWeight: FontWeight.w800, + letterSpacing: -0.8, + ), + ) + .animate() + .fadeIn(delay: 60.ms) + .slideY(begin: 0.03, end: 0), + const SizedBox(height: 8), + Row( + children: [ + MemberAvatarStack( + members: group.members, + maxVisible: 4, + size: 22, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '${group.memberCount} members · ${group.fileCount} files', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: subtle), + ), + ), + ], + ).animate().fadeIn(delay: 100.ms), + ], + ), + ), + ), + // Tab bar pinned at bottom of sliver + bottom: PreferredSize( + preferredSize: const Size.fromHeight(44), + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: fg.withValues(alpha: 0.08), + width: 0.5, + ), + ), + ), + child: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelColor: fg, + unselectedLabelColor: subtle, + indicatorColor: fg, + indicatorWeight: 1.5, + dividerColor: Colors.transparent, + labelStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 1.0, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + ), + tabs: const [ + Tab(text: 'FILES'), + Tab(text: 'NOTES'), + Tab(text: 'MEMBERS'), + Tab(text: 'ACTIVITY'), + ], + ), + ), + ), + ), + ], + body: TabBarView( + controller: _tabController, + children: [ + _FilesTab( + group: group, + onUpload: _openUploadModal, + highlightedFileId: widget.highlightedFileId, + ), + _NotesTab( + group: group, + fg: fg, + subtle: subtle, + cardBg: cardBg, + highlightedFileId: widget.highlightedFileId, + ), + _MembersTab(groupId: group.id, fg: fg, subtle: subtle), + _ActivityTab(groupId: group.id, fg: fg, subtle: subtle), + ], + ), + ), + ), + ); + } +} + +// ─── Files tab ──────────────────────────────────────────────────────────────── + +class _FilesTab extends ConsumerWidget { + final StudyGroup group; + final VoidCallback onUpload; + final String? highlightedFileId; + + const _FilesTab({ + required this.group, + required this.onUpload, + this.highlightedFileId, + }); + + void _confirmDeleteFile(BuildContext context, WidgetRef ref, String groupId, String fileId, String fileName) { + showDialog( + context: context, + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return AlertDialog( + backgroundColor: isDark ? const Color(0xFF141414) : Colors.white, + title: const Text('DELETE FILE?', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.redAccent)), + content: Text('Are you sure you want to permanently delete "$fileName"?', style: const TextStyle(fontSize: 12)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + TextButton( + onPressed: () { + HapticFeedback.heavyImpact(); + Navigator.pop(context); + ref.read(groupFilesProvider.notifier).removeFile(groupId, fileId); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('File "$fileName" deleted'), behavior: SnackBarBehavior.floating), + ); + }, + child: const Text('DELETE', style: TextStyle(color: Colors.redAccent, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ); + }, + ); + } + + void _renameFile(BuildContext context, WidgetRef ref, String fileId, String currentName) { + final controller = TextEditingController(text: currentName); + showDialog( + context: context, + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final fg = isDark ? Colors.white : Colors.black; + + return AlertDialog( + backgroundColor: isDark ? const Color(0xFF141414) : Colors.white, + title: const Text('RENAME FILE', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + content: TextField( + controller: controller, + style: TextStyle(color: fg), + decoration: InputDecoration( + labelText: 'FILE NAME', + labelStyle: TextStyle(color: fg.withValues(alpha: 0.5), fontSize: 10), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: fg)), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + TextButton( + onPressed: () { + final newName = controller.text.trim(); + if (newName.isNotEmpty) { + ref.read(groupFilesProvider.notifier).renameFile(fileId, newName); + Navigator.pop(context); + } + }, + child: Text('RENAME', style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filesAsync = ref.watch(groupFilesForGroupProvider(group.id)); + + return filesAsync.when( + loading: () => Padding( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: ShimmerListSkeleton( + itemBuilder: (context) => const _FileCardSkeleton(), + ), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Failed to load files', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () => ref.invalidate(groupFilesProvider), + child: const Text('RETRY', style: TextStyle(fontSize: 11, letterSpacing: 1.5)), + ), + ], + ), + ), + data: (allFiles) { + final files = allFiles.where((f) => f.type != FileType.markdown).toList(); + + return files.isEmpty + ? FilesEmptyState(onUpload: onUpload) + : ListView.separated( + padding: const EdgeInsets.all(NoSusTheme.s24), + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + itemCount: files.length, + separatorBuilder: (context, index) => + const SizedBox(height: NoSusTheme.s12), + itemBuilder: (context, i) { + final file = files[i]; + final isHighlighted = file.id == highlightedFileId; + final uploaderName = group.members.where((m) => m.id == file.uploadedBy).firstOrNull?.name ?? 'Unknown'; + Widget card = FileCard( + file: file, + uploaderName: uploaderName, + animationIndex: i, + onDelete: () => _confirmDeleteFile(context, ref, group.id, file.id, file.name), + onRename: () => _renameFile(context, ref, file.id, file.name), + onShare: () => showShareLinkDialog(context, ref, file.id), + onAnalytics: () async { + try { + final repo = ref.read(shareRepositoryProvider); + final links = await repo.myLinksForFile(file.id); + final activeLink = links.where((l) => !l.revoked).firstOrNull; + if (activeLink != null) { + if (!context.mounted) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ShareAnalyticsScreen( + linkId: activeLink.id, + fileName: file.name, + ), + ), + ); + } else { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No active share links found. Create one first!'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to check links: $e'), + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + onPin: () => ref + .read(groupFilesProvider.notifier) + .togglePin(group.id, file.id), + onOpen: () => _evaluateAndOpenFile( + context: context, + ref: ref, + file: file, + group: group, + ), + ); + if (isHighlighted) { + card = Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.amber, width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.amber.withValues(alpha: 0.15), + blurRadius: 10, + spreadRadius: 2, + ) + ], + ), + child: card, + ).animate().shake(delay: 200.ms, duration: 500.ms); + } + return card; + }, + ); + }, + ); + } +} + +// ─── Notes tab ──────────────────────────────────────────────────────────────── + +class _NotesTab extends ConsumerWidget { + final StudyGroup group; + final Color fg; + final Color subtle; + final Color cardBg; + final String? highlightedFileId; + + const _NotesTab({ + required this.group, + required this.fg, + required this.subtle, + required this.cardBg, + this.highlightedFileId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filesAsync = ref.watch(groupFilesForGroupProvider(group.id)); + + return filesAsync.when( + loading: () => Padding( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: ShimmerListSkeleton( + itemBuilder: (context) => const _FileCardSkeleton(), + ), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Failed to load notes', style: TextStyle(color: subtle)), + const SizedBox(height: 12), + TextButton( + onPressed: () => ref.invalidate(groupFilesProvider), + child: const Text('RETRY', style: TextStyle(fontSize: 11, letterSpacing: 1.5)), + ), + ], + ), + ), + data: (allFiles) { + final notes = allFiles.where((f) => f.type == FileType.markdown).toList(); + + if (notes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.note_alt_outlined, size: 48, color: subtle), + const SizedBox(height: 16), + Text( + 'NO SECURE NOTES YET', + style: TextStyle( + color: fg, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 8), + Text( + 'Auto-generated group notes will appear here shortly.', + style: TextStyle(color: subtle, fontSize: 13), + ), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(NoSusTheme.s24), + physics: NoSusTheme.getScrollPhysics(context), + itemCount: notes.length, + separatorBuilder: (context, index) => + const SizedBox(height: NoSusTheme.s12), + itemBuilder: (context, i) { + final note = notes[i]; + final isHighlighted = note.id == highlightedFileId; + final uploaderName = group.members.where((m) => m.id == note.uploadedBy).firstOrNull?.name ?? 'Unknown'; + Widget card = _PinnedNoteCard( + note: note, + uploaderName: uploaderName, + fg: fg, + subtle: subtle, + cardBg: cardBg, + index: i, + onOpen: () => _evaluateAndOpenFile( + context: context, + ref: ref, + file: note, + group: group, + ), + onRename: () => _renameNote(context, ref, note.id, note.name), + onDelete: () => _confirmDeleteNote(context, ref, group.id, note.id, note.name), + ); + if (isHighlighted) { + card = Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.amber, width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.amber.withValues(alpha: 0.15), + blurRadius: 10, + spreadRadius: 2, + ) + ], + ), + child: card, + ).animate().shake(delay: 200.ms, duration: 500.ms); + } + return card; + }, + ); + }, + ); + } + + void _confirmDeleteNote(BuildContext context, WidgetRef ref, String groupId, String fileId, String fileName) { + showDialog( + context: context, + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return AlertDialog( + backgroundColor: isDark ? const Color(0xFF141414) : Colors.white, + title: const Text('DELETE NOTE?', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.redAccent)), + content: Text('Are you sure you want to permanently delete "$fileName"?', style: const TextStyle(fontSize: 12)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + TextButton( + onPressed: () { + HapticFeedback.heavyImpact(); + Navigator.pop(context); + ref.read(groupFilesProvider.notifier).removeFile(groupId, fileId); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Note "$fileName" deleted'), behavior: SnackBarBehavior.floating), + ); + }, + child: const Text('DELETE', style: TextStyle(color: Colors.redAccent, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ); + }, + ); + } + + void _renameNote(BuildContext context, WidgetRef ref, String fileId, String currentName) { + final controller = TextEditingController(text: currentName); + showDialog( + context: context, + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final fg = isDark ? Colors.white : Colors.black; + + return AlertDialog( + backgroundColor: isDark ? const Color(0xFF141414) : Colors.white, + title: const Text('RENAME NOTE', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + content: TextField( + controller: controller, + style: TextStyle(color: fg), + decoration: InputDecoration( + labelText: 'NOTE NAME', + labelStyle: TextStyle(color: fg.withValues(alpha: 0.5), fontSize: 10), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: fg)), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + TextButton( + onPressed: () { + final newName = controller.text.trim(); + if (newName.isNotEmpty) { + ref.read(groupFilesProvider.notifier).renameFile(fileId, newName); + Navigator.pop(context); + } + }, + child: Text('RENAME', style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ); + }, + ); + } +} + +class _PinnedNoteCard extends StatelessWidget { + final GroupFile note; + final String uploaderName; + final Color fg; + final Color subtle; + final Color cardBg; + final int index; + final VoidCallback onOpen; + final VoidCallback onRename; + final VoidCallback onDelete; + + const _PinnedNoteCard({ + required this.note, + required this.uploaderName, + required this.fg, + required this.subtle, + required this.cardBg, + required this.index, + required this.onOpen, + required this.onRename, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'Open pinned note ${note.name}', + child: GestureDetector( + onTap: onOpen, + child: Container( + padding: const EdgeInsets.all(NoSusTheme.s16), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.push_pin, size: 12, color: subtle), + const SizedBox(width: 6), + Expanded( + child: Text( + note.name, + style: TextStyle( + color: fg, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + PopupMenuButton( + icon: Icon( + Icons.more_vert, + size: 16, + color: subtle.withValues(alpha: 0.7), + ), + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.zero, + ), + onSelected: (val) { + if (val == 'open') { + onOpen(); + } else if (val == 'rename') { + onRename(); + } else if (val == 'delete') { + onDelete(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'open', + child: Row( + children: [ + Icon(Icons.open_in_new_rounded, size: 14, color: fg), + const SizedBox(width: 8), + const Text('Open Note', style: TextStyle(fontSize: 12)), + ], + ), + ), + PopupMenuItem( + value: 'rename', + child: Row( + children: [ + Icon(Icons.edit_outlined, size: 14, color: fg), + const SizedBox(width: 8), + const Text('Rename Note', style: TextStyle(fontSize: 12)), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline, size: 14, color: Colors.redAccent), + SizedBox(width: 8), + Text('Delete Note', style: TextStyle(fontSize: 12, color: Colors.redAccent)), + ], + ), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Tap to view note content...', + style: TextStyle( + color: fg.withValues(alpha: 0.4), + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 8), + Text( + 'Uploaded by $uploaderName · ${note.uploadedAtLabel}', + style: TextStyle( + color: subtle, + fontSize: 10, + ), + ), + ], + ), + ), + ) + .animate(delay: (index.clamp(0, 10) * 80).ms) + .fadeIn(duration: 250.ms) + .slideY(begin: 0.03, end: 0), + ); + } +} + +// ─── Zero-Trust Gateway File Open Helper ────────────────────────────────────── + +Future _evaluateAndOpenFile({ + required BuildContext context, + required WidgetRef ref, + required GroupFile file, + required StudyGroup group, +}) async { + // Log successful document access + ref.read(auditLogsProvider.notifier).addLog( + "Document Access Authorized: ${file.name}", + "SUCCESS", + ); + + if (context.mounted) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SpyglassViewer( + fileId: file.id, + groupId: file.groupId, + documentTitle: file.name, + documentCategory: file.type.label, + ), + ), + ); + } +} + +/// Shimmer placeholder matching one [FileCard] row: icon chip + name + meta. +class _FileCardSkeleton extends StatelessWidget { + const _FileCardSkeleton(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(NoSusTheme.s16), + decoration: NoSusTheme.cardDecoration(context), + child: const Row( + children: [ + ShimmerBox(width: 40, height: 40, radius: 10), + SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerBox(width: 140, height: 14), + SizedBox(height: 6), + ShimmerBox(width: 90, height: 11), + ], + ), + ), + ], + ), + ); + } +} + +// ─── Members tab ────────────────────────────────────────────────────────────── + +/// Member roster and moderation. +/// +/// Every destructive control here is a convenience over a server-side rule, not +/// the rule itself: `set_group_member_role`, `remove_group_member` and +/// `ban_group_member` re-check admin rights in the database and refuse to strip +/// a group of its last admin. A member who patches the client into showing +/// these buttons gets a rejection, not a promotion. +class _MembersTab extends ConsumerStatefulWidget { + final String groupId; + final Color fg; + final Color subtle; + + const _MembersTab({ + required this.groupId, + required this.fg, + required this.subtle, + }); + + @override + ConsumerState<_MembersTab> createState() => _MembersTabState(); +} + +class _MembersTabState extends ConsumerState<_MembersTab> { + final _searchController = TextEditingController(); + String _query = ''; + + Color get _fg => widget.fg; + Color get _subtle => widget.subtle; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + /// The RPCs raise readable messages on purpose ("This is the last admin. + /// Promote someone else first.") so the reason for a refusal reaches the + /// person who tried, instead of being replaced by a generic failure toast + /// that makes a deliberate rule look like a bug. + String _cleanError(Object e) => e + .toString() + .replaceAll('Exception: ', '') + .replaceAll(RegExp(r'^PostgrestException\(message: '), '') + .replaceAll(RegExp(r', code: .*\)$'), ''); + + Future _run(Future Function() action, String successMessage) async { + final messenger = ScaffoldMessenger.of(context); + try { + await action(); + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text(successMessage), + behavior: SnackBarBehavior.floating, + ), + ); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text(_cleanError(e)), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _confirm({ + required String title, + required String body, + required String confirmLabel, + bool destructive = true, + }) async { + final result = await showDialog( + context: context, + builder: (dialogContext) { + final isDark = Theme.of(dialogContext).brightness == Brightness.dark; + return AlertDialog( + backgroundColor: isDark ? const Color(0xFF141414) : Colors.white, + title: Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: destructive ? Colors.redAccent : null, + ), + ), + content: Text(body, style: const TextStyle(fontSize: 12, height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text( + 'CANCEL', + style: TextStyle(color: Colors.grey, fontSize: 11), + ), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text( + confirmLabel, + style: TextStyle( + color: destructive ? Colors.redAccent : null, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + }, + ); + return result ?? false; + } + + Future _confirmRemoveMember(GroupMember member) async { + final ok = await _confirm( + title: 'REMOVE MEMBER?', + body: + '${member.name} loses access to this group\'s files immediately. They can ' + 'rejoin with a valid invite — ban them instead if that is not what you want.', + confirmLabel: 'REMOVE', + ); + if (!ok) return; + HapticFeedback.heavyImpact(); + await _run( + () => ref + .read(groupsProvider.notifier) + .removeMember(widget.groupId, member.id), + 'Removed ${member.name} from the group', + ); + } + + Future _confirmBanMember(GroupMember member) async { + final reasonController = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (dialogContext) { + final isDark = Theme.of(dialogContext).brightness == Brightness.dark; + return AlertDialog( + backgroundColor: isDark ? const Color(0xFF141414) : Colors.white, + title: const Text( + 'BAN MEMBER?', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.redAccent, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${member.name} is removed from the group and cannot rejoin with any ' + 'invite until you unban them.', + style: const TextStyle(fontSize: 12, height: 1.5), + ), + const SizedBox(height: 16), + TextField( + controller: reasonController, + maxLength: 280, + style: const TextStyle(fontSize: 12), + decoration: const InputDecoration( + labelText: 'Reason (optional)', + labelStyle: TextStyle(fontSize: 11), + helperText: 'Visible to every member of this group.', + helperStyle: TextStyle(fontSize: 10), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text( + 'CANCEL', + style: TextStyle(color: Colors.grey, fontSize: 11), + ), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text( + 'BAN', + style: TextStyle( + color: Colors.redAccent, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + }, + ); + + final reason = reasonController.text.trim(); + reasonController.dispose(); + if (ok != true) return; + HapticFeedback.heavyImpact(); + await _run( + () => ref.read(groupsProvider.notifier).banMember( + widget.groupId, + member.id, + reason: reason.isEmpty ? null : reason, + ), + 'Banned ${member.name}', + ); + } + + Future _confirmSetRole(GroupMember member, bool makeAdmin) async { + final ok = await _confirm( + title: makeAdmin ? 'MAKE ADMIN?' : 'REMOVE ADMIN?', + body: makeAdmin + ? '${member.name} will be able to manage members, delete anyone\'s files, ' + 'and delete the group itself.' + : '${member.name} keeps access to the group but loses every admin power.', + confirmLabel: makeAdmin ? 'MAKE ADMIN' : 'REMOVE ADMIN', + destructive: !makeAdmin, + ); + if (!ok) return; + HapticFeedback.mediumImpact(); + await _run( + () => ref + .read(groupsProvider.notifier) + .setMemberRole(widget.groupId, member.id, makeAdmin), + makeAdmin + ? '${member.name} is now an admin' + : '${member.name} is no longer an admin', + ); + } + + Future _confirmUnban(GroupBan ban) async { + final ok = await _confirm( + title: 'LIFT BAN?', + body: + '${ban.displayName} will be able to accept an invite again. They are not ' + 'added back to the group automatically.', + confirmLabel: 'LIFT BAN', + destructive: false, + ); + if (!ok) return; + await _run( + () => ref + .read(groupsProvider.notifier) + .unbanMember(widget.groupId, ban.userId), + 'Ban lifted for ${ban.displayName}', + ); + } + + @override + Widget build(BuildContext context) { + final membersAsync = ref.watch(groupMembersProvider(widget.groupId)); + + return membersAsync.when( + loading: () => Padding( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: ShimmerListSkeleton( + itemBuilder: (context) => const _MemberRowSkeleton(), + ), + ), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Failed to load members', style: TextStyle(color: _subtle)), + const SizedBox(height: 12), + TextButton( + onPressed: () => + ref.invalidate(groupMembersProvider(widget.groupId)), + child: const Text( + 'RETRY', + style: TextStyle(fontSize: 11, letterSpacing: 1.5), + ), + ), + ], + ), + ), + data: (list) { + if (list.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.group_outlined, size: 48, color: _subtle), + const SizedBox(height: 16), + Text( + 'NO MEMBERS YET', + style: TextStyle( + color: _fg, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ], + ), + ); + } + return _buildList(context, list); + }, + ); + } + + Widget _buildList(BuildContext context, List all) { + final currentUserId = ref.watch(authStateProvider).value?.id; + final isCurrentUserAdmin = + all.any((m) => m.id == currentUserId && m.isAdmin); + final adminCount = all.where((m) => m.isAdmin).length; + + // Admins first, then alphabetical — the roster's job is to answer "who runs + // this group" before "who is in it". + final sorted = [...all]..sort((a, b) { + if (a.isAdmin != b.isAdmin) return a.isAdmin ? -1 : 1; + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + final q = _query.trim().toLowerCase(); + final visible = q.isEmpty + ? sorted + : sorted.where((m) => m.name.toLowerCase().contains(q)).toList(); + + final bansAsync = ref.watch(groupBansProvider(widget.groupId)); + final bans = bansAsync.value ?? const []; + + return ListView( + padding: const EdgeInsets.all(NoSusTheme.s24), + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + children: [ + // Search appears once the roster is big enough to need it — a filter + // over four people is clutter. + if (all.length >= 8) ...[ + TextField( + controller: _searchController, + onChanged: (v) => setState(() => _query = v), + style: TextStyle(fontSize: 14, color: _fg), + cursorColor: _fg, + decoration: InputDecoration( + hintText: 'Search members', + hintStyle: TextStyle(fontSize: 14, color: _subtle), + prefixIcon: Icon(Icons.search, size: 18, color: _subtle), + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + borderSide: BorderSide(color: _fg.withValues(alpha: 0.12)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + borderSide: BorderSide(color: _fg.withValues(alpha: 0.12)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + borderSide: BorderSide(color: _fg), + ), + ), + ), + const SizedBox(height: NoSusTheme.s16), + ], + + _RosterLabel( + text: '${all.length} MEMBER${all.length == 1 ? '' : 'S'}' + ' · $adminCount ADMIN${adminCount == 1 ? '' : 'S'}', + color: _subtle, + ), + const SizedBox(height: NoSusTheme.s8), + + if (visible.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: NoSusTheme.s32), + child: Text( + 'No member matches "$_query".', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: _subtle), + ), + ) + else + for (var i = 0; i < visible.length; i++) ...[ + _MemberRow( + member: visible[i], + fg: _fg, + subtle: _subtle, + isSelf: visible[i].id == currentUserId, + canModerate: + isCurrentUserAdmin && visible[i].id != currentUserId, + index: i, + onSetRole: (makeAdmin) => + _confirmSetRole(visible[i], makeAdmin), + onRemove: () => _confirmRemoveMember(visible[i]), + onBan: () => _confirmBanMember(visible[i]), + ), + const SizedBox(height: NoSusTheme.s12), + ], + + // Bans are shown to every member, not just admins — the same + // reasoning that makes the audit log group-visible. Moderation that + // only moderators can see is not accountable. + if (bans.isNotEmpty) ...[ + const SizedBox(height: NoSusTheme.s16), + _RosterLabel(text: 'BANNED (${bans.length})', color: _subtle), + const SizedBox(height: NoSusTheme.s8), + for (final ban in bans) ...[ + _BanRow( + ban: ban, + fg: _fg, + subtle: _subtle, + onUnban: + isCurrentUserAdmin ? () => _confirmUnban(ban) : null, + ), + const SizedBox(height: NoSusTheme.s12), + ], + ], + const SizedBox(height: NoSusTheme.s32), + ], + ); + } +} + +class _RosterLabel extends StatelessWidget { + final String text; + final Color color; + const _RosterLabel({required this.text, required this.color}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ); + } +} + +class _MemberRow extends StatelessWidget { + final GroupMember member; + final Color fg; + final Color subtle; + final bool isSelf; + final bool canModerate; + final int index; + final ValueChanged onSetRole; + final VoidCallback onRemove; + final VoidCallback onBan; + + const _MemberRow({ + required this.member, + required this.fg, + required this.subtle, + required this.isSelf, + required this.canModerate, + required this.index, + required this.onSetRole, + required this.onRemove, + required this.onBan, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(NoSusTheme.s16), + decoration: NoSusTheme.cardDecoration(context), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: fg.withValues(alpha: 0.15), + width: 0.75, + ), + ), + child: Center( + child: Text( + member.initials, + style: TextStyle( + color: fg, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSelf ? '${member.name} (you)' : member.name, + style: TextStyle( + color: fg, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + Text( + member.isAdmin ? 'Admin' : 'Member', + style: TextStyle(color: subtle, fontSize: 11), + ), + ], + ), + ), + if (member.isAdmin) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + border: Border.all( + color: fg.withValues(alpha: 0.2), + width: 0.75, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'ADMIN', + style: TextStyle( + fontSize: 9, + color: subtle, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + ), + ), + ), + if (canModerate) + PopupMenuButton( + // 48dp minimum touch target, and a real label for screen + // readers — a bare icon announces nothing useful. + tooltip: 'Manage ${member.name}', + icon: Icon( + Icons.more_vert, + size: 20, + color: subtle, + ), + onSelected: (value) { + switch (value) { + case 'promote': + onSetRole(true); + case 'demote': + onSetRole(false); + case 'remove': + onRemove(); + case 'ban': + onBan(); + } + }, + itemBuilder: (context) => [ + if (member.isAdmin) + const PopupMenuItem( + value: 'demote', + child: ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Icon(Icons.remove_moderator_outlined, size: 18), + title: Text('Remove admin', style: TextStyle(fontSize: 13)), + ), + ) + else + const PopupMenuItem( + value: 'promote', + child: ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Icon(Icons.add_moderator_outlined, size: 18), + title: Text('Make admin', style: TextStyle(fontSize: 13)), + ), + ), + const PopupMenuItem( + value: 'remove', + child: ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Icon(Icons.person_remove_outlined, size: 18), + title: Text('Remove from group', + style: TextStyle(fontSize: 13)), + ), + ), + const PopupMenuItem( + value: 'ban', + child: ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Icon(Icons.block, size: 18, color: Colors.redAccent), + title: Text( + 'Ban from group', + style: TextStyle(fontSize: 13, color: Colors.redAccent), + ), + ), + ), + ], + ), + ], + ), + ) + .animate(delay: (index.clamp(0, 10) * 60).ms) + .fadeIn(duration: 250.ms) + .slideY(begin: 0.03, end: 0); + } +} + +class _BanRow extends StatelessWidget { + final GroupBan ban; + final Color fg; + final Color subtle; + final VoidCallback? onUnban; + + const _BanRow({ + required this.ban, + required this.fg, + required this.subtle, + required this.onUnban, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(NoSusTheme.s16), + decoration: BoxDecoration( + color: Colors.redAccent.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(NoSusTheme.r16), + border: Border.all( + color: Colors.redAccent.withValues(alpha: 0.15), + width: 0.75, + ), + ), + child: Row( + children: [ + Icon(Icons.block, size: 18, color: Colors.redAccent.withValues(alpha: 0.8)), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ban.displayName, + style: TextStyle( + color: fg, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + Text( + ban.reason == null || ban.reason!.isEmpty + ? 'No reason given' + : ban.reason!, + style: TextStyle(color: subtle, fontSize: 11, height: 1.4), + ), + ], + ), + ), + if (onUnban != null) + TextButton( + onPressed: onUnban, + child: const Text( + 'UNBAN', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + ), + ], + ), + ); + } +} + +/// Shimmer placeholder matching one member row: avatar + name + role. +class _MemberRowSkeleton extends StatelessWidget { + const _MemberRowSkeleton(); + + @override + Widget build(BuildContext context) { + return const Row( + children: [ + ShimmerBox(width: 40, height: 40, radius: 20), + SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerBox(width: 120, height: 14), + SizedBox(height: 6), + ShimmerBox(width: 70, height: 11), + ], + ), + ), + ], + ); + } +} + +// ─── Activity tab ───────────────────────────────────────────────────────────── + +class _ActivityTab extends ConsumerWidget { + final String groupId; + final Color fg; + final Color subtle; + + const _ActivityTab({ + required this.groupId, + required this.fg, + required this.subtle, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auditLogsAsync = ref.watch(auditLogsProvider); + + return AsyncStateView>>( + value: auditLogsAsync, + onRetry: () => ref.invalidate(auditLogsProvider), + errorMessage: 'Failed to load activity', + loading: (context) => Padding( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: ShimmerListSkeleton( + spacing: NoSusTheme.s16, + itemBuilder: (context) => const _ActivityRowSkeleton(), + ), + ), + isEmpty: (logs) => logs.isEmpty, + empty: (context) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history_toggle_off_outlined, size: 48, color: subtle), + const SizedBox(height: 16), + Text( + 'NO ACTIVITY YET', + style: TextStyle( + color: fg, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 8), + Text( + 'Group activities will appear here.', + style: TextStyle(color: subtle, fontSize: 13), + ), + ], + ), + ), + data: (context, auditLogs) => ListView.builder( + padding: const EdgeInsets.all(NoSusTheme.s24), + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + itemCount: auditLogs.length, + itemBuilder: (_, i) { + final item = auditLogs[i]; + final event = item['event'] ?? ''; + final time = item['time'] ?? ''; + final status = item['status'] ?? ''; + final icon = _iconForStatus(status); + + return Padding( + padding: const EdgeInsets.only(bottom: NoSusTheme.s16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: fg.withValues(alpha: 0.12), + width: 0.75, + ), + ), + child: Icon(icon, size: 14, color: subtle), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event, + style: TextStyle(color: fg, fontSize: 13, height: 1.4), + ), + const SizedBox(height: 2), + Text( + time, + style: TextStyle(color: subtle, fontSize: 11), + ), + ], + ), + ), + ], + ).animate(delay: (i.clamp(0, 10) * 60).ms).fadeIn(duration: 250.ms), + ); + }, + ), + ); + } + + IconData _iconForStatus(String status) => switch (status.toUpperCase()) { + 'SUCCESS' => Icons.check_circle_outline, + 'SECURITY' => Icons.gpp_maybe_outlined, + 'INFO' => Icons.info_outline, + _ => Icons.info_outline, + }; +} + +/// Shimmer placeholder matching one activity row: icon circle + event + time. +class _ActivityRowSkeleton extends StatelessWidget { + const _ActivityRowSkeleton(); + + @override + Widget build(BuildContext context) { + return const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerBox(width: 32, height: 32, radius: 16), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerBox(width: double.infinity, height: 13), + SizedBox(height: 6), + ShimmerBox(width: 80, height: 11), + ], + ), + ), + ], + ); + } +} + +class _InviteModal extends StatelessWidget { + final StudyGroup group; + + const _InviteModal({required this.group}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? NoSusTheme.dCard : NoSusTheme.lCard; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final (:origin, :basePath) = webShareLinkBase(); + final inviteLink = '$origin$basePath/#/join/${group.inviteCode}'; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: BoxDecoration( + color: bg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + border: Border.all(color: fg.withValues(alpha: 0.1), width: 0.75), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 3, + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'INVITE MEMBERS', + style: TextStyle( + color: fg.withValues(alpha: 0.5), + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: 6), + Text( + 'Share Access Link & Code', + style: TextStyle( + color: fg, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E1E) : Colors.grey[100], + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + const Text( + 'INVITE CODE', + style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 1.0, color: Colors.grey), + ), + const SizedBox(height: 4), + Text( + group.inviteCode ?? 'NO-CODE', + style: TextStyle( + color: fg, + fontSize: 24, + letterSpacing: 4.0, + fontWeight: FontWeight.bold, + ), + ), + const Divider(height: 24, thickness: 0.5), + const Text( + 'INVITE LINK', + style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 1.0, color: Colors.grey), + ), + const SizedBox(height: 6), + Text( + inviteLink, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg.withValues(alpha: 0.7), + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + HapticFeedback.lightImpact(); + Clipboard.setData(ClipboardData(text: inviteLink)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invite link copied to clipboard'), + behavior: SnackBarBehavior.floating, + ), + ); + Navigator.pop(context); + }, + icon: Icon(Icons.copy, size: 18, color: fg), + label: Text( + 'COPY LINK', + style: TextStyle( + color: fg, + fontWeight: FontWeight.bold, + fontSize: 11, + letterSpacing: 0.5, + ), + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: BorderSide(color: fg.withValues(alpha: 0.2)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + HapticFeedback.lightImpact(); + final text = 'Join my secure workspace "${group.name}" on NoSus! Use link: $inviteLink'; + SharePlus.instance.share(ShareParams(text: text)); + Navigator.pop(context); + }, + icon: Icon(Icons.ios_share, size: 18, color: isDark ? Colors.black : Colors.white), + label: const Text( + 'SHARE', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + letterSpacing: 0.5, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: fg, + foregroundColor: isDark ? Colors.black : Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + diff --git a/app/lib/features/groups/screens/group_invite_landing_screen.dart b/app/lib/features/groups/screens/group_invite_landing_screen.dart new file mode 100644 index 0000000..84270e8 --- /dev/null +++ b/app/lib/features/groups/screens/group_invite_landing_screen.dart @@ -0,0 +1,460 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../analytics/data/analytics_service.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; +import '../../auth/presentation/providers/pending_intent_provider.dart'; +import '../../auth/presentation/screens/auth_screen.dart'; +import '../../config/presentation/providers/config_provider.dart'; +import '../../notifications/presentation/widgets/notification_permission_prompt.dart'; +import '../../../core/mascot/mascot_controller.dart'; +import '../../../core/mascot/mascot_state.dart'; +import '../../../core/mascot/mascot_view.dart'; +import '../providers/groups_provider.dart'; + +class GroupInviteLandingScreen extends ConsumerStatefulWidget { + final String inviteCode; + + const GroupInviteLandingScreen({ + super.key, + required this.inviteCode, + }); + + @override + ConsumerState createState() => _GroupInviteLandingScreenState(); +} + +class _GroupInviteLandingScreenState extends ConsumerState { + bool _isLoading = true; + String? _errorMessage; + Map? _inviteDetails; + bool _isJoining = false; + + @override + void initState() { + super.initState(); + _fetchInviteDetails(); + } + + Future _fetchInviteDetails() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final client = Supabase.instance.client; + final response = await client.rpc( + 'get_invite_details', + params: {'p_invite_code': widget.inviteCode}, + ); + + final data = response as Map; + if (data['valid'] == false) { + setState(() { + _errorMessage = data['reason'] as String? ?? 'Invalid invite link'; + _isLoading = false; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.alert); + } else { + setState(() { + _inviteDetails = data; + _isLoading = false; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.approve); + } + } catch (e) { + setState(() { + _errorMessage = 'Could not load group invitation details.'; + _isLoading = false; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.alert); + } + } + + Future _downloadApp() async { + final downloadUrl = ref.read(remoteConfigValueProvider(SecureSendConfigs.appDownloadUrl)) as String; + final uri = Uri.parse(downloadUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open download link')), + ); + } + } + } + + Future _joinGroup() async { + final auth = ref.read(authStateProvider).value; + if (auth == null) { + // Joining a private group is one of the places identity is genuinely + // required, so this wall stays. What matters is that the intent survives + // it: the invite code is parked (durably, through an email-confirmation + // or OAuth round trip) and replayed once there is a session, so the user + // lands back on this join flow rather than on Home. + if (mounted) { + AnalyticsService.instance.log( + AnalyticsEvent.authWallHit, + properties: {'action': 'join_group'}, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Create an account to join this group — we will bring you ' + 'straight back here.', + ), + behavior: SnackBarBehavior.floating, + ), + ); + ref.read(pendingIntentProvider.notifier).set( + PendingIntent( + PendingIntentKind.joinGroup, + payload: widget.inviteCode, + ), + ); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AuthScreen( + pendingInviteCode: widget.inviteCode, + startOnSignUp: true, + ), + ), + ); + } + return; + } + + setState(() { + _isJoining = true; + }); + + try { + final client = Supabase.instance.client; + await client.rpc( + 'join_group_by_invite_link', + params: {'p_invite_code': widget.inviteCode}, + ); + + // Invalidate groups lists so they refresh immediately + ref.invalidate(groupsProvider); + AnalyticsService.instance.log(AnalyticsEvent.groupJoinCompleted); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Successfully joined the group! Welcome aboard.'), + backgroundColor: Colors.green, + behavior: SnackBarBehavior.floating, + ), + ); + + // Ask about notifications here, and only here-ish: the user has just + // joined a group, so "activity in your groups" is a concrete thing they + // now have rather than an abstraction. Awaited before popping so the + // sheet is not raced by the navigation. It self-limits to once per + // device and never spends the one-shot system dialog on a decline. + await maybePrimeNotifications( + context, + ref, + reason: 'You just joined a group. Want to know when people share ' + 'documents in it, or when your access changes?', + ); + + if (!mounted) return; + // Go back to the main app dashboard/workspace + Navigator.of(context).popUntil((route) => route.isFirst); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to join: ${e.toString().replaceAll('Exception: ', '')}'), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + ), + ); + } + setState(() { + _isJoining = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = theme.colorScheme.onSurface; + final primaryColor = isDark ? Colors.orangeAccent : const Color(0xFF0072FF); + + return Scaffold( + appBar: AppBar( + title: const Text('NO SUS INVITATION'), + centerTitle: true, + leading: Navigator.of(context).canPop() + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ) + : null, + ), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 32.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Card( + elevation: 8, + shadowColor: Colors.black45, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + color: isDark ? const Color(0xFF141414) : Colors.white, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: _isLoading + ? const Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.orangeAccent), + SizedBox(height: 16), + Text('Fetching secure invite details...', style: TextStyle(color: Colors.grey, fontSize: 13)), + ], + ) + : _errorMessage != null + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MascotView( + character: MascotCharacter.nox, + size: 64, + fallback: Icon(Icons.warning_amber_rounded, size: 64, color: Colors.orange), + ), + const SizedBox(height: 20), + const Text( + 'INVITATION FAILED', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, letterSpacing: 1.0), + ), + const SizedBox(height: 12), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } else { + Navigator.of(context).popUntil((route) => route.isFirst); + } + }, + style: FilledButton.styleFrom( + backgroundColor: isDark ? Colors.white12 : Colors.black12, + foregroundColor: fg, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: const Text('BACK TO APP'), + ), + ), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center( + child: MascotView( + character: MascotCharacter.duo, + size: 72, + fallback: Icon(Icons.group_add_outlined, size: 72, color: Colors.orangeAccent), + ), + ), + const SizedBox(height: 24), + Text( + 'YOU HAVE BEEN INVITED TO JOIN', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + color: fg.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 12), + Text( + _inviteDetails?['group_name'] ?? 'Study Group', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.people_alt_outlined, size: 14, color: primaryColor), + const SizedBox(width: 6), + Text( + '${_inviteDetails?['member_count'] ?? 0} active members', + style: TextStyle( + fontSize: 12, + color: fg.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if ((_inviteDetails?['group_description'] as String? ?? '').isNotEmpty) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1C1C1C) : Colors.black.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: fg.withValues(alpha: 0.08)), + ), + child: Text( + _inviteDetails!['group_description'], + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: fg.withValues(alpha: 0.7), + fontStyle: FontStyle.italic, + ), + ), + ), + ], + const SizedBox(height: 12), + Center( + child: Text( + 'Shared by: ${_inviteDetails?['creator_name'] ?? 'Admin'}', + style: TextStyle( + fontSize: 10, + color: fg.withValues(alpha: 0.4), + ), + ), + ), + const SizedBox(height: 32), + // Display Landing Page details if on mobile web browser + if (kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS)) ...[ + Text( + 'SECURITY & PRIVACY BENEFITS', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + color: fg.withValues(alpha: 0.4), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + const _BenefitRow(icon: Icons.security_rounded, title: 'Secure Vault', subtitle: 'Volatile RAM caching prevents local leaks'), + const _BenefitRow(icon: Icons.phonelink_lock, title: 'Anti-Screenshot', subtitle: 'Blocks system capture and screen recordings'), + const _BenefitRow(icon: Icons.branding_watermark_outlined, title: 'Dynamic Watermarks', subtitle: 'Overlays trace leaks back to source'), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: _downloadApp, + icon: const Icon(Icons.download_rounded, size: 16), + label: const Text('DOWNLOAD NO SUS APP', style: TextStyle(fontWeight: FontWeight.bold)), + style: FilledButton.styleFrom( + backgroundColor: Colors.orangeAccent, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _joinGroup, + icon: const Icon(Icons.web_rounded, size: 16), + label: const Text('JOIN VIA WEB BROWSER', style: TextStyle(fontWeight: FontWeight.bold)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ] else ...[ + FilledButton.icon( + onPressed: _isJoining ? null : _joinGroup, + icon: _isJoining + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), + ) + : const Icon(Icons.check_circle_outline, size: 16), + label: Text( + _isJoining ? 'JOINING...' : 'ACCEPT & JOIN GROUP', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.orangeAccent, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _BenefitRow extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + + const _BenefitRow({ + required this.icon, + required this.title, + required this.subtitle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: Colors.orangeAccent), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + Text( + subtitle, + style: TextStyle(fontSize: 9, color: fg.withValues(alpha: 0.5)), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/groups/screens/groups_screen.dart b/app/lib/features/groups/screens/groups_screen.dart new file mode 100644 index 0000000..62e3e8e --- /dev/null +++ b/app/lib/features/groups/screens/groups_screen.dart @@ -0,0 +1,684 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:no_sus/main.dart' show activeTabProvider; +import '../../../components/coach_mark.dart'; +import '../../analytics/data/analytics_service.dart'; +import '../../notifications/presentation/widgets/notification_permission_prompt.dart'; +import '../../onboarding/presentation/providers/tour_providers.dart'; +import '../providers/groups_provider.dart'; +import '../domain/models/study_group.dart'; +import '../widgets/group_card.dart'; +import '../widgets/empty_states.dart'; +import 'group_detail_screen.dart'; +import 'join_group_page.dart'; +import '../../../theme.dart'; +import '../../../components/shimmer_box.dart'; +import '../../../components/async_state_view.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; + +/// Main groups list screen — search, filtered list, FAB, skeleton loading. +class GroupsScreen extends ConsumerStatefulWidget { + const GroupsScreen({super.key}); + + @override + ConsumerState createState() => _GroupsScreenState(); +} + +class _GroupsScreenState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + static const _tabIndex = 4; + + final _searchController = TextEditingController(); + final _joinKey = GlobalKey(); + final _createKey = GlobalKey(); + + @override + void initState() { + super.initState(); + if (ref.read(activeTabProvider) == _tabIndex) _scheduleTips(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + /// See the note in WorkspaceTab: every tab is built up front, so tips have to + /// wait for this one to actually become the visible page. + void _scheduleTips() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + CoachMarks.showSequence(context, ref, [ + CoachMarkStep( + id: TourSteps.groupsJoin, + targetKey: _joinKey, + title: 'Got an invite?', + body: + 'Groups are invite-only — there is no directory to browse. Paste an invite ' + 'code or link here to join one.', + ), + CoachMarkStep( + id: TourSteps.groupsCreate, + targetKey: _createKey, + title: 'Or start your own', + body: + 'You become the admin: you decide who joins, and you can remove or ban ' + 'anyone. Everything shared inside stays inside.', + ), + ]); + }); + } + + void _openCreateGroup() { + HapticFeedback.lightImpact(); + AnalyticsService.instance.log(AnalyticsEvent.groupCreateStarted); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const _CreateGroupModal(), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + final groupsAsync = ref.watch(filteredGroupsProvider); + + ref.listen(activeTabProvider, (previous, next) { + if (next == _tabIndex && previous != _tabIndex) _scheduleTips(); + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ───────────────────────────────────────────────────────── + _GroupsHeader( + fg: fg, + subtle: subtle, + joinKey: _joinKey, + ).animate().fadeIn(duration: 250.ms), + + const SizedBox(height: NoSusTheme.s16), + + // ── Search bar ───────────────────────────────────────────────────── + _SearchBar( + controller: _searchController, + fg: fg, + subtle: subtle, + onChanged: (q) => ref.read(searchQueryProvider.notifier).update(q), + ).animate().fadeIn(delay: 80.ms, duration: 250.ms), + + const SizedBox(height: NoSusTheme.s16), + + // ── Group list ───────────────────────────────────────────────────── + Expanded( + child: RefreshIndicator( + onRefresh: () async { + ref.invalidate(groupsProvider); + await ref.read(groupsProvider.future); + }, + color: fg, + backgroundColor: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + child: AsyncStateView>( + value: groupsAsync, + onRetry: () => ref.invalidate(groupsProvider), + errorMessage: 'Failed to load groups', + loading: (context) => ShimmerListSkeleton( + itemBuilder: (context) => const GroupCardSkeleton(), + ), + isEmpty: (groups) => groups.isEmpty, + empty: (context) => GroupsEmptyState(onCreateGroup: _openCreateGroup), + data: (context, groups) => _GroupList( + groups: groups, + onCreateGroup: _openCreateGroup, + createKey: _createKey, + onGroupTap: (group) => Navigator.of( + context, + ).push(_slideRoute(GroupDetailScreen(group: group))), + ), + ), + ), + ), + ], + ); + } +} + +// ─── Sub-widgets ────────────────────────────────────────────────────────────── + +class _GroupsHeader extends StatelessWidget { + final Color fg; + final Color subtle; + final GlobalKey joinKey; + const _GroupsHeader({ + required this.fg, + required this.subtle, + required this.joinKey, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'STUDY GROUPS', + style: TextStyle( + color: subtle, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: 4), + Text( + 'Secure Communities', + style: TextStyle( + color: fg, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + ], + ), + const Spacer(), + Tooltip( + key: joinKey, + message: 'Join Group', + child: InkWell( + onTap: () { + HapticFeedback.lightImpact(); + AnalyticsService.instance.log(AnalyticsEvent.groupJoinStarted); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const JoinGroupPage()), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: fg.withValues(alpha: 0.1)), + ), + child: Icon(Icons.group_add_outlined, color: fg, size: 20), + ), + ), + ), + ], + ); + } +} + +class _SearchBar extends StatelessWidget { + final TextEditingController controller; + final Color fg; + final Color subtle; + final ValueChanged onChanged; + + const _SearchBar({ + required this.controller, + required this.fg, + required this.subtle, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + height: 44, + decoration: BoxDecoration( + color: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: fg.withValues(alpha: 0.12), width: 0.75), + ), + child: Row( + children: [ + const SizedBox(width: 12), + Icon(Icons.search, size: 16, color: subtle), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: controller, + onChanged: onChanged, + style: TextStyle(fontSize: 14, color: fg), + cursorColor: fg, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'Search groups...', + hintStyle: TextStyle(fontSize: 14, color: subtle), + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + const SizedBox(width: 12), + ], + ), + ); + } +} + +class _GroupList extends StatelessWidget { + final List groups; + final VoidCallback onCreateGroup; + final GlobalKey createKey; + final ValueChanged onGroupTap; + + const _GroupList({ + required this.groups, + required this.onCreateGroup, + required this.createKey, + required this.onGroupTap, + }); + + @override + Widget build(BuildContext context) { + final fg = Theme.of(context).colorScheme.onSurface; + + return Stack( + children: [ + ListView.separated( + padding: const EdgeInsets.only(bottom: 90), + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + itemCount: groups.length, + separatorBuilder: (context, index) => + const SizedBox(height: NoSusTheme.s12), + itemBuilder: (context, i) => GroupCard( + group: groups[i], + animationIndex: i, + onTap: () => onGroupTap(groups[i]), + ), + ), + + // Floating action button + Positioned( + key: createKey, + bottom: 16, + right: 0, + child: Semantics( + button: true, + label: 'Create group', + child: + GestureDetector( + onTap: onCreateGroup, + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: fg, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: fg.withValues(alpha: 0.25), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon( + Icons.add, + color: Theme.of(context).colorScheme.surface, + size: 24, + ), + ), + ) + .animate(delay: 400.ms) + .scale( + begin: const Offset(0.6, 0.6), + duration: 350.ms, + curve: Curves.elasticOut, + ) + .fadeIn(duration: 200.ms), + ), + ), + ], + ); + } +} + +// ─── Create group modal ─────────────────────────────────────────────────────── + +class _CreateGroupModal extends ConsumerStatefulWidget { + const _CreateGroupModal(); + + @override + ConsumerState<_CreateGroupModal> createState() => _CreateGroupModalState(); +} + +class _CreateGroupModalState extends ConsumerState<_CreateGroupModal> { + final _nameController = TextEditingController(); + final _descController = TextEditingController(); + bool _enableInviteCode = true; + bool _isCreating = false; + + @override + void dispose() { + _nameController.dispose(); + _descController.dispose(); + super.dispose(); + } + + /// 8 chars from a 32-symbol alphabet (no 0/O/1/I lookalikes) ≈ 1.1e12 + /// combinations — replaces the old `GRP-` scheme, whose 4 + /// digits were guessable by brute force. + String _generateInviteCode() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + final rng = Random.secure(); + return List.generate(8, (_) => chars[rng.nextInt(chars.length)]).join(); + } + + Future _create() async { + if (_isCreating) return; + if (_nameController.text.trim().isEmpty) return; + HapticFeedback.mediumImpact(); + + final currentUser = ref.read(authRepositoryProvider).currentUser; + final userId = currentUser?.id ?? 'me'; + final userEmail = currentUser?.email ?? 'You'; + final cleanEmailPrefix = userEmail.contains('@') ? userEmail.split('@').first : ''; + final userInitials = cleanEmailPrefix.isNotEmpty + ? cleanEmailPrefix.substring(0, cleanEmailPrefix.length >= 2 ? 2 : cleanEmailPrefix.length).toUpperCase() + : 'ME'; + + final newGroup = StudyGroup( + id: 'g_${DateTime.now().millisecondsSinceEpoch}', + name: _nameController.text.trim(), + description: _descController.text.trim().isEmpty + ? 'Private study group' + : _descController.text.trim(), + members: [ + GroupMember(id: userId, name: userEmail, initials: userInitials, isAdmin: true), + ], + fileCount: 0, + lastActivity: DateTime.now(), + isWatermarkEnabled: true, + inviteCode: _enableInviteCode ? _generateInviteCode() : null, + ); + + setState(() => _isCreating = true); + try { + await ref.read(groupsProvider.notifier).createGroup(newGroup); + AnalyticsService.instance.log(AnalyticsEvent.groupCreateCompleted); + if (mounted) { + Navigator.of(context).pop(); + // Contextual, not at launch: the user now administers a group, so + // join requests and document activity are things that will actually + // happen to them. Uses the parent context — this sheet is being + // dismissed, and its own context dies with it. + await maybePrimeNotifications( + context, + ref, + reason: "You're the admin of this group. Want to know when someone " + 'joins or shares a document?', + ); + } + } catch (e) { + if (mounted) { + setState(() => _isCreating = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to create group: ${_cleanError(e)}'), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + String _cleanError(Object e) => e + .toString() + .replaceAll('Exception: ', '') + .replaceAll(RegExp(r'^PostgrestException\(message: '), '') + .replaceAll(RegExp(r', code: .*\)$'), ''); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? NoSusTheme.dCard : NoSusTheme.lCard; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: BoxDecoration( + color: bg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + border: Border.all(color: fg.withValues(alpha: 0.1), width: 0.75), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 3, + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'NEW GROUP', + style: TextStyle( + color: subtle, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: 6), + Text( + 'Create Study Group', + style: TextStyle( + color: fg, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 24), + + // Name field + _ModalField( + controller: _nameController, + hint: 'Group name', + fg: fg, + subtle: subtle, + ), + const SizedBox(height: 12), + _ModalField( + controller: _descController, + hint: 'Description (optional)', + fg: fg, + subtle: subtle, + ), + const SizedBox(height: 16), + + // Invite code toggle + Row( + children: [ + Expanded( + child: Text( + 'Generate invite code', + style: TextStyle(fontSize: 13, color: fg), + ), + ), + Semantics( + toggled: _enableInviteCode, + label: 'Generate invite code', + child: GestureDetector( + onTap: () => + setState(() => _enableInviteCode = !_enableInviteCode), + child: Container( + // Invisible padding so the actual hit area reaches a + // reasonable minimum, without inflating the switch's + // small visual footprint. + padding: const EdgeInsets.symmetric(vertical: 12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 44, + height: 24, + decoration: BoxDecoration( + color: _enableInviteCode + ? fg + : fg.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Align( + alignment: _enableInviteCode + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.all(3), + width: 18, + height: 18, + decoration: BoxDecoration( + color: _enableInviteCode + ? (isDark ? Colors.black : Colors.white) + : fg.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Create button + Semantics( + button: true, + enabled: !_isCreating, + label: 'Create group', + child: GestureDetector( + onTap: _isCreating ? null : _create, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: _isCreating + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.black : Colors.white, + ), + ) + : Text( + 'CREATE GROUP', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _ModalField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final Color fg; + final Color subtle; + + const _ModalField({ + required this.controller, + required this.hint, + required this.fg, + required this.subtle, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: isDark ? NoSusTheme.dBackground : NoSusTheme.lBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: fg.withValues(alpha: 0.12), width: 0.75), + ), + child: TextField( + controller: controller, + style: TextStyle(fontSize: 14, color: fg), + cursorColor: fg, + decoration: InputDecoration( + border: InputBorder.none, + hintText: hint, + hintStyle: TextStyle(fontSize: 14, color: subtle), + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ); + } +} + +// ─── Slide page route ───────────────────────────────────────────────────────── + +PageRouteBuilder _slideRoute(Widget page) { + return PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero) + .animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ); + }, + transitionDuration: const Duration(milliseconds: 300), + ); +} diff --git a/app/lib/features/groups/screens/join_group_page.dart b/app/lib/features/groups/screens/join_group_page.dart new file mode 100644 index 0000000..620610c --- /dev/null +++ b/app/lib/features/groups/screens/join_group_page.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../../../theme.dart'; +import '../providers/groups_provider.dart'; + +class JoinGroupPage extends ConsumerStatefulWidget { + const JoinGroupPage({super.key}); + + @override + ConsumerState createState() => _JoinGroupPageState(); +} + +class _JoinGroupPageState extends ConsumerState { + final _codeController = TextEditingController(); + bool _isJoining = false; + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + Future _handleJoin() async { + final code = _codeController.text.trim(); + if (code.isEmpty) return; + + setState(() => _isJoining = true); + HapticFeedback.mediumImpact(); + + try { + await ref.read(groupsProvider.notifier).joinByInviteCode(code); + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Successfully joined the group!'), + backgroundColor: Colors.green, + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to join: ${_cleanError(e)}'), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isJoining = false); + } + } + } + + String _cleanError(Object e) => e + .toString() + .replaceAll('Exception: ', '') + .replaceAll(RegExp(r'^PostgrestException\(message: '), '') + .replaceAll(RegExp(r', code: .*\)$'), ''); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final bg = theme.scaffoldBackgroundColor; + final subtle = isDark ? NoSusTheme.dTextSecondary : NoSusTheme.lTextSecondary; + + return Scaffold( + backgroundColor: bg, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: IconThemeData(color: fg), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + 'JOIN SECURE GROUP', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: -1.0, + color: fg, + ), + ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0), + const SizedBox(height: 8), + Text( + 'Enter the secure invite code provided by the group admin to request access.', + style: TextStyle( + color: subtle, + fontSize: 15, + height: 1.4, + ), + ).animate().fadeIn(delay: 100.ms, duration: 300.ms), + const SizedBox(height: 48), + TextField( + controller: _codeController, + style: TextStyle(color: fg, fontSize: 24, letterSpacing: 4.0, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + decoration: InputDecoration( + hintText: 'CODE-XYZ', + hintStyle: TextStyle(color: subtle.withValues(alpha: 0.3), letterSpacing: 4.0), + filled: true, + fillColor: isDark ? const Color(0xFF1E1E1E) : Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 24), + ), + textCapitalization: TextCapitalization.characters, + ).animate().fadeIn(delay: 200.ms, duration: 300.ms), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isJoining ? null : _handleJoin, + style: ElevatedButton.styleFrom( + backgroundColor: fg, + foregroundColor: bg, + padding: const EdgeInsets.symmetric(vertical: 18), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: _isJoining + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: bg, + ), + ) + : const Text( + 'JOIN GROUP', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + fontSize: 15, + ), + ), + ), + ).animate().fadeIn(delay: 300.ms, duration: 300.ms), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/groups/widgets/empty_states.dart b/app/lib/features/groups/widgets/empty_states.dart new file mode 100644 index 0000000..28dd11d --- /dev/null +++ b/app/lib/features/groups/widgets/empty_states.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../../../core/mascot/mascot_state.dart'; +import '../../../core/mascot/mascot_view.dart'; +import '../../../theme.dart'; + +/// Minimal empty state for the groups list screen. +class GroupsEmptyState extends StatelessWidget { + final VoidCallback onCreateGroup; + const GroupsEmptyState({super.key, required this.onCreateGroup}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.35); + + return Center( + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MascotView( + character: MascotCharacter.lux, + size: 48, + fallback: Icon( + Icons.group_work_outlined, + size: 48, + color: subtle, + ), + ), + const SizedBox(height: 20), + Text( + 'No Groups Yet', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Create a private study group to share\nsecure documents with trusted people.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: subtle, height: 1.6), + ), + const SizedBox(height: 28), + Semantics( + button: true, + label: 'Create group', + child: GestureDetector( + onTap: onCreateGroup, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'CREATE GROUP', + style: TextStyle( + color: theme.colorScheme.surface, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ).animate().fadeIn(duration: 400.ms).slideY(begin: 0.05, end: 0); + } +} + +/// Minimal empty state for the files tab inside a group. +class FilesEmptyState extends StatelessWidget { + final VoidCallback onUpload; + const FilesEmptyState({super.key, required this.onUpload}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.35); + + return Center( + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MascotView( + character: MascotCharacter.lux, + size: 40, + fallback: Icon( + Icons.upload_file_outlined, + size: 40, + color: subtle, + ), + ), + const SizedBox(height: 16), + Text('No Files Shared', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Upload the first secure document\nto this group.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: subtle, height: 1.6), + ), + const SizedBox(height: 24), + Semantics( + button: true, + label: 'Upload file', + child: GestureDetector( + onTap: onUpload, + child: Text( + 'UPLOAD FILE', + style: TextStyle( + fontSize: 11, + color: fg.withValues(alpha: 0.5), + letterSpacing: 2.0, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ).animate().fadeIn(duration: 350.ms); + } +} diff --git a/app/lib/features/groups/widgets/file_card.dart b/app/lib/features/groups/widgets/file_card.dart new file mode 100644 index 0000000..ebf3835 --- /dev/null +++ b/app/lib/features/groups/widgets/file_card.dart @@ -0,0 +1,423 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../models/group_file.dart'; +import '../../../theme.dart'; + +/// Horizontal file card showing type icon, name, uploader, size, and security badges. +/// Supports swipe-right (delete) and swipe-left (pin) reveal actions. +class FileCard extends StatefulWidget { + final GroupFile file; + final VoidCallback? onDelete; + final VoidCallback? onPin; + final VoidCallback? onOpen; + final String uploaderName; + final int animationIndex; + + final VoidCallback? onRename; + final VoidCallback? onShare; + final VoidCallback? onAnalytics; + + const FileCard({ + super.key, + required this.file, + this.onDelete, + this.onPin, + this.onOpen, + this.onRename, + this.onShare, + this.onAnalytics, + required this.uploaderName, + this.animationIndex = 0, + }); + + @override + State createState() => _FileCardState(); +} + +class _FileCardState extends State { + double _swipeOffset = 0; + + static const _revealThreshold = 64.0; + static const _actionWidth = 96.0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return GestureDetector( + onHorizontalDragUpdate: (d) { + setState(() { + _swipeOffset = (_swipeOffset + d.delta.dx).clamp( + -_actionWidth, + _actionWidth, + ); + }); + }, + onHorizontalDragEnd: (_) { + if (_swipeOffset > _revealThreshold) { + // Swipe right → delete action + widget.onDelete?.call(); + } else if (_swipeOffset < -_revealThreshold) { + // Swipe left → pin action + widget.onPin?.call(); + } + setState(() => _swipeOffset = 0); + }, + child: ClipRect( + child: Stack( + children: [ + // ── Action backgrounds ───────────────────────────────────────── + if (_swipeOffset > 0) + Positioned.fill( + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: _swipeOffset.abs(), + color: Colors.red.withValues(alpha: 0.12), + child: const Center( + child: Icon( + Icons.delete_outline, + color: Colors.red, + size: 18, + ), + ), + ), + ), + ), + if (_swipeOffset < 0) + Positioned.fill( + child: Align( + alignment: Alignment.centerRight, + child: Container( + width: _swipeOffset.abs(), + color: Colors.amber.withValues(alpha: 0.10), + child: const Center( + child: Icon( + Icons.push_pin_outlined, + color: Colors.amber, + size: 18, + ), + ), + ), + ), + ), + + // ── Card content ─────────────────────────────────────────────── + Transform.translate( + offset: Offset(_swipeOffset, 0), + child: Container( + padding: const EdgeInsets.all(NoSusTheme.s16), + decoration: NoSusTheme.cardDecoration(context), + child: Row( + children: [ + // File type icon block + _FileTypeIcon(type: widget.file.type), + const SizedBox(width: NoSusTheme.s16), + + // File info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (widget.file.isPinned) ...[ + Icon( + Icons.push_pin, + size: 11, + color: subtle, + ), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + widget.file.name, + style: theme.textTheme.bodyLarge + ?.copyWith( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${widget.uploaderName} · ${widget.file.uploadedAtLabel} · ${widget.file.sizeLabel}', + style: TextStyle(fontSize: 11, color: subtle), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 10, + runSpacing: 4, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Security status dot + _SecurityDot( + status: widget.file.securityStatus, + ), + const SizedBox(width: 6), + Text( + widget.file.securityStatus == + FileSecurityStatus.secured + ? 'READY' + : 'PROCESSING', + style: TextStyle( + fontSize: 9, + color: subtle, + fontWeight: FontWeight.w600, + letterSpacing: 1.0, + ), + ), + ], + ), + if (widget.file.isWatermarked) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.water_drop_outlined, + size: 10, + color: subtle, + ), + const SizedBox(width: 3), + Text( + 'WATERMARKED', + style: TextStyle( + fontSize: 9, + color: subtle, + fontWeight: FontWeight.w600, + letterSpacing: 1.0, + ), + ), + ], + ), + ], + ), + ], + ), + ), + + // Open button + const SizedBox(width: NoSusTheme.s12), + Semantics( + button: true, + label: 'Open ${widget.file.name}', + child: InkWell( + onTap: widget.onOpen, + borderRadius: BorderRadius.circular(8), + child: Container( + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + alignment: Alignment.center, + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + border: Border.all( + color: theme.colorScheme.outline, + width: 0.75, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.open_in_new_rounded, + size: 14, + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + + // Options Popup Menu + const SizedBox(width: 8), + PopupMenuButton( + tooltip: 'More options for ${widget.file.name}', + icon: Icon( + Icons.more_vert, + size: 18, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + minimumSize: const Size(40, 40), + padding: EdgeInsets.zero, + ), + onSelected: (val) { + if (val == 'open') { + widget.onOpen?.call(); + } else if (val == 'pin') { + widget.onPin?.call(); + } else if (val == 'rename') { + widget.onRename?.call(); + } else if (val == 'share') { + widget.onShare?.call(); + } else if (val == 'analytics') { + widget.onAnalytics?.call(); + } else if (val == 'delete') { + widget.onDelete?.call(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'open', + child: Row( + children: [ + Icon(Icons.open_in_new_rounded, size: 14, color: theme.colorScheme.onSurface), + const SizedBox(width: 8), + const Text('Open Document', style: TextStyle(fontSize: 12)), + ], + ), + ), + PopupMenuItem( + value: 'pin', + child: Row( + children: [ + Icon( + widget.file.isPinned ? Icons.push_pin : Icons.push_pin_outlined, + size: 14, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: 8), + Text( + widget.file.isPinned ? 'Unpin Document' : 'Pin Document', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + PopupMenuItem( + value: 'rename', + child: Row( + children: [ + Icon(Icons.edit_outlined, size: 14, color: theme.colorScheme.onSurface), + const SizedBox(width: 8), + const Text('Rename Document', style: TextStyle(fontSize: 12)), + ], + ), + ), + PopupMenuItem( + value: 'share', + child: Row( + children: [ + Icon(Icons.ios_share_rounded, size: 14, color: theme.colorScheme.onSurface), + const SizedBox(width: 8), + const Text('Share Link', style: TextStyle(fontSize: 12)), + ], + ), + ), + PopupMenuItem( + value: 'analytics', + child: Row( + children: [ + Icon(Icons.bar_chart_rounded, size: 14, color: theme.colorScheme.onSurface), + const SizedBox(width: 8), + const Text('Link Analytics', style: TextStyle(fontSize: 12)), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline, size: 14, color: Colors.redAccent), + SizedBox(width: 8), + Text( + 'Delete Document', + style: TextStyle(fontSize: 12, color: Colors.redAccent), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ) + .animate(delay: (widget.animationIndex.clamp(0, 10) * 50).ms) + .fadeIn(duration: 250.ms) + .slideX(begin: 0.03, end: 0, duration: 250.ms, curve: Curves.easeOut); + } +} + +// ─── File type icon ─────────────────────────────────────────────────────────── + +class _FileTypeIcon extends StatelessWidget { + final FileType type; + const _FileTypeIcon({required this.type}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + final icon = switch (type) { + FileType.pdf => Icons.picture_as_pdf_outlined, + FileType.image => Icons.image_outlined, + FileType.markdown => Icons.article_outlined, + FileType.scan => Icons.document_scanner_outlined, + }; + + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: fg.withValues(alpha: 0.1), width: 0.75), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: fg), + const SizedBox(height: 2), + Text( + type.label, + style: TextStyle( + fontSize: 7, + color: subtle, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], + ), + ); + } +} + +// ─── Security status dot ────────────────────────────────────────────────────── + +class _SecurityDot extends StatelessWidget { + final FileSecurityStatus status; + const _SecurityDot({required this.status}); + + @override + Widget build(BuildContext context) { + final color = switch (status) { + FileSecurityStatus.secured => const Color(0xFF4CAF50), + FileSecurityStatus.processing => Colors.amber, + FileSecurityStatus.pending => Colors.grey, + }; + return Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } +} diff --git a/app/lib/features/groups/widgets/group_card.dart b/app/lib/features/groups/widgets/group_card.dart new file mode 100644 index 0000000..78fb9c4 --- /dev/null +++ b/app/lib/features/groups/widgets/group_card.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../domain/models/study_group.dart'; +import '../../../theme.dart'; +import '../../../components/shimmer_box.dart'; +import 'member_avatar_stack.dart'; + +/// Premium group card with group name, meta info, stacked avatars, and security badge. +/// Provides press feedback via [_pressed] scale animation. +class GroupCard extends StatefulWidget { + final StudyGroup group; + final VoidCallback onTap; + final int animationIndex; + + const GroupCard({ + super.key, + required this.group, + required this.onTap, + this.animationIndex = 0, + }); + + @override + State createState() => _GroupCardState(); +} + +class _GroupCardState extends State { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return Semantics( + button: true, + label: 'Open group ${widget.group.name}', + onTap: widget.onTap, + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) { + setState(() => _pressed = false); + widget.onTap(); + }, + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedScale( + scale: _pressed ? 0.97 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s16, + vertical: 14.0, + ), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Group name & Watermark Row ─────────────────────────────── + Row( + children: [ + Expanded( + child: Text( + widget.group.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.group.isWatermarkEnabled) ...[ + const SizedBox(width: 8), + Icon( + Icons.water_drop_outlined, + size: 13, + color: subtle, + ), + ], + ], + ), + const SizedBox(height: 6), + + // Description + Text( + widget.group.description, + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 12, + color: subtle.withValues(alpha: 0.85), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 14.0), + + // ── Bottom row: avatars + stats ────────────────────────────── + Row( + children: [ + MemberAvatarStack( + members: widget.group.members, + maxVisible: 3, + size: 24, + ), + const SizedBox(width: NoSusTheme.s12), + Text( + '${widget.group.memberCount} members', + style: TextStyle(fontSize: 11, color: subtle), + ), + const Spacer(), + // File count pill + _MetaPill( + icon: Icons.description_outlined, + label: '${widget.group.fileCount}', + fg: fg, + subtle: subtle, + ), + const SizedBox(width: 8), + // Last activity + Text( + widget.group.lastActivityLabel, + style: TextStyle(fontSize: 11, color: subtle), + ), + ], + ), + ], + ), + ), + ), + ) + // Cap the delay so items far down a long list don't wait multiple + // seconds before fading in once scrolled into view. + .animate(delay: (widget.animationIndex.clamp(0, 10) * 60).ms) + .fadeIn(duration: 300.ms) + .slideY(begin: 0.04, end: 0, duration: 300.ms, curve: Curves.easeOut), + ); + } +} + +class _MetaPill extends StatelessWidget { + final IconData icon; + final String label; + final Color fg; + final Color subtle; + + const _MetaPill({ + required this.icon, + required this.label, + required this.fg, + required this.subtle, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: subtle), + const SizedBox(width: 3), + Text(label, style: TextStyle(fontSize: 11, color: subtle)), + ], + ); + } +} + +// ─── Skeleton ───────────────────────────────────────────────────────────────── + +/// Shimmer loading placeholder matching the GroupCard layout. Delegates its +/// shimmer-sweep animation to the shared [ShimmerBox]/[ShimmerSweep] +/// primitives (lib/components/shimmer_box.dart) so the timing/curve lives +/// in one place. +class GroupCardSkeleton extends StatelessWidget { + const GroupCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ShimmerBox(width: 72, height: 18), + Spacer(), + ShimmerBox(width: 18, height: 18, radius: 9), + ], + ), + SizedBox(height: NoSusTheme.s16), + ShimmerBox(width: 160, height: 16), + SizedBox(height: 8), + ShimmerBox(width: double.infinity, height: 12), + SizedBox(height: 5), + ShimmerBox(width: 180, height: 12), + SizedBox(height: 20.0), + Row( + children: [ + ShimmerBox(width: 80, height: 24, radius: 12), + Spacer(), + ShimmerBox(width: 60, height: 12), + ], + ), + ], + ), + ); + } +} diff --git a/app/lib/features/groups/widgets/member_avatar_stack.dart b/app/lib/features/groups/widgets/member_avatar_stack.dart new file mode 100644 index 0000000..8edc175 --- /dev/null +++ b/app/lib/features/groups/widgets/member_avatar_stack.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import '../domain/models/study_group.dart'; +import '../../../theme.dart'; + +/// Stacked circular avatar chips — up to [maxVisible] shown, then "+N" overflow. +class MemberAvatarStack extends StatelessWidget { + final List members; + final int maxVisible; + final double size; + + const MemberAvatarStack({ + super.key, + required this.members, + this.maxVisible = 3, + this.size = 26, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final bg = isDark ? NoSusTheme.dCard : NoSusTheme.lCard; + final border = isDark ? NoSusTheme.dBackground : NoSusTheme.lBackground; + + final visible = members.take(maxVisible).toList(); + final overflow = members.length - maxVisible; + + return SizedBox( + height: size, + width: size * visible.length * 0.65 + (overflow > 0 ? size * 0.65 : 0), + child: Stack( + children: [ + // Render visible avatars right-to-left so left ones sit on top + for (int i = visible.length - 1; i >= 0; i--) + Positioned( + left: i * size * 0.65, + child: _Avatar( + initials: visible[i].initials, + size: size, + fg: fg, + bg: bg, + borderColor: border, + ), + ), + // Overflow count bubble + if (overflow > 0) + Positioned( + left: visible.length * size * 0.65, + child: _Avatar( + initials: '+$overflow', + size: size, + fg: fg.withValues(alpha: 0.5), + bg: bg, + borderColor: border, + fontSize: 8, + ), + ), + ], + ), + ); + } +} + +class _Avatar extends StatelessWidget { + final String initials; + final double size; + final Color fg; + final Color bg; + final Color borderColor; + final double? fontSize; + + const _Avatar({ + required this.initials, + required this.size, + required this.fg, + required this.bg, + required this.borderColor, + this.fontSize, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: bg, + border: Border.all(color: borderColor, width: 1.5), + ), + child: Center( + child: Text( + initials, + style: TextStyle( + fontSize: fontSize ?? (size * 0.3), + fontWeight: FontWeight.w600, + color: fg, + height: 1, + ), + ), + ), + ); + } +} diff --git a/app/lib/features/groups/widgets/upload_modal.dart b/app/lib/features/groups/widgets/upload_modal.dart new file mode 100644 index 0000000..f720084 --- /dev/null +++ b/app/lib/features/groups/widgets/upload_modal.dart @@ -0,0 +1,1245 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:file_picker/file_picker.dart' as fp; +import '../models/group_file.dart'; +import '../../../theme.dart'; +import '../../files/domain/models/secure_file_metadata.dart'; +import '../../files/presentation/providers/secure_file_providers.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; +import '../../profile/providers/profile_provider.dart'; +import '../../files/presentation/providers/upload_provider.dart'; +import '../../../core/utils/debug_logger.dart'; +import '../../../core/mascot/mascot_controller.dart'; +import '../../../core/mascot/mascot_state.dart'; +import '../../../core/mascot/mascot_view.dart'; + +/// Premium upload bottom sheet. Slides up via DraggableScrollableSheet. +/// States: pick/link → processing (animated progress) → complete (checkmark). +class UploadModal extends ConsumerStatefulWidget { + final String groupId; + + const UploadModal({super.key, required this.groupId}); + + @override + ConsumerState createState() => _UploadModalState(); +} + +class _UploadModalState extends ConsumerState + with SingleTickerProviderStateMixin { + FileType? _selectedType; + late AnimationController _checkController; + late Animation _checkProgress; + + // New Link from Google Drive state + final TextEditingController _urlController = TextEditingController(); + final TextEditingController _nameController = TextEditingController(); + final FocusNode _nameFocus = FocusNode(); + final FocusNode _urlFocus = FocusNode(); + FileType _linkType = FileType.pdf; + bool _isLinking = false; + int _activeTab = 0; // 0 = upload local, 1 = link drive + String? _serviceAccountEmail; + + @override + void initState() { + super.initState(); + _checkController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _checkProgress = CurvedAnimation( + parent: _checkController, + curve: Curves.easeOut, + ); + _loadServiceAccountEmail(); + } + + @override + void dispose() { + _checkController.dispose(); + _urlController.dispose(); + _nameController.dispose(); + _nameFocus.dispose(); + _urlFocus.dispose(); + super.dispose(); + } + + Future _loadServiceAccountEmail() async { + if (!mounted) return; + try { + final email = await ref + .read(secureFileRepositoryProvider) + .getServiceAccountEmail(); + if (mounted) { + setState(() { + _serviceAccountEmail = email; + }); + } + } catch (_) {} + } + + Future _linkDocument() async { + final name = _nameController.text.trim(); + final url = _urlController.text.trim(); + + if (name.isEmpty || url.isEmpty) { + _showErrorSnackBar( + "Please enter both a document name and a Google Drive URL.", + ); + return; + } + + setState(() => _isLinking = true); + HapticFeedback.mediumImpact(); + + try { + final domainType = SecureFileType.values.firstWhere( + (e) => e.name == _linkType.name, + orElse: () => SecureFileType.pdf, + ); + + final currentUser = ref.read(authRepositoryProvider).currentUser; + final profileVal = ref.read(profileProvider).value; + final uploaderName = + profileVal?.displayName ?? currentUser?.email ?? 'Anonymous'; + String uploaderInitials = 'AN'; + if (uploaderName.isNotEmpty) { + if (uploaderName.contains('@')) { + final prefix = uploaderName.split('@').first; + uploaderInitials = prefix + .substring(0, prefix.length >= 2 ? 2 : prefix.length) + .toUpperCase(); + } else { + uploaderInitials = uploaderName + .substring(0, uploaderName.length >= 2 ? 2 : uploaderName.length) + .toUpperCase(); + } + } + + await ref + .read(secureFileRepositoryProvider) + .addGoogleDriveLink( + groupId: widget.groupId, + name: name, + type: domainType, + driveUrl: url, + uploaderName: uploaderName, + uploaderInitials: uploaderInitials, + ); + + if (mounted) { + // Trigger completion animation! + ref.read(uploadProvider.notifier).reset(); // ensure state is clear + setState(() => _isLinking = false); + _checkController.forward().then((_) { + if (mounted) { + Navigator.of(context).pop(); // close modal after complete + } + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLinking = false); + _showErrorSnackBar("Error linking file: $e"); + } + } + } + + Future _startUpload(FileType type) async { + HapticFeedback.lightImpact(); + + fp.FileType pickerType; + List? allowedExtensions; + + switch (type) { + case FileType.pdf: + pickerType = fp.FileType.custom; + allowedExtensions = ['pdf']; + break; + case FileType.image: + pickerType = fp.FileType.image; + break; + case FileType.markdown: + pickerType = fp.FileType.custom; + allowedExtensions = ['md', 'txt']; + break; + case FileType.scan: + pickerType = fp.FileType.custom; + allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf']; + break; + } + + try { + final result = await fp.FilePicker.pickFiles( + type: pickerType, + allowedExtensions: allowedExtensions, + withData: true, + ); + + if (result == null || result.files.isEmpty) { + debugLog("File picker: User cancelled picking"); + return; + } + + final file = result.files.first; + + // Enforce 10MB limit per file + if (file.size > 10 * 1024 * 1024) { + _showErrorSnackBar( + "File exceeds 10MB limit. Please select a smaller file.", + ); + return; + } + + final name = file.name; + final bytes = file.bytes; + + if (bytes == null) { + if (file.path != null) { + final ioFile = File(file.path!); + final fileBytes = await ioFile.readAsBytes(); + _uploadRealFile(name, type, fileBytes); + } else { + _showErrorSnackBar("Could not read file data. Try another file."); + } + } else { + _uploadRealFile(name, type, bytes); + } + } catch (e) { + debugLog("File picker error: $e"); + _showErrorSnackBar("Error picking file: $e"); + } + } + + void _uploadRealFile(String fileName, FileType type, Uint8List bytes) { + ref + .read(uploadProvider.notifier) + .uploadDocument(fileName, type, widget.groupId, bytes); + } + + void _showErrorSnackBar(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.redAccent), + ); + } + } + + @override + Widget build(BuildContext context) { + ref.listen(uploadProvider, (previous, next) { + if (next.stage == previous?.stage) return; + switch (next.stage) { + case UploadStage.processing: + ref.read(luxMascotProvider.notifier).play(MascotMood.think); + break; + case UploadStage.complete: + ref.read(luxMascotProvider.notifier).play(MascotMood.celebrate); + break; + case UploadStage.error: + ref.read(noxMascotProvider.notifier).play(MascotMood.alert); + break; + default: + break; + } + }); + final upload = ref.watch(uploadProvider); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? NoSusTheme.dCard : NoSusTheme.lCard; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + final displayEmail = + _serviceAccountEmail ?? "Supabase Storage (no service account)"; + + // Auto-trigger checkmark when complete + final isComplete = + (upload.stage == UploadStage.complete) || (_checkController.value > 0); + final isProcessing = (upload.stage == UploadStage.processing); + final isError = (upload.stage == UploadStage.error); + + if (upload.stage == UploadStage.complete && !_checkController.isCompleted) { + _checkController.forward(); + } else if (upload.stage == UploadStage.idle && + _checkController.value == 0) { + _selectedType = null; + } + + return Container( + decoration: BoxDecoration( + color: bg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + border: Border.all(color: fg.withValues(alpha: 0.1), width: 0.75), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + const SizedBox(height: 12), + Container( + width: 36, + height: 3, + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + + // ── Content based on upload state ───────────────────────────── + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: () { + if (isComplete) { + return _CompleteState( + key: const ValueKey('complete'), + checkProgress: _checkProgress, + fg: fg, + ); + } + if (isProcessing) { + return _ProcessingState( + key: const ValueKey('processing'), + fileName: upload.fileName ?? '', + progress: upload.progress, + fg: fg, + subtle: subtle, + onCancel: () => + ref.read(uploadProvider.notifier).cancelUpload(), + ); + } + if (isError) { + return _ErrorState( + key: const ValueKey('error'), + message: upload.errorMessage ?? 'Upload failed', + fg: fg, + onRetry: () => ref.read(uploadProvider.notifier).reset(), + ); + } + + // Idle / Picking - Render selector tabs + return Column( + key: const ValueKey('picker_tabs'), + children: [ + // Tab selection row + Padding( + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s24, + ), + child: Row( + children: [ + Expanded( + child: Semantics( + button: true, + selected: _activeTab == 0, + label: 'Upload local file', + child: GestureDetector( + onTap: () => setState(() => _activeTab = 0), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: _activeTab == 0 + ? fg + : Colors.transparent, + width: 2.0, + ), + ), + ), + child: Center( + child: Text( + "UPLOAD LOCAL", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + color: _activeTab == 0 + ? fg + : fg.withValues(alpha: 0.4), + ), + ), + ), + ), + ), + ), + ), + Expanded( + child: Semantics( + button: true, + selected: _activeTab == 1, + label: 'Link a Google Drive URL', + child: GestureDetector( + onTap: () => setState(() => _activeTab = 1), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: _activeTab == 1 + ? fg + : Colors.transparent, + width: 2.0, + ), + ), + ), + child: Center( + child: Text( + "LINK DRIVE URL", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + color: _activeTab == 1 + ? fg + : fg.withValues(alpha: 0.4), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: _activeTab == 0 + ? _PickerState( + key: const ValueKey('picker'), + fg: fg, + subtle: subtle, + selectedType: _selectedType, + onTypeSelected: (type) { + setState(() => _selectedType = type); + _startUpload(type); + }, + ) + : _LinkState( + key: const ValueKey('link'), + fg: fg, + subtle: subtle, + urlController: _urlController, + nameController: _nameController, + nameFocus: _nameFocus, + urlFocus: _urlFocus, + selectedType: _linkType, + onTypeSelected: (type) => + setState(() => _linkType = type), + serviceAccountEmail: displayEmail, + isLinking: _isLinking, + onLinkTap: _linkDocument, + ), + ), + ], + ); + }(), + ), + + const SizedBox(height: 32), + ], + ), + ); + } +} + +// ─── Picker state ───────────────────────────────────────────────────────────── + +class _PickerState extends StatelessWidget { + final Color fg; + final Color subtle; + final FileType? selectedType; + final ValueChanged onTypeSelected; + + const _PickerState({ + super.key, + required this.fg, + required this.subtle, + required this.selectedType, + required this.onTypeSelected, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: NoSusTheme.s24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ADD TO GROUP', + style: TextStyle( + color: subtle, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: 8), + Text( + 'Secure Upload', + style: TextStyle( + color: fg, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 24), + + // Dashed drop zone + Container( + width: double.infinity, + height: 90, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: fg.withValues(alpha: 0.15), + width: 1, + strokeAlign: BorderSide.strokeAlignCenter, + ), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cloud_upload_outlined, + size: 28, + color: fg.withValues(alpha: 0.3), + ), + const SizedBox(height: 6), + Text( + 'Select a file type below', + style: TextStyle( + fontSize: 13, + color: fg.withValues(alpha: 0.4), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + + // File type selector + Row( + children: FileType.values + .map( + (type) => Expanded( + child: Padding( + padding: EdgeInsets.only( + right: type == FileType.scan ? 0 : 10, + ), + child: _TypeButton( + type: type, + fg: fg, + subtle: subtle, + onTap: () => onTypeSelected(type), + ), + ), + ), + ) + .toList(), + ), + ], + ), + ); + } +} + +class _TypeButton extends StatelessWidget { + final FileType type; + final Color fg; + final Color subtle; + final VoidCallback onTap; + + const _TypeButton({ + required this.type, + required this.fg, + required this.subtle, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final icon = switch (type) { + FileType.pdf => Icons.picture_as_pdf_outlined, + FileType.image => Icons.image_outlined, + FileType.markdown => Icons.article_outlined, + FileType.scan => Icons.document_scanner_outlined, + }; + + return Semantics( + button: true, + label: 'Upload ${type.label}', + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: fg.withValues(alpha: 0.12), width: 0.75), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon(icon, size: 20, color: fg), + const SizedBox(height: 5), + Text( + type.label, + style: TextStyle( + fontSize: 9, + color: subtle, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ─── Processing state ───────────────────────────────────────────────────────── + +class _ProcessingState extends StatelessWidget { + final String fileName; + final double progress; + final Color fg; + final Color subtle; + final VoidCallback onCancel; + + const _ProcessingState({ + super.key, + required this.fileName, + required this.progress, + required this.fg, + required this.subtle, + required this.onCancel, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: NoSusTheme.s24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'PREPARING & UPLOADING', + style: TextStyle( + color: subtle, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: 8), + Text( + fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + + const Center( + child: MascotView(character: MascotCharacter.lux, size: 32), + ), + const SizedBox(height: 12), + + // Progress track + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: fg.withValues(alpha: 0.08), + valueColor: AlwaysStoppedAnimation(fg), + minHeight: 3, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Uploading to secure database...', + style: TextStyle(fontSize: 11, color: subtle), + ), + Text( + '${(progress * 100).toInt()}%', + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 20), + Center( + child: TextButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close, size: 14, color: Colors.redAccent), + label: const Text( + 'CANCEL UPLOAD', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.redAccent, + letterSpacing: 1.0, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + ), + ), + ], + ), + ); + } +} + +// ─── Complete state ─────────────────────────────────────────────────────────── + +class _CompleteState extends StatelessWidget { + final Animation checkProgress; + final Color fg; + + const _CompleteState({ + super.key, + required this.checkProgress, + required this.fg, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: NoSusTheme.s24), + child: Column( + children: [ + AnimatedBuilder( + animation: checkProgress, + builder: (context, child) => CustomPaint( + size: const Size(56, 56), + painter: _CheckPainter(progress: checkProgress.value, color: fg), + ), + ), + const SizedBox(height: 8), + const MascotView(character: MascotCharacter.lux, size: 28), + const SizedBox(height: 16), + Text( + 'Secured & Uploaded', + style: TextStyle( + color: fg, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ).animate().fadeIn(duration: 300.ms), + const SizedBox(height: 4), + Text( + 'File added to group.', + style: TextStyle(color: fg.withValues(alpha: 0.45), fontSize: 13), + ).animate().fadeIn(delay: 100.ms, duration: 300.ms), + ], + ), + ); + } +} + +// ─── Error state ────────────────────────────────────────────────────────────── + +class _ErrorState extends StatelessWidget { + final String message; + final Color fg; + final VoidCallback onRetry; + + const _ErrorState({ + super.key, + required this.message, + required this.fg, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: NoSusTheme.s24), + child: Column( + children: [ + MascotView( + character: MascotCharacter.nox, + size: 40, + fallback: Icon( + Icons.error_outline, + size: 40, + color: Colors.red.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 12), + Text(message, style: TextStyle(color: fg, fontSize: 16)), + const SizedBox(height: 16), + Semantics( + button: true, + label: 'Try again', + child: GestureDetector( + onTap: onRetry, + child: Text( + 'TRY AGAIN', + style: TextStyle( + fontSize: 11, + color: fg.withValues(alpha: 0.5), + letterSpacing: 1.5, + ), + ), + ), + ), + ], + ), + ); + } +} + +// ─── Check mark painter ─────────────────────────────────────────────────────── + +class _CheckPainter extends CustomPainter { + final double progress; + final Color color; + + const _CheckPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..strokeCap = StrokeCap.round; + + // Draw circle + canvas.drawCircle( + Offset(size.width / 2, size.height / 2), + size.width / 2 - 2, + paint..color = color.withValues(alpha: 0.2), + ); + + if (progress <= 0) return; + + // Draw checkmark path: starts at left, goes to center-bottom, then top-right + final path = Path(); + final cx = size.width / 2; + final cy = size.height / 2; + path.moveTo(cx - 10, cy); + path.lineTo(cx - 3, cy + 8); + path.lineTo(cx + 12, cy - 8); + + final pathMetrics = path.computeMetrics().first; + final extractedPath = pathMetrics.extractPath( + 0, + pathMetrics.length * progress, + ); + + canvas.drawPath(extractedPath, paint..color = color); + } + + @override + bool shouldRepaint(_CheckPainter old) => old.progress != progress; +} + +class _LinkState extends StatelessWidget { + final Color fg; + final Color subtle; + final TextEditingController urlController; + final TextEditingController nameController; + final FocusNode nameFocus; + final FocusNode urlFocus; + final FileType selectedType; + final ValueChanged onTypeSelected; + final String serviceAccountEmail; + final bool isLinking; + final VoidCallback onLinkTap; + + const _LinkState({ + super.key, + required this.fg, + required this.subtle, + required this.urlController, + required this.nameController, + required this.nameFocus, + required this.urlFocus, + required this.selectedType, + required this.onTypeSelected, + required this.serviceAccountEmail, + required this.isLinking, + required this.onLinkTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: NoSusTheme.s24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'LINK GOOGLE DRIVE', + style: TextStyle( + color: subtle, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: 8), + Text( + 'Link Document', + style: TextStyle( + color: fg, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 16), + + // Service account card + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: fg.withValues(alpha: 0.08), + width: 0.75, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.lock_person_outlined, + size: 16, + color: fg.withValues(alpha: 0.6), + ), + const SizedBox(width: 8), + Text( + "Secure Private Access", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: fg.withValues(alpha: 0.8), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "To keep the file private, share it as 'Viewer' with this service account email in Google Drive:", + style: TextStyle( + fontSize: 11, + color: fg.withValues(alpha: 0.5), + height: 1.4, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: fg.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + serviceAccountEmail, + style: TextStyle( + fontSize: 11, + fontFamily: 'Courier', + color: fg.withValues(alpha: 0.7), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + _CopyButton(email: serviceAccountEmail, fg: fg), + ], + ), + const SizedBox(height: 8), + Text( + "Alternatively, make the file 'Anyone with the link can view' for instant public linking.", + style: TextStyle( + fontSize: 11, + fontStyle: FontStyle.italic, + color: fg.withValues(alpha: 0.4), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Document Name Field + _buildTextField( + controller: nameController, + focusNode: nameFocus, + label: "Document Name", + hint: "e.g., Lecture 4 Notes", + textInputAction: TextInputAction.next, + onSubmitted: (_) => urlFocus.requestFocus(), + ), + const SizedBox(height: 12), + + // Drive URL Field + _buildTextField( + controller: urlController, + focusNode: urlFocus, + label: "Google Drive Link", + hint: "https://drive.google.com/file/d/...", + textInputAction: TextInputAction.done, + onSubmitted: (_) => isLinking ? null : onLinkTap(), + ), + const SizedBox(height: 16), + + // File Type Selector + Text( + "Document Type", + style: TextStyle( + fontSize: 11, + color: fg.withValues(alpha: 0.5), + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Row( + children: FileType.values + .map( + (type) => Expanded( + child: Padding( + padding: EdgeInsets.only( + right: type == FileType.scan ? 0 : 8, + ), + child: Semantics( + button: true, + selected: selectedType == type, + label: 'Document type ${type.label}', + child: GestureDetector( + onTap: () => onTypeSelected(type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: selectedType == type + ? fg.withValues(alpha: 0.05) + : Colors.transparent, + border: Border.all( + color: selectedType == type + ? fg + : fg.withValues(alpha: 0.12), + width: 0.75, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + type.label, + style: TextStyle( + fontSize: 9, + color: selectedType == type ? fg : subtle, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ), + ), + ), + ) + .toList(), + ), + const SizedBox(height: 24), + + // Action Button + SizedBox( + width: double.infinity, + height: 48, + child: OutlinedButton( + onPressed: isLinking ? null : onLinkTap, + style: OutlinedButton.styleFrom( + side: BorderSide(color: fg, width: 1.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + backgroundColor: fg.withValues(alpha: isLinking ? 0.05 : 0.0), + ), + child: isLinking + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: fg, + ), + ) + : Text( + "LINK DOCUMENT", + style: TextStyle( + color: fg, + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required String hint, + FocusNode? focusNode, + TextInputAction? textInputAction, + ValueChanged? onSubmitted, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 11, + color: fg.withValues(alpha: 0.5), + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.02), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: fg.withValues(alpha: 0.1), width: 0.75), + ), + child: TextField( + controller: controller, + focusNode: focusNode, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + cursorColor: fg, + style: TextStyle(fontSize: 13, color: fg), + decoration: InputDecoration( + border: InputBorder.none, + hintText: hint, + hintStyle: TextStyle( + fontSize: 13, + color: fg.withValues(alpha: 0.3), + ), + ), + ), + ), + ], + ); + } +} + +class _CopyButton extends StatefulWidget { + final String email; + final Color fg; + + const _CopyButton({required this.email, required this.fg}); + + @override + State<_CopyButton> createState() => _CopyButtonState(); +} + +class _CopyButtonState extends State<_CopyButton> { + bool _copied = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: _copied + ? 'Service account email copied' + : 'Copy service account email', + child: GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: widget.email)); + HapticFeedback.lightImpact(); + setState(() => _copied = true); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _copied = false); + } + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Service account email copied to clipboard."), + duration: Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: widget.fg.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: widget.fg.withValues(alpha: 0.1), + width: 0.5, + ), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _copied + ? const Icon(Icons.check, size: 14, color: Colors.green) + : Icon(Icons.copy_all_outlined, size: 14, color: widget.fg), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/help/domain/help_topic.dart b/app/lib/features/help/domain/help_topic.dart new file mode 100644 index 0000000..ea56ace --- /dev/null +++ b/app/lib/features/help/domain/help_topic.dart @@ -0,0 +1,542 @@ +import 'package:flutter/material.dart'; + +/// One explainable concept in the product. +/// +/// Content lives in Dart rather than a remote table on purpose: Help has to +/// work for a signed-out visitor who has never reached Supabase, and it is the +/// surface a confused user reaches for when the network is the thing that is +/// broken. +class HelpTopic { + /// Stable identifier. Contextual "What's this?" buttons deep-link by id, so + /// renaming a topic must not change it. + final String id; + final String title; + + /// One line shown in the topic list — the answer in miniature, not a teaser. + final String summary; + final IconData icon; + final List sections; + + /// Extra words that should match this topic in search but do not belong in + /// the visible copy (jargon, old names, symptoms). + final List keywords; + + const HelpTopic({ + required this.id, + required this.title, + required this.summary, + required this.icon, + required this.sections, + this.keywords = const [], + }); + + bool matches(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + if (title.toLowerCase().contains(q)) return true; + if (summary.toLowerCase().contains(q)) return true; + if (keywords.any((k) => k.toLowerCase().contains(q))) return true; + return sections.any( + (s) => + s.heading.toLowerCase().contains(q) || + s.body.toLowerCase().contains(q), + ); + } +} + +class HelpSection { + final String heading; + final String body; + + /// Renders as a caution callout. Used for the places where the product's + /// protection genuinely stops — never softened into marketing. + final bool isCaution; + + const HelpSection({ + required this.heading, + required this.body, + this.isCaution = false, + }); +} + +/// The help catalogue. +/// +/// Every claim here must describe something that actually ships. In particular +/// the screenshot-blocking copy is scoped to Android because `ScreenshotGuard` +/// returns early on web and iOS — overstating that would break the honesty rule +/// in PROJECT_CONSTITUTION.md §2.3, which outranks polish. +abstract final class HelpCatalog { + static const String whatIsNoSus = 'what-is-no-sus'; + static const String groups = 'groups'; + static const String roles = 'roles'; + static const String secureDocuments = 'secure-documents'; + static const String watermarking = 'watermarking'; + static const String burnNotes = 'burn-notes'; + static const String burnFiles = 'burn-files'; + static const String shareLinks = 'share-links'; + static const String auditLog = 'audit-log'; + static const String notifications = 'notifications'; + static const String account = 'account'; + static const String troubleshooting = 'troubleshooting'; + + static const List topics = [ + HelpTopic( + id: whatIsNoSus, + title: 'What NO SUS is', + summary: + 'A workspace for sharing study material with people you choose — and knowing what happened to it afterwards.', + icon: Icons.help_outline, + keywords: ['about', 'overview', 'start', 'what is this', 'purpose'], + sections: [ + HelpSection( + heading: 'The short version', + body: + 'You put documents somewhere only your group can reach them, you open them in a viewer that ' + 'discourages casual copying, and every open is written to a log the whole group can see. ' + 'The point is not that leaking becomes impossible — it is that it stops being anonymous.', + ), + HelpSection( + heading: 'Two halves of the app', + body: + 'Groups and documents need an account, because they are tied to who you are.\n\n' + 'Burn Notes, Burn Files and code redemption do not. Nobody signs in on either end of those — ' + 'not the sender, not the recipient.', + ), + HelpSection( + heading: 'Where things live', + body: + 'Workspace is your home: quick tools and your private scratchpad.\n' + 'Vault is every document shared with you across all your groups.\n' + 'Study Desk is the secure reader you open documents in.\n' + 'Activity is the audit log.\n' + 'Groups is where communities and their files live.', + ), + ], + ), + HelpTopic( + id: groups, + title: 'Groups and communities', + summary: + 'A group is a private space with its own members, files and activity log. You join by invite.', + icon: Icons.group_outlined, + keywords: ['community', 'join', 'invite', 'create group', 'leave'], + sections: [ + HelpSection( + heading: 'Joining', + body: + 'You need an invite link or an invite code. Opening an invite link shows you the group name ' + 'and how many members it has before you commit to anything.\n\n' + 'There is no directory to browse. If you were not invited, you cannot find or join a group.', + ), + HelpSection( + heading: 'Creating', + body: + 'Groups → the + button. You become the group admin. Turning on an invite code generates a ' + 'shareable 8-character code; leave it off to add people only through invite links you ' + 'generate yourself.', + ), + HelpSection( + heading: 'Leaving', + body: + 'Open the group → the menu in the top right → Leave group. You lose access to its files ' + 'immediately. Anything you uploaded stays with the group.', + ), + HelpSection( + heading: 'Global Community', + body: + 'Every new account joins one shared group called Global Community so the app is not empty on ' + 'day one. Treat it as public: assume anyone with a NO SUS account can read what you put there. ' + 'You can leave it like any other group.', + isCaution: true, + ), + ], + ), + HelpTopic( + id: roles, + title: 'Roles and permissions', + summary: + 'Groups have admins and members. Admins manage people and content; members read and contribute.', + icon: Icons.shield_outlined, + keywords: [ + 'admin', + 'member', + 'permission', + 'promote', + 'demote', + 'remove', + 'ban', + 'moderation', + ], + sections: [ + HelpSection( + heading: 'Members can', + body: + 'Read every file in the group, upload their own, rename and delete what they uploaded, and ' + 'see the full activity log.', + ), + HelpSection( + heading: 'Admins can also', + body: + 'Promote and demote other members, remove members, ban and unban people, delete anyone\'s ' + 'file, manage invite links, and delete the group.', + ), + HelpSection( + heading: 'How it is enforced', + body: + 'By the database, not by the app. Hiding a button is a convenience; the permission check runs ' + 'server-side on every write. An edited client gets a rejection, not a shortcut.', + ), + HelpSection( + heading: 'Banning', + body: + 'A ban removes someone and blocks them from rejoining with any invite. Unbanning lets them ' + 'accept an invite again — it does not re-add them automatically.', + ), + ], + ), + HelpTopic( + id: secureDocuments, + title: 'Secure documents', + summary: + 'Files shared into a group open in a reader built to make copying awkward and attributable.', + icon: Icons.description_outlined, + keywords: [ + 'file', + 'upload', + 'pdf', + 'vault', + 'study desk', + 'spyglass', + 'viewer', + ], + sections: [ + HelpSection( + heading: 'Uploading', + body: + 'Open a group → Files → the upload button. Everyone in that group can read it. There is no ' + 'per-file audience inside a group — the group is the audience.', + ), + HelpSection( + heading: 'Reading', + body: + 'Tap Reveal on any file to open it in the Study Desk. Documents are streamed into memory for ' + 'the session rather than saved to your device\'s file system.', + ), + HelpSection( + heading: 'Touch to reveal', + body: + 'Content sits behind a blur until you hold your finger on it. It is aimed at the person ' + 'standing behind you, not at a determined attacker. Turn it off in Settings → Privacy & ' + 'Security.', + ), + HelpSection( + heading: 'What this does not do', + body: + 'It does not encrypt files end-to-end — group documents are stored server-side and access is ' + 'enforced by database rules. If that is your threat model, use Burn Files instead: those are ' + 'encrypted on your device and the key never reaches the server.', + isCaution: true, + ), + ], + ), + HelpTopic( + id: watermarking, + title: 'Watermarks and screenshots', + summary: + 'Open documents carry your identity on-screen, and on Android the system screenshot is blocked.', + icon: Icons.branding_watermark_outlined, + keywords: ['watermark', 'screenshot', 'screen record', 'capture', 'leak'], + sections: [ + HelpSection( + heading: 'Watermarking', + body: + 'Your email and a timestamp are drawn over every page you open. A photograph of the screen ' + 'carries them too, which is the point: a leaked page identifies the session it came from.', + ), + HelpSection( + heading: 'Screenshot blocking is Android-only', + body: + 'On Android the app sets the system flag that blocks screenshots and screen recording, and ' + 'logs the attempt. On the web app and on iOS there is no such flag — nothing stops a ' + 'screenshot there, and the app does not pretend otherwise. The watermark and the audit log ' + 'are what protect you on those platforms.', + isCaution: true, + ), + HelpSection( + heading: 'A camera always wins', + body: + 'No app can stop someone photographing their own screen with another device. Every control ' + 'here is designed for attribution after the fact, not prevention.', + isCaution: true, + ), + ], + ), + HelpTopic( + id: burnNotes, + title: 'Burn Notes', + summary: + 'Send a secret that is destroyed the moment it is read. No account needed, on either end.', + icon: Icons.local_fire_department_outlined, + keywords: [ + 'secret', + 'self destruct', + 'one time', + 'password', + 'anonymous', + ], + sections: [ + HelpSection( + heading: 'How it works', + body: + 'Your note is encrypted on your device. Only the encrypted blob is uploaded — the key is put ' + 'in the link, after the # symbol, which browsers never send to a server. The first person to ' + 'open the link gets the note and the stored copy is deleted.', + ), + HelpSection( + heading: 'Sending the link safely', + body: + 'The link is the secret. Anyone who sees it can read the note, once. Send it over a different ' + 'channel than the thing it protects.', + ), + HelpSection( + heading: 'It really is one read', + body: + 'If a link preview service or an over-eager chat client fetches the link, it burns. The ' + 'recipient then sees "already read" — which is your signal that something opened it first.', + isCaution: true, + ), + ], + ), + HelpTopic( + id: burnFiles, + title: 'Burn Files', + summary: + 'The same idea as a Burn Note, for files. Encrypted on your device, deleted after one download.', + icon: Icons.upload_file_outlined, + keywords: [ + 'file', + 'anonymous', + 'upload', + 'self destruct', + 'send file', + 'size limit', + ], + sections: [ + HelpSection( + heading: 'How it works', + body: + 'Each file is encrypted on your device with its own key before anything is uploaded. The keys ' + 'ride in the link fragment and never reach the server. First download wins; the stored copy ' + 'is then deleted.', + ), + HelpSection( + heading: 'Limits', + body: + 'Up to 10 files and 25 MB combined per share. Larger uploads are rejected before anything ' + 'leaves your device, so a too-big file fails instantly rather than after a long upload.', + ), + HelpSection( + heading: 'Short codes', + body: + 'A single-file share can also generate a short code for someone to type in, for when a long ' + 'link is awkward. Multi-file shares are link-only.', + ), + ], + ), + HelpTopic( + id: shareLinks, + title: 'Share links and codes', + summary: + 'Send one group document to someone outside the group, and see when they opened it.', + icon: Icons.link_outlined, + keywords: [ + 'securesend', + 'external', + 'link', + 'redeem', + 'code', + 'analytics', + 'revoke', + ], + sections: [ + HelpSection( + heading: 'Sharing outward', + body: + 'A share link lets one person read one document without joining the group. They open it in a ' + 'watermarked viewer — they never get the file itself.', + ), + HelpSection( + heading: 'Seeing what happened', + body: + 'You get a view log for each link: when it was opened and from where. If a link is opened by ' + 'someone you did not send it to, that shows up.', + ), + HelpSection( + heading: 'Redeeming a code', + body: + 'Workspace → Redeem a Code. Codes are single-use and expire; once redeemed the content is ' + 'gone for everyone, including you.', + ), + ], + ), + HelpTopic( + id: auditLog, + title: 'Activity and the audit log', + summary: + 'Every meaningful action in a group is recorded, in order, visible to the whole group.', + icon: Icons.history_edu_outlined, + keywords: ['audit', 'log', 'activity', 'history', 'who opened', 'tamper'], + sections: [ + HelpSection( + heading: 'What is recorded', + body: + 'Joins and leaves, uploads, renames, deletions, document opens, screenshot attempts, and ' + 'moderation actions. Entries record who and when — never the contents of a document.', + ), + HelpSection( + heading: 'Why it is visible to everyone', + body: + 'Accountability only works if it is mutual. Members can see admins\' actions as readily as ' + 'admins see members\'. Nobody gets a private log.', + ), + HelpSection( + heading: 'Tamper evidence', + body: + 'Entries are chained together, so removing or editing one breaks the chain and is detectable. ' + 'The log is append-only — actions cannot be quietly retracted, including by admins.', + ), + ], + ), + HelpTopic( + id: notifications, + title: 'Notifications', + summary: + 'Get told when something you care about happens, without the details showing on your lock screen.', + icon: Icons.notifications_none_outlined, + keywords: [ + 'push', + 'alert', + 'permission', + 'mute', + 'lock screen', + 'privacy', + ], + sections: [ + HelpSection( + heading: 'What you can be notified about', + body: + 'Invitations and join requests, role changes, moderation actions affecting you, documents ' + 'shared into your groups, and security events on your account. Each category can be turned ' + 'off on its own in Settings → Notifications.', + ), + HelpSection( + heading: 'Nothing sensitive on the lock screen', + body: + 'Notification text names the group and the kind of event — never a document\'s name or ' + 'contents. Anyone glancing at your phone learns that something happened, not what.', + ), + HelpSection( + heading: 'If you said no to the permission', + body: + 'Android only asks once. Everything still appears in the in-app inbox, and Settings → ' + 'Notifications has a shortcut to the system settings page if you change your mind.', + ), + ], + ), + HelpTopic( + id: account, + title: 'Your account and your data', + summary: + 'What is stored about you, who can see it, and how to change or delete it.', + icon: Icons.person_outline, + keywords: [ + 'profile', + 'password', + 'delete account', + 'privacy', + 'email', + 'data', + ], + sections: [ + HelpSection( + heading: 'What is stored', + body: + 'Your email, display name and avatar choice. Documents you upload to groups. Audit entries ' + 'for actions you took. That is the account; there is no advertising profile behind it.', + ), + HelpSection( + heading: 'Who can see your email', + body: + 'Other members of groups you are in — the audit log names people by email, which is what ' + 'makes it useful. If that matters to you, be deliberate about which groups you join.', + isCaution: true, + ), + HelpSection( + heading: 'Deleting your account', + body: + 'Settings → Danger Zone → Delete account. It removes your profile and every group membership ' + 'permanently and cannot be undone. Audit entries recording actions you took stay, because the ' + 'log is append-only and other members rely on it.', + ), + ], + ), + HelpTopic( + id: troubleshooting, + title: 'When something goes wrong', + summary: 'The failures people actually hit, and what each one means.', + icon: Icons.build_outlined, + keywords: [ + 'error', + 'expired', + 'broken', + 'cannot open', + 'offline', + 'stuck', + 'failed', + ], + sections: [ + HelpSection( + heading: '"This link has already been opened"', + body: + 'A Burn Note or Burn File is genuinely gone — that is the feature working. Ask the sender for ' + 'a new one. If you never opened it, assume something else did and treat the contents as ' + 'exposed.', + ), + HelpSection( + heading: '"This invite has expired or been revoked"', + body: + 'Invite links can have an expiry date and a usage limit, and an admin can revoke one. Ask for ' + 'a fresh link.', + ), + HelpSection( + heading: 'A document will not open', + body: + 'Usually access was removed — you left the group, were removed from it, or the file was ' + 'deleted. Pull to refresh; if the file is gone from the list, it was deleted.', + ), + HelpSection( + heading: 'The app says it is offline', + body: + 'Groups, documents and the audit log need a connection. Burn Notes and Burn Files need one ' + 'too, to upload. Your private scratchpad keeps working, and syncs when you are back.', + ), + HelpSection( + heading: 'Signed out unexpectedly', + body: + 'If unusual security signals are detected on your account, the app asks you to sign in again. ' + 'That is a refresh, not a lockout — signing back in clears the flag if the risk has passed.', + ), + ], + ), + ]; + + static HelpTopic? byId(String id) { + for (final t in topics) { + if (t.id == id) return t; + } + return null; + } + + static List search(String query) => + topics.where((t) => t.matches(query)).toList(growable: false); +} diff --git a/app/lib/features/help/presentation/screens/help_screen.dart b/app/lib/features/help/presentation/screens/help_screen.dart new file mode 100644 index 0000000..f5b0897 --- /dev/null +++ b/app/lib/features/help/presentation/screens/help_screen.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../theme.dart'; +import '../../domain/help_topic.dart'; +import '../../../onboarding/presentation/providers/tour_providers.dart'; +import 'help_topic_screen.dart'; + +/// Searchable Help centre. +/// +/// Works signed out — it is reachable from the welcome surface, and a confused +/// user often cannot get past the thing they are confused by. Nothing here +/// reads Supabase. +class HelpScreen extends ConsumerStatefulWidget { + const HelpScreen({super.key}); + + @override + ConsumerState createState() => _HelpScreenState(); +} + +class _HelpScreenState extends ConsumerState { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _replayTour() async { + await ref.read(tourProgressProvider.notifier).resetAll(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Tips reset. They will appear again as you move around the app.', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.6); + final results = HelpCatalog.search(_query); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + 'HELP', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + fontSize: 13, + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s24, + vertical: NoSusTheme.s8, + ), + child: TextField( + controller: _searchController, + onChanged: (v) => setState(() => _query = v), + style: TextStyle(fontSize: 14, color: fg), + cursorColor: fg, + decoration: InputDecoration( + hintText: 'Search help', + hintStyle: TextStyle(fontSize: 14, color: subtle), + prefixIcon: Icon(Icons.search, size: 18, color: subtle), + suffixIcon: _query.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.close, size: 18), + tooltip: 'Clear search', + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ), + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + borderSide: BorderSide( + color: fg.withValues(alpha: 0.12), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + borderSide: BorderSide( + color: fg.withValues(alpha: 0.12), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + borderSide: BorderSide(color: fg), + ), + ), + ), + ), + Expanded( + child: results.isEmpty + ? _NoResults(query: _query) + : ListView.separated( + padding: const EdgeInsets.fromLTRB( + NoSusTheme.s24, + NoSusTheme.s8, + NoSusTheme.s24, + NoSusTheme.s32, + ), + physics: NoSusTheme.getScrollPhysics(context), + itemCount: results.length + 1, + separatorBuilder: (_, _) => + const SizedBox(height: NoSusTheme.s12), + itemBuilder: (context, i) { + if (i == results.length) { + return _ReplayTourTile(onTap: _replayTour); + } + return _TopicTile(topic: results[i]); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TopicTile extends StatelessWidget { + final HelpTopic topic; + const _TopicTile({required this.topic}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + + return Semantics( + button: true, + label: '${topic.title}. ${topic.summary}', + child: InkWell( + borderRadius: BorderRadius.circular(NoSusTheme.r16), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => HelpTopicScreen(topicId: topic.id)), + ), + child: Container( + padding: const EdgeInsets.all(NoSusTheme.s16), + decoration: NoSusTheme.cardDecoration(context), + child: ExcludeSemantics( + child: Row( + children: [ + Icon(topic.icon, size: 20, color: fg.withValues(alpha: 0.8)), + const SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + topic.title, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + const SizedBox(height: 3), + Text( + topic.summary, + style: theme.textTheme.bodyMedium?.copyWith( + color: fg.withValues(alpha: 0.55), + fontSize: 12, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(width: NoSusTheme.s8), + Icon( + Icons.chevron_right, + size: 20, + color: fg.withValues(alpha: 0.4), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ReplayTourTile extends StatelessWidget { + final VoidCallback onTap; + const _ReplayTourTile({required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + + return Padding( + padding: const EdgeInsets.only(top: NoSusTheme.s16), + child: OutlinedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.restart_alt, size: 16), + label: const Text( + 'SHOW THE IN-APP TIPS AGAIN', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + style: OutlinedButton.styleFrom( + foregroundColor: fg, + padding: const EdgeInsets.symmetric(vertical: 14), + side: BorderSide(color: fg.withValues(alpha: 0.25), width: 0.75), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + ), + ), + ); + } +} + +class _NoResults extends StatelessWidget { + final String query; + const _NoResults({required this.query}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtle = theme.colorScheme.onSurface.withValues(alpha: 0.5); + + return Center( + child: Padding( + padding: const EdgeInsets.all(NoSusTheme.s32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off_outlined, size: 40, color: subtle), + const SizedBox(height: NoSusTheme.s16), + Text( + 'Nothing matches "$query"', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: NoSusTheme.s8), + Text( + 'Try a shorter word — "invite", "burn", "watermark", "admin".', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: subtle, height: 1.6), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/help/presentation/screens/help_topic_screen.dart b/app/lib/features/help/presentation/screens/help_topic_screen.dart new file mode 100644 index 0000000..b4628fb --- /dev/null +++ b/app/lib/features/help/presentation/screens/help_topic_screen.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme.dart'; +import '../../domain/help_topic.dart'; + +/// A single help topic. Reached from the Help list or from a contextual +/// "What's this?" button next to the feature it explains. +class HelpTopicScreen extends StatelessWidget { + final String topicId; + + const HelpTopicScreen({super.key, required this.topicId}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + final topic = HelpCatalog.byId(topicId); + + if (topic == null) { + // Only reachable if a caller passes an id that no longer exists, i.e. a + // renamed topic. Fail visibly rather than showing an empty page. + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: const Text( + 'HELP', + style: TextStyle(fontSize: 13, letterSpacing: 2), + ), + centerTitle: true, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(NoSusTheme.s32), + child: Text( + 'That help topic is no longer available.', + textAlign: TextAlign.center, + style: TextStyle(color: fg.withValues(alpha: 0.6), fontSize: 13), + ), + ), + ), + ); + } + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + topic.title.toUpperCase(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + fontSize: 12, + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: ListView( + physics: NoSusTheme.getScrollPhysics(context), + padding: const EdgeInsets.fromLTRB( + NoSusTheme.s24, + NoSusTheme.s8, + NoSusTheme.s24, + NoSusTheme.s32, + ), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + topic.icon, + size: 26, + color: fg.withValues(alpha: 0.8), + ), + const SizedBox(width: NoSusTheme.s16), + Expanded( + child: Text( + topic.summary, + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 15, + height: 1.5, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: NoSusTheme.s24), + for (final section in topic.sections) ...[ + _SectionBlock(section: section), + const SizedBox(height: NoSusTheme.s16), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _SectionBlock extends StatelessWidget { + final HelpSection section; + const _SectionBlock({required this.section}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + final caution = section.isCaution; + // Amber rather than red: these are boundaries of the product's protection, + // which the user needs to read, not errors they need to panic about. + final accent = caution ? Colors.amber : fg; + + return Container( + padding: const EdgeInsets.all(NoSusTheme.s16), + decoration: caution + ? BoxDecoration( + color: accent.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(NoSusTheme.r16), + border: Border.all( + color: accent.withValues(alpha: 0.3), + width: 0.75, + ), + ) + : NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (caution) ...[ + Icon(Icons.info_outline, size: 14, color: accent), + const SizedBox(width: 6), + ], + Expanded( + child: Text( + section.heading, + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 11, + letterSpacing: 1.2, + fontWeight: FontWeight.w800, + color: caution ? accent : fg.withValues(alpha: 0.75), + ), + ), + ), + ], + ), + const SizedBox(height: NoSusTheme.s8), + Text( + section.body, + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 13, + height: 1.65, + color: fg.withValues(alpha: 0.8), + ), + ), + ], + ), + ); + } +} + +/// Small "?" affordance to drop next to a control whose purpose is not obvious +/// from its label. Opens the matching help topic rather than a bespoke tooltip, +/// so the explanation stays in one place. +class WhatsThisButton extends StatelessWidget { + final String topicId; + + /// Announced to screen readers, which cannot infer meaning from a "?" glyph. + final String semanticLabel; + + const WhatsThisButton({ + super.key, + required this.topicId, + required this.semanticLabel, + }); + + @override + Widget build(BuildContext context) { + final fg = Theme.of(context).colorScheme.onSurface; + return IconButton( + icon: Icon( + Icons.help_outline, + size: 16, + color: fg.withValues(alpha: 0.45), + ), + tooltip: semanticLabel, + visualDensity: VisualDensity.compact, + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => HelpTopicScreen(topicId: topicId)), + ), + ); + } +} diff --git a/app/lib/features/intelligence/data/disabled_document_intelligence.dart b/app/lib/features/intelligence/data/disabled_document_intelligence.dart new file mode 100644 index 0000000..665baef --- /dev/null +++ b/app/lib/features/intelligence/data/disabled_document_intelligence.dart @@ -0,0 +1,20 @@ +import '../domain/document_intelligence.dart'; + +class DisabledDocumentIntelligence implements DocumentIntelligence { + const DisabledDocumentIntelligence(); + + @override + bool get isAvailable => false; + + @override + String get providerLabel => 'Off'; + + @override + Future inspect({ + required String title, + required String mimeType, + String? plainText, + }) async { + throw StateError('Document intelligence is disabled in this build.'); + } +} diff --git a/app/lib/features/intelligence/data/gemini_document_intelligence.dart b/app/lib/features/intelligence/data/gemini_document_intelligence.dart new file mode 100644 index 0000000..c6e444b --- /dev/null +++ b/app/lib/features/intelligence/data/gemini_document_intelligence.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../../config/supabase_credentials.dart'; +import '../../../services/supabase_service.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../domain/document_intelligence.dart'; + +/// Gemini adapter. The API key stays on the server — this client only +/// calls the `document-intelligence` Edge Function with the user JWT. +class GeminiDocumentIntelligence implements DocumentIntelligence { + GeminiDocumentIntelligence({http.Client? httpClient}) + : _http = httpClient ?? http.Client(); + + final http.Client _http; + + @override + bool get isAvailable => + SupabaseCredentials.url.isNotEmpty && SupabaseCredentials.anonKey.isNotEmpty; + + @override + String get providerLabel => 'Gemini'; + + @override + Future inspect({ + required String title, + required String mimeType, + String? plainText, + }) async { + final uri = Uri.parse( + '${SupabaseCredentials.url}/functions/v1/document-intelligence', + ); + final session = SupabaseService.instance.isReachable + ? Supabase.instance.client.auth.currentSession + : null; + final bearer = session?.accessToken ?? SupabaseCredentials.anonKey; + final res = await _http.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'apikey': SupabaseCredentials.anonKey, + 'Authorization': 'Bearer $bearer', + }, + body: jsonEncode({ + 'title': title, + 'mime_type': mimeType, + 'text': plainText, + }), + ); + if (res.statusCode != 200) { + throw StateError('Insight request failed (${res.statusCode}).'); + } + final data = jsonDecode(res.body) as Map; + return DocumentInsight( + summary: data['summary'] as String? ?? '', + classification: data['classification'] as String? ?? 'Document', + highlights: List.from(data['highlights'] as List? ?? const []), + ); + } +} diff --git a/app/lib/features/intelligence/data/local_document_intelligence.dart b/app/lib/features/intelligence/data/local_document_intelligence.dart new file mode 100644 index 0000000..212f15f --- /dev/null +++ b/app/lib/features/intelligence/data/local_document_intelligence.dart @@ -0,0 +1,65 @@ +import '../domain/document_intelligence.dart'; + +/// Tiny on-device classifier. No model load, no network, no startup cost. +/// +/// Good enough for a local-AI competition lane without dragging a LLM into +/// the default product. Gemini is a separate adapter. +class LocalDocumentIntelligence implements DocumentIntelligence { + const LocalDocumentIntelligence(); + + @override + bool get isAvailable => true; + + @override + String get providerLabel => 'On-device'; + + @override + Future inspect({ + required String title, + required String mimeType, + String? plainText, + }) async { + final classification = _classify(title, mimeType, plainText); + final text = (plainText ?? '').trim(); + final summary = text.isEmpty + ? 'No extractable text. Classified as $classification from the file name and type.' + : _firstSentences(text, 2); + return DocumentInsight( + summary: summary, + classification: classification, + highlights: _keywords(text), + ); + } + + static String _classify(String title, String mimeType, String? text) { + final hay = '${title.toLowerCase()} ${mimeType.toLowerCase()} ${(text ?? '').toLowerCase()}'; + if (hay.contains('nda') || hay.contains('non-disclosure')) return 'Agreement'; + if (hay.contains('invoice') || hay.contains('receipt')) return 'Financial'; + if (hay.contains('resume') || hay.contains('cv')) return 'Identity'; + if (mimeType.contains('pdf')) return 'Document'; + if (mimeType.startsWith('image/')) return 'Image'; + return 'File'; + } + + static String _firstSentences(String text, int count) { + final parts = text.split(RegExp(r'(?<=[.!?])\s+')); + return parts.take(count).join(' ').trim(); + } + + static List _keywords(String text) { + if (text.isEmpty) return const []; + final words = text + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9\s]'), ' ') + .split(RegExp(r'\s+')) + .where((w) => w.length > 5) + .toList(); + final seen = {}; + final out = []; + for (final w in words) { + if (seen.add(w)) out.add(w); + if (out.length >= 5) break; + } + return out; + } +} diff --git a/app/lib/features/intelligence/domain/document_intelligence.dart b/app/lib/features/intelligence/domain/document_intelligence.dart new file mode 100644 index 0000000..2e4c5f9 --- /dev/null +++ b/app/lib/features/intelligence/domain/document_intelligence.dart @@ -0,0 +1,25 @@ +/// Provider-agnostic document insights. UI talks only to this interface. +abstract class DocumentIntelligence { + bool get isAvailable; + + /// Short label for settings/debug ("Off", "Gemini", "On-device"). + String get providerLabel; + + Future inspect({ + required String title, + required String mimeType, + String? plainText, + }); +} + +class DocumentInsight { + final String summary; + final String classification; + final List highlights; + + const DocumentInsight({ + required this.summary, + required this.classification, + this.highlights = const [], + }); +} diff --git a/app/lib/features/intelligence/intelligence.dart b/app/lib/features/intelligence/intelligence.dart new file mode 100644 index 0000000..b5c7fe7 --- /dev/null +++ b/app/lib/features/intelligence/intelligence.dart @@ -0,0 +1,14 @@ +import '../../config/intelligence_config.dart'; +import 'data/disabled_document_intelligence.dart'; +import 'data/gemini_document_intelligence.dart'; +import 'data/local_document_intelligence.dart'; +import 'domain/document_intelligence.dart'; + +export 'domain/document_intelligence.dart'; + +/// Single factory for the optional intelligence layer. +DocumentIntelligence createDocumentIntelligence() { + if (IntelligenceConfig.useGemini) return GeminiDocumentIntelligence(); + if (IntelligenceConfig.useLocal) return const LocalDocumentIntelligence(); + return const DisabledDocumentIntelligence(); +} diff --git a/app/lib/features/intelligence/presentation/intelligence_providers.dart b/app/lib/features/intelligence/presentation/intelligence_providers.dart new file mode 100644 index 0000000..a0760c6 --- /dev/null +++ b/app/lib/features/intelligence/presentation/intelligence_providers.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../config/intelligence_config.dart'; +import '../data/disabled_document_intelligence.dart'; +import '../data/gemini_document_intelligence.dart'; +import '../data/local_document_intelligence.dart'; +import '../domain/document_intelligence.dart'; + +final documentIntelligenceProvider = Provider((ref) { + switch (IntelligenceConfig.provider) { + case 'gemini': + return GeminiDocumentIntelligence(); + case 'local': + return const LocalDocumentIntelligence(); + case 'disabled': + default: + return const DisabledDocumentIntelligence(); + } +}); diff --git a/app/lib/features/notes/providers/notes_provider.dart b/app/lib/features/notes/providers/notes_provider.dart new file mode 100644 index 0000000..eb8818b --- /dev/null +++ b/app/lib/features/notes/providers/notes_provider.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; +import '../../../services/supabase_service.dart'; + +class NoteState { + final String content; + final bool isSaving; + const NoteState({required this.content, required this.isSaving}); +} + +class UserNoteNotifier extends Notifier { + Timer? _debounceTimer; + + @override + NoteState build() { + final user = ref.watch(authStateProvider).value; + + ref.onDispose(() { + _debounceTimer?.cancel(); + if (user != null && state.content.isNotEmpty) { + SupabaseService.instance.saveUserNote(user.id, state.content); + } + }); + + if (user == null) { + return const NoteState(content: '', isSaving: false); + } + + // Auto-load note asynchronously for the signed-in user + _loadNote(user.id); + + return const NoteState(content: '', isSaving: false); + } + + Future _loadNote(String userId) async { + try { + final content = await SupabaseService.instance.fetchUserNote(userId); + if (!ref.mounted) return; + state = NoteState(content: content, isSaving: false); + } catch (_) { + if (!ref.mounted) return; + state = const NoteState(content: '', isSaving: false); + } + } + + void loadNote(String userId) { + _loadNote(userId); + } + + void updateNote(String newContent) { + final user = ref.read(authStateProvider).value; + if (user == null) return; + + state = NoteState(content: newContent, isSaving: true); + + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(seconds: 1), () async { + await SupabaseService.instance.saveUserNote(user.id, newContent); + if (!ref.mounted) return; + state = NoteState(content: newContent, isSaving: false); + }); + } +} + +final userNoteProvider = NotifierProvider( + UserNoteNotifier.new, +); diff --git a/app/lib/features/notifications/data/notification_repository.dart b/app/lib/features/notifications/data/notification_repository.dart new file mode 100644 index 0000000..0b97df2 --- /dev/null +++ b/app/lib/features/notifications/data/notification_repository.dart @@ -0,0 +1,119 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../core/utils/debug_logger.dart'; +import '../../../services/supabase_service.dart'; +import '../domain/app_notification.dart'; + +/// Reads and writes the notification inbox and its preferences. +/// +/// Everything here is a plain table read or an RPC — no push provider involved. +/// That separation is what lets the inbox keep working when FCM is not +/// configured, which is its state today. +class NotificationRepository { + final SupabaseClient _client; + + const NotificationRepository(this._client); + + bool get _isLive => + SupabaseService.instance.isConfigured && + SupabaseService.instance.isReachable && + _client.auth.currentUser != null; + + /// Live inbox for the signed-in user, newest first. + /// + /// A realtime stream rather than a poll: a notification that shows up two + /// minutes late has usually been overtaken by the thing it was announcing. + /// The `.eq` filter is defence in depth — RLS already restricts these rows to + /// their owner. + Stream> watchInbox() { + if (!_isLive) return Stream.value(const []); + + final userId = _client.auth.currentUser!.id; + return _client + .from('notifications') + .stream(primaryKey: ['id']) + .eq('user_id', userId) + .order('created_at', ascending: false) + .limit(100) + .map((rows) => rows.map(AppNotification.fromRow).toList()) + .handleError((Object e) { + debugLog('NO SUS: Notification stream error: $e'); + }); + } + + Future> fetchInbox() async { + if (!_isLive) return const []; + try { + final rows = await _client + .from('notifications') + .select() + .order('created_at', ascending: false) + .limit(100); + return (rows as List) + .map((r) => AppNotification.fromRow(r as Map)) + .toList(); + } catch (e) { + debugLog('NO SUS: fetchInbox failed: $e'); + return const []; + } + } + + /// Marks [ids] read, or the whole inbox when [ids] is null. + /// + /// Goes through the RPC rather than an UPDATE because `notifications` has no + /// UPDATE policy at all — clients must not be able to touch `pushed_at` and + /// make the delivery sweep skip a row. + Future markRead({List? ids}) async { + if (!_isLive) return; + try { + await _client.rpc('mark_notifications_read', params: {'p_ids': ids}); + } catch (e) { + debugLog('NO SUS: markRead failed: $e'); + } + } + + Future delete(String id) async { + if (!_isLive) return; + try { + await _client.from('notifications').delete().eq('id', id); + } catch (e) { + debugLog('NO SUS: notification delete failed: $e'); + } + } + + /// Current per-category preferences. + /// + /// Falls back to all-on when no row exists — the same assumption + /// `enqueue_notification` makes, so the UI never shows a state the server + /// disagrees with. + Future fetchPreferences() async { + if (!_isLive) return NotificationPreferences.allOn; + try { + final row = await _client + .from('notification_preferences') + .select() + .eq('user_id', _client.auth.currentUser!.id) + .maybeSingle(); + if (row == null) return NotificationPreferences.allOn; + return NotificationPreferences.fromRow(row); + } catch (e) { + debugLog('NO SUS: fetchPreferences failed: $e'); + return NotificationPreferences.allOn; + } + } + + /// Persists the full preference set. + /// + /// Upsert rather than update: the row is created lazily by + /// `register_device_token`, so a user who never granted the push permission + /// has no row to update — and their in-app inbox preferences still need to + /// stick. Throws so the caller can roll its optimistic UI back. + Future savePreferences(NotificationPreferences prefs) async { + if (!_isLive) return; + await _client.from('notification_preferences').upsert({ + 'user_id': _client.auth.currentUser!.id, + ...prefs.toColumns(), + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }); + } +} diff --git a/app/lib/features/notifications/data/push_service.dart b/app/lib/features/notifications/data/push_service.dart new file mode 100644 index 0000000..c3ad138 --- /dev/null +++ b/app/lib/features/notifications/data/push_service.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../core/utils/debug_logger.dart'; +import '../../../services/supabase_service.dart'; + +/// Push token lifecycle: acquire, register, refresh, release. +/// +/// **This has never been exercised against a real Firebase project.** There is +/// no `google-services.json` in the repo, so `Firebase.initializeApp()` throws +/// and [initialize] returns false, leaving every method here a no-op. The +/// in-app inbox works regardless — it reads the same `notifications` rows +/// straight from Postgres — so the product degrades to "notifications you see +/// when you open the app" rather than breaking. +/// +/// Written this way deliberately: NO SUS already carries one never-exercised +/// security scaffold (Play Integrity, AGENTS.md §8), and the lesson from it is +/// that a scaffold must fail loudly closed and never pretend to work. Nothing +/// here reports success it did not achieve. +/// +/// To provision: create the Firebase project, drop `google-services.json` into +/// `android/app/`, and set FCM_SERVICE_ACCOUNT_EMAIL / FCM_PRIVATE_KEY / +/// FCM_PROJECT_ID / PUSH_SWEEP_SECRET as Supabase secrets. No code change. +class PushService { + PushService._(); + static final PushService instance = PushService._(); + + bool _initialized = false; + bool _available = false; + String? _token; + StreamSubscription? _refreshSub; + + /// True only when Firebase initialised *and* a token was obtained. + bool get isAvailable => _available; + + /// The token currently registered server-side, if any. + String? get token => _token; + + String get _platform { + if (kIsWeb) return 'web'; + return defaultTargetPlatform == TargetPlatform.iOS ? 'ios' : 'android'; + } + + /// Brings up Firebase if it is configured. Safe to call repeatedly. + /// + /// Does **not** request the notification permission — that is a separate, + /// contextual decision made by the UI (see NotificationPermissionPrompt). + /// Initialising and asking are deliberately decoupled so the permission + /// dialog never fires as a side effect of app start. + Future initialize() async { + if (_initialized) return _available; + _initialized = true; + + try { + await Firebase.initializeApp(); + _available = true; + debugLog('NO SUS: Firebase initialised — push transport available.'); + } catch (e) { + // Overwhelmingly the expected path today: no Firebase config present. + _available = false; + debugLog('NO SUS: Push unavailable (Firebase not configured): $e'); + return false; + } + + // Tokens rotate — on app restore, reinstall, or at Google's discretion. A + // rotated token that is not re-registered is a device that silently stops + // receiving anything, which looks exactly like "notifications are broken". + _refreshSub?.cancel(); + _refreshSub = FirebaseMessaging.instance.onTokenRefresh.listen( + (fresh) async { + _token = fresh; + await _registerToken(fresh); + }, + onError: (Object e) => debugLog('NO SUS: Token refresh stream error: $e'), + ); + + return true; + } + + /// Whether the OS will currently deliver notifications to this app. + Future hasPermission() async { + if (!_available) return false; + try { + final settings = await FirebaseMessaging.instance + .getNotificationSettings(); + return settings.authorizationStatus == AuthorizationStatus.authorized || + settings.authorizationStatus == AuthorizationStatus.provisional; + } catch (_) { + return false; + } + } + + /// Shows the system permission dialog, then registers a token if granted. + /// + /// Android only ever shows this once; a second call after a denial returns + /// the previous answer without any UI. That is why callers must explain the + /// value *before* getting here — there is no second chance to ask well. + Future requestPermissionAndRegister() async { + if (!await initialize()) return false; + + try { + final settings = await FirebaseMessaging.instance.requestPermission( + alert: true, + badge: true, + sound: true, + ); + + final granted = + settings.authorizationStatus == AuthorizationStatus.authorized || + settings.authorizationStatus == AuthorizationStatus.provisional; + if (!granted) { + debugLog( + 'NO SUS: Notification permission not granted ' + '(${settings.authorizationStatus}).', + ); + return false; + } + + return await registerCurrentDevice(); + } catch (e) { + debugLog('NO SUS: Permission request failed: $e'); + return false; + } + } + + /// Fetches this device's token and records it against the signed-in account. + Future registerCurrentDevice() async { + if (!_available) return false; + if (!SupabaseService.instance.isConfigured || + !SupabaseService.instance.isReachable) { + return false; + } + if (Supabase.instance.client.auth.currentUser == null) return false; + + try { + final token = await FirebaseMessaging.instance.getToken(); + if (token == null || token.isEmpty) return false; + _token = token; + return await _registerToken(token); + } catch (e) { + debugLog('NO SUS: Could not obtain push token: $e'); + return false; + } + } + + Future _registerToken(String token) async { + try { + await Supabase.instance.client.rpc( + 'register_device_token', + params: {'p_token': token, 'p_platform': _platform}, + ); + return true; + } catch (e) { + debugLog('NO SUS: Device token registration failed: $e'); + return false; + } + } + + /// Releases this device's token on sign-out. + /// + /// Must run *before* the session is torn down: `unregister_device_token` is + /// scoped by `auth.uid()`, so after sign-out the delete matches nothing and + /// the next person to sign in on this handset would keep receiving the + /// previous account's notifications until their own registration overwrote + /// the row. + Future releaseCurrentDevice() async { + final token = _token; + _token = null; + if (token == null || !_available) return; + if (!SupabaseService.instance.isConfigured || + !SupabaseService.instance.isReachable) { + return; + } + + try { + await Supabase.instance.client.rpc( + 'unregister_device_token', + params: {'p_token': token}, + ); + } catch (e) { + debugLog('NO SUS: Device token release failed: $e'); + } + } + + /// Foreground messages. Background/terminated delivery is handled by the OS. + Stream get onForegroundMessage => + _available ? FirebaseMessaging.onMessage : const Stream.empty(); + + /// Fires when a notification is tapped and the app was already running. + Stream get onNotificationTap => + _available ? FirebaseMessaging.onMessageOpenedApp : const Stream.empty(); + + /// The notification that launched the app from terminated, if any. + Future initialMessage() async { + if (!_available) return null; + try { + return await FirebaseMessaging.instance.getInitialMessage(); + } catch (_) { + return null; + } + } + + @visibleForTesting + void disposeForTest() { + _refreshSub?.cancel(); + _refreshSub = null; + _initialized = false; + _available = false; + _token = null; + } +} diff --git a/app/lib/features/notifications/domain/app_notification.dart b/app/lib/features/notifications/domain/app_notification.dart new file mode 100644 index 0000000..664fe58 --- /dev/null +++ b/app/lib/features/notifications/domain/app_notification.dart @@ -0,0 +1,142 @@ +/// The notification categories a user can switch independently. +/// +/// Mirrors the CHECK constraint on `notifications.category` and the boolean +/// columns on `notification_preferences` — see +/// supabase/migrations/20260730112523_notifications.sql. The wire name is the +/// column name; changing one without the other silently stops delivery. +enum NotificationCategory { + invites( + 'invites', + 'Invites & requests', + 'Someone invited you to a group, or asked to join one you administer.', + ), + membership( + 'membership', + 'Membership & roles', + 'You were added or removed from a group, or your role changed.', + ), + documents( + 'documents', + 'Document activity', + 'New documents shared in groups you belong to.', + ), + security( + 'security', + 'Security alerts', + 'Access revoked, unusual sign-ins, and integrity warnings.', + ); + + final String wireName; + final String label; + final String description; + + const NotificationCategory(this.wireName, this.label, this.description); + + static NotificationCategory? fromWire(String? name) { + for (final c in NotificationCategory.values) { + if (c.wireName == name) return c; + } + return null; + } + + /// Security alerts are the ones a user would still want after muting + /// everything else, so the UI warns before switching them off rather than + /// treating every category as interchangeable. + bool get isCritical => this == NotificationCategory.security; +} + +class AppNotification { + final String id; + final NotificationCategory category; + final String title; + final String body; + + /// In-app route: `group:`, `groups`, or `audit`. Never a URL, and never + /// carries key material — see NotificationRouter. + final String? deepLink; + final String? groupId; + final DateTime createdAt; + final DateTime? readAt; + + const AppNotification({ + required this.id, + required this.category, + required this.title, + required this.body, + required this.createdAt, + this.deepLink, + this.groupId, + this.readAt, + }); + + bool get isUnread => readAt == null; + + factory AppNotification.fromRow(Map row) { + return AppNotification( + id: row['id'] as String, + category: + NotificationCategory.fromWire(row['category'] as String?) ?? + NotificationCategory.membership, + title: row['title'] as String? ?? 'Notification', + body: row['body'] as String? ?? '', + deepLink: row['deep_link'] as String?, + groupId: row['group_id'] as String?, + createdAt: + DateTime.tryParse(row['created_at']?.toString() ?? '')?.toLocal() ?? + DateTime.fromMillisecondsSinceEpoch(0), + readAt: DateTime.tryParse(row['read_at']?.toString() ?? '')?.toLocal(), + ); + } + + AppNotification copyWith({DateTime? readAt}) => AppNotification( + id: id, + category: category, + title: title, + body: body, + deepLink: deepLink, + groupId: groupId, + createdAt: createdAt, + readAt: readAt ?? this.readAt, + ); + + @override + bool operator ==(Object other) => + other is AppNotification && other.id == id && other.readAt == readAt; + + @override + int get hashCode => Object.hash(id, readAt); +} + +/// Per-category on/off state for the signed-in account. +class NotificationPreferences { + final Map enabled; + + const NotificationPreferences(this.enabled); + + /// Matches the column defaults, and is what the enqueue function assumes when + /// no preferences row exists yet. + static const NotificationPreferences allOn = NotificationPreferences({ + NotificationCategory.invites: true, + NotificationCategory.membership: true, + NotificationCategory.documents: true, + NotificationCategory.security: true, + }); + + bool isEnabled(NotificationCategory category) => enabled[category] ?? true; + + factory NotificationPreferences.fromRow(Map row) { + return NotificationPreferences({ + for (final c in NotificationCategory.values) + c: row[c.wireName] as bool? ?? true, + }); + } + + NotificationPreferences withCategory( + NotificationCategory category, + bool value, + ) => NotificationPreferences({...enabled, category: value}); + + Map toColumns() => { + for (final entry in enabled.entries) entry.key.wireName: entry.value, + }; +} diff --git a/app/lib/features/notifications/presentation/notification_router.dart b/app/lib/features/notifications/presentation/notification_router.dart new file mode 100644 index 0000000..ecbfcb9 --- /dev/null +++ b/app/lib/features/notifications/presentation/notification_router.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../main.dart' show activeTabProvider; +import '../../groups/domain/models/study_group.dart'; +import '../../groups/providers/groups_provider.dart'; +import '../../groups/screens/group_detail_screen.dart'; + +/// Turns a notification's `deep_link` into navigation. +/// +/// Deep links are opaque in-app routes (`group:`, `groups`, `audit`) rather +/// than URLs. Notifications travel through a third-party push provider and land +/// on lock screens, so nothing here may carry a token, key or file name — the +/// id is resolved against the database, behind RLS, after the app is open. +/// +/// Every path handles the resource having gone away. A notification outlives +/// what it points at more often than not: groups get deleted, access gets +/// revoked, and "you were removed from a group" is *by definition* a link to +/// something the recipient can no longer open. +abstract final class NotificationRouter { + static Future open( + BuildContext context, + WidgetRef ref, + String? deepLink, + ) async { + if (deepLink == null || deepLink.isEmpty) return; + + if (deepLink == 'groups') { + ref.read(activeTabProvider.notifier).changeTab(4); + return; + } + + if (deepLink == 'audit') { + ref.read(activeTabProvider.notifier).changeTab(3); + return; + } + + if (deepLink.startsWith('group:')) { + final groupId = deepLink.substring('group:'.length); + if (groupId.isEmpty) return; + + // Resolve from the already-loaded list rather than fetching: if the group + // is not in it, the user is not a member any more, which is exactly the + // case that must not open a detail screen. + final groups = ref.read(groupsProvider).value ?? const []; + StudyGroup? match; + for (final g in groups) { + if (g.id == groupId) { + match = g; + break; + } + } + + if (match == null) { + _showGone( + context, + 'That group is no longer available to you. It may have been deleted, ' + 'or your access may have been removed.', + ); + ref.read(activeTabProvider.notifier).changeTab(4); + return; + } + + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => GroupDetailScreen(group: match!)), + ); + return; + } + + // Unknown scheme: a notification from a newer build than this client. Land + // somewhere sensible instead of doing nothing, which reads as a broken tap. + ref.read(activeTabProvider.notifier).changeTab(0); + } + + static void _showGone(BuildContext context, String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 5), + ), + ); + } +} diff --git a/app/lib/features/notifications/presentation/providers/notification_providers.dart b/app/lib/features/notifications/presentation/providers/notification_providers.dart new file mode 100644 index 0000000..6f7743f --- /dev/null +++ b/app/lib/features/notifications/presentation/providers/notification_providers.dart @@ -0,0 +1,82 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../../core/utils/debug_logger.dart'; +import '../../../../services/supabase_service.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../data/notification_repository.dart'; +import '../../domain/app_notification.dart'; + +final notificationRepositoryProvider = Provider((ref) { + // Mock fallback mode has no Supabase client at all; touching + // Supabase.instance there throws and would take the whole tree down. The + // repository's own _isLive check then makes every call a no-op. + final client = SupabaseService.instance.isConfigured + ? Supabase.instance.client + : null; + if (client == null) { + throw StateError('Notifications require a configured Supabase client'); + } + return NotificationRepository(client); +}); + +/// The inbox. Re-subscribes on sign-in/sign-out so one account never sees +/// another's notifications after a session change on a shared device. +final notificationInboxProvider = StreamProvider>((ref) { + final user = ref.watch(authStateProvider).value; + if (user == null || !SupabaseService.instance.isConfigured) { + return Stream.value(const []); + } + return ref.watch(notificationRepositoryProvider).watchInbox(); +}); + +/// Unread count for the header badge. +/// +/// Derived with `.select` so the badge rebuilds only when the number actually +/// changes — not on every realtime emission of an unchanged list. +final unreadNotificationCountProvider = Provider((ref) { + return ref.watch( + notificationInboxProvider.select( + (async) => async.value?.where((n) => n.isUnread).length ?? 0, + ), + ); +}); + +/// Per-category preferences, with optimistic writes. +class NotificationPreferencesNotifier + extends AsyncNotifier { + @override + Future build() async { + final user = ref.watch(authStateProvider).value; + if (user == null || !SupabaseService.instance.isConfigured) { + return NotificationPreferences.allOn; + } + return ref.read(notificationRepositoryProvider).fetchPreferences(); + } + + /// Flips one category. + /// + /// Optimistic, then reverted on failure. A switch that visibly springs back + /// is a clearer signal that nothing was saved than a switch that stays put + /// while the server disagrees — this setting decides whether someone hears + /// about a security event, so silently diverging is the worst outcome. + Future setCategory(NotificationCategory category, bool enabled) async { + final current = state.value ?? NotificationPreferences.allOn; + final updated = current.withCategory(category, enabled); + state = AsyncValue.data(updated); + + try { + await ref.read(notificationRepositoryProvider).savePreferences(updated); + } catch (e) { + debugLog('NO SUS: Could not save notification preferences: $e'); + state = AsyncValue.data(current); + rethrow; + } + } +} + +final notificationPreferencesProvider = + AsyncNotifierProvider< + NotificationPreferencesNotifier, + NotificationPreferences + >(NotificationPreferencesNotifier.new); diff --git a/app/lib/features/notifications/presentation/screens/notification_inbox_screen.dart b/app/lib/features/notifications/presentation/screens/notification_inbox_screen.dart new file mode 100644 index 0000000..fd65603 --- /dev/null +++ b/app/lib/features/notifications/presentation/screens/notification_inbox_screen.dart @@ -0,0 +1,326 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../components/async_state_view.dart'; +import '../../../../components/shimmer_box.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; +import '../../../../theme.dart'; +import '../../../help/domain/help_topic.dart'; +import '../../../help/presentation/screens/help_topic_screen.dart'; +import '../../domain/app_notification.dart'; +import '../notification_router.dart'; +import '../providers/notification_providers.dart'; + +/// The in-app notification inbox. +/// +/// Reads the same `notifications` rows push delivery would send, so it is the +/// complete record whether or not FCM is configured — which today it is not. +/// Someone who declined the OS permission, or is on the web build, loses +/// nothing except the interruption. +class NotificationInboxScreen extends ConsumerWidget { + const NotificationInboxScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.6); + final inbox = ref.watch(notificationInboxProvider); + final unread = ref.watch(unreadNotificationCountProvider); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + 'NOTIFICATIONS', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + fontSize: 13, + ), + ), + centerTitle: true, + actions: [ + if (unread > 0) + TextButton( + onPressed: () { + HapticFeedback.lightImpact(); + ref.read(notificationRepositoryProvider).markRead(); + }, + child: Text( + 'MARK ALL READ', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: subtle, + ), + ), + ), + ], + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: AsyncStateView>( + value: inbox, + onRetry: () => ref.invalidate(notificationInboxProvider), + errorMessage: 'Could not load your notifications', + loading: (context) => Padding( + padding: const EdgeInsets.all(NoSusTheme.s24), + child: ShimmerListSkeleton( + spacing: NoSusTheme.s12, + itemBuilder: (context) => const _NotificationSkeleton(), + ), + ), + isEmpty: (items) => items.isEmpty, + empty: (context) => const _EmptyInbox(), + data: (context, items) => ListView.separated( + physics: NoSusTheme.getScrollPhysics(context), + padding: const EdgeInsets.all(NoSusTheme.s24), + itemCount: items.length, + separatorBuilder: (_, _) => + const SizedBox(height: NoSusTheme.s12), + itemBuilder: (context, i) => + _NotificationRow(notification: items[i], index: i), + ), + ), + ), + ), + ), + ); + } +} + +class _NotificationRow extends ConsumerWidget { + final AppNotification notification; + final int index; + + const _NotificationRow({required this.notification, required this.index}); + + IconData get _icon => switch (notification.category) { + NotificationCategory.invites => Icons.mail_outline, + NotificationCategory.membership => Icons.group_outlined, + NotificationCategory.documents => Icons.description_outlined, + NotificationCategory.security => Icons.gpp_maybe_outlined, + }; + + String _relativeTime() { + final diff = DateTime.now().difference(notification.createdAt); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + final d = notification.createdAt; + return '${d.day}/${d.month}/${d.year}'; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.6); + final isUnread = notification.isUnread; + + return Dismissible( + key: ValueKey(notification.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: Colors.redAccent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(NoSusTheme.r16), + ), + child: const Icon( + Icons.delete_outline, + color: Colors.redAccent, + size: 20, + ), + ), + onDismissed: (_) { + ref.read(notificationRepositoryProvider).delete(notification.id); + }, + child: Semantics( + button: true, + label: + '${notification.title}. ${notification.body}. ' + '${_relativeTime()}.${isUnread ? ' Unread.' : ''}', + child: InkWell( + borderRadius: BorderRadius.circular(NoSusTheme.r16), + onTap: () async { + HapticFeedback.selectionClick(); + // Read first: if the destination is gone, the user has still seen + // it, and leaving it unread would make the badge unclearable. + await ref + .read(notificationRepositoryProvider) + .markRead(ids: [notification.id]); + if (!context.mounted) return; + await NotificationRouter.open( + context, + ref, + notification.deepLink, + ); + }, + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: isUnread ? fg.withValues(alpha: 0.04) : null, + borderRadius: BorderRadius.circular(NoSusTheme.r16), + border: Border.all( + color: fg.withValues(alpha: isUnread ? 0.18 : 0.08), + width: 0.75, + ), + ), + child: ExcludeSemantics( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_icon, size: 18, color: fg.withValues(alpha: 0.75)), + const SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + notification.title, + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 13.5, + fontWeight: isUnread + ? FontWeight.w800 + : FontWeight.w600, + ), + ), + ), + // Unread is carried by weight and a dot, not by + // colour alone. + if (isUnread) + Container( + width: 7, + height: 7, + margin: const EdgeInsets.only(left: 8), + decoration: BoxDecoration( + color: fg, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + notification.body, + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + fontSize: 12.5, + height: 1.4, + ), + ), + const SizedBox(height: 6), + Text( + _relativeTime().toUpperCase(), + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 9, + letterSpacing: 1.0, + color: subtle.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ) + .animate(delay: (index.clamp(0, 8) * 40).ms) + .fadeIn(duration: 220.ms) + .slideY(begin: 0.03, end: 0); + } +} + +/// Empty state as an explanation, not a dead end — it says what would appear +/// here and where the switches are. +class _EmptyInbox extends StatelessWidget { + const _EmptyInbox(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtle = theme.colorScheme.onSurface.withValues(alpha: 0.55); + + return Center( + child: Padding( + padding: const EdgeInsets.all(NoSusTheme.s32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MascotView( + character: MascotCharacter.lux, + size: 48, + fallback: Icon(Icons.notifications_none, size: 48, color: subtle), + ), + const SizedBox(height: NoSusTheme.s24), + Text( + 'Nothing yet', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: NoSusTheme.s8), + Text( + 'This is where you will hear about invites, changes to your ' + 'group membership, new documents, and security alerts.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + fontSize: 13, + height: 1.6, + ), + ), + const SizedBox(height: NoSusTheme.s16), + const WhatsThisButton( + topicId: HelpCatalog.notifications, + semanticLabel: 'What will I be notified about?', + ), + ], + ), + ), + ).animate().fadeIn(duration: 350.ms); + } +} + +class _NotificationSkeleton extends StatelessWidget { + const _NotificationSkeleton(); + + @override + Widget build(BuildContext context) { + return const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerBox(width: 18, height: 18, radius: 4), + SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerBox(width: 140, height: 13), + SizedBox(height: 8), + ShimmerBox(width: 220, height: 11), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/features/notifications/presentation/widgets/notification_permission_prompt.dart b/app/lib/features/notifications/presentation/widgets/notification_permission_prompt.dart new file mode 100644 index 0000000..b09ab97 --- /dev/null +++ b/app/lib/features/notifications/presentation/widgets/notification_permission_prompt.dart @@ -0,0 +1,245 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/providers/theme_provider.dart'; +import '../../../../core/utils/debug_logger.dart'; +import '../../../../theme.dart'; +import '../../../analytics/data/analytics_service.dart'; +import '../../data/push_service.dart'; + +/// Tracks whether this device has already been asked about notifications. +/// +/// Android shows the POST_NOTIFICATIONS dialog exactly once. Every later +/// request returns the previous answer with no UI at all, so the system prompt +/// is a single non-renewable resource: spend it at launch, before the user +/// knows what the app does, and a "no" is permanent and uninformed. +/// +/// Persisted so the in-app explainer is not re-shown across restarts either — +/// nagging is how an app teaches people to dismiss it reflexively. +class NotificationPrimingNotifier extends Notifier { + static const _key = 'nosus_notification_primed'; + + @override + bool build() { + try { + return ref.watch(sharedPreferencesProvider).getBool(_key) ?? false; + } catch (_) { + return false; + } + } + + Future markPrimed() async { + if (state) return; + state = true; + try { + await ref.read(sharedPreferencesProvider).setBool(_key, true); + } catch (_) {} + } + + @visibleForTesting + Future reset() async { + state = false; + try { + await ref.read(sharedPreferencesProvider).remove(_key); + } catch (_) {} + } +} + +final notificationPrimingProvider = + NotifierProvider( + NotificationPrimingNotifier.new, + ); + +/// Asks for the notification permission, in context, at most once. +/// +/// Call this *after* the user does something whose follow-up they would want to +/// know about — joining a group, uploading a first document — not on first +/// launch. The explainer runs first so the system dialog is never the first +/// time the question is posed, and declining the explainer never spends the +/// OS-level prompt. +/// +/// Returns true only if permission ended up granted. +Future maybePrimeNotifications( + BuildContext context, + WidgetRef ref, { + required String reason, +}) async { + // Android is the only platform this ships to that has a runtime notification + // permission and a configured push path. + if (kIsWeb) return false; + if (ref.read(notificationPrimingProvider)) return false; + + // If the transport is not configured there is nothing to grant, and burning + // the one-shot system dialog for a capability that cannot deliver would be + // straightforwardly user-hostile. + if (!await PushService.instance.initialize()) { + debugLog('NO SUS: Skipping notification priming — push not configured.'); + return false; + } + if (await PushService.instance.hasPermission()) { + await ref.read(notificationPrimingProvider.notifier).markPrimed(); + return true; + } + + if (!context.mounted) return false; + + AnalyticsService.instance.log(AnalyticsEvent.notificationPermissionPrompted); + + final wantsIt = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) => _PrimingSheet(reason: reason), + ); + + // Mark primed either way. "Not now" is an answer, and re-asking on the next + // group join would be exactly the nagging this is designed to avoid — the + // switch in Settings is the way back in. + await ref.read(notificationPrimingProvider.notifier).markPrimed(); + + if (wantsIt != true) return false; + + final granted = await PushService.instance.requestPermissionAndRegister(); + AnalyticsService.instance.log( + granted + ? AnalyticsEvent.notificationPermissionGranted + : AnalyticsEvent.notificationPermissionDenied, + ); + return granted; +} + +class _PrimingSheet extends StatelessWidget { + final String reason; + const _PrimingSheet({required this.reason}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return Container( + padding: EdgeInsets.only( + left: NoSusTheme.s24, + right: NoSusTheme.s24, + top: NoSusTheme.s24, + bottom: MediaQuery.of(context).viewInsets.bottom + NoSusTheme.s32, + ), + decoration: BoxDecoration( + color: isDark ? NoSusTheme.dCard : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + border: Border.all(color: fg.withValues(alpha: 0.1), width: 0.75), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 36, + height: 3, + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: NoSusTheme.s24), + Icon( + Icons.notifications_active_outlined, + size: 32, + color: fg.withValues(alpha: 0.85), + ), + const SizedBox(height: NoSusTheme.s16), + Text( + 'Want to know when this happens?', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + fontSize: 17, + ), + ), + const SizedBox(height: NoSusTheme.s8), + Text( + reason, + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + fontSize: 13, + height: 1.55, + ), + ), + const SizedBox(height: NoSusTheme.s16), + // The privacy answer belongs here, next to the ask — it is the + // actual objection someone has to notifications from a security + // product, and it is true: enqueue_notification composes the text + // from templates that name no file and no group. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.lock_outline, + size: 14, + color: subtle.withValues(alpha: 0.8), + ), + const SizedBox(width: NoSusTheme.s8), + Expanded( + child: Text( + 'Notifications never include a document name or its ' + 'contents, so nothing sensitive appears on your lock screen.', + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle.withValues(alpha: 0.9), + fontSize: 11.5, + height: 1.45, + ), + ), + ), + ], + ), + const SizedBox(height: NoSusTheme.s24), + FilledButton( + onPressed: () { + HapticFeedback.mediumImpact(); + Navigator.of(context).pop(true); + }, + style: FilledButton.styleFrom( + backgroundColor: fg, + foregroundColor: isDark ? Colors.black : Colors.white, + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + ), + child: const Text( + 'TURN ON NOTIFICATIONS', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ), + const SizedBox(height: NoSusTheme.s8), + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + 'NOT NOW', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + color: subtle, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/providers/onboarding_providers.dart b/app/lib/features/onboarding/presentation/providers/onboarding_providers.dart new file mode 100644 index 0000000..d3e7f33 --- /dev/null +++ b/app/lib/features/onboarding/presentation/providers/onboarding_providers.dart @@ -0,0 +1,118 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/providers/theme_provider.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; + +/// Whether first-run setup has been completed, by the account currently signed +/// in, on this device. +/// +/// Backed by SharedPreferences, not just memory. The previous implementation +/// was a plain `Notifier` whose `build()` returned `false`, so the only +/// durable record of completion was `profiles.onboarding_completed`, written by +/// the last onboarding step — and that write was skipped entirely whenever +/// `SupabaseService.isReachable` was false. Net effect: finish onboarding on a +/// flaky connection (or in mock fallback mode, which has no backend by design) +/// and the whole flow replayed on every single launch, forever. +/// +/// Stores the completing account's id rather than a bare `true`. A device-wide +/// boolean would make a *second* account signing in on the same handset skip +/// setup entirely, because [AuthGate] treats the local flag as an OR against +/// the server's `profiles.onboarding_completed`, which is false for that new +/// account. Comparing ids gets both cases right without a sign-out hook. +/// +/// Reads are synchronous off the pre-warmed prefs instance so [AuthGate] never +/// flashes setup at a returning user while an async read settles. +class OnboardingNotifier extends Notifier { + static const _key = 'nosus_onboarding_completed_uid'; + + /// Used when there is no session to attribute completion to — mock fallback + /// mode. AuthGate only reaches first-run setup with a non-null user, so this + /// is a safety net rather than a normal path. + static const _localSentinel = '_local'; + + String get _currentId => + ref.watch(authStateProvider).value?.id ?? _localSentinel; + + @override + bool build() { + try { + final stored = ref.watch(sharedPreferencesProvider).getString(_key); + return stored != null && stored == _currentId; + } catch (_) { + return false; + } + } + + Future complete() async { + state = true; + try { + await ref.read(sharedPreferencesProvider).setString(_key, _currentId); + } catch (_) {} + } + + Future reset() async { + state = false; + try { + await ref.read(sharedPreferencesProvider).remove(_key); + } catch (_) {} + } +} + +final onboardingCompletedProvider = NotifierProvider( + OnboardingNotifier.new, +); + +/// Whether the signed-out welcome/explore surface has been seen on this device. +/// +/// Separate from [onboardingCompletedProvider] on purpose: the welcome surface +/// is pre-auth and per-device, first-run setup is post-auth and per-account. +/// Someone who explores as a guest, signs up, then signs out should not be +/// re-pitched the intro. +class WelcomeSeenNotifier extends Notifier { + static const _key = 'nosus_welcome_seen'; + + @override + bool build() { + try { + return ref.watch(sharedPreferencesProvider).getBool(_key) ?? false; + } catch (_) { + return false; + } + } + + Future markSeen() async { + if (state) return; + state = true; + try { + await ref.read(sharedPreferencesProvider).setBool(_key, true); + } catch (_) {} + } + + Future reset() async { + state = false; + try { + await ref.read(sharedPreferencesProvider).remove(_key); + } catch (_) {} + } +} + +final welcomeSeenProvider = NotifierProvider( + WelcomeSeenNotifier.new, +); + +/// Opt-in to the TSEC, Kandivali community during first-run setup. +/// +/// This is now a real choice. `handle_new_user()` used to force-join every new +/// account worldwide to TSEC regardless of the answer, which made this toggle +/// decorative — see 20260727000000_private_group_boundary.sql. +class TsecCommunityNotifier extends Notifier { + @override + bool build() => false; + + void toggle() => state = !state; + void set(bool value) => state = value; +} + +final tsecCommunityProvider = NotifierProvider( + TsecCommunityNotifier.new, +); diff --git a/app/lib/features/onboarding/presentation/providers/tour_providers.dart b/app/lib/features/onboarding/presentation/providers/tour_providers.dart new file mode 100644 index 0000000..26bc909 --- /dev/null +++ b/app/lib/features/onboarding/presentation/providers/tour_providers.dart @@ -0,0 +1,90 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/providers/theme_provider.dart'; + +/// Stable ids for every contextual tip in the app. +/// +/// One id per tip rather than a single "tour completed" flag: tips are attached +/// to screens the user may reach in any order, or never. A user who lives in +/// Groups should not have burned their Vault tip by not visiting the Vault. +abstract final class TourSteps { + static const workspaceTools = 'workspace_tools'; + static const workspacePad = 'workspace_pad'; + static const groupsCreate = 'groups_create'; + static const groupsJoin = 'groups_join'; + static const vaultReveal = 'vault_reveal'; + static const groupMembers = 'group_members'; + static const groupActivity = 'group_activity'; +} + +/// Which contextual tips have already been shown and dismissed on this device. +/// +/// Persisted, because a tip that reappears on every launch stops being help and +/// starts being noise. Resettable from Help so the tour can be replayed. +class TourProgressNotifier extends Notifier> { + static const _key = 'nosus_tour_seen'; + + @override + Set build() { + try { + final stored = ref.watch(sharedPreferencesProvider).getStringList(_key); + return stored == null ? {} : stored.toSet(); + } catch (_) { + return {}; + } + } + + bool hasSeen(String stepId) => state.contains(stepId); + + Future markSeen(Iterable stepIds) async { + final next = {...state, ...stepIds}; + if (next.length == state.length) return; + state = next; + try { + await ref + .read(sharedPreferencesProvider) + .setStringList(_key, next.toList(growable: false)); + } catch (_) {} + } + + Future resetAll() async { + state = {}; + try { + await ref.read(sharedPreferencesProvider).remove(_key); + } catch (_) {} + } +} + +final tourProgressProvider = + NotifierProvider>( + TourProgressNotifier.new, + ); + +/// Master switch for contextual tips. +/// +/// Separate from [tourProgressProvider] so "stop showing me tips" is one +/// durable decision rather than something the user has to re-express every time +/// a new screen introduces one. +class TipsEnabledNotifier extends Notifier { + static const _key = 'nosus_tips_enabled'; + + @override + bool build() { + try { + return ref.watch(sharedPreferencesProvider).getBool(_key) ?? true; + } catch (_) { + return true; + } + } + + Future setEnabled(bool value) async { + state = value; + try { + await ref.read(sharedPreferencesProvider).setBool(_key, value); + } catch (_) {} + } +} + +final tipsEnabledProvider = NotifierProvider( + TipsEnabledNotifier.new, +); diff --git a/app/lib/features/onboarding/presentation/screens/get_started_screen.dart b/app/lib/features/onboarding/presentation/screens/get_started_screen.dart new file mode 100644 index 0000000..cd54e91 --- /dev/null +++ b/app/lib/features/onboarding/presentation/screens/get_started_screen.dart @@ -0,0 +1,414 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; +import '../../../../core/utils/debug_logger.dart'; +import '../../../../services/supabase_service.dart'; +import '../../../../theme.dart'; +import '../../../analytics/data/analytics_service.dart'; +import '../../../groups/presentation/providers/group_dependencies.dart'; +import '../../../help/domain/help_topic.dart'; +import '../../../help/presentation/screens/help_topic_screen.dart'; +import '../../../profile/providers/profile_provider.dart'; +import '../providers/onboarding_providers.dart'; + +/// First-run setup, post-signup. One screen, both fields optional. +/// +/// This replaces a six-act narrative slideshow ("ACT 1: THE INCIDENT" … +/// "ACT 6: COMMUNITY") that ran *after* the user had already signed up — so it +/// was neither a pitch (they were sold) nor setup (five of six acts collected +/// nothing). Its only Skip control lived on act 1 and jumped to act 4, so +/// there was no way out of acts 2–6 at all. +/// +/// The product explanation now lives pre-auth on the welcome surface and in +/// Help; what is left here is the two things the app genuinely cannot guess. +class GetStartedScreen extends ConsumerStatefulWidget { + const GetStartedScreen({super.key}); + + @override + ConsumerState createState() => _GetStartedScreenState(); +} + +class _GetStartedScreenState extends ConsumerState { + final _nameController = TextEditingController(); + bool _isFinishing = false; + String? _error; + + @override + void initState() { + super.initState(); + _nameController.text = _defaultDisplayName(); + AnalyticsService.instance.log(AnalyticsEvent.onboardingStarted); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + String _defaultDisplayName() { + if (!SupabaseService.instance.isReachable) return ''; + final email = Supabase.instance.client.auth.currentUser?.email ?? ''; + if (email.contains('@')) return email.split('@').first; + return ''; + } + + Future _finish({required bool skipped}) async { + if (_isFinishing) return; + setState(() { + _isFinishing = true; + _error = null; + }); + HapticFeedback.mediumImpact(); + + final wantsTsec = !skipped && ref.read(tsecCommunityProvider); + + try { + final user = SupabaseService.instance.isReachable + ? Supabase.instance.client.auth.currentUser + : null; + + if (user != null) { + final cache = await SupabaseService.instance.fetchProfile(user.id); + var name = skipped ? '' : _nameController.text.trim(); + if (name.isEmpty) { + name = (cache['display_name'] as String? ?? '').trim(); + } + if (name.isEmpty) name = _defaultDisplayName(); + if (name.isEmpty) name = 'Enclave Member'; + + await SupabaseService.instance.saveProfile( + userId: user.id, + email: user.email ?? '', + displayName: name, + // Preserve whatever avatar the account already has. These two columns + // carry the avatar selection as JSON, so defaulting them on every + // save would silently reset a returning user's avatar. + avatarColorStart: + cache['avatar_color_start'] as String? ?? 'FF0072FF', + avatarColorEnd: cache['avatar_color_end'] as String? ?? 'FF00F2FE', + ); + + final repo = ref.read(studyGroupRepositoryProvider); + // Global Community is joined by the handle_new_user() signup trigger. + // Repeating it here is a cheap idempotent safety net for accounts whose + // trigger run failed — without it those users land on an empty Groups + // tab with no explanation. + await repo.joinGroupByName('Global Community', user.id); + + if (wantsTsec) { + await repo.ensureCommunityExists( + 'TSEC, Kandivali', + 'TSEC Kandivali Students Community', + true, + ); + await repo.joinGroupByName('TSEC, Kandivali', user.id); + } + + ref.invalidate(profileProvider); + } + } catch (e) { + // Setup is not load-bearing: the account already exists and every field + // here is editable later. Blocking entry on a flaky network would be a + // worse failure than starting with a default name, so surface it and + // continue. + debugLog('NO SUS: first-run setup could not be saved: $e'); + _error = + 'We could not save that right now — you can set it later in Settings.'; + } + + await ref.read(onboardingCompletedProvider.notifier).complete(); + AnalyticsService.instance.log( + skipped + ? AnalyticsEvent.onboardingSkipped + : AnalyticsEvent.onboardingCompleted, + ); + + if (!mounted) return; + if (_error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_error!), behavior: SnackBarBehavior.floating), + ); + } + // No pop: AuthGate watches onboardingCompletedProvider and swaps this + // screen for the app shell. + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.6); + final wantsTsec = ref.watch(tsecCommunityProvider); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: ListView( + physics: NoSusTheme.getScrollPhysics(context), + padding: const EdgeInsets.all(NoSusTheme.s24), + children: [ + const SizedBox(height: NoSusTheme.s16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + "You're in.", + style: theme.textTheme.displayMedium, + ), + ), + const MascotView(character: MascotCharacter.lux, size: 36), + ], + ), + const SizedBox(height: NoSusTheme.s8), + Text( + 'Two optional things, then the app. You can change both later.', + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + fontSize: 14, + height: 1.6, + ), + ), + const SizedBox(height: NoSusTheme.s32), + + // ── Display name ───────────────────────────────────────────── + Text( + 'WHAT SHOULD PEOPLE CALL YOU?', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 10, + letterSpacing: 1.5, + color: subtle, + ), + ), + const SizedBox(height: NoSusTheme.s8), + TextField( + controller: _nameController, + enabled: !_isFinishing, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _finish(skipped: false), + style: TextStyle(fontSize: 15, color: fg), + cursorColor: fg, + decoration: InputDecoration( + hintText: 'Display name', + hintStyle: TextStyle(fontSize: 15, color: subtle), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + borderSide: BorderSide(color: fg.withValues(alpha: 0.15)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + borderSide: BorderSide(color: fg.withValues(alpha: 0.15)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + borderSide: BorderSide(color: fg), + ), + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: Text( + 'Shown to other members of your groups, and in the activity log.', + style: TextStyle( + fontSize: 11, + color: subtle, + height: 1.5, + ), + ), + ), + const WhatsThisButton( + topicId: HelpCatalog.account, + semanticLabel: 'What is stored about me?', + ), + ], + ), + const SizedBox(height: NoSusTheme.s32), + + // ── Community opt-in ───────────────────────────────────────── + Text( + 'ARE YOU AT TSEC, KANDIVALI?', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 10, + letterSpacing: 1.5, + color: subtle, + ), + ), + const SizedBox(height: NoSusTheme.s8), + Row( + children: [ + Expanded( + child: _ChoiceTile( + icon: Icons.school_outlined, + label: 'YES', + selected: wantsTsec, + onTap: _isFinishing + ? null + : () { + HapticFeedback.selectionClick(); + ref + .read(tsecCommunityProvider.notifier) + .set(true); + }, + ), + ), + const SizedBox(width: NoSusTheme.s12), + Expanded( + child: _ChoiceTile( + icon: Icons.public_outlined, + label: 'NO', + selected: !wantsTsec, + onTap: _isFinishing + ? null + : () { + HapticFeedback.selectionClick(); + ref + .read(tsecCommunityProvider.notifier) + .set(false); + }, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + wantsTsec + ? "You'll join the TSEC, Kandivali community as well as Global Community." + : "You'll start in Global Community. Treat it as public — anyone with an " + 'account can read what you put there.', + style: TextStyle(fontSize: 11, color: subtle, height: 1.5), + ), + + const SizedBox(height: NoSusTheme.s32), + + // ── Actions ────────────────────────────────────────────────── + Semantics( + button: true, + enabled: !_isFinishing, + label: 'Start using NO SUS', + child: GestureDetector( + onTap: _isFinishing ? null : () => _finish(skipped: false), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + child: Center( + child: _isFinishing + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.black : Colors.white, + ), + ) + : Text( + 'START USING NO SUS', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ), + const SizedBox(height: NoSusTheme.s8), + Center( + child: TextButton( + onPressed: _isFinishing + ? null + : () => _finish(skipped: true), + child: Text( + 'SKIP FOR NOW', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: subtle, + ), + ), + ), + ), + ], + ).animate().fadeIn(duration: 260.ms), + ), + ), + ), + ); + } +} + +class _ChoiceTile extends StatelessWidget { + final IconData icon; + final String label; + final bool selected; + final VoidCallback? onTap; + + const _ChoiceTile({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = theme.colorScheme.onSurface; + final onSelected = isDark ? Colors.black : Colors.white; + + return Semantics( + button: true, + selected: selected, + label: label, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: selected ? fg : Colors.transparent, + border: Border.all( + color: selected ? fg : fg.withValues(alpha: 0.15), + ), + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + child: ExcludeSemantics( + child: Column( + children: [ + Icon(icon, size: 20, color: selected ? onSelected : fg), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + color: selected ? onSelected : fg, + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/screens/welcome_screen.dart b/app/lib/features/onboarding/presentation/screens/welcome_screen.dart new file mode 100644 index 0000000..8c21af1 --- /dev/null +++ b/app/lib/features/onboarding/presentation/screens/welcome_screen.dart @@ -0,0 +1,439 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; +import '../../../../core/providers/theme_provider.dart'; +import '../../../../theme.dart'; +import '../../../analytics/data/analytics_service.dart'; +import '../../../canary/presentation/canary_entry_card.dart'; +import '../../../canary/presentation/canary_home_screen.dart'; +import '../../../help/domain/help_topic.dart'; +import '../../../help/presentation/screens/help_screen.dart'; +import '../../../help/presentation/screens/help_topic_screen.dart'; +import '../../../share/presentation/screens/burn_file_creator_screen.dart'; +import '../../../share/presentation/screens/burn_note_creator_screen.dart'; +import '../../../share/presentation/screens/redeem_code_screen.dart'; +import '../providers/onboarding_providers.dart'; + +/// What a signed-out visitor sees instead of a login form. +/// +/// The app used to render [AuthScreen] the instant `user == null`, so the first +/// thing anyone ever saw was an email field with no explanation of what they +/// were signing up for. Everything offered here is a real, working feature — +/// Burn Notes, Burn Files and code redemption are anonymous on both ends by +/// design (open INSERT policy on `burn_notes`, `verify_jwt: false` on the +/// burn-file and redeem Edge Functions), so this is genuine value before +/// signup rather than a screenshot tour. +/// +/// Authentication is asked for at the point identity actually matters: joining +/// a group, or putting a document somewhere other people can read it. +class WelcomeScreen extends ConsumerStatefulWidget { + const WelcomeScreen({super.key}); + + @override + ConsumerState createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + // Top of the activation funnel. Everything downstream — signup_started, + // onboarding_completed, first_document_uploaded — is measured against this. + AnalyticsService.instance.log(AnalyticsEvent.welcomeViewed); + } + + /// Opens one of the tools that genuinely needs no account. + /// + /// `tool` is a fixed identifier from this file, never user content — see + /// AnalyticsService, which also drops anything that is not a primitive. + void _openGuestTool( + BuildContext context, + WidgetRef ref, { + required String tool, + required Widget Function() builder, + }) { + ref.read(welcomeSeenProvider.notifier).markSeen(); + AnalyticsService.instance.log( + AnalyticsEvent.guestToolOpened, + properties: {'tool': tool}, + ); + Navigator.of(context).push(MaterialPageRoute(builder: (_) => builder())); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.6); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: ListView( + physics: NoSusTheme.getScrollPhysics(context), + padding: const EdgeInsets.fromLTRB( + NoSusTheme.s24, + NoSusTheme.s16, + NoSusTheme.s24, + NoSusTheme.s32, + ), + children: [ + // ── Header ─────────────────────────────────────────────────── + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'NO SUS', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: 2), + Text( + 'SILENT SECURITY WORKSPACE', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 10, + color: subtle, + letterSpacing: 1.5, + ), + ), + ], + ), + ), + IconButton( + tooltip: 'Switch theme', + onPressed: () => + ref.read(themeModeProvider.notifier).toggle(), + icon: Icon( + isDark + ? Icons.wb_sunny_outlined + : Icons.nightlight_round_outlined, + size: 18, + ), + ), + IconButton( + tooltip: 'Help', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const HelpScreen()), + ), + icon: const Icon(Icons.help_outline, size: 18), + ), + ], + ), + const SizedBox(height: NoSusTheme.s32), + + // ── Value proposition ──────────────────────────────────────── + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + 'Share study material\nwithout losing track of it.', + style: theme.textTheme.displayMedium?.copyWith( + height: 1.25, + ), + ), + ), + const MascotView(character: MascotCharacter.lux, size: 40), + ], + ), + const SizedBox(height: NoSusTheme.s12), + Text( + 'Documents open in a watermarked reader, every open is logged for the whole ' + 'group to see, and anything you send outside the group can be set to ' + 'self-destruct.', + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + fontSize: 14, + height: 1.6, + ), + ), + const SizedBox(height: NoSusTheme.s32), + + // ── Usable right now, no account ───────────────────────────── + _SectionLabel( + text: 'TRY IT NOW — NO ACCOUNT NEEDED', + color: subtle, + ), + const SizedBox(height: NoSusTheme.s12), + CanaryEntryCard( + onOpen: () => _openGuestTool( + context, + ref, + tool: 'canary', + builder: CanaryHomeScreen.new, + ), + ) + .animate() + .fadeIn(duration: 260.ms) + .slideY(begin: 0.04, end: 0), + const SizedBox(height: NoSusTheme.s12), + _ActionCard( + icon: Icons.local_fire_department_outlined, + title: 'Send a Burn Note', + body: + 'A secret that deletes itself the moment it is read.', + onTap: () => _openGuestTool( + context, + ref, + tool: 'burn_note', + builder: BurnNoteCreatorScreen.new, + ), + ) + .animate() + .fadeIn(duration: 300.ms) + .slideY(begin: 0.04, end: 0), + const SizedBox(height: NoSusTheme.s12), + _ActionCard( + icon: Icons.upload_file_outlined, + title: 'Send a Burn File', + body: 'Encrypted on your device. Deleted after one download.', + onTap: () => _openGuestTool( + context, + ref, + tool: 'burn_file', + builder: BurnFileCreatorScreen.new, + ), + ).animate().fadeIn(duration: 340.ms).slideY(begin: 0.04, end: 0), + const SizedBox(height: NoSusTheme.s12), + _ActionCard( + icon: Icons.key_outlined, + title: 'Redeem a code', + body: 'Someone sent you a short code instead of a link.', + onTap: () => _openGuestTool( + context, + ref, + tool: 'redeem_code', + builder: RedeemCodeScreen.new, + ), + ) + .animate() + .fadeIn(duration: 380.ms) + .slideY(begin: 0.04, end: 0), + + const SizedBox(height: NoSusTheme.s32), + + // ── Account features as a static mockup (sign-in is off) ───── + _SectionLabel(text: 'COMING LATER — MOCKUP', color: subtle), + const SizedBox(height: NoSusTheme.s12), + Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sign-in is off for this demo. These are preview-only.', + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + fontSize: 12, + height: 1.45, + ), + ), + const SizedBox(height: NoSusTheme.s16), + const _Benefit( + icon: Icons.group_outlined, + title: 'Study groups', + body: + 'A private space with its own members, files and activity log.', + ), + const SizedBox(height: NoSusTheme.s16), + const _Benefit( + icon: Icons.description_outlined, + title: 'Secure documents', + body: + 'Shared files open in a watermarked reader instead of downloading.', + ), + const SizedBox(height: NoSusTheme.s16), + const _Benefit( + icon: Icons.history_edu_outlined, + title: 'Activity log', + body: + 'Who opened what, and when — visible to every member, including you.', + ), + ], + ), + ).animate().fadeIn(duration: 420.ms), + + const SizedBox(height: NoSusTheme.s16), + Center( + child: TextButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const HelpTopicScreen( + topicId: HelpCatalog.whatIsNoSus, + ), + ), + ), + icon: Icon(Icons.help_outline, size: 14, color: subtle), + label: Text( + 'What is NO SUS?', + style: TextStyle(fontSize: 12, color: subtle), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + final String text; + final Color color; + const _SectionLabel({required this.text, required this.color}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontSize: 10, + letterSpacing: 1.5, + color: color.withValues(alpha: 0.7), + ), + ); + } +} + +class _ActionCard extends StatelessWidget { + final IconData icon; + final String title; + final String body; + final VoidCallback onTap; + + const _ActionCard({ + required this.icon, + required this.title, + required this.body, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + + return Semantics( + button: true, + label: '$title. $body', + child: InkWell( + borderRadius: BorderRadius.circular(NoSusTheme.r16), + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: ExcludeSemantics( + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + border: Border.all(color: fg.withValues(alpha: 0.2)), + color: fg.withValues(alpha: 0.03), + ), + child: Icon(icon, color: fg.withValues(alpha: 0.8), size: 22), + ), + const SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w900, + fontSize: 14.5, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 3), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith( + color: fg.withValues(alpha: 0.54), + fontSize: 12, + height: 1.4, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: fg.withValues(alpha: 0.45), + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _Benefit extends StatelessWidget { + final IconData icon; + final String title; + final String body; + + const _Benefit({ + required this.icon, + required this.title, + required this.body, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: fg.withValues(alpha: 0.75)), + const SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 13.5, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 12, + height: 1.45, + color: fg.withValues(alpha: 0.55), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/features/profile/data/avatar_processor.dart b/app/lib/features/profile/data/avatar_processor.dart new file mode 100644 index 0000000..501a574 --- /dev/null +++ b/app/lib/features/profile/data/avatar_processor.dart @@ -0,0 +1,73 @@ +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +/// How the user's uploaded photo is rendered as their avatar. +enum AvatarStyle { + /// The photo as-is (square-cropped, resized) — full color. + original, + + /// Black & white photographic (grayscale, no pixelation). + noir, + + /// The app's own look: 1-bit black/white pixel art via Floyd–Steinberg + /// dithering, matching the Lux & Nox mark. + pixel, +} + +class _AvatarJob { + final Uint8List bytes; + final AvatarStyle style; + const _AvatarJob(this.bytes, this.style); +} + +/// Square-crops, styles, and re-encodes an uploaded photo as a 256×256 PNG. +/// Runs in a background isolate on mobile/desktop ([compute]); on web it +/// executes inline, which is acceptable at these sizes because the source is +/// downscaled before the expensive filters run. +/// +/// Returns null if [bytes] can't be decoded as an image. +Future processAvatar(Uint8List bytes, AvatarStyle style) { + return compute(_process, _AvatarJob(bytes, style)); +} + +Uint8List? _process(_AvatarJob job) { + final decoded = img.decodeImage(job.bytes); + if (decoded == null) return null; + + // Center-square first so every style starts from the same framing. + final square = img.copyResizeCropSquare(decoded, size: 256); + + img.Image styled; + switch (job.style) { + case AvatarStyle.original: + styled = square; + break; + case AvatarStyle.noir: + styled = img.grayscale(square); + break; + case AvatarStyle.pixel: + // Downsample to a coarse grid, force to pure black/white with + // Floyd–Steinberg dithering, then upscale with nearest-neighbor so + // each cell stays a hard-edged square pixel. + final small = img.copyResize( + img.grayscale(square), + width: 48, + height: 48, + interpolation: img.Interpolation.average, + ); + final dithered = img.ditherImage( + small, + quantizer: img.BinaryQuantizer(), + kernel: img.DitherKernel.floydSteinberg, + ); + styled = img.copyResize( + dithered, + width: 256, + height: 256, + interpolation: img.Interpolation.nearest, + ); + break; + } + + return Uint8List.fromList(img.encodePng(styled)); +} diff --git a/app/lib/features/profile/presentation/screens/advanced_settings_screen.dart b/app/lib/features/profile/presentation/screens/advanced_settings_screen.dart new file mode 100644 index 0000000..44a416e --- /dev/null +++ b/app/lib/features/profile/presentation/screens/advanced_settings_screen.dart @@ -0,0 +1,202 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import '../../../../theme.dart'; +import '../../../../config/supabase_credentials.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; + +class AdvancedSettingsScreen extends ConsumerStatefulWidget { + const AdvancedSettingsScreen({super.key}); + + @override + ConsumerState createState() => + _AdvancedSettingsScreenState(); +} + +class _AdvancedSettingsScreenState + extends ConsumerState { + bool _isTestingLatency = false; + int _latencyMs = -1; + + Future _testLatency() async { + if (_isTestingLatency) return; + setState(() { + _isTestingLatency = true; + _latencyMs = -1; + }); + + final stopwatch = Stopwatch()..start(); + try { + final uri = Uri.parse('${SupabaseCredentials.url}/rest/v1/'); + final response = await http + .get(uri, headers: {'apikey': SupabaseCredentials.anonKey}) + .timeout(const Duration(seconds: 4)); + stopwatch.stop(); + if (response.statusCode == 200) { + setState(() { + _latencyMs = stopwatch.elapsedMilliseconds; + }); + } else { + setState(() { + _latencyMs = -2; // server error + }); + } + } catch (_) { + setState(() { + _latencyMs = -3; // timeout / offline + }); + } finally { + setState(() { + _isTestingLatency = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? Colors.white : Colors.black; + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: fg), + onPressed: () => Navigator.of(context).pop(), + ), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'ADVANCED SETTINGS', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + fontSize: 14, + ), + ), + const SizedBox(width: 8), + const MascotView(character: MascotCharacter.nox, size: 18), + ], + ), + centerTitle: true, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'NETWORK DIAGNOSTICS', + style: theme.textTheme.labelLarge?.copyWith( + color: fg.withValues(alpha: 0.4), + fontSize: 10, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + children: [ + _buildDiagRow( + context, + 'System Endpoint', + SupabaseCredentials.url.replaceFirst('https://', ''), + ), + const SizedBox(height: 12), + _buildDiagRow( + context, + 'Tunnel Latency', + _latencyMs == -1 + ? 'Not Pinged' + : _latencyMs == -2 + ? 'Gateway Timeout' + : _latencyMs == -3 + ? 'Offline Mode' + : '${_latencyMs}ms', + ), + const SizedBox(height: 20), + Semantics( + button: true, + label: _isTestingLatency + ? 'Testing connection latency' + : 'Test connection latency', + child: GestureDetector( + onTap: _testLatency, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border.all(color: fg.withValues(alpha: 0.2)), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: _isTestingLatency + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.grey, + ), + ) + : Text( + 'TEST LATENCY', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 10, + letterSpacing: 1.0, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDiagRow(BuildContext context, String label, String value) { + final fg = Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 11, + color: fg.withValues(alpha: 0.5), + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/profile/presentation/screens/profile_screen.dart b/app/lib/features/profile/presentation/screens/profile_screen.dart new file mode 100644 index 0000000..8cd7968 --- /dev/null +++ b/app/lib/features/profile/presentation/screens/profile_screen.dart @@ -0,0 +1,1005 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart' as fp; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../../theme.dart'; +import '../../../../services/supabase_service.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../../groups/domain/models/study_group.dart'; +import '../../../groups/providers/groups_provider.dart'; +import '../../../groups/screens/group_detail_screen.dart'; +import '../../../help/domain/help_topic.dart'; +import '../../../help/presentation/screens/help_topic_screen.dart'; +import '../../data/avatar_processor.dart'; +import '../../providers/profile_provider.dart'; +import '../widgets/profile_avatar.dart'; +import 'advanced_settings_screen.dart'; +import '../../../settings/presentation/screens/settings_screen.dart'; + +/// Who you are in NO SUS. +/// +/// Previously this screen was profile *and* settings *and* legal text *and* +/// account deletion in one ~2,000-line scroll. Settings moved to +/// [SettingsScreen]; what is left is identity and the things that genuinely +/// describe this account. +/// +/// Two "metrics" were removed rather than relocated: +/// +/// * a *Contributions* score computed as `groups*10 + uploads*5 + views`, +/// which was an invented number with no meaning inside or outside the app; +/// * a *Trust Tier* of SECURE/WARNING derived by substring-matching the +/// rendered English of audit-log lines — so it flipped on the word +/// "screenshot" appearing in a description, and would have silently +/// stopped working the first time that copy was reworded. +/// +/// What replaced them is the group list, which is checkable, actionable, and +/// answers a question a user actually has. +class ProfileScreen extends ConsumerStatefulWidget { + const ProfileScreen({super.key}); + + @override + ConsumerState createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends ConsumerState { + int _avatarTapCount = 0; + + final List> _avatarPresets = [ + { + 'id': 'avatar_01', + 'name': 'Builder', + 'asset': 'assets/images/avatar_builder.png', + }, + { + 'id': 'avatar_02', + 'name': 'Researcher', + 'asset': 'assets/images/avatar_researcher.png', + }, + { + 'id': 'avatar_03', + 'name': 'Creator', + 'asset': 'assets/images/avatar_creator.png', + }, + { + 'id': 'avatar_04', + 'name': 'Scholar', + 'asset': 'assets/images/avatar_academic.png', + }, + { + 'id': 'avatar_05', + 'name': 'Agent', + 'asset': 'assets/images/avatar_chaos.png', + }, + { + 'id': 'avatar_06', + 'name': 'Archivist', + 'asset': 'assets/images/avatar_archivist.png', + }, + ]; + + void _saveProfileChanges(String displayName, String avatarId, bool isCustom) { + if (displayName.trim().isEmpty) return; + ref + .read(profileProvider.notifier) + .updateProfile( + displayName: displayName.trim(), + avatarColorStart: jsonEncode({ + 'avatar_id': avatarId, + 'is_custom': isCustom, + }), + avatarColorEnd: 'false', + ); + } + + /// Five taps on the email opens diagnostics. Undocumented on purpose — it is + /// a developer affordance, and Settings → Help & Support → Diagnostics is the + /// discoverable route to the same screen. + void _handleEmailTap() { + setState(() => _avatarTapCount++); + if (_avatarTapCount < 5) { + HapticFeedback.lightImpact(); + return; + } + _avatarTapCount = 0; + HapticFeedback.heavyImpact(); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const AdvancedSettingsScreen())); + } + + Future _pickAndStylePhoto() async { + final fp.FilePickerResult? result; + try { + result = await fp.FilePicker.pickFiles( + type: fp.FileType.image, + withData: true, + ); + } catch (_) { + return; + } + final bytes = result?.files.firstOrNull?.bytes; + if (bytes == null || !mounted) return; + + AvatarStyle selected = AvatarStyle.pixel; + final previews = {}; + bool isSaving = false; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (sheetContext, setSheetState) { + final isDark = Theme.of(sheetContext).brightness == Brightness.dark; + final fgc = isDark ? Colors.white : Colors.black; + + // One preview per style, cached for the sheet's lifetime, so + // switching styles is instant after the first render. + if (!previews.containsKey(selected)) { + previews[selected] = null; + processAvatar(bytes, selected).then((out) { + if (sheetContext.mounted) { + setSheetState(() => previews[selected] = out); + } + }); + } + final preview = previews[selected]; + + Future save() async { + setSheetState(() => isSaving = true); + try { + await ref + .read(profileProvider.notifier) + .uploadCustomAvatar(bytes, selected); + if (sheetContext.mounted) Navigator.pop(sheetContext); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Profile photo updated.'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + setSheetState(() => isSaving = false); + if (sheetContext.mounted) { + ScaffoldMessenger.of(sheetContext).showSnackBar( + SnackBar( + content: Text( + 'Could not save photo: ${e.toString().replaceAll('StateError: ', '').replaceAll('Exception: ', '')}', + ), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + Widget styleChip(AvatarStyle style, String label) { + final isSel = selected == style; + return Expanded( + child: Semantics( + button: true, + selected: isSel, + label: label, + child: GestureDetector( + onTap: isSaving + ? null + : () => setSheetState(() => selected = style), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: isSel ? fgc : Colors.transparent, + border: Border.all( + color: fgc.withValues(alpha: isSel ? 1.0 : 0.2), + width: 0.75, + ), + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + child: ExcludeSemantics( + child: Center( + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: isSel + ? (isDark ? Colors.black : Colors.white) + : fgc.withValues(alpha: 0.7), + ), + ), + ), + ), + ), + ), + ), + ); + } + + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 24, + bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'CHOOSE YOUR STYLE', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + color: fgc, + ), + ), + const SizedBox(height: 20), + Center( + child: Container( + width: 160, + height: 160, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: fgc.withValues(alpha: 0.15)), + ), + child: ClipOval( + child: preview == null + ? Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: fgc, + ), + ), + ) + : Image.memory( + preview, + key: ValueKey(selected), + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + styleChip(AvatarStyle.pixel, 'PIXEL B/W'), + styleChip(AvatarStyle.noir, 'NOIR'), + styleChip(AvatarStyle.original, 'ORIGINAL'), + ], + ), + const SizedBox(height: 8), + Text( + selected == AvatarStyle.pixel + ? 'Dithered 1-bit pixel art — the NO SUS look.' + : selected == AvatarStyle.noir + ? 'Black & white photograph.' + : 'Your photo, full colour.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + color: fgc.withValues(alpha: 0.45), + ), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: isSaving || preview == null ? null : save, + style: ElevatedButton.styleFrom( + backgroundColor: fgc, + foregroundColor: isDark ? Colors.black : Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + ), + child: isSaving + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.black : Colors.white, + ), + ) + : const Text( + 'USE THIS PHOTO', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 11, + ), + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + void _openAvatarPicker(ProfileData profile) { + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + final isDark = Theme.of(sheetContext).brightness == Brightness.dark; + final fg = isDark ? Colors.white : Colors.black; + + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'CHOOSE AVATAR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + color: fg, + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () { + Navigator.of(sheetContext).pop(); + _pickAndStylePhoto(); + }, + icon: const Icon(Icons.add_a_photo_outlined, size: 16), + label: const Text( + 'UPLOAD YOUR PHOTO', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + style: OutlinedButton.styleFrom( + foregroundColor: fg, + padding: const EdgeInsets.symmetric(vertical: 12), + side: BorderSide( + color: fg.withValues(alpha: 0.25), + width: 0.75, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + ), + ), + const SizedBox(height: 12), + Text( + 'OR PICK A CHARACTER', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + color: fg.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: 12), + Flexible( + child: GridView.builder( + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.9, + ), + itemCount: _avatarPresets.length, + itemBuilder: (context, idx) { + final item = _avatarPresets[idx]; + final isSelected = profile.avatarId == item['id']; + + return Semantics( + button: true, + selected: isSelected, + label: '${item['name']} avatar', + child: GestureDetector( + onTap: () { + HapticFeedback.selectionClick(); + _saveProfileChanges( + profile.displayName, + item['id']!, + false, + ); + Navigator.of(sheetContext).pop(); + }, + child: ExcludeSemantics( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + NoSusTheme.r16, + ), + border: Border.all( + color: isSelected + ? fg + : fg.withValues(alpha: 0.15), + width: isSelected ? 2.0 : 1.0, + ), + color: const Color(0xFF1A1A1A), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Image.asset( + item['asset']!, + fit: BoxFit.contain, + ), + ), + ), + Text( + item['name']!, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: isSelected + ? Colors.white + : Colors.white.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 6), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + void _openEditName(ProfileData profile) { + final controller = TextEditingController(text: profile.displayName); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + final isDark = Theme.of(sheetContext).brightness == Brightness.dark; + final fg = isDark ? Colors.white : Colors.black; + + void submit() { + final newName = controller.text.trim(); + if (newName.isNotEmpty) { + _saveProfileChanges(newName, profile.avatarId, profile.isCustom); + } + Navigator.pop(sheetContext); + } + + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 24, + bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'EDIT DISPLAY NAME', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + color: fg, + ), + ), + const SizedBox(height: 8), + Text( + 'Other members of your groups see this, and so does the activity log.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + height: 1.5, + color: fg.withValues(alpha: 0.55), + ), + ), + const SizedBox(height: 20), + TextField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) => submit(), + style: TextStyle(color: fg, fontSize: 15), + decoration: InputDecoration( + labelText: 'DISPLAY NAME', + labelStyle: TextStyle( + color: fg.withValues(alpha: 0.5), + fontSize: 10, + letterSpacing: 1.0, + ), + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: submit, + style: ElevatedButton.styleFrom( + backgroundColor: fg, + foregroundColor: isDark ? Colors.black : Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + ), + child: const Text( + 'SAVE', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 11, + ), + ), + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.55); + final user = ref.watch(authStateProvider).value; + final profileAsync = ref.watch(profileProvider); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + 'PROFILE', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + fontSize: 13, + ), + ), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined, size: 20), + tooltip: 'Settings', + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SettingsScreen())), + ), + ], + ), + body: profileAsync.when( + loading: () => Center(child: CircularProgressIndicator(color: fg)), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(NoSusTheme.s32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + color: Colors.redAccent, + size: 40, + ), + const SizedBox(height: NoSusTheme.s16), + Text( + 'Could not load your profile.', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: NoSusTheme.s8), + Text( + 'Check your connection and try again.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: subtle, height: 1.5), + ), + const SizedBox(height: NoSusTheme.s24), + FilledButton( + onPressed: () => ref.invalidate(profileProvider), + child: const Text('RETRY', style: TextStyle(fontSize: 11)), + ), + ], + ), + ), + ), + data: (profile) { + final groups = + ref.watch(groupsProvider).value ?? const []; + final adminOf = groups + .where((g) => g.members.any((m) => m.id == user?.id && m.isAdmin)) + .length; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: ListView( + physics: NoSusTheme.getScrollPhysics(context), + padding: const EdgeInsets.fromLTRB( + NoSusTheme.s24, + NoSusTheme.s8, + NoSusTheme.s24, + NoSusTheme.s32, + ), + children: [ + // ── Identity ─────────────────────────────────────────────── + Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: Row( + children: [ + Semantics( + button: true, + label: 'Change avatar', + child: GestureDetector( + onTap: () => _openAvatarPicker(profile), + child: ExcludeSemantics( + child: Stack( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF1A1A1A), + border: Border.all( + color: fg.withValues(alpha: 0.1), + ), + ), + child: ClipOval( + child: ProfileAvatar( + profile: profile, + size: 80, + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: fg, + shape: BoxShape.circle, + ), + child: Icon( + Icons.camera_alt, + size: 10, + color: isDark + ? Colors.black + : Colors.white, + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + profile.displayName, + style: theme.textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: 0.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + onPressed: () => _openEditName(profile), + tooltip: 'Edit display name', + icon: Icon( + Icons.edit_outlined, + size: 16, + color: subtle, + ), + ), + ], + ), + const SizedBox(height: 2), + GestureDetector( + onTap: _handleEmailTap, + child: Text( + user?.email ?? '—', + style: TextStyle(color: subtle, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 10), + const _VerificationBadge(), + ], + ), + ), + ], + ), + ).animate().fadeIn(duration: 250.ms), + + const SizedBox(height: NoSusTheme.s24), + + // ── Communities ──────────────────────────────────────────── + Row( + children: [ + Text( + 'YOUR COMMUNITIES', + style: theme.textTheme.labelLarge?.copyWith( + color: fg.withValues(alpha: 0.4), + fontSize: 10, + letterSpacing: 1.5, + ), + ), + const Spacer(), + const WhatsThisButton( + topicId: HelpCatalog.roles, + semanticLabel: 'What can admins do?', + ), + ], + ), + const SizedBox(height: NoSusTheme.s8), + if (groups.isEmpty) + Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Not in any groups yet', + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + const SizedBox(height: 6), + Text( + 'Groups are where shared documents live. Join one with an ' + 'invite, or create your own from the Groups tab.', + style: TextStyle( + fontSize: 12, + height: 1.5, + color: subtle, + ), + ), + ], + ), + ) + else ...[ + Container( + decoration: NoSusTheme.cardDecoration(context), + // Transparent Material between card and rows so each + // group row's ink splash paints over the decoration + // instead of being hidden by it. + child: Material( + type: MaterialType.transparency, + borderRadius: BorderRadius.circular(NoSusTheme.r16), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (var i = 0; i < groups.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + thickness: 0.5, + color: fg.withValues(alpha: 0.08), + ), + _GroupRow( + group: groups[i], + isAdmin: groups[i].members.any( + (m) => m.id == user?.id && m.isAdmin, + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: NoSusTheme.s8), + Text( + adminOf == 0 + ? 'Member of ${groups.length} ' + '${groups.length == 1 ? 'group' : 'groups'}.' + : 'Member of ${groups.length} ' + '${groups.length == 1 ? 'group' : 'groups'}, ' + 'admin of $adminOf.', + style: TextStyle(fontSize: 11, color: subtle), + ), + ], + + const SizedBox(height: NoSusTheme.s24), + + // Settings is a separate screen; this is the discoverable + // route to it besides the app-bar icon. + Container( + decoration: NoSusTheme.cardDecoration(context), + // Transparent Material so the tap ripple paints over the + // card decoration rather than being hidden beneath it. + child: Material( + type: MaterialType.transparency, + borderRadius: BorderRadius.circular(NoSusTheme.r16), + clipBehavior: Clip.antiAlias, + child: ListTile( + leading: Icon( + Icons.settings_outlined, + size: 18, + color: fg, + ), + title: const Text( + 'Settings', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + 'Notifications, privacy, help, and account controls', + style: TextStyle(color: subtle, fontSize: 10), + ), + trailing: Icon( + Icons.chevron_right, + size: 16, + color: subtle, + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SettingsScreen(), + ), + ), + ), + ), + ).animate().fadeIn(delay: 120.ms, duration: 250.ms), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// Real verification state, not decoration. +class _VerificationBadge extends StatelessWidget { + const _VerificationBadge(); + + @override + Widget build(BuildContext context) { + final isVerified = + SupabaseService.instance.isReachable && + Supabase.instance.client.auth.currentUser?.emailConfirmedAt != null; + final color = isVerified ? Colors.green : Colors.amber; + + return Semantics( + label: isVerified ? 'Email verified' : 'Email not yet verified', + child: ExcludeSemantics( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: color.withValues(alpha: 0.25), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isVerified + ? Icons.verified_user_outlined + : Icons.mark_email_unread_outlined, + color: color, + size: 10, + ), + const SizedBox(width: 4), + Text( + isVerified ? 'EMAIL VERIFIED' : 'EMAIL UNVERIFIED', + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _GroupRow extends StatelessWidget { + final StudyGroup group; + final bool isAdmin; + + const _GroupRow({required this.group, required this.isAdmin}); + + @override + Widget build(BuildContext context) { + final fg = Theme.of(context).colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.55); + + return ListTile( + leading: Icon( + isAdmin ? Icons.shield_outlined : Icons.group_outlined, + size: 18, + color: fg, + ), + title: Text( + group.name, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + isAdmin + ? 'Admin · ${group.members.length} ' + '${group.members.length == 1 ? 'member' : 'members'}' + : 'Member · ${group.members.length} ' + '${group.members.length == 1 ? 'member' : 'members'}', + style: TextStyle(color: subtle, fontSize: 10), + ), + trailing: Icon(Icons.chevron_right, size: 16, color: subtle), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => GroupDetailScreen(group: group)), + ), + ); + } +} diff --git a/app/lib/features/profile/presentation/widgets/profile_avatar.dart b/app/lib/features/profile/presentation/widgets/profile_avatar.dart new file mode 100644 index 0000000..61f6f30 --- /dev/null +++ b/app/lib/features/profile/presentation/widgets/profile_avatar.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/constants/app_constants.dart'; +import '../../providers/profile_provider.dart'; + +/// Single source of truth for rendering a user's avatar: an uploaded photo +/// when one exists, otherwise their chosen preset pixel-art character. Every +/// screen that shows the current user's avatar should use this so a new +/// upload appears everywhere at once. +class ProfileAvatar extends StatelessWidget { + final ProfileData profile; + final double size; + + const ProfileAvatar({super.key, required this.profile, required this.size}); + + @override + Widget build(BuildContext context) { + final url = profile.avatarUrl; + + Widget preset() => Padding( + padding: EdgeInsets.all(size * 0.1), + child: Image.asset( + AppConstants.avatarAssetForId(profile.avatarId), + fit: BoxFit.contain, + ), + ); + + if (url == null) return preset(); + + return Image.network( + url, + width: size, + height: size, + fit: BoxFit.cover, + // Decode at display size — a 256px avatar shown at 42px shouldn't + // hold a full-size texture in memory. + cacheWidth: (size * MediaQuery.of(context).devicePixelRatio).round(), + // Photo unavailable (offline, deleted object): fall back to the + // preset character rather than a broken-image glyph. + errorBuilder: (_, _, _) => preset(), + loadingBuilder: (context, child, progress) => + progress == null ? child : preset(), + ); + } +} diff --git a/app/lib/features/profile/providers/profile_provider.dart b/app/lib/features/profile/providers/profile_provider.dart new file mode 100644 index 0000000..dff9307 --- /dev/null +++ b/app/lib/features/profile/providers/profile_provider.dart @@ -0,0 +1,250 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; +import '../../../services/supabase_service.dart'; +import '../data/avatar_processor.dart'; + +class ProfileData { + final String displayName; + final String avatarColorStart; + final String avatarColorEnd; + final String? userType; + final List goals; + final List features; + + const ProfileData({ + required this.displayName, + required this.avatarColorStart, + required this.avatarColorEnd, + this.userType, + this.goals = const [], + this.features = const [], + }); + + String get avatarId { + if (avatarColorStart.startsWith('{')) { + try { + final Map parsed = jsonDecode(avatarColorStart); + return parsed['avatar_id'] ?? 'avatar_01'; + } catch (_) { + return 'avatar_01'; + } + } + // Legacy hex mapping + switch (avatarColorStart) { + case 'FF0072FF': + return 'avatar_01'; + case 'FFCCCCCC': + return 'avatar_02'; + case 'FFFF0072': + return 'avatar_03'; + case 'FFF5A623': + return 'avatar_04'; + case 'FF800080': + return 'avatar_05'; + case 'FFADF474': + return 'avatar_06'; + default: + return 'avatar_01'; + } + } + + bool get isCustom { + if (avatarColorStart.startsWith('{')) { + try { + final Map parsed = jsonDecode(avatarColorStart); + return parsed['is_custom'] ?? false; + } catch (_) { + return false; + } + } + return false; + } + + /// Public URL of an uploaded profile photo, stored in the same JSON blob + /// as [avatarId]. Null means "use the preset pixel-art character". + String? get avatarUrl { + if (avatarColorStart.startsWith('{')) { + try { + final Map parsed = jsonDecode(avatarColorStart); + final url = parsed['avatar_url'] as String?; + return (url != null && url.isNotEmpty) ? url : null; + } catch (_) { + return null; + } + } + return null; + } + + ProfileData copyWith({ + String? displayName, + String? avatarColorStart, + String? avatarColorEnd, + String? userType, + List? goals, + List? features, + }) { + return ProfileData( + displayName: displayName ?? this.displayName, + avatarColorStart: avatarColorStart ?? this.avatarColorStart, + avatarColorEnd: avatarColorEnd ?? this.avatarColorEnd, + userType: userType ?? this.userType, + goals: goals ?? this.goals, + features: features ?? this.features, + ); + } +} + +class ProfileNotifier extends Notifier> { + @override + AsyncValue build() { + final authState = ref.watch(authStateProvider); + + // If auth is still loading, return default + if (authState.isLoading) { + return const AsyncValue.data(ProfileData( + displayName: 'Scholar', + avatarColorStart: 'FF0072FF', + avatarColorEnd: 'FF00F2FE', + )); + } + + final user = authState.value; + + if (user == null) { + return const AsyncValue.data(ProfileData( + displayName: 'Scholar', + avatarColorStart: 'FF0072FF', + avatarColorEnd: 'FF00F2FE', + )); + } + + // Authenticated user — load their profile from Supabase + _loadProfile(user.id, user.email ?? 'Student Guest'); + + // Return a temporary default from email until async load completes + final defaultName = user.email != null && user.email!.isNotEmpty + ? (user.email!.length > 7 ? user.email!.substring(0, 7) : user.email!) + : 'Student Guest'; + return AsyncValue.data(ProfileData( + displayName: defaultName, + avatarColorStart: 'FF0072FF', + avatarColorEnd: 'FF00F2FE', + )); + } + + Future _loadProfile(String id, String email) async { + try { + final cache = await SupabaseService.instance.fetchProfile(id); + final defaultName = email.isNotEmpty + ? (email.length > 7 ? email.substring(0, 7) : email) + : 'Student Guest'; + + final goalsList = (cache['survey_goals'] as List?)?.map((e) => e.toString()).toList() ?? const []; + final featuresList = (cache['survey_features'] as List?)?.map((e) => e.toString()).toList() ?? const []; + + state = AsyncValue.data(ProfileData( + displayName: cache['display_name'] ?? defaultName, + avatarColorStart: cache['avatar_color_start'] ?? 'FF0072FF', + avatarColorEnd: cache['avatar_color_end'] ?? 'FF00F2FE', + userType: cache['survey_user_type'] as String?, + goals: goalsList, + features: featuresList, + )); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } + + /// Processes an uploaded photo with [style], stores it as + /// `avatars/.png` (upsert — one object per user), and points the + /// profile's avatar JSON at its public URL. Throws on failure so the UI + /// can show an honest error. + Future uploadCustomAvatar(Uint8List photoBytes, AvatarStyle style) async { + final user = ref.read(authStateProvider).value; + if (user == null) throw StateError('Not signed in.'); + final current = state.value; + + final processed = await processAvatar(photoBytes, style); + if (processed == null) { + throw StateError('That file could not be read as an image.'); + } + + final client = Supabase.instance.client; + final objectName = '${user.id}.png'; + await client.storage.from('avatars').uploadBinary( + objectName, + processed, + fileOptions: const FileOptions(contentType: 'image/png', upsert: true), + ); + + // Cache-buster so the new image shows immediately despite the public + // bucket's browser/CDN caching of the previous upload. + final publicUrl = client.storage.from('avatars').getPublicUrl(objectName); + final bustedUrl = + '$publicUrl?v=${DateTime.now().millisecondsSinceEpoch}'; + + final avatarJson = jsonEncode({ + 'avatar_id': 'custom', + 'is_custom': true, + 'avatar_url': bustedUrl, + }); + + await updateProfile( + displayName: current?.displayName ?? 'Scholar', + avatarColorStart: avatarJson, + avatarColorEnd: 'false', + ); + } + + Future updateProfile({ + required String displayName, + required String avatarColorStart, + required String avatarColorEnd, + }) async { + final user = ref.read(authStateProvider).value; + // Preserve survey fields — rebuilding ProfileData from scratch here used + // to silently drop userType/goals/features from in-memory state. + final previous = state.value; + + // Signed out there is no row to write. This used to fall back to the + // literal string 'temp_user' for the uuid primary key, so the save always + // failed deep in Postgres with `invalid input syntax for type uuid` + // instead of reporting the actual problem. + if (user == null) { + state = AsyncValue.error( + StateError('Cannot save a profile while signed out.'), + StackTrace.current, + ); + return; + } + + state = const AsyncValue.loading(); + try { + await SupabaseService.instance.saveProfile( + userId: user.id, + email: user.email ?? '', + displayName: displayName, + avatarColorStart: avatarColorStart, + avatarColorEnd: avatarColorEnd, + ); + state = AsyncValue.data(ProfileData( + displayName: displayName, + avatarColorStart: avatarColorStart, + avatarColorEnd: avatarColorEnd, + userType: previous?.userType, + goals: previous?.goals ?? const [], + features: previous?.features ?? const [], + )); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } +} + +final profileProvider = NotifierProvider>( + ProfileNotifier.new, +); diff --git a/app/lib/features/profile/providers/settings_provider.dart b/app/lib/features/profile/providers/settings_provider.dart new file mode 100644 index 0000000..7eaf7ae --- /dev/null +++ b/app/lib/features/profile/providers/settings_provider.dart @@ -0,0 +1,66 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class TouchToRevealNotifier extends Notifier { + static const _key = 'nosus_touch_to_reveal'; + + @override + bool build() { + _loadState(); + return true; // Default to true + } + + Future _loadState() async { + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getBool(_key); + if (val != null) { + state = val; + } + } + + Future setEnabled(bool enabled) async { + state = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key, enabled); + } + + Future toggle() async { + await setEnabled(!state); + } +} + +final touchToRevealProvider = NotifierProvider( + TouchToRevealNotifier.new, +); + +class WatermarkVisibilityNotifier extends Notifier { + static const _key = 'nosus_watermark_visibility'; + + @override + bool build() { + _loadState(); + return true; // Default to true + } + + Future _loadState() async { + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getBool(_key); + if (val != null) { + state = val; + } + } + + Future setEnabled(bool enabled) async { + state = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key, enabled); + } + + Future toggle() async { + await setEnabled(!state); + } +} + +final watermarkVisibilityProvider = NotifierProvider( + WatermarkVisibilityNotifier.new, +); diff --git a/app/lib/features/settings/presentation/screens/notification_settings_screen.dart b/app/lib/features/settings/presentation/screens/notification_settings_screen.dart new file mode 100644 index 0000000..bbe6973 --- /dev/null +++ b/app/lib/features/settings/presentation/screens/notification_settings_screen.dart @@ -0,0 +1,282 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../theme.dart'; +import '../../../help/domain/help_topic.dart'; +import '../../../help/presentation/screens/help_topic_screen.dart'; +import '../../../notifications/data/push_service.dart'; +import '../../../notifications/domain/app_notification.dart'; +import '../../../notifications/presentation/providers/notification_providers.dart'; +import '../widgets/settings_section.dart'; + +/// Per-category notification switches, plus an honest account of what the OS +/// and the backend are currently doing. +/// +/// The system-permission state is surfaced rather than hidden: a user whose +/// switches are all on but whose Android permission is denied would otherwise +/// see a screen that claims notifications are configured while nothing ever +/// arrives, and no way to work out why. +class NotificationSettingsScreen extends ConsumerStatefulWidget { + const NotificationSettingsScreen({super.key}); + + @override + ConsumerState createState() => + _NotificationSettingsScreenState(); +} + +class _NotificationSettingsScreenState + extends ConsumerState { + bool? _systemAllowed; + bool _transportConfigured = false; + + @override + void initState() { + super.initState(); + _refreshPermissionState(); + } + + Future _refreshPermissionState() async { + final configured = await PushService.instance.initialize(); + final allowed = configured && await PushService.instance.hasPermission(); + if (!mounted) return; + setState(() { + _transportConfigured = configured; + _systemAllowed = allowed; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final prefsAsync = ref.watch(notificationPreferencesProvider); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + 'NOTIFICATIONS', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + fontSize: 13, + ), + ), + centerTitle: true, + actions: const [ + WhatsThisButton( + topicId: HelpCatalog.notifications, + semanticLabel: 'What will I be notified about?', + ), + ], + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: ListView( + physics: NoSusTheme.getScrollPhysics(context), + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s24, + vertical: NoSusTheme.s8, + ), + children: [ + _deliveryStatus(theme), + SettingsSection( + title: 'IN-APP & PUSH', + children: [ + for (final category in NotificationCategory.values) + SettingsSwitchRow( + title: category.label, + subtitle: category.description, + value: prefsAsync.value?.isEnabled(category) ?? true, + onChanged: (v) => _setCategory(category, v), + ), + ], + ), + Padding( + padding: const EdgeInsets.only( + top: NoSusTheme.s16, + left: NoSusTheme.s4, + ), + child: Text( + 'These switches control the in-app inbox as well as push, so ' + 'turning a category off stops it reaching you either way.', + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 11.5, + height: 1.5, + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ), + const SizedBox(height: NoSusTheme.s32), + ], + ), + ), + ), + ), + ); + } + + Future _setCategory(NotificationCategory category, bool value) async { + // Security alerts are the category someone would still want after muting + // everything else, so switching them off is a decision worth confirming — + // but it is still the user's decision to make. + if (category.isCritical && !value) { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text( + 'Turn off security alerts?', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ), + content: const Text( + 'You will not be told when access is revoked, when a document is ' + 'opened from an unrecognised device, or when an integrity check ' + 'fails. Everything is still recorded in the audit log.', + style: TextStyle(fontSize: 12.5, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('KEEP ON', style: TextStyle(fontSize: 11)), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text( + 'TURN OFF', + style: TextStyle(fontSize: 11, color: Colors.redAccent), + ), + ), + ], + ), + ); + if (confirmed != true) return; + } + + try { + await ref + .read(notificationPreferencesProvider.notifier) + .setCategory(category, value); + } catch (_) { + // The notifier reverts its own state; this only reports it, because a + // switch snapping back with no explanation is its own small mystery. + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not save that. Check your connection.'), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + /// Explains the actual delivery state instead of implying push works. + Widget _deliveryStatus(ThemeData theme) { + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.6); + + final (IconData icon, String title, String body) = switch (( + _transportConfigured, + _systemAllowed, + )) { + // The state this build actually ships in: no Firebase project, so there + // is no push transport. Saying so beats a screen that quietly implies + // notifications will arrive. + (false, _) => ( + Icons.inbox_outlined, + 'Delivered in the app', + 'Push delivery is not set up on this build, so these arrive in your ' + 'in-app notifications rather than as system notifications. ' + 'Everything below still applies.', + ), + (true, false) => ( + Icons.notifications_off_outlined, + 'Blocked by your device', + 'Android is not allowing notifications from NO SUS. Turn them on in ' + 'system settings, and your choices below take effect again.', + ), + (true, _) => ( + Icons.notifications_active_outlined, + 'Push notifications are on', + 'Notification text never includes a document name or its contents.', + ), + }; + + return Padding( + padding: const EdgeInsets.only(top: NoSusTheme.s16), + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: fg.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(NoSusTheme.r16), + border: Border.all(color: fg.withValues(alpha: 0.1), width: 0.75), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: fg.withValues(alpha: 0.8)), + const SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith( + color: subtle, + fontSize: 11.5, + height: 1.45, + ), + ), + if (_transportConfigured && + _systemAllowed == false && + !kIsWeb) + Padding( + padding: const EdgeInsets.only(top: NoSusTheme.s8), + child: TextButton( + onPressed: () async { + // Android only shows its permission dialog once, so + // after a denial the only route back is the system + // settings app. requestPermissionAndRegister() opens + // it where the platform allows and is a no-op + // otherwise; re-check either way. + await PushService.instance + .requestPermissionAndRegister(); + await _refreshPermissionState(); + }, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(48, 40), + alignment: Alignment.centerLeft, + ), + child: const Text( + 'TRY AGAIN', + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/settings/presentation/screens/settings_screen.dart b/app/lib/features/settings/presentation/screens/settings_screen.dart new file mode 100644 index 0000000..64f2529 --- /dev/null +++ b/app/lib/features/settings/presentation/screens/settings_screen.dart @@ -0,0 +1,810 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../core/providers/theme_provider.dart'; +import '../../../../services/supabase_service.dart'; +import '../../../../theme.dart'; +import '../../../admin/presentation/screens/admin_dashboard_screen.dart'; +import '../../../auth/presentation/controllers/auth_controller.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../../config/domain/app_update.dart'; +import '../../../config/presentation/providers/app_update_provider.dart'; +import '../../../config/presentation/providers/config_provider.dart'; +import '../../../help/presentation/screens/help_screen.dart'; +import '../../../notifications/data/push_service.dart'; +import '../../../notifications/domain/app_notification.dart'; +import '../../../notifications/presentation/providers/notification_providers.dart'; +import '../../../onboarding/presentation/providers/onboarding_providers.dart'; +import '../../../onboarding/presentation/providers/tour_providers.dart'; +import '../../../profile/presentation/screens/advanced_settings_screen.dart'; +import '../../../profile/providers/settings_provider.dart'; +import '../widgets/settings_section.dart'; +import 'notification_settings_screen.dart'; + +/// Settings, organised by what a person is trying to change. +/// +/// Split out of `profile_screen.dart`, which had grown to ~2,100 lines holding +/// identity, storage, privacy toggles, admin entry, help, legal text, invented +/// metrics and account deletion in one scroll. Two different jobs — "who am I +/// here" and "how does this app behave" — sharing one screen meant neither had +/// a findable shape. +/// +/// Every row here reaches something real. No section exists to look complete. +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final user = ref.watch(authStateProvider).value; + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + 'SETTINGS', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + fontSize: 13, + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: ListView( + physics: NoSusTheme.getScrollPhysics(context), + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s24, + vertical: NoSusTheme.s8, + ), + children: [ + _account(context, ref, user?.email), + _notifications(context, ref), + _privacy(context, ref), + _appearance(context, ref), + _tips(context, ref), + _adminSection(context, ref), + _helpAndSupport(context, ref), + _about(context, ref), + _dangerZone(context, ref, user?.email ?? ''), + const SizedBox(height: NoSusTheme.s32), + ], + ), + ), + ), + ), + ); + } + + // ── Account ─────────────────────────────────────────────────────────────── + Widget _account(BuildContext context, WidgetRef ref, String? email) { + return SettingsSection( + title: 'ACCOUNT', + children: [ + SettingsRow( + icon: Icons.alternate_email, + title: 'Email', + subtitle: email ?? '—', + ), + SettingsRow( + icon: Icons.lock_outline, + title: 'Change password', + subtitle: 'Set a new password for this account', + onTap: () => _changePassword(context, ref), + ), + ], + ); + } + + // ── Notifications ───────────────────────────────────────────────────────── + Widget _notifications(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(notificationPreferencesProvider); + final enabledCount = prefs.value?.enabled.values.where((v) => v).length; + + return SettingsSection( + title: 'NOTIFICATIONS', + children: [ + SettingsRow( + icon: Icons.notifications_outlined, + title: 'Notification categories', + subtitle: enabledCount == null + ? 'Choose what you hear about' + : '$enabledCount of ${NotificationCategory.values.length} turned on', + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const NotificationSettingsScreen(), + ), + ), + ), + ], + ); + } + + // ── Privacy & Security ──────────────────────────────────────────────────── + Widget _privacy(BuildContext context, WidgetRef ref) { + return SettingsSection( + title: 'PRIVACY & SECURITY', + children: [ + SettingsSwitchRow( + title: 'Touch to reveal', + subtitle: 'Keep documents blurred until you hold the screen', + value: ref.watch(touchToRevealProvider), + onChanged: (v) => + ref.read(touchToRevealProvider.notifier).setEnabled(v), + ), + SettingsSwitchRow( + title: 'Watermark overlay', + subtitle: 'Draw your identity across documents you open', + value: ref.watch(watermarkVisibilityProvider), + onChanged: (v) => + ref.read(watermarkVisibilityProvider.notifier).setEnabled(v), + ), + ], + ); + } + + // ── Appearance ──────────────────────────────────────────────────────────── + Widget _appearance(BuildContext context, WidgetRef ref) { + final mode = ref.watch(themeModeProvider); + return SettingsSection( + title: 'APPEARANCE', + children: [ + SettingsSwitchRow( + title: 'Dark theme', + subtitle: 'Also available from the toggle in the app header', + value: mode == ThemeMode.dark, + onChanged: (v) => ref + .read(themeModeProvider.notifier) + .setMode(v ? ThemeMode.dark : ThemeMode.light), + ), + ], + ); + } + + // ── Tips ────────────────────────────────────────────────────────────────── + Widget _tips(BuildContext context, WidgetRef ref) { + return SettingsSection( + title: 'TIPS & TUTORIALS', + children: [ + SettingsSwitchRow( + title: 'Show contextual tips', + subtitle: 'Short explanations the first time you open a screen', + value: ref.watch(tipsEnabledProvider), + onChanged: (v) => + ref.read(tipsEnabledProvider.notifier).setEnabled(v), + ), + SettingsRow( + icon: Icons.replay_outlined, + title: 'Replay all tips', + subtitle: 'Show every tip again from the start', + onTap: () async { + HapticFeedback.lightImpact(); + await ref.read(tourProgressProvider.notifier).resetAll(); + await ref.read(tipsEnabledProvider.notifier).setEnabled(true); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Tips reset. They will appear again as you move around the app.', + ), + behavior: SnackBarBehavior.floating, + ), + ); + }, + ), + SettingsRow( + icon: Icons.restart_alt, + title: 'Replay first-run setup', + subtitle: 'Go through name and community setup again', + onTap: () async { + HapticFeedback.lightImpact(); + await ref.read(onboardingCompletedProvider.notifier).reset(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Setup will run next time you open the app.'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + ), + ], + ); + } + + // ── Administration (only for accounts that actually have the role) ──────── + Widget _adminSection(BuildContext context, WidgetRef ref) { + return ref + .watch(userRoleProvider) + .maybeWhen( + data: (role) { + if (role != 'admin' && role != 'super_admin') { + return const SizedBox.shrink(); + } + return SettingsSection( + title: 'ADMINISTRATION', + children: [ + SettingsRow( + icon: Icons.admin_panel_settings_outlined, + title: 'Super admin console', + subtitle: 'Feature flags, user roles and remote config', + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AdminDashboardScreen(), + ), + ), + ), + ], + ); + }, + orElse: () => const SizedBox.shrink(), + ); + } + + // ── Help ────────────────────────────────────────────────────────────────── + Widget _helpAndSupport(BuildContext context, WidgetRef ref) { + return SettingsSection( + title: 'HELP & SUPPORT', + children: [ + SettingsRow( + icon: Icons.help_outline, + title: 'Help centre', + subtitle: 'How groups, documents and burn links work', + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const HelpScreen())), + ), + SettingsRow( + icon: Icons.rate_review_outlined, + title: 'Send feedback', + subtitle: 'Report a bug or suggest something', + onTap: () => _sendFeedback(context, ref), + ), + SettingsRow( + icon: Icons.speed_outlined, + title: 'Network diagnostics', + subtitle: 'Check connectivity to the NO SUS backend', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AdvancedSettingsScreen()), + ), + ), + ], + ); + } + + // ── About & legal ───────────────────────────────────────────────────────── + Widget _about(BuildContext context, WidgetRef ref) { + final update = ref.watch(appUpdateProvider).value; + return SettingsSection( + title: 'ABOUT & LEGAL', + children: [ + SettingsRow( + icon: Icons.info_outline, + title: 'About NO SUS', + subtitle: update is AppUpdateAvailable + ? 'Update available: v${update.latestVersion}' + : 'Version and build information', + onTap: () => _showAbout(context, ref), + ), + SettingsRow( + icon: Icons.gavel_outlined, + title: 'Terms of service', + onTap: () => _showLegal(context, 'TERMS OF SERVICE', _termsBody), + ), + SettingsRow( + icon: Icons.privacy_tip_outlined, + title: 'Privacy policy', + onTap: () => _showLegal(context, 'PRIVACY POLICY', _privacyBody), + ), + ], + ); + } + + // ── Danger zone ─────────────────────────────────────────────────────────── + Widget _dangerZone(BuildContext context, WidgetRef ref, String email) { + return SettingsSection( + title: 'DANGER ZONE', + isDanger: true, + children: [ + SettingsRow( + icon: Icons.logout, + title: 'Sign out', + isDanger: true, + onTap: () => _confirmSignOut(context, ref), + ), + SettingsRow( + icon: Icons.delete_forever_outlined, + title: 'Delete account', + subtitle: 'Permanently removes your profile and memberships', + isDanger: true, + onTap: () => _confirmDelete(context, ref, email), + ), + ], + ); + } + + // ── Dialogs ─────────────────────────────────────────────────────────────── + + void _changePassword(BuildContext context, WidgetRef ref) { + final newCtrl = TextEditingController(); + final confirmCtrl = TextEditingController(); + bool isSaving = false; + bool obscure = true; + String? errorText; + + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) { + Future save() async { + final pw = newCtrl.text; + if (pw.length < 6) { + setDialogState( + () => errorText = 'Must be at least 6 characters.', + ); + return; + } + if (pw != confirmCtrl.text) { + setDialogState(() => errorText = 'Passwords do not match.'); + return; + } + setDialogState(() { + isSaving = true; + errorText = null; + }); + try { + await ref.read(authRepositoryProvider).updatePassword(pw); + if (dialogContext.mounted) Navigator.pop(dialogContext); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password updated.'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + setDialogState(() { + isSaving = false; + errorText = e + .toString() + .replaceAll('AuthApiException: ', '') + .replaceAll('AuthException: ', '') + .replaceAll('Exception: ', ''); + }); + } + } + + return AlertDialog( + title: const Text( + 'Change password', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: newCtrl, + obscureText: obscure, + autofocus: true, + decoration: InputDecoration( + labelText: 'New password', + suffixIcon: IconButton( + tooltip: obscure ? 'Show password' : 'Hide password', + icon: Icon( + obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + ), + onPressed: () => setDialogState(() => obscure = !obscure), + ), + ), + ), + const SizedBox(height: NoSusTheme.s8), + TextField( + controller: confirmCtrl, + obscureText: obscure, + onSubmitted: (_) => save(), + decoration: InputDecoration( + labelText: 'Confirm new password', + errorText: errorText, + errorMaxLines: 3, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: isSaving ? null : () => Navigator.pop(dialogContext), + child: const Text('CANCEL', style: TextStyle(fontSize: 11)), + ), + FilledButton( + onPressed: isSaving ? null : save, + child: isSaving + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('UPDATE', style: TextStyle(fontSize: 11)), + ), + ], + ); + }, + ), + ); + } + + void _sendFeedback(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(); + bool isSending = false; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => StatefulBuilder( + builder: (sheetContext, setSheetState) => Padding( + padding: EdgeInsets.only( + left: NoSusTheme.s24, + right: NoSusTheme.s24, + top: NoSusTheme.s24, + bottom: + MediaQuery.of(sheetContext).viewInsets.bottom + NoSusTheme.s24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'SEND FEEDBACK', + textAlign: TextAlign.center, + style: Theme.of(sheetContext).textTheme.labelLarge?.copyWith( + fontSize: 11, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: NoSusTheme.s24), + TextField( + controller: controller, + maxLines: 4, + autofocus: true, + decoration: const InputDecoration( + hintText: 'What happened, or what would you change?', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: NoSusTheme.s16), + FilledButton( + onPressed: isSending + ? null + : () async { + final text = controller.text.trim(); + if (text.isEmpty) return; + setSheetState(() => isSending = true); + try { + String? version; + try { + version = + (await PackageInfo.fromPlatform()).version; + } catch (_) {} + await Supabase.instance.client + .from('feedback') + .insert({ + 'user_id': Supabase + .instance + .client + .auth + .currentUser + ?.id, + 'message': text, + 'app_version': version, + }); + if (sheetContext.mounted) { + Navigator.pop(sheetContext); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Thanks — feedback sent.'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (_) { + setSheetState(() => isSending = false); + if (sheetContext.mounted) { + ScaffoldMessenger.of(sheetContext).showSnackBar( + const SnackBar( + content: Text( + 'Could not send. Check your connection and try again.', + ), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + }, + child: Text( + isSending ? 'SENDING…' : 'SUBMIT', + style: const TextStyle(fontSize: 11), + ), + ), + ], + ), + ), + ), + ); + } + + Future _showAbout(BuildContext context, WidgetRef ref) async { + // Real build metadata, never a hardcoded string that drifts from pubspec. + String versionLabel = 'Version unavailable'; + try { + final info = await PackageInfo.fromPlatform(); + versionLabel = 'Version ${info.version} (build ${info.buildNumber})'; + } catch (_) {} + final update = ref.read(appUpdateProvider).value; + if (!context.mounted) return; + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text( + 'About NO SUS', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(versionLabel, style: const TextStyle(fontSize: 12)), + if (update is AppUpdateAvailable) ...[ + const SizedBox(height: NoSusTheme.s8), + Text( + 'Update available: v${update.latestVersion}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + const SizedBox(height: NoSusTheme.s16), + const Text( + 'A secure study-group workspace: shared documents that stay ' + 'watermarked and logged, plus burn links that need no account.', + style: TextStyle(fontSize: 11.5, height: 1.5), + ), + ], + ), + actions: [ + if (update is AppUpdateAvailable) + TextButton( + onPressed: () async { + final uri = Uri.parse(update.downloadUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + }, + child: const Text('GET UPDATE', style: TextStyle(fontSize: 11)), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('CLOSE', style: TextStyle(fontSize: 11)), + ), + ], + ), + ); + } + + void _showLegal(BuildContext context, String title, String body) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text( + title, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: double.maxFinite, + height: 320, + child: SingleChildScrollView( + child: Text( + body, + style: const TextStyle(fontSize: 11.5, height: 1.6), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('CLOSE', style: TextStyle(fontSize: 11)), + ), + ], + ), + ); + } + + void _confirmSignOut(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text( + 'Sign out?', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ), + content: const Text( + 'You will need to sign in again to reach your groups and documents.', + style: TextStyle(fontSize: 12.5, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('CANCEL', style: TextStyle(fontSize: 11)), + ), + TextButton( + onPressed: () async { + Navigator.pop(dialogContext); + HapticFeedback.mediumImpact(); + // Release the push token while the session still exists — + // unregister_device_token is scoped by auth.uid(), so after + // signOut() it would match nothing and this handset would keep + // receiving the previous account's notifications. + await PushService.instance.releaseCurrentDevice(); + await ref.read(authControllerProvider.notifier).signOut(); + if (context.mounted) Navigator.of(context).pop(); + }, + child: const Text( + 'SIGN OUT', + style: TextStyle(fontSize: 11, color: Colors.redAccent), + ), + ), + ], + ), + ); + } + + void _confirmDelete(BuildContext context, WidgetRef ref, String email) { + final confirmCtrl = TextEditingController(); + + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) { + final matches = confirmCtrl.text.trim() == email.trim(); + return AlertDialog( + title: const Text( + 'Delete account?', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.redAccent, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'This is permanent. Your profile, group memberships and ' + 'private notes are removed immediately.', + style: TextStyle(fontSize: 12.5, height: 1.5), + ), + const SizedBox(height: NoSusTheme.s12), + // Stated up front rather than discovered afterwards. Both are + // deliberate: other members may depend on files you uploaded, + // and an audit log the subject can erase is not an audit log. + const Text( + 'Files you uploaded stay with their groups, and audit log ' + 'entries are kept. Delete your uploads first if you want ' + 'them gone.', + style: TextStyle(fontSize: 11.5, height: 1.5), + ), + const SizedBox(height: NoSusTheme.s16), + Text( + 'Type $email to confirm:', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: NoSusTheme.s8), + TextField( + controller: confirmCtrl, + autocorrect: false, + onChanged: (_) => setDialogState(() {}), + decoration: const InputDecoration(hintText: 'Your email'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('CANCEL', style: TextStyle(fontSize: 11)), + ), + TextButton( + onPressed: !matches + ? null + : () async { + Navigator.pop(dialogContext); + HapticFeedback.heavyImpact(); + try { + await PushService.instance.releaseCurrentDevice(); + if (SupabaseService.instance.isReachable) { + await Supabase.instance.client.functions.invoke( + 'account-manager', + ); + } + await ref.read(authRepositoryProvider).signOut(); + await ref + .read(onboardingCompletedProvider.notifier) + .reset(); + ref.invalidate(authStateProvider); + if (context.mounted) Navigator.of(context).pop(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not delete account: $e'), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + }, + child: Text( + 'DELETE PERMANENTLY', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: matches ? Colors.redAccent : Colors.grey, + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +// Kept verbatim from the previous Profile screen so this refactor does not +// quietly reword the legal text users have already been shown. +const String _termsBody = + 'Welcome to NO SUS.\n\n' + '1. Acceptable Use\n' + 'You agree to use this secure enclave application solely for academic, private study collaborations. Any dissemination of unauthorized content or security audits bypasses is strictly forbidden.\n\n' + '2. RLS & Access Protocols\n' + 'All operations are logged to a public audit ledger to ensure integrity. You are fully responsible for all documents uploaded under your credentials.\n\n' + '3. Limitation of Liability\n' + 'NO SUS is provided "as is". We are not responsible for any transient network failures, client disconnects, or device-level screen recordings.'; + +const String _privacyBody = + 'Last updated: July 2026\n\n' + '1. Data Collection\n' + 'We store your email, display name, and avatar selection inside secure Supabase database tables. Documents are streamed directly into volatile device memory and never cached permanently on disk.\n\n' + '2. Audit Logs\n' + 'To detect potential leaks, we record events such as file openings and screenshots. These records containing user email and timestamps are immutable and visible to all group members.\n\n' + '3. Product Analytics\n' + 'We record a small set of product events — app opened, first-run setup started/completed, sign-up completed, first document uploaded or viewed, and notification permission outcomes — against your account so we can see where the app is confusing. These records never contain document names, document contents, group names, or message text.\n\n' + '4. Notifications\n' + 'If you enable push notifications we store a device token so messages can be delivered. Notification text never includes a document name or its contents. Signing out releases that device token.\n\n' + '5. Data Deletion\n' + 'You may delete your account at any time. Account deletion removes your profile, memberships, private notes and analytics records. Files you uploaded to a group remain with that group, and audit log entries are retained for integrity.'; diff --git a/app/lib/features/settings/presentation/widgets/settings_section.dart b/app/lib/features/settings/presentation/widgets/settings_section.dart new file mode 100644 index 0000000..bf1af15 --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/settings_section.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../theme.dart'; + +/// A labelled group of settings rows. +/// +/// Shared so every section is spaced, bordered and announced identically — +/// the previous single-screen version hand-rolled each block and drifted. +class SettingsSection extends StatelessWidget { + final String title; + final List children; + + /// Tints the heading and border. Used only for destructive groups, so the + /// colour means something rather than being decoration. + final bool isDanger; + + const SettingsSection({ + super.key, + required this.title, + required this.children, + this.isDanger = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + final accent = isDanger ? Colors.redAccent : fg; + + return Padding( + padding: const EdgeInsets.only(top: NoSusTheme.s24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 10, + letterSpacing: 1.5, + color: accent.withValues(alpha: isDanger ? 0.7 : 0.45), + ), + ), + const SizedBox(height: NoSusTheme.s8), + Container( + decoration: BoxDecoration( + color: isDanger + ? Colors.redAccent.withValues(alpha: 0.03) + : (theme.brightness == Brightness.dark + ? NoSusTheme.dCard + : NoSusTheme.lCard), + borderRadius: BorderRadius.circular(NoSusTheme.r16), + border: Border.all( + color: isDanger + ? Colors.redAccent.withValues(alpha: 0.18) + : fg.withValues(alpha: 0.08), + width: 0.75, + ), + ), + // The decoration above would otherwise be the nearest thing painted + // under the rows, swallowing every tile's ink splash. A transparent + // Material sits between the two so taps ripple over the card, and + // clipping keeps the splash inside the rounded corners. + child: Material( + type: MaterialType.transparency, + borderRadius: BorderRadius.circular(NoSusTheme.r16), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + thickness: 0.5, + indent: NoSusTheme.s16, + endIndent: NoSusTheme.s16, + color: fg.withValues(alpha: 0.08), + ), + children[i], + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +/// A tappable (or purely informational) settings row. +/// +/// A row without [onTap] renders as a value display and is not announced as a +/// button — an "Email" row that reads as tappable but does nothing is exactly +/// the dead control this refactor was meant to remove. +class SettingsRow extends StatelessWidget { + final IconData? icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + final bool isDanger; + + const SettingsRow({ + super.key, + required this.title, + this.icon, + this.subtitle, + this.onTap, + this.isDanger = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.55); + final tint = isDanger ? Colors.redAccent : fg; + + final tile = ListTile( + // ListTile's default minimum height already clears the 48dp target. + leading: icon == null + ? null + : Icon(icon, size: 18, color: tint.withValues(alpha: 0.9)), + title: Text( + title, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: isDanger ? Colors.redAccent : null, + ), + ), + subtitle: subtitle == null + ? null + : Text( + subtitle!, + style: TextStyle(color: subtle, fontSize: 10.5, height: 1.35), + ), + trailing: onTap == null + ? null + : Icon(Icons.chevron_right, size: 16, color: subtle), + onTap: onTap == null + ? null + : () { + HapticFeedback.lightImpact(); + onTap!(); + }, + ); + + if (onTap == null) return tile; + + return Semantics( + button: true, + label: subtitle == null ? title : '$title. $subtitle', + child: ExcludeSemantics(child: tile), + ); + } +} + +/// A settings row whose control is a switch. +class SettingsSwitchRow extends StatelessWidget { + final String title; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + + const SettingsSwitchRow({ + super.key, + required this.title, + required this.value, + required this.onChanged, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = theme.colorScheme.onSurface; + final subtle = fg.withValues(alpha: 0.55); + + return Semantics( + toggled: value, + label: subtitle == null ? title : '$title. $subtitle', + child: ExcludeSemantics( + child: SwitchListTile( + title: Text( + title, + style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600), + ), + subtitle: subtitle == null + ? null + : Text( + subtitle!, + style: TextStyle(color: subtle, fontSize: 10.5, height: 1.35), + ), + value: value, + onChanged: (v) { + HapticFeedback.selectionClick(); + onChanged(v); + }, + activeTrackColor: fg, + activeThumbColor: isDark ? Colors.black : Colors.white, + ), + ), + ); + } +} diff --git a/app/lib/features/share/data/burn_file_client.dart b/app/lib/features/share/data/burn_file_client.dart new file mode 100644 index 0000000..d7d9c10 --- /dev/null +++ b/app/lib/features/share/data/burn_file_client.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../../../config/supabase_credentials.dart'; + +/// Calls the three public "Burn Files" Edge Functions directly over HTTPS — +/// no Supabase client, no session — matching [ShareFetchClient]'s pattern +/// exactly, since both uploader and recipient are anonymous by design (see +/// supabase/migrations/20260710050000_burn_files.sql). +class BurnFileClient { + BurnFileClient._(); + static final BurnFileClient instance = BurnFileClient._(); + + static final Uri _initEndpoint = + Uri.parse('${SupabaseCredentials.url}/functions/v1/burn-file-init'); + static final Uri _confirmEndpoint = + Uri.parse('${SupabaseCredentials.url}/functions/v1/burn-file-confirm'); + static final Uri _fetchEndpoint = + Uri.parse('${SupabaseCredentials.url}/functions/v1/burn-file-fetch'); + + Map get _headers => { + 'Content-Type': 'application/json', + 'apikey': SupabaseCredentials.anonKey, + }; + + /// Step 1: reserves a file id + signed upload URL. [expiryHours] is + /// clamped server-side to `burn_files_max_expiry_hours`. + Future<({String fileId, String signedUploadUrl, String uploadToken})> init({ + required int declaredSizeBytes, + int? expiryHours, + }) async { + final res = await http.post( + _initEndpoint, + headers: _headers, + body: jsonEncode({ + 'declared_size_bytes': declaredSizeBytes, + 'expiry_hours': expiryHours, + }), + ); + final decoded = jsonDecode(res.body) as Map; + if (res.statusCode != 200) { + throw Exception(decoded['error'] as String? ?? 'Could not start this upload.'); + } + return ( + fileId: decoded['file_id'] as String, + signedUploadUrl: decoded['signed_upload_url'] as String, + uploadToken: decoded['upload_token'] as String, + ); + } + + /// Step 2: called after the client's PUT to the signed upload URL + /// succeeds. The file isn't downloadable by anyone until this returns ok. + Future confirm(String fileId) async { + final res = await http.post( + _confirmEndpoint, + headers: _headers, + body: jsonEncode({'file_id': fileId}), + ); + if (res.statusCode != 200) { + final decoded = jsonDecode(res.body) as Map; + throw Exception(decoded['error'] as String? ?? 'Could not confirm this upload.'); + } + } + + /// Recipient side: atomically claims the file (single-use — a second call + /// with the same [fileId] always throws) and returns a short-lived signed + /// download URL. + Future fetch(String fileId) async { + final res = await http.post( + _fetchEndpoint, + headers: _headers, + body: jsonEncode({'file_id': fileId}), + ); + final decoded = jsonDecode(res.body) as Map; + if (res.statusCode != 200) { + throw Exception(decoded['error'] as String? ?? 'This link could not be opened.'); + } + return decoded['signed_url'] as String; + } +} diff --git a/app/lib/features/share/data/redemption_code_client.dart b/app/lib/features/share/data/redemption_code_client.dart new file mode 100644 index 0000000..ef1d6f6 --- /dev/null +++ b/app/lib/features/share/data/redemption_code_client.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../../../config/supabase_credentials.dart'; + +/// Calls the "create-redemption-code" / "redeem-code" Edge Functions — +/// matches [BurnFileClient]'s pattern exactly (no Supabase client, no +/// session, anonymous on both ends). See +/// supabase/migrations/20260713000000_burn_redemption_codes.sql for why +/// this path deliberately differs from the link-based zero-knowledge +/// guarantee: the server briefly holds the key/IV. New two-digit codes are +/// paired with an unguessable link token, so the short value is never the +/// authorization boundary. +class RedemptionCodeClient { + RedemptionCodeClient._(); + static final RedemptionCodeClient instance = RedemptionCodeClient._(); + + static final Uri _createEndpoint = Uri.parse( + '${SupabaseCredentials.url}/functions/v1/create-redemption-code', + ); + static final Uri _redeemEndpoint = Uri.parse( + '${SupabaseCredentials.url}/functions/v1/redeem-code', + ); + + Map get _headers => { + 'Content-Type': 'application/json', + 'apikey': SupabaseCredentials.anonKey, + }; + + /// Sender side: mints a two-digit pairing code and an unguessable redeem + /// token. Both are required for the alternate retrieval path. + /// [targetKind] is `'note'` or `'file'`. + Future<({String code, String redeemToken, DateTime expiresAt})> createCode({ + required String targetKind, + required String targetId, + required String keyHex, + required String ivHex, + }) async { + final res = await http.post( + _createEndpoint, + headers: _headers, + body: jsonEncode({ + 'target_kind': targetKind, + 'target_id': targetId, + 'key_hex': keyHex, + 'iv_hex': ivHex, + }), + ); + final decoded = jsonDecode(res.body) as Map; + if (res.statusCode != 200) { + throw Exception( + decoded['error'] as String? ?? 'Could not create a code.', + ); + } + return ( + code: decoded['code'] as String, + redeemToken: decoded['redeem_token'] as String, + expiresAt: DateTime.parse(decoded['expires_at'] as String), + ); + } + + /// Recipient side: atomically claims a code. New two-digit pairing codes + /// require [redeemToken] from the secure link; the optional form only keeps + /// short-lived legacy eight-character codes working during rollout. + Future<({String targetKind, String targetId, String keyHex, String ivHex})> + redeem(String code, {String? redeemToken}) async { + final payload = {'code': code}; + if (redeemToken != null) payload['redeem_token'] = redeemToken; + final res = await http.post( + _redeemEndpoint, + headers: _headers, + body: jsonEncode(payload), + ); + final decoded = jsonDecode(res.body) as Map; + if (res.statusCode != 200) { + throw Exception( + decoded['error'] as String? ?? 'That code could not be redeemed.', + ); + } + return ( + targetKind: decoded['target_kind'] as String, + targetId: decoded['target_id'] as String, + keyHex: decoded['key_hex'] as String, + ivHex: decoded['iv_hex'] as String, + ); + } +} diff --git a/app/lib/features/share/data/repositories/supabase_share_repository.dart b/app/lib/features/share/data/repositories/supabase_share_repository.dart new file mode 100644 index 0000000..3fbdcd5 --- /dev/null +++ b/app/lib/features/share/data/repositories/supabase_share_repository.dart @@ -0,0 +1,64 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../domain/entities/share_link.dart'; +import '../../domain/repositories/share_repository.dart'; +import '../share_fetch_client.dart'; + +/// Supabase-backed implementation of [ShareRepository]. +/// +/// Creating/managing links goes straight to Postgres under RLS (only the +/// file's owner may insert a link for it — see the `share_links_insert_owner` +/// policy). Resolving a token for viewing NEVER uses this client's session — +/// that path is deliberately anonymous (see [ShareFetchClient]). +class SupabaseShareRepository implements ShareRepository { + SupabaseShareRepository(this._client, this._fetchClient); + + final SupabaseClient _client; + final ShareFetchClient _fetchClient; + + String get _userId { + final id = _client.auth.currentUser?.id; + if (id == null) throw StateError('Signed-out: sharing requires a session.'); + return id; + } + + @override + Future createShareLink( + String fileId, { + bool requireTouchReveal = true, + }) async { + final row = await _client + .from('share_links') + .insert({ + 'file_id': fileId, + 'created_by': _userId, + 'require_touch_reveal': requireTouchReveal, + }) + .select() + .single(); + return ShareLink.fromMap(row); + } + + @override + Future> myLinksForFile(String fileId) async { + final rows = await _client + .from('share_links') + .select() + .eq('file_id', fileId) + .order('created_at', ascending: false); + return rows.map((r) => ShareLink.fromMap(Map.from(r))).toList(); + } + + @override + Future revokeLink(String linkId) async { + await _client.from('share_links').update({'revoked': true}).eq('id', linkId); + } + + @override + Future fetchForView({ + required String token, + required String viewerEmail, + }) { + return _fetchClient.fetch(token: token, viewerEmail: viewerEmail); + } +} diff --git a/app/lib/features/share/data/share_fetch_client.dart b/app/lib/features/share/data/share_fetch_client.dart new file mode 100644 index 0000000..4276f49 --- /dev/null +++ b/app/lib/features/share/data/share_fetch_client.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../../../config/supabase_credentials.dart'; +import '../domain/entities/share_link.dart'; + +/// Calls the public `share-fetch` Edge Function directly over HTTPS — no +/// Supabase client, no session, no JWT. This is intentional: a share-link +/// recipient may have no account at all, so this client can never depend on +/// being signed in. The token itself is the credential (see +/// supabase/functions/share-fetch/index.ts). +class ShareFetchClient { + ShareFetchClient._(); + static final ShareFetchClient instance = ShareFetchClient._(); + + static final Uri _endpoint = + Uri.parse('${SupabaseCredentials.url}/functions/v1/share-fetch'); + + Future fetch({ + required String token, + required String viewerEmail, + String? deviceType, + }) async { + final res = await http.post( + _endpoint, + headers: { + 'Content-Type': 'application/json', + 'apikey': SupabaseCredentials.anonKey, + }, + body: jsonEncode({ + 'token': token, + 'viewer_email': viewerEmail, + 'device_type': deviceType ?? 'web', + }), + ); + + final decoded = jsonDecode(res.body) as Map; + if (res.statusCode != 200) { + throw Exception(decoded['error'] as String? ?? 'This link could not be opened.'); + } + return ShareFetchResult.fromMap(decoded); + } +} diff --git a/app/lib/features/share/data/share_heartbeat_client.dart b/app/lib/features/share/data/share_heartbeat_client.dart new file mode 100644 index 0000000..01fc146 --- /dev/null +++ b/app/lib/features/share/data/share_heartbeat_client.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../../../config/supabase_credentials.dart'; + +/// Sends heartbeats and close events to the share-heartbeat Edge Function. +/// This is used by both the Web client and the native app viewer to log +/// how long the document was viewed. +class ShareHeartbeatClient { + ShareHeartbeatClient._(); + static final ShareHeartbeatClient instance = ShareHeartbeatClient._(); + + static final Uri _endpoint = + Uri.parse('${SupabaseCredentials.url}/functions/v1/share-heartbeat'); + + /// Sends a heartbeat tick or close signal for a given view event ID. + /// + /// [eventType], when provided, reports a web-viewer deterrent signal + /// (devtools opened, repeated tab-visibility switches, a blocked + /// copy/print/save/right-click attempt — see [WebSecurityGuard]) which + /// the share-heartbeat function appends to that session's audit trail. + Future sendHeartbeat( + String eventId, { + bool close = false, + String? eventType, + }) async { + try { + await http.post( + _endpoint, + headers: { + 'Content-Type': 'application/json', + 'apikey': SupabaseCredentials.anonKey, + }, + body: jsonEncode({ + 'view_event_id': eventId, + 'close': close, + 'event_type': eventType, + }), + ); + } catch (e) { + // heartbeats are strictly best-effort, never throw errors back to user + } + } +} diff --git a/app/lib/features/share/domain/entities/share_link.dart b/app/lib/features/share/domain/entities/share_link.dart new file mode 100644 index 0000000..44091e8 --- /dev/null +++ b/app/lib/features/share/domain/entities/share_link.dart @@ -0,0 +1,125 @@ +/// A SecureSend share link — an unguessable token that lets an anonymous +/// recipient view one file, watermarked with their own identity. +/// +/// Maps to `public.share_links`. +class ShareLink { + final String id; + final String token; + final String fileId; + final bool revoked; + final DateTime? expiresAt; + final int viewCount; + final int? maxViews; + + /// Whether the sender required viewers to touch-and-hold to reveal the + /// document. Chosen by the sender when creating the link (defaults true). + final bool requireTouchReveal; + + const ShareLink({ + required this.id, + required this.token, + required this.fileId, + required this.revoked, + required this.expiresAt, + required this.viewCount, + required this.maxViews, + this.requireTouchReveal = true, + }); + + bool get isExpired => + expiresAt != null && expiresAt!.isBefore(DateTime.now()); + + bool get isLive => + !revoked && !isExpired && (maxViews == null || viewCount < maxViews!); + + factory ShareLink.fromMap(Map map) => ShareLink( + id: map['id'] as String, + token: map['token'] as String, + fileId: map['file_id'] as String, + revoked: map['revoked'] as bool? ?? false, + expiresAt: map['expires_at'] != null + ? DateTime.parse(map['expires_at'] as String) + : null, + viewCount: (map['view_count'] as num?)?.toInt() ?? 0, + maxViews: (map['max_views'] as num?)?.toInt(), + requireTouchReveal: map['require_touch_reveal'] as bool? ?? true, + ); +} + +/// What an anonymous recipient gets back after resolving a token: enough to +/// render the document, nothing more. +class ShareFetchResult { + final String fileName; + final String fileType; // 'pdf' | 'image' | 'markdown' | 'scan' + final String signedUrl; + final bool watermarkEnforced; + final bool blurEnforced; + final String? viewEventId; + + const ShareFetchResult({ + required this.fileName, + required this.fileType, + required this.signedUrl, + this.watermarkEnforced = true, + this.blurEnforced = true, + this.viewEventId, + }); + + factory ShareFetchResult.fromMap(Map map) => ShareFetchResult( + fileName: map['file_name'] as String? ?? 'document', + fileType: map['type'] as String? ?? 'pdf', + signedUrl: map['signed_url'] as String, + watermarkEnforced: map['watermark_enforced'] as bool? ?? true, + blurEnforced: map['blur_enforced'] as bool? ?? true, + viewEventId: map['view_event_id'] as String?, + ); +} + +class ShareViewEvent { + final String id; + final String linkId; + final String viewerEmail; + final String deviceType; // 'web' | 'app' + final DateTime startedAt; + final DateTime lastHeartbeat; + final DateTime? endedAt; + final int durationSeconds; + + const ShareViewEvent({ + required this.id, + required this.linkId, + required this.viewerEmail, + required this.deviceType, + required this.startedAt, + required this.lastHeartbeat, + this.endedAt, + required this.durationSeconds, + }); + + bool get isLive => + endedAt == null && + DateTime.now().difference(lastHeartbeat).inSeconds < 90; + + factory ShareViewEvent.fromMap(Map map) { + final started = DateTime.parse(map['started_at'] as String); + final heartbeat = DateTime.parse(map['last_heartbeat'] as String); + final ended = map['ended_at'] != null ? DateTime.parse(map['ended_at'] as String) : null; + + int duration = (map['duration_seconds'] as num?)?.toInt() ?? 0; + if (duration == 0) { + duration = (ended ?? heartbeat).difference(started).inSeconds; + } + + return ShareViewEvent( + id: map['id'] as String, + linkId: map['link_id'] as String, + viewerEmail: map['viewer_email'] as String, + deviceType: map['device_type'] as String? ?? 'web', + startedAt: started, + lastHeartbeat: heartbeat, + endedAt: ended, + durationSeconds: duration, + ); + } +} + diff --git a/app/lib/features/share/domain/repositories/share_repository.dart b/app/lib/features/share/domain/repositories/share_repository.dart new file mode 100644 index 0000000..5dbdb42 --- /dev/null +++ b/app/lib/features/share/domain/repositories/share_repository.dart @@ -0,0 +1,30 @@ +import '../entities/share_link.dart'; + +/// Domain contract for SecureSend share links. Implementations live in `data/`. +abstract class ShareRepository { + /// Creates a share link for [fileId] (caller must own the file — enforced + /// by RLS). Returns the created link, whose `token` is embedded in the + /// public URL shown to the sender. + /// + /// [requireTouchReveal] is the sender's own choice, made at share time: if + /// false, the recipient sees the document immediately with no blur/touch + /// gate (still watermarked and view-logged either way). + Future createShareLink( + String fileId, { + bool requireTouchReveal = true, + }); + + /// The caller's own links for a file (own-links only, per RLS). + Future> myLinksForFile(String fileId); + + /// Revokes a link the caller created. + Future revokeLink(String linkId); + + /// Anonymous resolution of a token into a viewable document. No Supabase + /// session is used or required — this call authenticates with the token + /// alone, exactly as a signed-out recipient would. + Future fetchForView({ + required String token, + required String viewerEmail, + }); +} diff --git a/app/lib/features/share/presentation/providers/share_providers.dart b/app/lib/features/share/presentation/providers/share_providers.dart new file mode 100644 index 0000000..99369e7 --- /dev/null +++ b/app/lib/features/share/presentation/providers/share_providers.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/supabase/supabase_providers.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../data/repositories/supabase_share_repository.dart'; +import '../../data/share_fetch_client.dart'; +import '../../domain/entities/share_link.dart'; +import '../../domain/repositories/share_repository.dart'; + +/// Repository singleton for creating/managing SecureSend share links. +/// (The anonymous viewing path never uses this — see [ShareFetchClient].) +final shareRepositoryProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return SupabaseShareRepository(client, ShareFetchClient.instance); +}); + +class ShareAnalytics { + final List events; + final int totalViews; + final int uniqueViewers; + final int liveCount; + + const ShareAnalytics({ + required this.events, + required this.totalViews, + required this.uniqueViewers, + required this.liveCount, + }); +} + +/// Provides realtime views/analytics for a specific share link ID. +final shareAnalyticsProvider = StreamProvider.family((ref, linkId) { + final client = ref.watch(supabaseClientProvider); + return client + .from('share_view_events') + .stream(primaryKey: ['id']) + .eq('link_id', linkId) + .order('started_at', ascending: false) + .map((rows) { + final events = rows.map((row) => ShareViewEvent.fromMap(row)).toList(); + final totalViews = events.length; + + final uniqueEmails = events.map((e) => e.viewerEmail).toSet(); + final uniqueViewers = uniqueEmails.length; + + final liveCount = events.where((e) => e.isLive).length; + + return ShareAnalytics( + events: events, + totalViews: totalViews, + uniqueViewers: uniqueViewers, + liveCount: liveCount, + ); + }); +}); + +/// Raw stream of all share view events for current user's links +final shareNotificationStreamProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + final user = ref.watch(authStateProvider).value; + if (user == null) return Stream.value([]); + + return client + .from('share_view_events') + .stream(primaryKey: ['id']) + .order('started_at', ascending: false) + .map((rows) => rows.map((row) => ShareViewEvent.fromMap(row)).toList()); +}); + +class ShareNotificationState { + final ShareViewEvent? latestEvent; + final String? fileName; + const ShareNotificationState({this.latestEvent, this.fileName}); +} + +class ShareNotificationNotifier extends Notifier { + final Set _seenEventIds = {}; + + @override + ShareNotificationState build() { + ref.listen>>(shareNotificationStreamProvider, (prev, next) { + if (next.hasValue) { + // Deferred off Riverpod's callback stack. checkNewEvents reads a + // provider and runs synchronously up to its first await, so calling it + // straight from this listener trips the "cannot use Ref inside + // life-cycles" assert. The read stays lazy so it keeps degrading + // gracefully when Supabase is unreachable. + Future.microtask(() => checkNewEvents(next.value!)); + } + }); + return const ShareNotificationState(); + } + + void checkNewEvents(List events) async { + if (events.isEmpty) return; + + final newest = events.first; + if (_seenEventIds.contains(newest.id)) return; + + _seenEventIds.add(newest.id); + + // If it started more than 15 seconds ago, it's not a fresh view (e.g. initial load of list) + if (DateTime.now().difference(newest.startedAt).inSeconds > 15) return; + + try { + final client = ref.read(supabaseClientProvider); + final res = await client + .from('share_links') + .select('secure_files(name)') + .eq('id', newest.linkId) + .maybeSingle(); + + final fileName = (res?['secure_files'] as Map?)?['name'] as String? ?? 'a file'; + + state = ShareNotificationState(latestEvent: newest, fileName: fileName); + } catch (_) { + state = ShareNotificationState(latestEvent: newest, fileName: 'a file'); + } + } + + void dismiss() { + state = const ShareNotificationState(); + } +} + +/// Provides real-time banner notifications when a user's link is opened. +final shareNotificationProvider = NotifierProvider(() { + return ShareNotificationNotifier(); +}); + diff --git a/app/lib/features/share/presentation/screens/anonymous_share_viewer_screen.dart b/app/lib/features/share/presentation/screens/anonymous_share_viewer_screen.dart new file mode 100644 index 0000000..34d60b6 --- /dev/null +++ b/app/lib/features/share/presentation/screens/anonymous_share_viewer_screen.dart @@ -0,0 +1,413 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import '../../../../components/experiment_frame.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:pdfrx/pdfrx.dart'; + +import '../../../../components/secure_viewer/models/viewer_config.dart'; +import '../../../../components/secure_viewer/models/watermark_config.dart'; +import '../../../../components/secure_viewer/secure_document_viewer.dart'; +import '../../data/share_fetch_client.dart'; +import '../../data/share_heartbeat_client.dart'; +import '../../../../services/web_security_guard.dart'; +import '../../domain/entities/share_link.dart'; + +/// SecureSend's anonymous recipient view. Deliberately NOT a [ConsumerWidget] +/// and NOT wired to any Supabase session — a share-link recipient may have no +/// NO SUS account at all. Auth for this screen is the link token itself. +/// +/// Flow: enter email (the watermark identity + what gets logged) -> resolve +/// the token via the public `share-fetch` function -> fetch bytes from the +/// short-lived signed URL -> render in the SAME secure viewer used in-app. +/// +/// HONESTY RULE: this is a browser. Screenshot-blocking is impossible here — +/// protection is watermark-with-identity + blur-until-touch + view logging. +/// Never claim "screenshot-proof" in this screen's copy. +class AnonymousShareViewerScreen extends ConsumerStatefulWidget { + const AnonymousShareViewerScreen({super.key, required this.token}); + + final String token; + + @override + ConsumerState createState() => + _AnonymousShareViewerScreenState(); +} + +enum _Stage { emailGate, loading, viewing, error } + +class _AnonymousShareViewerScreenState + extends ConsumerState { + _Stage _stage = _Stage.emailGate; + final _emailController = TextEditingController(); + String? _errorMessage; + ShareFetchResult? _result; + Uint8List? _bytes; + Timer? _heartbeatTimer; + final _webGuard = WebSecurityGuard(); + // Broadcasts to SecureDocumentViewer's blur layer whenever WebSecurityGuard + // reports a signal that suggests active inspection or capture. This can't + // detect an actual OS screenshot — no browser API exposes that — but + // DevTools opening or repeated tab-hiding are the closest real proxies, + // and blurring immediately on them is a genuine reactive deterrent rather + // than a claim of prevention. + final _concealSignal = StreamController.broadcast(); + + static const _concealTriggers = { + 'devtools_detected', + 'visibility_hidden_repeated', + 'automation_detected', + }; + + @override + void dispose() { + _emailController.dispose(); + _stopHeartbeat(close: true); + _webGuard.detach(); + _concealSignal.close(); + super.dispose(); + } + + void _startHeartbeat(String eventId) { + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) { + ShareHeartbeatClient.instance.sendHeartbeat(eventId); + }); + } + + void _stopHeartbeat({bool close = false}) { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + final eventId = _result?.viewEventId; + if (eventId != null && close) { + ShareHeartbeatClient.instance.sendHeartbeat(eventId, close: true); + } + } + + Future _submit() async { + final email = _emailController.text.trim(); + if (!email.contains('@') || !email.contains('.')) { + setState(() => _errorMessage = 'Enter a valid email to continue.'); + return; + } + + setState(() { + _stage = _Stage.loading; + _errorMessage = null; + }); + try { + final result = await ShareFetchClient.instance.fetch( + token: widget.token, + viewerEmail: email, + ); + final bytesRes = await http.get(Uri.parse(result.signedUrl)); + if (bytesRes.statusCode != 200) { + throw Exception('Could not download the document.'); + } + if (!mounted) return; + setState(() { + _result = result; + _bytes = bytesRes.bodyBytes; + _stage = _Stage.viewing; + }); + if (result.viewEventId != null) { + _startHeartbeat(result.viewEventId!); + } + // Deterrent layer for this viewing session — see WebSecurityGuard's + // honesty-rule docs: this reduces casual leak paths and reports + // devtools/visibility signals, it does not prevent screenshots. + _webGuard.attach((eventType) { + final eventId = _result?.viewEventId; + if (eventId != null) { + ShareHeartbeatClient.instance.sendHeartbeat( + eventId, + eventType: eventType, + ); + } + if (_concealTriggers.contains(eventType)) { + _concealSignal.add(null); + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _stage = _Stage.error; + _errorMessage = e.toString().replaceFirst('Exception: ', ''); + }); + } + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + builder: (context, child) => ExperimentFrame(child: child ?? const SizedBox.shrink()), + title: 'NO SUS — Secure Document', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true), + home: Scaffold( + body: SafeArea( + child: switch (_stage) { + _Stage.emailGate => _buildEmailGate(), + _Stage.loading => _buildLoading(), + _Stage.error => _buildError(), + _Stage.viewing => _buildViewer(), + }, + ), + ), + ); + } + + Widget _buildEmailGate() { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _NoSusWordmark(), + const SizedBox(height: 36), + Text( + 'You have been sent a document', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 12), + const Text( + 'Enter your email to open it. It will appear in the document\'s access activity.', + style: TextStyle(color: Colors.white70, height: 1.45), + ), + const SizedBox(height: 24), + TextField( + controller: _emailController, + autofocus: true, + keyboardType: TextInputType.emailAddress, + onSubmitted: (_) => _submit(), + decoration: InputDecoration( + hintText: 'you@example.com', + hintStyle: const TextStyle(color: Colors.white30), + errorText: _errorMessage, + errorStyle: const TextStyle(color: Colors.redAccent), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.white38), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.white), + ), + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.white38), + ), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _submit, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.black, + backgroundColor: Colors.white, + side: const BorderSide(color: Colors.white, width: 1.5), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + padding: const EdgeInsets.symmetric(vertical: 15), + ), + child: const Text( + 'OPEN DOCUMENT', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildLoading() { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + SizedBox(height: 20), + Text( + 'Opening document', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + SizedBox(height: 8), + Text( + 'This only takes a moment.', + style: TextStyle(color: Colors.white70), + ), + ], + ), + ); + } + + Widget _buildError() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40, color: Colors.redAccent), + const SizedBox(height: 16), + Text( + _errorMessage ?? 'This document is unavailable.', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16, color: Colors.redAccent), + ), + const SizedBox(height: 24), + OutlinedButton( + onPressed: () => setState(() { + _stage = _Stage.emailGate; + _errorMessage = null; + }), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38, width: 1.2), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: const Text( + 'TRY AGAIN', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ); + } + + Widget _buildViewer() { + final result = _result!; + final bytes = _bytes!; + final email = _emailController.text.trim(); + + final watermarkConfig = WatermarkConfig( + name: email, + role: 'RECIPIENT', + email: email, + timestamp: DateTime.now().toIso8601String(), + ); + + Widget child; + switch (result.fileType.toLowerCase()) { + case 'pdf': + child = PdfViewer.data( + bytes, + sourceName: result.fileName, + // No text selection in the protected viewer (copy leak + pdfrx + // 2.4.4 selection painter crash on textless pages). + params: const PdfViewerParams( + textSelectionParams: PdfTextSelectionParams(enabled: false), + ), + ); + break; + case 'image': + case 'scan': + child = InteractiveViewer(child: Center(child: Image.memory(bytes))); + break; + default: + child = const Center( + child: Text( + 'This file type cannot be previewed in the browser.', + style: TextStyle(fontSize: 13), + ), + ); + } + + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + color: Colors.black, + child: Row( + children: [ + Expanded( + child: Text( + result.fileName, + style: const TextStyle(fontSize: 14, color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + result.watermarkEnforced ? 'Watermarked' : 'View logged', + style: const TextStyle(fontSize: 11, color: Colors.white54), + ), + ], + ), + ), + Expanded( + child: SecureDocumentViewer( + watermarkConfig: watermarkConfig, + viewerConfig: const ViewerConfig(), + touchToRevealEnabled: result.blurEnforced, + watermarkEnabled: result.watermarkEnforced, + forceConcealSignal: _concealSignal.stream, + child: child, + ), + ), + ], + ); + } +} + +class _NoSusWordmark extends StatelessWidget { + const _NoSusWordmark(); + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'NO SUS', + child: RichText( + text: const TextSpan( + style: TextStyle( + color: Colors.white, + fontSize: 23, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ), + children: [ + TextSpan(text: 'NO SUS'), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Padding( + padding: EdgeInsets.only(left: 3), + child: SizedBox( + width: 5, + height: 5, + child: ColoredBox(color: Color(0xFF808080)), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/share/presentation/screens/burn_file_creator_screen.dart b/app/lib/features/share/presentation/screens/burn_file_creator_screen.dart new file mode 100644 index 0000000..626dacb --- /dev/null +++ b/app/lib/features/share/presentation/screens/burn_file_creator_screen.dart @@ -0,0 +1,778 @@ +import 'package:encrypt/encrypt.dart' as enc; +import 'package:file_picker/file_picker.dart' as fp; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../../core/mascot/mascot_controller.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; +import '../../../../core/utils/web_links.dart'; +import '../../../../services/burn_file_crypto.dart'; +import '../../data/burn_file_client.dart'; +import '../../data/redemption_code_client.dart'; + +/// "Burn Files" creator — file.io-style anonymous upload. No login on +/// either end (product decision): the sender doesn't need an account, and +/// neither does the recipient. Mirrors BurnNoteCreatorScreen's zero- +/// knowledge design, extended to arbitrary binary files — see +/// lib/services/burn_file_crypto.dart for why the filename/mimetype are +/// packed INSIDE the encrypted payload rather than sent to the server. +/// +/// Supports sharing multiple files as one link: each file keeps its own +/// independent key/IV (same crypto as a single file, just N of them) and +/// the whole encrypt→upload→confirm pipeline runs in PARALLEL across files +/// — for real speed, not just as a checkbox feature. The combined ciphertext +/// across every file in one share is capped at [_maxTotalBytes] (25MB); +/// the per-file cap is enforced authoritatively server-side via +/// remote_configs' burn_files_max_size_bytes. +class BurnFileCreatorScreen extends ConsumerStatefulWidget { + /// Pre-fills the picker with a file shared into the app from another app + /// (Photos, Files, WhatsApp, etc. via Android's share intent) — the + /// highest-leverage "seamless" entry point: share a file into NO SUS and + /// land here with it already selected. + final fp.PlatformFile? prefilledFile; + + const BurnFileCreatorScreen({super.key, this.prefilledFile}); + + @override + ConsumerState createState() => + _BurnFileCreatorScreenState(); +} + +enum _ExpiryOption { oneHour, oneDay, sevenDays } + +extension on _ExpiryOption { + int get hours => switch (this) { + _ExpiryOption.oneHour => 1, + _ExpiryOption.oneDay => 24, + _ExpiryOption.sevenDays => 24 * 7, + }; + + String get label => switch (this) { + _ExpiryOption.oneHour => '1 HOUR', + _ExpiryOption.oneDay => '24 HOURS', + _ExpiryOption.sevenDays => '7 DAYS', + }; +} + +// Combined cap across every file in ONE share. The per-file cap is the +// authoritative server-side remote_configs value (also 25MB by default) — +// this is the client-side "does this whole share fit" pre-flight check, so +// the user gets instant feedback instead of a failed network call partway +// through a multi-file upload. +const int _maxTotalBytes = 25 * 1024 * 1024; +const int _maxFilesPerShare = 10; + +class _EncryptJob { + final Uint8List packed; + final Uint8List keyBytes; + final Uint8List ivBytes; + const _EncryptJob(this.packed, this.keyBytes, this.ivBytes); +} + +// Top-level so it's eligible to run on a background isolate via compute() — +// AES-CBC over a multi-MB file is real CPU work, and running it off the UI +// thread is what keeps the picker/progress UI responsive while several +// files encrypt at once. compute() degrades to a plain synchronous call on +// platforms without isolate support, so this is safe everywhere. +Uint8List _encryptOnIsolate(_EncryptJob job) { + return encryptBurnFilePayload( + job.packed, + enc.Key(job.keyBytes), + enc.IV(job.ivBytes), + ); +} + +class _BurnFileCreatorScreenState extends ConsumerState { + final List _selectedFiles = []; + _ExpiryOption _expiry = _ExpiryOption.oneDay; + bool _isProcessing = false; + String? _statusLabel; + String? _generatedLink; + String? _generatedCode; + String? _generatedPairingLink; + int _filesDoneCount = 0; + + @override + void initState() { + super.initState(); + if (widget.prefilledFile != null) _selectedFiles.add(widget.prefilledFile!); + } + + int get _totalBytes => _selectedFiles.fold(0, (sum, f) => sum + f.size); + bool get _overLimit => _totalBytes > _maxTotalBytes; + + Future _pickFiles() async { + final result = await fp.FilePicker.pickFiles( + withData: true, + allowMultiple: true, + ); + if (result == null || result.files.isEmpty) return; + setState(() { + for (final f in result.files) { + if (_selectedFiles.length >= _maxFilesPerShare) break; + _selectedFiles.add(f); + } + }); + } + + void _removeFile(int index) { + setState(() => _selectedFiles.removeAt(index)); + } + + Future<({String fileId, String keyHex, String ivHex})> _burnOneFile( + fp.PlatformFile file, + ) async { + final bytes = file.bytes!; + final keyMaterial = generateBurnFileKeyMaterial(); + final packed = packBurnFilePayload( + fileName: file.name, + mimeType: _guessMimeType(file.extension), + fileBytes: bytes, + ); + final ciphertext = await compute( + _encryptOnIsolate, + _EncryptJob(packed, keyMaterial.key.bytes, keyMaterial.iv.bytes), + ); + + final initResult = await BurnFileClient.instance.init( + declaredSizeBytes: ciphertext.length, + expiryHours: _expiry.hours, + ); + + await Supabase.instance.client.storage + .from('burn-files') + .uploadBinaryToSignedUrl( + initResult.fileId, + initResult.uploadToken, + ciphertext, + ); + + await BurnFileClient.instance.confirm(initResult.fileId); + + if (mounted) setState(() => _filesDoneCount++); + + return ( + fileId: initResult.fileId, + keyHex: bytesToHex(keyMaterial.key.bytes), + ivHex: bytesToHex(keyMaterial.iv.bytes), + ); + } + + Future _burnAndUpload() async { + if (_selectedFiles.isEmpty || _selectedFiles.any((f) => f.bytes == null)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please choose at least one file first.')), + ); + return; + } + if (_overLimit) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Total size is ${_formatSize(_totalBytes)} — Burn Files shares are capped at 25MB combined.', + ), + ), + ); + return; + } + + setState(() { + _isProcessing = true; + _filesDoneCount = 0; + _statusLabel = _selectedFiles.length > 1 + ? 'ENCRYPTING ${_selectedFiles.length} FILES...' + : 'ENCRYPTING...'; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.guard); + + try { + // Every file's encrypt→init→upload→confirm pipeline is independent — + // running them concurrently is the real speed win for a multi-file + // share, not just parallelizing the upload step. + final triples = await Future.wait( + _selectedFiles.map((f) => _burnOneFile(f)), + ); + + setState(() => _statusLabel = 'SEALING...'); + + final (:origin, :basePath) = webShareLinkBase(); + final String link; + String? code; + String? pairingLink; + if (triples.length == 1) { + // Exactly the original single-file link shape — unchanged so every + // burn-file link already shared into the wild keeps working, and so + // the common case doesn't pay for multi-file link overhead. + final t = triples.single; + link = + '$origin$basePath/#/burnfile/${t.fileId}?k=${t.keyHex}&v=${t.ivHex}'; + try { + final codeResult = await RedemptionCodeClient.instance.createCode( + targetKind: 'file', + targetId: t.fileId, + keyHex: t.keyHex, + ivHex: t.ivHex, + ); + code = codeResult.code; + pairingLink = '$origin$basePath/#/redeem/${codeResult.redeemToken}'; + } catch (_) { + code = null; + pairingLink = null; + } + } else { + // New multi-file shape. Redemption codes stay single-target only + // (see supabase/migrations/20260713000000_burn_redemption_codes.sql) + // — a multi-file share only gets the link, not a short code. + final ids = triples.map((t) => t.fileId).join(','); + final keys = triples.map((t) => t.keyHex).join(','); + final ivs = triples.map((t) => t.ivHex).join(','); + link = '$origin$basePath/#/burnfiles/$ids?k=$keys&v=$ivs'; + code = null; + pairingLink = null; + } + + setState(() { + _generatedLink = link; + _generatedCode = code; + _generatedPairingLink = pairingLink; + _isProcessing = false; + _statusLabel = null; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.approve); + } catch (e) { + setState(() { + _isProcessing = false; + _statusLabel = null; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.alert); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to create Burn File: ${e.toString().replaceFirst('Exception: ', '')}', + ), + ), + ); + } + } + + static String _guessMimeType(String? extension) { + switch (extension?.toLowerCase()) { + case 'pdf': + return 'application/pdf'; + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'txt': + return 'text/plain'; + case 'zip': + return 'application/zip'; + default: + return 'application/octet-stream'; + } + } + + void _copyToClipboard() { + if (_generatedLink == null) return; + Clipboard.setData(ClipboardData(text: _generatedLink!)); + HapticFeedback.mediumImpact(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Burn Files link copied to clipboard!'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + void _shareLink() { + if (_generatedLink == null) return; + SharePlus.instance.share( + ShareParams( + text: + 'Here\'s a file — it self-destructs after one download: $_generatedLink', + ), + ); + } + + void _copyCodeToClipboard() { + if (_generatedCode == null) return; + Clipboard.setData(ClipboardData(text: _generatedCode!)); + HapticFeedback.mediumImpact(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Code copied to clipboard!'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + void _copyPairingLinkToClipboard() { + final pairingLink = _generatedPairingLink; + if (pairingLink == null) return; + Clipboard.setData(ClipboardData(text: pairingLink)); + HapticFeedback.mediumImpact(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Secure pairing link copied to clipboard!'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = theme.colorScheme.onSurface; + + return Scaffold( + appBar: AppBar( + title: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'BURN FILES', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + Text( + 'ANONYMOUS · SELF-DESTRUCTS ON DOWNLOAD', + style: TextStyle( + fontSize: 9, + color: Colors.orangeAccent, + letterSpacing: 1.0, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + body: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_generatedLink == null) ...[ + Text( + 'Drop up to $_maxFilesPerShare files, get one link. Nobody needs an account — not you, not them. Once downloaded, they\'re gone.', + style: theme.textTheme.bodyMedium?.copyWith( + color: fg.withValues(alpha: 0.6), + fontSize: 13, + ), + ), + const SizedBox(height: 20), + if (_selectedFiles.isNotEmpty) ...[ + ..._selectedFiles.asMap().entries.map((entry) { + final i = entry.key; + final f = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF161616) + : Colors.black.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.orangeAccent.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + const Icon( + Icons.insert_drive_file_outlined, + size: 18, + color: Colors.orangeAccent, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + f.name, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: fg.withValues(alpha: 0.85), + ), + ), + ), + const SizedBox(width: 8), + Text( + _formatSize(f.size), + style: TextStyle( + fontSize: 11, + color: fg.withValues(alpha: 0.4), + ), + ), + if (!_isProcessing) ...[ + const SizedBox(width: 4), + IconButton( + onPressed: () => _removeFile(i), + tooltip: 'Remove ${f.name}', + icon: Icon( + Icons.close_rounded, + size: 16, + color: fg.withValues(alpha: 0.4), + ), + ), + ], + ], + ), + ), + ); + }), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${_selectedFiles.length}/$_maxFilesPerShare files · ${_formatSize(_totalBytes)} / 25.0 MB', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: _overLimit + ? Colors.redAccent + : fg.withValues(alpha: 0.45), + ), + ), + if (_selectedFiles.length < _maxFilesPerShare && + !_isProcessing) + TextButton.icon( + onPressed: _pickFiles, + icon: const Icon(Icons.add, size: 14), + label: const Text( + 'ADD MORE', + style: TextStyle(fontSize: 11), + ), + ), + ], + ), + const SizedBox(height: 16), + ] else + Semantics( + button: true, + enabled: !_isProcessing, + label: 'Choose files', + child: InkWell( + onTap: _isProcessing ? null : _pickFiles, + borderRadius: BorderRadius.circular(16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 28, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF161616) + : Colors.black.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: fg.withValues(alpha: 0.12), + ), + ), + child: Column( + children: [ + Icon( + Icons.upload_file_outlined, + size: 36, + color: fg.withValues(alpha: 0.4), + ), + const SizedBox(height: 12), + Text( + 'TAP TO CHOOSE FILES', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: fg.withValues(alpha: 0.85), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'LINK EXPIRES AFTER', + style: TextStyle( + fontSize: 10, + letterSpacing: 1.0, + color: fg.withValues(alpha: 0.4), + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + SegmentedButton<_ExpiryOption>( + segments: _ExpiryOption.values + .map( + (e) => ButtonSegment( + value: e, + label: Text( + e.label, + style: const TextStyle(fontSize: 11), + ), + ), + ) + .toList(), + selected: {_expiry}, + onSelectionChanged: _isProcessing + ? null + : (s) => setState(() => _expiry = s.first), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.orangeAccent, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: + (_isProcessing || + _selectedFiles.isEmpty || + _overLimit) + ? null + : _burnAndUpload, + icon: _isProcessing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), + ) + : const Icon( + Icons.local_fire_department_outlined, + size: 16, + ), + label: Text( + _isProcessing + ? '${_statusLabel ?? 'WORKING...'} ${_selectedFiles.length > 1 ? '($_filesDoneCount/${_selectedFiles.length})' : ''}' + : (_selectedFiles.length > 1 + ? 'ENCRYPT & GENERATE LINK (${_selectedFiles.length} FILES)' + : 'ENCRYPT & GENERATE LINK'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ] else ...[ + Center( + child: Column( + children: [ + const SizedBox(height: 20), + const MascotView( + character: MascotCharacter.nox, + size: 48, + fallback: Icon( + Icons.verified_user_outlined, + size: 48, + color: Colors.green, + ), + ), + const SizedBox(height: 16), + Text( + _selectedFiles.length > 1 + ? '${_selectedFiles.length} FILES READY TO SHARE' + : 'FILE READY TO SHARE', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 12), + Text( + _selectedFiles.length > 1 + ? 'Your files are encrypted. The keys live only in this link — the server cannot read them, and each is deleted permanently the moment it\'s downloaded.' + : 'Your file is encrypted. The key lives only in this link — the server cannot read it, and it\'s deleted permanently the moment it\'s downloaded.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + ), + ), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF161616) + : Colors.black.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.green.withValues(alpha: 0.3), + ), + ), + child: Text( + _generatedLink!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: Colors.green, + ), + ), + ), + if (_generatedCode != null && + _generatedPairingLink != null) ...[ + const SizedBox(height: 20), + Text( + 'OPTIONAL QUICK PAIRING', + style: TextStyle( + fontSize: 10, + letterSpacing: 1.0, + color: fg.withValues(alpha: 0.4), + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 20, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF161616) + : Colors.black.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.lightBlueAccent.withValues( + alpha: 0.3, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _generatedCode!, + style: const TextStyle( + fontSize: 20, + letterSpacing: 4, + fontWeight: FontWeight.bold, + color: Colors.lightBlueAccent, + ), + ), + const SizedBox(width: 12), + IconButton( + onPressed: _copyCodeToClipboard, + tooltip: 'Copy code', + icon: const Icon( + Icons.copy_rounded, + size: 16, + color: Colors.lightBlueAccent, + ), + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + 'Send the secure pairing link, then share these two digits. The link controls access; the code only confirms the share.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10.5, + color: fg.withValues(alpha: 0.4), + ), + ), + ], + const SizedBox(height: 32), + if (_generatedPairingLink != null) ...[ + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 14, + ), + ), + onPressed: _copyPairingLinkToClipboard, + icon: const Icon(Icons.link_rounded, size: 16), + label: const Text( + 'COPY PAIRING LINK', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 12), + ], + SizedBox( + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: _copyToClipboard, + icon: const Icon(Icons.copy_rounded, size: 16), + label: const Text( + 'COPY LINK', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: _shareLink, + icon: const Icon(Icons.ios_share_rounded, size: 16), + label: const Text( + 'SHARE LINK', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 24), + TextButton( + onPressed: () { + setState(() { + _generatedLink = null; + _generatedCode = null; + _generatedPairingLink = null; + _selectedFiles.clear(); + }); + }, + child: const Text( + 'BURN MORE FILES', + style: TextStyle(color: Colors.grey, fontSize: 11), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/share/presentation/screens/burn_file_viewer_screen.dart b/app/lib/features/share/presentation/screens/burn_file_viewer_screen.dart new file mode 100644 index 0000000..624b2eb --- /dev/null +++ b/app/lib/features/share/presentation/screens/burn_file_viewer_screen.dart @@ -0,0 +1,240 @@ +import 'package:encrypt/encrypt.dart' as enc; +import 'package:flutter/material.dart'; +import '../../../../components/experiment_frame.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:share_plus/share_plus.dart'; + +import '../../../../core/mascot/mascot_controller.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; +import '../../../../services/burn_file_crypto.dart'; +import '../../data/burn_file_client.dart'; + +typedef BurnFileTarget = ({String id, String keyHex, String ivHex}); + +/// Anonymous recipient side of "Burn Files" — no account, no email gate, +/// single tap. Handles both a single file (the original link shape) and a +/// multi-file share (a NEW link shape, see extractBurnFilesToken in +/// lib/main.dart) through the same code path: [files] is just a +/// single-element list for the classic case. +/// +/// Every file in [files] is independently fetched, claimed, and decrypted — +/// each with its own key/IV, exactly like the single-file crypto always +/// worked (no new crypto primitive for the multi-file case, just N of them +/// run in parallel for speed). Fetch/claim is a one-time, irreversible burn +/// PER FILE, so a multi-file share can partially succeed (e.g. one link +/// already used) — this screen tracks and reports that honestly rather than +/// treating the batch as all-or-nothing. +class BurnFileViewerScreen extends ConsumerStatefulWidget { + final List files; + + const BurnFileViewerScreen({super.key, required this.files}); + + @override + ConsumerState createState() => _BurnFileViewerScreenState(); +} + +enum _Stage { gate, fetching, delivered, error } + +class _FileOutcome { + final String fileName; + final bool ok; + final String? error; + const _FileOutcome({required this.fileName, required this.ok, this.error}); +} + +class _BurnFileViewerScreenState extends ConsumerState { + _Stage _stage = _Stage.gate; + List<_FileOutcome> _outcomes = []; + + bool get _isBatch => widget.files.length > 1; + + Future _downloadAndDecryptOne(BurnFileTarget target) async { + final signedUrl = await BurnFileClient.instance.fetch(target.id); + final res = await http.get(Uri.parse(signedUrl)); + if (res.statusCode != 200) { + throw Exception('Could not download the file.'); + } + final key = enc.Key(hexToBytes(target.keyHex)); + final iv = enc.IV(hexToBytes(target.ivHex)); + final packed = decryptBurnFilePayload(res.bodyBytes, key, iv); + final payload = unpackBurnFilePayload(packed); + + await SharePlus.instance.share( + ShareParams( + files: [XFile.fromData(payload.bytes, name: payload.fileName, mimeType: payload.mimeType)], + ), + ); + return; + } + + Future _downloadAndDecryptAll() async { + setState(() => _stage = _Stage.fetching); + ref.read(noxMascotProvider.notifier).play(MascotMood.guard); + + // Each file's fetch→decrypt→hand-off pipeline is fully independent of + // every other file's — running them concurrently (rather than one at a + // time) is the actual "as fast as possible" win for a multi-file share. + // errors are captured per-file so one bad/expired link in the batch + // doesn't hide whether the OTHER files were delivered successfully. + final results = await Future.wait(widget.files.map((target) async { + try { + await _downloadAndDecryptOne(target); + return _FileOutcome(fileName: target.id, ok: true); + } catch (e) { + return _FileOutcome( + fileName: target.id, + ok: false, + error: e.toString().replaceFirst('Exception: ', ''), + ); + } + })); + + if (!mounted) return; + final anyOk = results.any((r) => r.ok); + setState(() { + _outcomes = results; + _stage = anyOk ? _Stage.delivered : _Stage.error; + }); + ref.read(noxMascotProvider.notifier).play(anyOk ? MascotMood.approve : MascotMood.alert); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + builder: (context, child) => ExperimentFrame(child: child ?? const SizedBox.shrink()), + title: 'NO SUS — Burn File', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true), + home: Scaffold( + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: switch (_stage) { + _Stage.gate => _buildGate(), + _Stage.fetching => _buildFetching(), + _Stage.delivered => _buildDelivered(), + _Stage.error => _buildError(), + }, + ), + ), + ), + ), + ), + ); + } + + Widget _buildGate() { + final count = widget.files.length; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.local_fire_department, size: 56, color: Colors.orangeAccent), + const SizedBox(height: 24), + Text( + _isBatch ? '$count BURN FILES DETECTED' : 'BURN FILE DETECTED', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w900, letterSpacing: 1.5), + ), + const SizedBox(height: 12), + Text( + _isBatch + ? 'This is $count encrypted, one-time file drops shared together. No account needed. Once downloaded, they are permanently deleted from the server.' + : 'This is an encrypted, one-time file drop. No account needed. Once you download it, it is permanently deleted from the server.', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Colors.white70, height: 1.4), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.orangeAccent, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: _downloadAndDecryptAll, + icon: const Icon(Icons.download_rounded, size: 16), + label: Text( + _isBatch ? 'DOWNLOAD $count FILES' : 'DOWNLOAD FILE', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ), + ], + ); + } + + Widget _buildFetching() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(color: Colors.orangeAccent), + const SizedBox(height: 20), + Text( + _isBatch ? 'DECRYPTING & DELIVERING ${widget.files.length} FILES...' : 'DECRYPTING & DELIVERING...', + style: const TextStyle(fontSize: 10, letterSpacing: 1.0, color: Colors.orangeAccent), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildDelivered() { + final okCount = _outcomes.where((o) => o.ok).length; + final failed = _outcomes.where((o) => !o.ok).toList(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MascotView( + character: MascotCharacter.nox, + size: 48, + fallback: Icon(Icons.verified_user_outlined, size: 48, color: Colors.green), + ), + const SizedBox(height: 20), + Text( + _isBatch ? '$okCount OF ${_outcomes.length} FILES DELIVERED' : 'FILE DELIVERED', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 1.0), + ), + const SizedBox(height: 12), + const Text( + 'Delivered files have been permanently deleted from our servers. These links will never work again.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.white38), + ), + if (failed.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + '${failed.length} could not be delivered (already used or expired).', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: Colors.redAccent), + ), + ], + ], + ); + } + + Widget _buildError() { + final err = _outcomes.isNotEmpty ? _outcomes.first.error : null; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MascotView( + character: MascotCharacter.nox, + size: 48, + fallback: Icon(Icons.error_outline, size: 48, color: Colors.redAccent), + ), + const SizedBox(height: 20), + Text( + err ?? 'THIS LINK HAS EXPIRED OR ALREADY BEEN USED.', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14, color: Colors.redAccent), + ), + ], + ); + } +} diff --git a/app/lib/features/share/presentation/screens/burn_note_creator_screen.dart b/app/lib/features/share/presentation/screens/burn_note_creator_screen.dart new file mode 100644 index 0000000..4b874e5 --- /dev/null +++ b/app/lib/features/share/presentation/screens/burn_note_creator_screen.dart @@ -0,0 +1,459 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:encrypt/encrypt.dart' as enc; +import 'package:uuid/uuid.dart'; +import '../../../../core/mascot/mascot_controller.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; +import '../../../../core/utils/web_links.dart'; +import '../../../../services/burn_file_crypto.dart' show bytesToHex; +import '../../data/redemption_code_client.dart'; + +class BurnNoteCreatorScreen extends ConsumerStatefulWidget { + const BurnNoteCreatorScreen({super.key}); + + @override + ConsumerState createState() => + _BurnNoteCreatorScreenState(); +} + +class _BurnNoteCreatorScreenState extends ConsumerState { + final _textController = TextEditingController(); + bool _isGenerating = false; + String? _generatedLink; + String? _generatedCode; + String? _generatedPairingLink; + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + Future _generateLink() async { + final text = _textController.text.trim(); + if (text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a secret note first.')), + ); + return; + } + + setState(() { + _isGenerating = true; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.guard); + + try { + // 1. Generate 256-bit AES Key and 128-bit IV + final key = enc.Key.fromSecureRandom(32); + final iv = enc.IV.fromSecureRandom(16); + final encrypter = enc.Encrypter(enc.AES(key)); + + // 2. Encrypt plaintext note + final encrypted = encrypter.encrypt(text, iv: iv); + final ciphertext = encrypted.base64; + + // 3. Upload to Supabase database (Server only gets UUID and ciphertext). + // The id is generated client-side (not read back via .select()) so the + // burn_notes table needs no public SELECT policy — reads only ever + // happen through the atomic read_and_burn_note RPC. + final noteId = const Uuid().v4(); + await Supabase.instance.client.from('burn_notes').insert({ + 'id': noteId, + 'ciphertext': ciphertext, + }); + + // 4. Construct URL with key and iv as query params inside the hash fragment (Zero-Knowledge) + // We use ?k=&v= INSIDE the fragment so they are never sent to the server. + // Double-hash (#...#...) breaks in most browsers — query params inside the + // hash are spec-compliant and preserved reliably. + final (:origin, :basePath) = webShareLinkBase(); + final keyHex = bytesToHex(key.bytes); + final ivHex = bytesToHex(iv.bytes); + final link = '$origin$basePath/#/burn/$noteId?k=$keyHex&v=$ivHex'; + + // 5. Also mint a short redemption code pointing at the same key/IV — + // best-effort: the link already works on its own. + String? code; + String? pairingLink; + try { + final codeResult = await RedemptionCodeClient.instance.createCode( + targetKind: 'note', + targetId: noteId, + keyHex: keyHex, + ivHex: ivHex, + ); + code = codeResult.code; + pairingLink = '$origin$basePath/#/redeem/${codeResult.redeemToken}'; + } catch (_) { + code = null; + pairingLink = null; + } + + setState(() { + _generatedLink = link; + _generatedCode = code; + _generatedPairingLink = pairingLink; + _isGenerating = false; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.approve); + } catch (e) { + setState(() { + _isGenerating = false; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.alert); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to generate link: $e'))); + } + } + + void _copyToClipboard() { + if (_generatedLink == null) return; + Clipboard.setData(ClipboardData(text: _generatedLink!)); + HapticFeedback.mediumImpact(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Secret link copied to clipboard! Share it in your Story.', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + + void _shareLink() { + if (_generatedLink == null) return; + SharePlus.instance.share( + ShareParams( + text: 'Read my self-destructing secret note: $_generatedLink', + ), + ); + } + + void _copyCodeToClipboard() { + if (_generatedCode == null) return; + Clipboard.setData(ClipboardData(text: _generatedCode!)); + HapticFeedback.mediumImpact(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Code copied to clipboard!'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + void _copyPairingLinkToClipboard() { + final pairingLink = _generatedPairingLink; + if (pairingLink == null) return; + Clipboard.setData(ClipboardData(text: pairingLink)); + HapticFeedback.mediumImpact(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Secure pairing link copied to clipboard!'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = theme.colorScheme.onSurface; + + return Scaffold( + appBar: AppBar( + title: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'BURN NOTES', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + Text( + 'ZERO-KNOWLEDGE SELF-DESTRUCTS', + style: TextStyle( + fontSize: 9, + color: Colors.orangeAccent, + letterSpacing: 1.0, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + body: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_generatedLink == null) ...[ + Text( + 'Write a secret message to share. The recipient can only view it once before it burns.', + style: theme.textTheme.bodyMedium?.copyWith( + color: fg.withValues(alpha: 0.6), + fontSize: 13, + ), + ), + const SizedBox(height: 20), + SizedBox( + height: 240, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF161616) + : Colors.black.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: fg.withValues(alpha: 0.12)), + ), + child: TextField( + controller: _textController, + maxLines: null, + expands: true, + // Well under the server's 100 KB ciphertext cap, so a + // legitimate note can never hit the raw DB error. + maxLength: 10000, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: 'Type your secret message here...', + border: InputBorder.none, + counterStyle: TextStyle( + fontSize: 9, + color: fg.withValues(alpha: 0.35), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.orangeAccent, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: _isGenerating ? null : _generateLink, + icon: _isGenerating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), + ) + : const Icon(Icons.lock_outline, size: 16), + label: Text( + _isGenerating + ? 'ENCRYPTING...' + : 'ENCRYPT & GENERATE LINK', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ] else ...[ + Center( + child: Column( + children: [ + const SizedBox(height: 20), + const MascotView( + character: MascotCharacter.nox, + size: 48, + fallback: Icon( + Icons.verified_user_outlined, + size: 48, + color: Colors.green, + ), + ), + const SizedBox(height: 16), + const Text( + 'NOTE READY TO SHARE', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 12), + const Text( + 'Your note is encrypted. The key is embedded in the link fragment. The server cannot read it, and it will be deleted permanently once opened.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.white70), + ), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF161616) + : Colors.black.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.green.withValues(alpha: 0.3), + ), + ), + child: Text( + _generatedLink!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: Colors.green, + ), + ), + ), + if (_generatedCode != null && + _generatedPairingLink != null) ...[ + const SizedBox(height: 20), + Text( + 'OPTIONAL QUICK PAIRING', + style: TextStyle( + fontSize: 10, + letterSpacing: 1.0, + color: fg.withValues(alpha: 0.4), + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 20, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF161616) + : Colors.black.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.lightBlueAccent.withValues( + alpha: 0.3, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _generatedCode!, + style: const TextStyle( + fontSize: 20, + letterSpacing: 4, + fontWeight: FontWeight.bold, + color: Colors.lightBlueAccent, + ), + ), + const SizedBox(width: 12), + IconButton( + onPressed: _copyCodeToClipboard, + tooltip: 'Copy code', + icon: const Icon( + Icons.copy_rounded, + size: 16, + color: Colors.lightBlueAccent, + ), + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + 'Send the secure pairing link, then share these two digits. The link controls access; the code only confirms the share.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10.5, + color: fg.withValues(alpha: 0.4), + ), + ), + ], + const SizedBox(height: 32), + if (_generatedPairingLink != null) ...[ + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 14, + ), + ), + onPressed: _copyPairingLinkToClipboard, + icon: const Icon(Icons.link_rounded, size: 16), + label: const Text( + 'COPY PAIRING LINK', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 12), + ], + SizedBox( + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: _copyToClipboard, + icon: const Icon(Icons.copy_rounded, size: 16), + label: const Text( + 'COPY LINK', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: _shareLink, + icon: const Icon(Icons.ios_share_rounded, size: 16), + label: const Text( + 'SHARE LINK', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 24), + TextButton( + onPressed: () { + setState(() { + _generatedLink = null; + _generatedCode = null; + _generatedPairingLink = null; + _textController.clear(); + }); + }, + child: const Text( + 'CREATE ANOTHER NOTE', + style: TextStyle(color: Colors.grey, fontSize: 11), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/share/presentation/screens/burn_note_viewer_screen.dart b/app/lib/features/share/presentation/screens/burn_note_viewer_screen.dart new file mode 100644 index 0000000..61a942d --- /dev/null +++ b/app/lib/features/share/presentation/screens/burn_note_viewer_screen.dart @@ -0,0 +1,344 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../../../components/experiment_frame.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:encrypt/encrypt.dart' as enc; +import '../../../../core/mascot/mascot_controller.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; +import '../../../../services/web_security_guard.dart'; + +List _hexToBytes(String hex) { + final bytes = []; + for (int i = 0; i < hex.length; i += 2) { + bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); + } + return bytes; +} + +class BurnNoteViewerScreen extends ConsumerStatefulWidget { + const BurnNoteViewerScreen({ + super.key, + required this.noteId, + required this.keyHex, + required this.ivHex, + }); + + final String noteId; + final String keyHex; + final String ivHex; + + @override + ConsumerState createState() => _BurnNoteViewerScreenState(); +} + +enum _BurnStage { gate, decrypting, active, burned, error } + +class _BurnNoteViewerScreenState extends ConsumerState { + _BurnStage _stage = _BurnStage.gate; + String? _decryptedContent; + String? _errorMessage; + int _secondsLeft = 60; + Timer? _timer; + StreamSubscription? _blurSubscription; + // Deliberately does NOT block copy/select: the note body below uses + // SelectableText on purpose (a recipient may need to copy the secret + // before it burns). No server reporting either — burn notes have no + // view-event ledger the way SecureSend share links do. + final _webGuard = WebSecurityGuard( + blockCopy: false, + blockCut: false, + blockSelect: false, + detectDevTools: false, + detectVisibilityChanges: false, + ); + + @override + void dispose() { + _timer?.cancel(); + _blurSubscription?.cancel(); + _webGuard.detach(); + super.dispose(); + } + + void _burnImmediately() { + if (_stage != _BurnStage.active) return; + _timer?.cancel(); + _blurSubscription?.cancel(); + _webGuard.detach(); + setState(() { + _decryptedContent = null; + _stage = _BurnStage.burned; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.returnToLogo); + } + + Future _reveal() async { + setState(() { + _stage = _BurnStage.decrypting; + }); + + try { + // 1. Fetch ciphertext & delete instantly from DB using the RPC + final response = await Supabase.instance.client + .rpc('read_and_burn_note', params: {'note_id': widget.noteId}); + + if (response == null) { + throw Exception('This secret has already been burned, read, or expired.'); + } + + final ciphertext = response as String; + + // 2. Decrypt in memory using the hash key and IV + final keyBytes = _hexToBytes(widget.keyHex); + final ivBytes = _hexToBytes(widget.ivHex); + final key = enc.Key(Uint8List.fromList(keyBytes)); + final iv = enc.IV(Uint8List.fromList(ivBytes)); + final encrypter = enc.Encrypter(enc.AES(key)); + + final decrypted = encrypter.decrypt( + enc.Encrypted.fromBase64(ciphertext), + iv: iv, + ); + + if (!mounted) return; + setState(() { + _decryptedContent = decrypted; + _stage = _BurnStage.active; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.guard); + if (kIsWeb) _webGuard.attach((_) {}); + + // 3. Start 60s countdown + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_secondsLeft <= 1) { + _burnImmediately(); + } else { + setState(() { + _secondsLeft--; + }); + if (_secondsLeft == 10) { + ref.read(noxMascotProvider.notifier).play(MascotMood.alert); + } + } + }); + + // 4. Tab Blur detection on Web (Burn note immediately if tab switched) + if (kIsWeb) { + _blurSubscription = html.window.onBlur.listen((_) { + _burnImmediately(); + }); + } + } catch (e) { + if (!mounted) return; + setState(() { + _stage = _BurnStage.error; + _errorMessage = e.toString().replaceFirst('Exception: ', ''); + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.alert); + } + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + builder: (context, child) => ExperimentFrame(child: child ?? const SizedBox.shrink()), + title: 'NO SUS — Burn Secret', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true), + home: Scaffold( + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: switch (_stage) { + _BurnStage.gate => _buildGate(), + _BurnStage.decrypting => _buildDecrypting(), + _BurnStage.active => _buildActive(), + _BurnStage.burned => _buildBurned(), + _BurnStage.error => _buildError(), + }, + ), + ), + ), + ), + ), + ); + } + + Widget _buildGate() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.local_fire_department, size: 56, color: Colors.orangeAccent) + .animate(onPlay: (c) => c.repeat(reverse: true)) + .scale(begin: const Offset(1, 1), end: const Offset(1.15, 1.15), duration: 800.ms), + const SizedBox(height: 24), + const Text( + 'BURN NOTE DETECTED', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w900, letterSpacing: 1.5), + ), + const SizedBox(height: 12), + const Text( + 'This is a zero-knowledge encrypted burn note. Once you open it, the server deletes it permanently. You will have exactly 60 seconds to read it.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: Colors.white70, height: 1.4), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.orangeAccent, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: _reveal, + icon: const Icon(Icons.lock_open_rounded, size: 16), + label: const Text( + 'DECRYPT & REVEAL NOTE', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ), + ], + ); + } + + Widget _buildDecrypting() { + return const Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.orangeAccent), + SizedBox(height: 20), + Text( + 'CONNECTING TO ENCLAVE & BURNING DATA...', + style: TextStyle(fontSize: 10, letterSpacing: 1.0, color: Colors.orangeAccent), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildActive() { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.local_fire_department, size: 16, color: Colors.orangeAccent), + SizedBox(width: 6), + MascotView(character: MascotCharacter.nox, size: 18), + SizedBox(width: 6), + Text( + 'BURN-ON-READ ACTIVE', + style: TextStyle(fontSize: 10, color: Colors.orangeAccent, fontWeight: FontWeight.bold), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.redAccent, width: 0.5), + ), + child: Text( + '${_secondsLeft}s LEFT', + style: const TextStyle(color: Colors.redAccent, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 16), + // Progress burning bar + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: _secondsLeft / 60, + color: Colors.orangeAccent, + backgroundColor: Colors.white10, + minHeight: 4, + ), + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.orangeAccent.withValues(alpha: 0.2)), + ), + child: SelectableText( + _decryptedContent ?? '', + style: const TextStyle( + fontSize: 14, + fontFamily: 'Courier', + height: 1.5, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: _burnImmediately, + icon: const Icon(Icons.delete_forever, size: 16, color: Colors.redAccent), + label: const Text('BURN IMMEDIATELY', style: TextStyle(color: Colors.redAccent, fontSize: 11)), + ), + ], + ); + } + + Widget _buildBurned() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MascotView( + character: MascotCharacter.duo, + size: 56, + fallback: Icon(Icons.whatshot, size: 56, color: Colors.grey), + ).animate().shake(duration: 400.ms).fadeOut(duration: 800.ms), + const SizedBox(height: 24), + const Text( + 'SECRET ZEROIZED', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w900, color: Colors.grey), + ), + const SizedBox(height: 12), + const Text( + 'This note has been completely deleted from the database and cleared from browser memory. It cannot be recovered.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: Colors.white38), + ), + ], + ); + } + + Widget _buildError() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MascotView( + character: MascotCharacter.nox, + size: 48, + fallback: Icon(Icons.error_outline, size: 48, color: Colors.redAccent), + ), + const SizedBox(height: 20), + Text( + _errorMessage ?? 'Failed to decrypt note.', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13), + ), + ], + ); + } +} diff --git a/app/lib/features/share/presentation/screens/in_app_share_viewer_screen.dart b/app/lib/features/share/presentation/screens/in_app_share_viewer_screen.dart new file mode 100644 index 0000000..fbe4e10 --- /dev/null +++ b/app/lib/features/share/presentation/screens/in_app_share_viewer_screen.dart @@ -0,0 +1,248 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:pdfrx/pdfrx.dart'; + +import '../../../../components/secure_viewer/models/viewer_config.dart'; +import '../../../../components/secure_viewer/models/watermark_config.dart'; +import '../../../../components/secure_viewer/secure_document_viewer.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../../auth/presentation/widgets/auth_gate.dart'; +import '../../data/share_fetch_client.dart'; +import '../../data/share_heartbeat_client.dart'; +import '../../domain/entities/share_link.dart'; + +/// Entry point wrapper that forces the recipient to be signed in +/// (via [AuthGate]) before resolving and displaying the secure document. +class InAppShareViewerScreen extends StatelessWidget { + const InAppShareViewerScreen({super.key, required this.token}); + + final String token; + + @override + Widget build(BuildContext context) { + return AuthGate( + child: _InAppShareViewerContent(token: token), + ); + } +} + +class _InAppShareViewerContent extends ConsumerStatefulWidget { + const _InAppShareViewerContent({required this.token}); + + final String token; + + @override + ConsumerState<_InAppShareViewerContent> createState() => + __InAppShareViewerContentState(); +} + +class __InAppShareViewerContentState + extends ConsumerState<_InAppShareViewerContent> { + bool _isLoading = true; + String? _errorMessage; + ShareFetchResult? _result; + Uint8List? _bytes; + Timer? _heartbeatTimer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadDocument(); + }); + } + + @override + void dispose() { + _stopHeartbeat(close: true); + super.dispose(); + } + + Future _loadDocument() async { + final user = ref.read(authStateProvider).value; + final email = user?.email ?? 'app-user@nosus.internal'; + + try { + final result = await ShareFetchClient.instance.fetch( + token: widget.token, + viewerEmail: email, + deviceType: 'app', + ); + + final bytesRes = await http.get(Uri.parse(result.signedUrl)); + if (bytesRes.statusCode != 200) { + throw Exception('Could not download the document.'); + } + + if (!mounted) return; + setState(() { + _result = result; + _bytes = bytesRes.bodyBytes; + _isLoading = false; + }); + + if (result.viewEventId != null) { + _startHeartbeat(result.viewEventId!); + } + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString().replaceFirst('Exception: ', ''); + _isLoading = false; + }); + } + } + + void _startHeartbeat(String eventId) { + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) { + ShareHeartbeatClient.instance.sendHeartbeat(eventId); + }); + } + + void _stopHeartbeat({bool close = false}) { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + final eventId = _result?.viewEventId; + if (eventId != null && close) { + ShareHeartbeatClient.instance.sendHeartbeat(eventId, close: true); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final user = ref.watch(authStateProvider).value; + final email = user?.email ?? ''; + + if (_isLoading) { + return Scaffold( + appBar: AppBar( + title: const Text('PREPARING DOCUMENT', style: TextStyle(fontSize: 12, letterSpacing: 1.0)), + ), + body: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(strokeWidth: 2.0), + SizedBox(height: 16), + Text('Fetching secure file…', style: TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ), + ); + } + + if (_errorMessage != null) { + return Scaffold( + appBar: AppBar( + title: const Text('ERROR', style: TextStyle(fontSize: 12)), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.redAccent), + const SizedBox(height: 16), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + _loadDocument(); + }, + child: const Text('TRY AGAIN'), + ), + ], + ), + ), + ), + ); + } + + final result = _result!; + final bytes = _bytes!; + + final watermarkConfig = WatermarkConfig( + name: email, + role: 'RECIPIENT', + email: email, + timestamp: DateTime.now().toIso8601String(), + ); + + Widget docWidget; + switch (result.fileType.toLowerCase()) { + case 'pdf': + docWidget = PdfViewer.data( + bytes, + sourceName: result.fileName, + // No text selection in the protected viewer (copy leak + pdfrx + // 2.4.4 selection painter crash on textless pages). + params: const PdfViewerParams( + textSelectionParams: PdfTextSelectionParams(enabled: false), + ), + ); + break; + case 'image': + case 'scan': + docWidget = InteractiveViewer(child: Center(child: Image.memory(bytes))); + break; + default: + docWidget = Center( + child: Text( + 'This file type cannot be previewed in the app.', + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + result.fileName, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + const Text( + 'SHARED LINK VIEW', + style: TextStyle(fontSize: 9, color: Colors.blueAccent, letterSpacing: 1.0, fontWeight: FontWeight.w800), + ), + ], + ), + ), + ], + ), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: SafeArea( + child: SecureDocumentViewer( + watermarkConfig: watermarkConfig, + viewerConfig: const ViewerConfig(), + touchToRevealEnabled: result.blurEnforced, + watermarkEnabled: result.watermarkEnforced, + child: docWidget, + ), + ), + ); + } +} diff --git a/app/lib/features/share/presentation/screens/redeem_code_screen.dart b/app/lib/features/share/presentation/screens/redeem_code_screen.dart new file mode 100644 index 0000000..20ef981 --- /dev/null +++ b/app/lib/features/share/presentation/screens/redeem_code_screen.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../theme.dart'; +import '../../data/redemption_code_client.dart'; +import 'burn_file_viewer_screen.dart'; +import 'burn_note_viewer_screen.dart'; + +/// In-app entry point for the short-code retrieval path — the counterpart +/// to opening a Burn Note/File link, for when the sender read/typed a code +/// out instead of sending the link itself. See +/// supabase/migrations/20260713000000_burn_redemption_codes.sql for why +/// this path has a different (server briefly holds the key) guarantee than +/// the link. +class RedeemCodeScreen extends StatefulWidget { + /// The opaque token from a `#/redeem/` pairing link. It is the real + /// credential; the visible two digits only confirm the intended share. + final String? redeemToken; + + const RedeemCodeScreen({super.key, this.redeemToken}); + + @override + State createState() => _RedeemCodeScreenState(); +} + +class _RedeemCodeScreenState extends State { + final _codeController = TextEditingController(); + final _codeFocus = FocusNode(); + bool _isLoading = false; + String? _error; + + @override + void dispose() { + _codeController.dispose(); + _codeFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_isLoading) return; + final code = _codeController.text.trim(); + if (code.isEmpty) { + setState(() => _error = 'Enter a code first.'); + return; + } + + HapticFeedback.mediumImpact(); + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await RedemptionCodeClient.instance.redeem( + code, + redeemToken: widget.redeemToken, + ); + if (!mounted) return; + final viewer = result.targetKind == 'file' + ? BurnFileViewerScreen( + files: [ + ( + id: result.targetId, + keyHex: result.keyHex, + ivHex: result.ivHex, + ), + ], + ) + : BurnNoteViewerScreen( + noteId: result.targetId, + keyHex: result.keyHex, + ivHex: result.ivHex, + ); + Navigator.of( + context, + ).pushReplacement(MaterialPageRoute(builder: (_) => viewer)); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString().replaceFirst('Exception: ', ''); + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return Scaffold( + appBar: AppBar(title: const Text('Redeem a Code')), + body: SafeArea( + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + padding: const EdgeInsets.all(NoSusTheme.s24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.key_outlined, + size: 40, + color: fg.withValues(alpha: 0.6), + ), + const SizedBox(height: NoSusTheme.s16), + Text( + 'Got a code instead of a link?', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: NoSusTheme.s8), + Text( + widget.redeemToken == null + ? 'Open the secure pairing link first, then enter its ' + 'two-digit confirmation code. This screen also accepts ' + 'an older 8-character code while existing shares expire.' + : 'Enter the two digits the sender gave you. The secure ' + 'link you opened is the access credential; these digits ' + 'simply confirm the right share.', + style: theme.textTheme.bodyMedium?.copyWith(color: subtle), + ), + const SizedBox(height: NoSusTheme.s32), + TextField( + controller: _codeController, + focusNode: _codeFocus, + autofocus: true, + textCapitalization: TextCapitalization.characters, + textInputAction: TextInputAction.done, + maxLength: widget.redeemToken == null ? 8 : 2, + style: theme.textTheme.titleLarge?.copyWith( + letterSpacing: 4, + fontFeatures: const [FontFeature.tabularFigures()], + ), + decoration: InputDecoration( + hintText: widget.redeemToken == null ? 'ABCD1234' : '00', + counterText: '', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + errorText: _error, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + widget.redeemToken == null + ? RegExp(r'[A-Za-z0-9]') + : RegExp(r'[0-9]'), + ), + TextInputFormatter.withFunction( + (oldValue, newValue) => newValue.copyWith( + text: widget.redeemToken == null + ? newValue.text.toUpperCase() + : newValue.text, + ), + ), + ], + onSubmitted: (_) => _submit(), + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + ), + const SizedBox(height: NoSusTheme.s24), + SizedBox( + width: double.infinity, + child: Semantics( + button: true, + enabled: !_isLoading, + label: _isLoading ? 'Opening' : 'Open code', + child: GestureDetector( + onTap: _isLoading ? null : _submit, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration( + color: fg, + borderRadius: BorderRadius.circular(NoSusTheme.r12), + ), + child: Center( + child: _isLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.black : Colors.white, + ), + ) + : Text( + 'OPEN', + style: TextStyle( + color: isDark ? Colors.black : Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/share/presentation/screens/share_analytics_screen.dart b/app/lib/features/share/presentation/screens/share_analytics_screen.dart new file mode 100644 index 0000000..5059910 --- /dev/null +++ b/app/lib/features/share/presentation/screens/share_analytics_screen.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/entities/share_link.dart'; +import '../providers/share_providers.dart'; + +class ShareAnalyticsScreen extends ConsumerWidget { + const ShareAnalyticsScreen({ + super.key, + required this.linkId, + required this.fileName, + }); + + final String linkId; + final String fileName; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final analyticsAsync = ref.watch(shareAnalyticsProvider(linkId)); + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + const Text( + 'DOCUMENT INTELLIGENCE', + style: TextStyle( + fontSize: 9, + color: Colors.blueAccent, + letterSpacing: 1.0, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.delete_forever_outlined, color: Colors.redAccent), + tooltip: 'REVOKE LINK', + onPressed: () => _confirmRevocation(context, ref), + ), + ], + ), + body: analyticsAsync.when( + data: (data) => _buildContent(context, ref, data), + loading: () => const Center( + child: CircularProgressIndicator(strokeWidth: 2.0), + ), + error: (error, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Failed to load analytics: $error', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.redAccent, fontSize: 13), + ), + ), + ), + ), + ); + } + + Widget _buildContent(BuildContext context, WidgetRef ref, ShareAnalytics data) { + final theme = Theme.of(context); + + return Column( + children: [ + // Stats Overview Row + Container( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem('TOTAL VIEWS', '${data.totalViews}'), + _buildStatItem('UNIQUE VIEWERS', '${data.uniqueViewers}'), + _buildStatItem( + 'LIVE NOW', + '${data.liveCount}', + isLive: data.liveCount > 0, + ), + ], + ), + ), + const Divider(height: 1), + // List Header + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 20, bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'RECENT VIEWS', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 10, + letterSpacing: 1.0, + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + if (data.liveCount > 0) + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + 'REAL-TIME UPDATING', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 9, + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ), + // Views List + Expanded( + child: data.events.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.track_changes_outlined, + size: 32, + color: theme.colorScheme.onSurface.withValues(alpha: 0.2), + ), + const SizedBox(height: 12), + const Text( + 'No views recorded yet.', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ) + : ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: data.events.length, + separatorBuilder: (_, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final event = data.events[index]; + return _buildEventTile(context, event); + }, + ), + ), + ], + ); + } + + Widget _buildStatItem(String label, String value, {bool isLive = false}) { + return Column( + children: [ + Text( + label, + style: const TextStyle(fontSize: 9, color: Colors.grey, letterSpacing: 0.5), + ), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLive) ...[ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + ], + Text( + value, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w900, + color: isLive ? Colors.green : null, + ), + ), + ], + ), + ], + ); + } + + Widget _buildEventTile(BuildContext context, ShareViewEvent event) { + final theme = Theme.of(context); + final isLive = event.isLive; + + // Format duration + String durationText; + if (event.durationSeconds < 60) { + durationText = '${event.durationSeconds}s'; + } else { + final min = event.durationSeconds ~/ 60; + final sec = event.durationSeconds % 60; + durationText = '${min}m ${sec}s'; + } + + // Format time ago + final diff = DateTime.now().difference(event.startedAt); + String timeAgo; + if (diff.inMinutes < 1) { + timeAgo = 'just now'; + } else if (diff.inHours < 1) { + timeAgo = '${diff.inMinutes}m ago'; + } else if (diff.inDays < 1) { + timeAgo = '${diff.inHours}h ago'; + } else { + timeAgo = '${diff.inDays}d ago'; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + // Left Status Indicator/Device Icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isLive + ? Colors.green.withValues(alpha: 0.1) + : theme.colorScheme.onSurface.withValues(alpha: 0.05), + shape: BoxShape.circle, + ), + child: Icon( + event.deviceType == 'app' ? Icons.phone_android : Icons.language, + size: 16, + color: isLive ? Colors.green : Colors.grey, + ), + ), + const SizedBox(width: 14), + // Email & Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + event.viewerEmail, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + if (isLive) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + border: Border.all(color: Colors.green, width: 0.5), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '● LIVE', + style: TextStyle(color: Colors.green, fontSize: 8, fontWeight: FontWeight.bold), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + timeAgo, + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + const SizedBox(width: 8), + const Text('•', style: TextStyle(fontSize: 11, color: Colors.grey)), + const SizedBox(width: 8), + Text( + 'Viewed for $durationText', + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Future _confirmRevocation(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('REVOKE LINK', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + content: const Text( + 'This will permanently disable this share link. Anyone trying to open it will be blocked. Are you sure?', + style: TextStyle(fontSize: 12), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.redAccent), + onPressed: () => Navigator.pop(context, true), + child: const Text('REVOKE', style: TextStyle(fontSize: 11)), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await ref.read(shareRepositoryProvider).revokeLink(linkId); + if (!context.mounted) return; + Navigator.pop(context); // Close analytics screen + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Link revoked successfully.'), + behavior: SnackBarBehavior.floating, + ), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to revoke link: $e'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } +} diff --git a/app/lib/features/share/presentation/widgets/share_link_dialog.dart b/app/lib/features/share/presentation/widgets/share_link_dialog.dart new file mode 100644 index 0000000..87163a7 --- /dev/null +++ b/app/lib/features/share/presentation/widgets/share_link_dialog.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../providers/share_providers.dart'; +import '../../../../core/mascot/mascot_controller.dart'; +import '../../../../core/utils/debug_logger.dart'; +import '../../../../core/utils/web_links.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; + +/// Creates a share link for [fileId] and shows it in a copyable dialog. +/// +/// The link opens for ANYONE — no NO SUS account required. Protection on that +/// anonymous view is watermark + (optionally) blur-until-touch + view +/// logging, never a hard screenshot block (that's only possible inside the +/// installed app). Whether the blur/touch-reveal gate applies is the +/// sender's own choice, made below before the link is created. +Future showShareLinkDialog( + BuildContext context, + WidgetRef ref, + String fileId, +) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _ShareLinkDialog(fileId: fileId), + ); +} + +enum _Step { settings, creating, ready, error } + +class _ShareLinkDialog extends ConsumerStatefulWidget { + const _ShareLinkDialog({required this.fileId}); + final String fileId; + + @override + ConsumerState<_ShareLinkDialog> createState() => _ShareLinkDialogState(); +} + +class _ShareLinkDialogState extends ConsumerState<_ShareLinkDialog> { + _Step _step = _Step.settings; + bool _requireTouchReveal = true; + String? _url; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ref.read(luxMascotProvider.notifier).play(MascotMood.guide); + }); + } + + Future _create() async { + setState(() => _step = _Step.creating); + ref.read(noxMascotProvider.notifier).play(MascotMood.verify); + try { + final link = await ref.read(shareRepositoryProvider).createShareLink( + widget.fileId, + requireTouchReveal: _requireTouchReveal, + ); + if (!mounted) return; + final (:origin, :basePath) = webShareLinkBase(); + final cb = DateTime.now().millisecondsSinceEpoch; + setState(() { + _url = '$origin$basePath/?cb=$cb#/v/${link.token}'; + _step = _Step.ready; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.approve); + } catch (e, s) { + debugLog('ShareLinkDialog: Failed to create share link: $e\n$s'); + if (!mounted) return; + setState(() { + _error = 'Could not create a share link. Only the person who uploaded ' + 'this file can share it.'; + _step = _Step.error; + }); + ref.read(noxMascotProvider.notifier).play(MascotMood.alert); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return AlertDialog( + backgroundColor: isDark ? const Color(0xFF141414) : Colors.white, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + MascotView( + character: _step == _Step.settings + ? MascotCharacter.lux + : MascotCharacter.nox, + size: 24, + ), + const SizedBox(width: 8), + const Text('SHARE LINK', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + ], + ), + content: SizedBox( + width: 320, + child: switch (_step) { + _Step.settings => _buildSettingsStep(context), + _Step.creating => const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + ), + ), + _Step.error => Text(_error!, style: const TextStyle(fontSize: 12)), + _Step.ready => _buildReadyStep(context), + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('CLOSE', + style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + if (_step == _Step.settings) + FilledButton( + onPressed: _create, + child: const Text('CREATE LINK', style: TextStyle(fontSize: 11)), + ), + if (_step == _Step.ready) ...[ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: _url!)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Link copied'), + behavior: SnackBarBehavior.floating), + ); + }, + child: const Text('COPY', style: TextStyle(fontSize: 11)), + ), + FilledButton( + onPressed: () => SharePlus.instance.share( + ShareParams(text: 'Here is a secure document link: $_url'), + ), + child: const Text('SHARE', style: TextStyle(fontSize: 11)), + ), + ], + ], + ); + } + + Widget _buildSettingsStep(BuildContext context) { + final subtle = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Anyone with this link can view the document — no account needed. ' + 'It is always watermarked with their email and every view is logged.', + style: TextStyle(fontSize: 11, color: subtle), + ), + const SizedBox(height: 16), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Require touch-to-reveal', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), + subtitle: Text( + _requireTouchReveal + ? 'Viewer must touch and hold to reveal the document.' + : 'Document is visible immediately — no touch gate.', + style: TextStyle(fontSize: 11, color: subtle), + ), + value: _requireTouchReveal, + onChanged: (v) => setState(() => _requireTouchReveal = v), + ), + ], + ); + } + + Widget _buildReadyStep(BuildContext context) { + final subtle = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.4)), + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText(_url!, style: const TextStyle(fontSize: 11)), + ), + const SizedBox(height: 12), + Text( + _requireTouchReveal + ? 'Touch-to-reveal is required for this link.' + : 'Touch-to-reveal is OFF — the document shows immediately.', + style: TextStyle(fontSize: 11, color: subtle), + ), + ], + ); + } +} diff --git a/app/lib/features/vault/presentation/pages/study_desk_tab.dart b/app/lib/features/vault/presentation/pages/study_desk_tab.dart new file mode 100644 index 0000000..d16aae3 --- /dev/null +++ b/app/lib/features/vault/presentation/pages/study_desk_tab.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../../../../theme.dart'; +import '../../../../components/shimmer_box.dart'; +import '../../../../components/async_state_view.dart'; +import '../../../groups/models/group_file.dart'; +import '../../../groups/providers/groups_provider.dart'; +import '../../../../components/spyglass_viewer.dart'; + +class StudyDeskTab extends ConsumerStatefulWidget { + final String? initialFileId; + + const StudyDeskTab({ + super.key, + this.initialFileId, + }); + + @override + ConsumerState createState() => _StudyDeskTabState(); +} + +class _StudyDeskTabState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + String? _selectedFileId; + + @override + void initState() { + super.initState(); + _selectedFileId = widget.initialFileId; + } + + @override + void didUpdateWidget(covariant StudyDeskTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialFileId != oldWidget.initialFileId && widget.initialFileId != null) { + setState(() { + _selectedFileId = widget.initialFileId; + }); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + + final filesAsync = ref.watch(groupFilesProvider); + + return RefreshIndicator( + key: const ValueKey('studydesk_tab'), + onRefresh: () async { + ref.invalidate(groupFilesProvider); + await ref.read(groupFilesProvider.future); + }, + color: fg, + backgroundColor: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: AsyncStateView>>( + value: filesAsync, + onRetry: () => ref.invalidate(groupFilesProvider), + errorMessage: 'Failed to load your documents', + loading: (context) => + SizedBox(height: constraints.maxHeight, child: const _ViewerSkeleton()), + isEmpty: (filesMap) => + filesMap.values.every((files) => files.isEmpty), + empty: (context) => SizedBox( + height: constraints.maxHeight, + child: _buildEmptyState(theme, isDark, fg), + ), + data: (context, filesMap) { + final allFiles = filesMap.values.expand((x) => x).toList(); + final selectedFile = allFiles.firstWhere( + (f) => f.id == _selectedFileId, + orElse: () => allFiles.first, + ); + return SizedBox( + height: constraints.maxHeight, + child: _buildViewer(theme, allFiles, selectedFile), + ); + }, + ), + ), + ), + ), + ); + } + + Widget _buildEmptyState(ThemeData theme, bool isDark, Color fg) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.lock_person_outlined, + size: 48, + color: fg.withValues(alpha: 0.25), + ), + const SizedBox(height: 16), + Text( + 'NO DOCUMENT SELECTED', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 2.0, + color: fg.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: 8), + Text( + 'Upload and select a document in the Vault or Groups tab.', + style: TextStyle( + fontSize: 13, + color: isDark ? NoSusTheme.dTextSecondary : NoSusTheme.lTextSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildViewer( + ThemeData theme, + List allFiles, + GroupFile selectedFile, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SPYGLASS VIEWER', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: NoSusTheme.s4), + Text( + '${selectedFile.name}${selectedFile.type.extension}', + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(width: NoSusTheme.s16), + // Outlined dropdown to swap files + PopupMenuButton( + icon: Container( + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s12, + vertical: NoSusTheme.s8, + ), + decoration: NoSusTheme.buttonDecoration(context, radius: 10), + child: Row( + children: [ + Icon( + Icons.folder_open, + size: 14, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: NoSusTheme.s8), + Text( + 'Swap Doc', + style: theme.textTheme.labelLarge?.copyWith(fontSize: 11), + ), + ], + ), + ), + onSelected: (String id) { + setState(() { + _selectedFileId = id; + }); + }, + itemBuilder: (BuildContext context) { + return allFiles.map((f) { + return PopupMenuItem( + value: f.id, + child: Text( + '${f.name}${f.type.extension}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: selectedFile.id == f.id + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ); + }).toList(); + }, + ), + ], + ), + const SizedBox(height: NoSusTheme.s24), + + // Secure viewer entry card — tapping opens full-screen SpyglassViewer + Expanded( + child: Semantics( + button: true, + label: 'Open secure viewer for ${selectedFile.name}${selectedFile.type.extension}', + child: GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SpyglassViewer( + fileId: selectedFile.id, + groupId: selectedFile.groupId, + documentTitle: '${selectedFile.name}${selectedFile.type.extension}', + documentCategory: selectedFile.type.label, + ), + ), + ); + }, + child: Container( + width: double.infinity, + decoration: NoSusTheme.cardDecoration(context), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.lock_open_outlined, + size: 36, + color: theme.colorScheme.onSurface.withValues(alpha: 0.25), + ), + const SizedBox(height: 16), + Text( + 'TAP TO OPEN SECURE VIEWER', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 11, + letterSpacing: 2.0, + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: 8), + Text( + '${selectedFile.name}${selectedFile.type.extension}', + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 13, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + decoration: NoSusTheme.buttonDecoration( + context, + radius: 12, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.touch_app_outlined, + size: 14, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: 8), + Text( + 'OPEN SECURE VIEWER', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ).animate().fadeIn(duration: 350.ms), + ), + ], + ); + } +} + +/// Shimmer placeholder for the viewer entry card while [groupFilesProvider] +/// is loading. +class _ViewerSkeleton extends StatelessWidget { + const _ViewerSkeleton(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + ShimmerBox(width: 140, height: 14), + ], + ), + const SizedBox(height: NoSusTheme.s8), + const ShimmerBox(width: 200, height: 20), + const SizedBox(height: NoSusTheme.s24), + Expanded( + child: Container( + width: double.infinity, + decoration: NoSusTheme.cardDecoration(context), + child: const Center( + child: ShimmerBox(width: 180, height: 44, radius: 12), + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/vault/presentation/pages/vault_tab.dart b/app/lib/features/vault/presentation/pages/vault_tab.dart new file mode 100644 index 0000000..e97fb9e --- /dev/null +++ b/app/lib/features/vault/presentation/pages/vault_tab.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:no_sus/main.dart' show activeTabProvider; +import '../../../../theme.dart'; +import '../../../../components/coach_mark.dart'; +import '../../../../components/shimmer_box.dart'; +import '../../../../components/async_state_view.dart'; +import '../../../groups/models/group_file.dart'; +import '../../../groups/providers/groups_provider.dart'; +import '../../../onboarding/presentation/providers/tour_providers.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; + +class VaultTab extends ConsumerStatefulWidget { + final ValueChanged onRevealRequested; + + const VaultTab({ + super.key, + required this.onRevealRequested, + }); + + @override + ConsumerState createState() => _VaultTabState(); +} + +class _VaultTabState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + String? _selectedFileId; + + static const _tabIndex = 1; + + /// Anchors the first document row. Deliberately not a fixed header: the tip + /// explains what happens when you open a document, which only means anything + /// once there is one to open. + final _firstFileKey = GlobalKey(); + + /// See [_WorkspaceTabState._scheduleTips] — every tab is constructed with the + /// shell, so visibility has to be gated on the active tab rather than mount. + void _scheduleTips() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + CoachMarks.showSequence(context, ref, [ + CoachMarkStep( + id: TourSteps.vaultReveal, + targetKey: _firstFileKey, + title: 'Documents open in a protected reader', + body: + 'Tap REVEAL and the file opens in the Study Desk rather than downloading — ' + 'watermarked with your identity, and recorded in the group\'s audit log.', + ), + ]); + }); + } + + @override + void initState() { + super.initState(); + if (ref.read(activeTabProvider) == _tabIndex) _scheduleTips(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final subtle = isDark ? NoSusTheme.dTextSecondary : NoSusTheme.lTextSecondary; + + ref.listen(activeTabProvider, (previous, next) { + if (next == _tabIndex && previous != _tabIndex) _scheduleTips(); + }); + + final filesAsync = ref.watch(groupFilesProvider); + + return Column( + key: const ValueKey('vault_tab'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'STUDY VAULT', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + const SizedBox(height: NoSusTheme.s24), + + // List of classified study documents + Expanded( + child: RefreshIndicator( + onRefresh: () async { + ref.invalidate(groupFilesProvider); + await ref.read(groupFilesProvider.future); + }, + color: fg, + backgroundColor: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + child: AsyncStateView>>( + value: filesAsync, + onRetry: () => ref.invalidate(groupFilesProvider), + errorMessage: 'Failed to load your secure documents', + loading: (context) => ShimmerListSkeleton( + spacing: NoSusTheme.s16, + itemBuilder: (context) => const _FileRowSkeleton(), + ), + isEmpty: (filesMap) => + filesMap.values.every((files) => files.isEmpty), + empty: (context) => _buildEmptyState(fg, subtle), + data: (context, filesMap) => _buildFileList( + theme, + filesMap.values.expand((x) => x).toList(), + ), + ), + ), + ), + ], + ); + } + + Widget _buildEmptyState(Color fg, Color subtle) { + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Nothing to guard yet — Nox waits. (Falls back to the + // original folder icon until nox.riv exists.) + MascotView( + character: MascotCharacter.nox, + size: 48, + fallback: Icon( + Icons.folder_off_outlined, + size: 48, + color: fg.withValues(alpha: 0.25), + ), + ), + const SizedBox(height: 16), + Text( + 'NO SECURE DOCUMENTS YET', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 2.0, + color: fg.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: 8), + Text( + 'Navigate to the Groups tab to upload secure files.', + style: TextStyle(fontSize: 13, color: subtle), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildFileList(ThemeData theme, List allFiles) { + return ListView.separated( + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + itemCount: allFiles.length, + separatorBuilder: (context, index) => + const SizedBox(height: NoSusTheme.s16), + itemBuilder: (context, index) { + final file = allFiles[index]; + final isSelected = _selectedFileId == file.id; + + return Container( + key: index == 0 ? _firstFileKey : null, + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s8, + vertical: 3, + ), + decoration: NoSusTheme.buttonDecoration( + context, + radius: 6, + color: theme.colorScheme.outline + .withValues(alpha: 0.05), + ), + child: Text( + file.type.label, + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 9, + color: theme.colorScheme.onSurface + .withValues(alpha: 0.6), + ), + ), + ), + const SizedBox(width: NoSusTheme.s8), + Text( + file.sizeLabel, + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: NoSusTheme.s12), + Text( + '${file.name}${file.type.extension}', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: NoSusTheme.s4), + Text( + 'Imported: ${file.uploadedAtLabel}', + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 12, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.4, + ), + ), + ), + ], + ), + ), + const SizedBox(width: NoSusTheme.s24), + // Action button to open in Spyglass + Semantics( + button: true, + label: 'Reveal ${file.name}', + child: GestureDetector( + onTap: () { + setState(() { + _selectedFileId = file.id; + }); + widget.onRevealRequested(file.id); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NoSusTheme.s16, + vertical: NoSusTheme.s12, + ), + decoration: NoSusTheme.buttonDecoration( + context, + radius: 12, + color: isSelected + ? theme.colorScheme.onSurface + : Colors.transparent, + ), + child: Row( + children: [ + Icon( + Icons.remove_red_eye_outlined, + size: 16, + color: isSelected + ? theme.colorScheme.surface + : theme.colorScheme.onSurface, + ), + const SizedBox(width: NoSusTheme.s8), + Text( + 'REVEAL', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 11, + color: isSelected + ? theme.colorScheme.surface + : theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ).animate().fadeIn(delay: (index * 80).ms).slideY(begin: 0.05, end: 0); + }, + ); + } +} + +/// Shimmer placeholder matching the file-row card layout in [_buildFileList]. +class _FileRowSkeleton extends StatelessWidget { + const _FileRowSkeleton(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ShimmerBox(width: 48, height: 16, radius: 6), + SizedBox(width: NoSusTheme.s8), + ShimmerBox(width: 40, height: 14), + ], + ), + SizedBox(height: NoSusTheme.s12), + ShimmerBox(width: 160, height: 16), + SizedBox(height: NoSusTheme.s4), + ShimmerBox(width: 120, height: 12), + ], + ), + ), + SizedBox(width: NoSusTheme.s24), + ShimmerBox(width: 90, height: 40, radius: 12), + ], + ), + ); + } +} diff --git a/app/lib/features/workspace/presentation/pages/workspace_tab.dart b/app/lib/features/workspace/presentation/pages/workspace_tab.dart new file mode 100644 index 0000000..7c690e0 --- /dev/null +++ b/app/lib/features/workspace/presentation/pages/workspace_tab.dart @@ -0,0 +1,807 @@ +import 'package:flutter/material.dart'; +import '../../../canary/presentation/canary_entry_card.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:no_sus/main.dart' show activeTabProvider; +import '../../../../components/coach_mark.dart'; +import '../../../onboarding/presentation/providers/tour_providers.dart'; +import '../../../../theme.dart'; +import '../../../../components/study_chart.dart'; +import '../../../../components/offline_banner.dart'; +import '../../../../components/update_banner.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../../profile/providers/profile_provider.dart'; +import '../../../../core/supabase/supabase_bootstrap.dart'; +import '../../../../services/supabase_service.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../notes/providers/notes_provider.dart'; +import '../../../focus/providers/focus_provider.dart'; +import '../../providers/recently_saved_provider.dart'; +import '../../../files/presentation/providers/upload_provider.dart'; +import '../../../share/presentation/screens/burn_note_creator_screen.dart'; +import '../../../share/presentation/screens/burn_file_creator_screen.dart'; +import '../../../share/presentation/screens/redeem_code_screen.dart'; +import '../../../../core/mascot/mascot_controller.dart'; +import '../../../../core/mascot/mascot_state.dart'; +import '../../../../core/mascot/mascot_view.dart'; +/// Tab 0 — Workspace Dashboard +/// Welcome banner + Study chart + Secure notepad. +class WorkspaceTab extends ConsumerStatefulWidget { + const WorkspaceTab({super.key}); + + @override + ConsumerState createState() => _WorkspaceTabState(); +} + +class _WorkspaceTabState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + static const _tabIndex = 0; + + final TextEditingController _noteController = TextEditingController( + text: AppConstants.defaultNoteContent, + ); + + final _toolsKey = GlobalKey(); + final _padKey = GlobalKey(); + + /// Every tab in the shell's PageView is built up front, so "this widget + /// mounted" is not "the user is looking at this tab" — firing tips from + /// initState alone would spotlight the Groups screen over the Workspace. + /// Gate on the active tab instead. + void _scheduleTips() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + CoachMarks.showSequence(context, ref, [ + CoachMarkStep( + id: TourSteps.workspaceTools, + targetKey: _toolsKey, + title: 'Send something that self-destructs', + body: + 'Burn Notes and Burn Files are encrypted on this device and deleted after ' + 'one read. The person receiving them needs no account — and neither did you.', + ), + CoachMarkStep( + id: TourSteps.workspacePad, + targetKey: _padKey, + title: 'This pad is yours alone', + body: + 'Nothing typed here is shared with any group. It saves as you type, and ' + 'syncs to your account so it follows you between devices.', + ), + ]); + }); + } + + @override + void initState() { + super.initState(); + if (ref.read(activeTabProvider) == _tabIndex) _scheduleTips(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + // First time this tab is built this session — Lux orients, once. + ref.read(luxMascotProvider.notifier).play(MascotMood.lookAround); + final user = ref.read(authRepositoryProvider).currentUser; + if (user != null && mounted) { + final content = await SupabaseService.instance.fetchUserNote(user.id); + if (mounted) { + ref.read(userNoteProvider.notifier).loadNote(user.id); + _noteController.text = content.isNotEmpty + ? content + : AppConstants.defaultNoteContent; + } + } + }); + } + + @override + void dispose() { + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? Colors.white : Colors.black; + + ref.listen(activeTabProvider, (previous, next) { + if (next == _tabIndex && previous != _tabIndex) _scheduleTips(); + }); + + final authState = ref.watch(authStateProvider); + final userEmail = authState.value?.email ?? 'Student Guest'; + final profileAsync = ref.watch(profileProvider); + final displayName = profileAsync.maybeWhen( + data: (p) => p.displayName, + orElse: () => userEmail.contains('@') + ? userEmail.split('@').first + : 'Student Guest', + ); + final isLive = + SupabaseBootstrap.isConfigured && SupabaseService.instance.isReachable; + + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(studyTimelineProvider); + ref.invalidate(userNoteProvider); + await ref.read(studyTimelineProvider.future); + }, + color: fg, + backgroundColor: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + child: ListView( + key: const ValueKey('workspace_tab'), + physics: AlwaysScrollableScrollPhysics( + parent: NoSusTheme.getScrollPhysics(context), + ), + padding: const EdgeInsets.only(bottom: 24), + children: [ + const OfflineBanner(), + const UpdateBanner(), + const CanaryEntryCard(), + if (!isLive) const SizedBox(height: NoSusTheme.s16), + // ── Welcome + Status Banner ─────────────────────────────────────────── + Container( + width: double.infinity, + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Welcome, ${displayName.toUpperCase()}', + style: theme.textTheme.displayMedium, + ), + ), + const MascotView(character: MascotCharacter.lux, size: 32), + ], + ), + const SizedBox(height: NoSusTheme.s4), + Text( + userEmail, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + fontSize: 13, + ), + ), + const SizedBox(height: NoSusTheme.s12), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: isLive ? const Color(0xFF10B981) : Colors.amber, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: NoSusTheme.s8), + Text( + isLive ? 'Ready to send' : 'Offline — local only', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + fontSize: 13, + ), + ), + ], + ), + ], + ), + ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.05, end: 0), + + const SizedBox(height: NoSusTheme.s16), + + // Grouped under one key so the coach mark spotlights the whole + // no-account toolset rather than one arbitrary card of three. + Column( + key: _toolsKey, + children: [ + const _BurnNoteTeaserCard() + .animate() + .fadeIn(duration: 370.ms) + .slideY(begin: 0.05, end: 0), + const SizedBox(height: NoSusTheme.s16), + const _BurnFileTeaserCard() + .animate() + .fadeIn(duration: 400.ms) + .slideY(begin: 0.05, end: 0), + const SizedBox(height: NoSusTheme.s16), + const _RedeemCodeTeaserCard() + .animate() + .fadeIn(duration: 430.ms) + .slideY(begin: 0.05, end: 0), + ], + ), + const SizedBox(height: NoSusTheme.s16), + + // ── Study Chart ────────────────────────────────────────────────────── + SizedBox( + height: 200, + child: const StudyChart() + .animate() + .fadeIn(duration: 400.ms) + .slideY(begin: 0.05, end: 0), + ), + + const SizedBox(height: NoSusTheme.s16), + + // ── Recently Saved ─────────────────────────────────────────────────── + const _RecentlySavedSection() + .animate() + .fadeIn(duration: 450.ms) + .slideY(begin: 0.05, end: 0), + + const SizedBox(height: NoSusTheme.s16), + + // ── Secure Notepad ─────────────────────────────────────────────────── + SizedBox( + key: _padKey, + height: 280, + child: _SecurePad( + controller: _noteController, + ).animate().fadeIn(duration: 500.ms).slideY(begin: 0.05, end: 0), + ), + ], + ), + ); + } +} + +// ─── Secure Notepad ─────────────────────────────────────────────────────────── + +class _SecurePad extends ConsumerWidget { + final TextEditingController controller; + const _SecurePad({required this.controller}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isSaving = ref.watch(userNoteProvider).isSaving; + + return Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'SECURE PAD', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + AnimatedOpacity( + opacity: isSaving ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: Text( + 'SAVING...', + style: theme.textTheme.labelLarge?.copyWith( + color: Colors.amber, + fontSize: 10, + ), + ), + ), + ], + ), + const SizedBox(height: NoSusTheme.s16), + Expanded( + child: TextField( + controller: controller, + maxLines: null, + expands: true, + onChanged: (text) => + ref.read(userNoteProvider.notifier).updateNote(text), + cursorColor: theme.colorScheme.onSurface, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'Courier', + fontSize: 13, + color: theme.colorScheme.onSurface.withValues(alpha: 0.85), + ), + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Start writing private study logs...', + ), + ), + ), + ], + ), + ); + } +} + +class _RecentlySavedSection extends ConsumerWidget { + const _RecentlySavedSection(); + + String _formatTimeAgo(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${dt.day}/${dt.month}/${dt.year}'; + } + + IconData _getTypeIcon(String type) { + switch (type) { + case 'pdf': + return Icons.picture_as_pdf_outlined; + case 'image': + return Icons.image_outlined; + case 'text': + return Icons.note_alt_outlined; + case 'url': + return Icons.link_outlined; + default: + return Icons.insert_drive_file_outlined; + } + } + + Color _getTypeColor(String type, bool isDark) { + return isDark ? Colors.white70 : Colors.black87; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final historyState = ref.watch(recentlySavedProvider); + final items = historyState.items; + + if (items.isEmpty) { + return const SizedBox.shrink(); // Hide if history is empty + } + + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final fgSub = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + return Container( + padding: const EdgeInsets.all(NoSusTheme.s24), + decoration: NoSusTheme.cardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'RECENTLY SAVED', + style: theme.textTheme.labelLarge?.copyWith( + color: fgSub, + letterSpacing: 1.5, + fontWeight: FontWeight.bold, + ), + ), + Text( + '${items.length} items', + style: theme.textTheme.bodyMedium?.copyWith( + color: fgSub.withValues(alpha: 0.6), + fontSize: 11, + ), + ), + ], + ), + const SizedBox(height: NoSusTheme.s16), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: items.length, + separatorBuilder: (context, index) => Divider( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05), + height: 24, + ), + itemBuilder: (context, index) { + final item = items[index]; + final isUploading = item.status == 'uploading'; + final isFailed = item.status == 'failed'; + final isCompleted = item.status == 'completed'; + + final uploadState = ref.watch(uploadProvider); + final double progress = (historyState.activeUploadId == item.id) + ? uploadState.progress + : 0.0; + + return InkWell( + onTap: isCompleted + ? () => ref + .read(recentlySavedProvider.notifier) + .navigateToItem(item, context, ref) + : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _getTypeColor( + item.type, + isDark, + ).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _getTypeIcon(item.type), + color: _getTypeColor(item.type, isDark), + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 14, + color: fg, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'Saved to ${item.destinationName} · ${_formatTimeAgo(item.timestamp)}', + style: theme.textTheme.bodyMedium?.copyWith( + color: fgSub, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + if (isCompleted) + Icon( + Icons.check_circle_outline, + color: isDark + ? const Color(0xFF10B981) + : const Color(0xFF059669), + size: 18, + ), + if (isFailed) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + onPressed: () => ref + .read(recentlySavedProvider.notifier) + .retryUpload(item.id, ref), + icon: const Icon(Icons.refresh, size: 14), + label: const Text( + 'RETRY', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + minimumSize: Size.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + foregroundColor: isDark + ? Colors.amber[300] + : Colors.amber[800], + ), + ), + const SizedBox(width: 4), + IconButton( + onPressed: () => ref + .read(recentlySavedProvider.notifier) + .removeItem(item.id), + icon: const Icon( + Icons.delete_outline, + size: 16, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: IconButton.styleFrom( + foregroundColor: isDark + ? Colors.red[300] + : Colors.red[700], + ), + ), + ], + ), + if (isUploading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation( + Colors.grey, + ), + ), + ), + ], + ), + if (isUploading) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress > 0 ? progress : null, + minHeight: 3, + backgroundColor: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05), + valueColor: AlwaysStoppedAnimation( + fgSub, + ), + ), + ), + ), + const SizedBox(width: 8), + Text( + '${(progress * 100).toInt()}%', + style: theme.textTheme.bodyMedium?.copyWith( + color: fgSub, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ], + ), + ), + ); + }, + ), + ], + ), + ); + } +} + +class _BurnNoteTeaserCard extends StatelessWidget { + const _BurnNoteTeaserCard(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + + return Semantics( + button: true, + label: 'Burn Notes — zero-knowledge encrypted self-destructing secrets', + child: InkWell( + borderRadius: BorderRadius.circular(NoSusTheme.r16), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const BurnNoteCreatorScreen(), + ), + ); + }, + child: Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: fg.withValues(alpha: 0.2)), + color: fg.withValues(alpha: 0.03), + ), + child: Icon(Icons.local_fire_department_outlined, color: fg.withValues(alpha: 0.8), size: 22), + ), + const SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Burn Notes', + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w900, + fontSize: 14.5, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 3), + Text( + 'A one-time note. Burns after they open it.', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: fg.withValues(alpha: 0.54), + fontSize: 12, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: fg.withValues(alpha: 0.45), + size: 20, + ), + ], + ), + ), + ), + ); + } +} + +class _BurnFileTeaserCard extends StatelessWidget { + const _BurnFileTeaserCard(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + + return Semantics( + button: true, + label: 'Burn Files — share files that self-destruct after one download', + child: InkWell( + borderRadius: BorderRadius.circular(NoSusTheme.r16), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const BurnFileCreatorScreen(), + ), + ); + }, + child: Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: fg.withValues(alpha: 0.2)), + color: fg.withValues(alpha: 0.03), + ), + child: Icon(Icons.upload_file_outlined, color: fg.withValues(alpha: 0.8), size: 22), + ), + const SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Burn Files', + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w900, + fontSize: 14.5, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 3), + Text( + 'A file drop that disappears after one download.', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: fg.withValues(alpha: 0.54), + fontSize: 12, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: fg.withValues(alpha: 0.45), + size: 20, + ), + ], + ), + ), + ), + ); + } +} + +class _RedeemCodeTeaserCard extends StatelessWidget { + const _RedeemCodeTeaserCard(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = theme.colorScheme.onSurface; + + return Semantics( + button: true, + label: 'Redeem a code', + child: InkWell( + borderRadius: BorderRadius.circular(NoSusTheme.r16), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const RedeemCodeScreen(), + ), + ); + }, + child: Container( + padding: const EdgeInsets.all(20), + decoration: NoSusTheme.cardDecoration(context), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: fg.withValues(alpha: 0.2)), + color: fg.withValues(alpha: 0.03), + ), + child: Icon(Icons.key_outlined, color: fg.withValues(alpha: 0.8), size: 22), + ), + const SizedBox(width: NoSusTheme.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Redeem a Code', + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w900, + fontSize: 14.5, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 3), + Text( + 'Paste the link, then enter the 2-digit code.', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: fg.withValues(alpha: 0.54), + fontSize: 12, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: fg.withValues(alpha: 0.45), + size: 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/workspace/presentation/widgets/save_to_no_sus_dialog.dart b/app/lib/features/workspace/presentation/widgets/save_to_no_sus_dialog.dart new file mode 100644 index 0000000..e07eff3 --- /dev/null +++ b/app/lib/features/workspace/presentation/widgets/save_to_no_sus_dialog.dart @@ -0,0 +1,1129 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../theme.dart'; +import '../../../../services/share_intent_service.dart'; +import '../../../../services/supabase_service.dart'; +import '../../../groups/providers/groups_provider.dart'; +import '../../../groups/domain/models/study_group.dart'; +import '../../../groups/models/group_file.dart'; +import '../../../files/presentation/providers/upload_provider.dart'; +import '../../../auth/presentation/providers/auth_providers.dart'; +import '../../providers/recently_saved_provider.dart'; +import '../../../../core/utils/debug_logger.dart'; + +class SaveToNoSusDialog extends ConsumerStatefulWidget { + final SharedContent content; + + const SaveToNoSusDialog({super.key, required this.content}); + + @override + ConsumerState createState() => _SaveToNoSusDialogState(); +} + +class _SaveToNoSusDialogState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _checkController; + late Animation _checkProgress; + + bool _isVaultSelected = true; + String? _selectedGroupId; + String _textSaveType = 'private'; // 'private' or 'group' + + List _pinnedGroupIds = []; + bool _isLoadingPrefs = true; + + @override + void initState() { + super.initState(); + _checkController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _checkProgress = CurvedAnimation( + parent: _checkController, + curve: Curves.easeOut, + ); + _loadPreferences(); + } + + @override + void dispose() { + _checkController.dispose(); + _cleanupTempFile(); + super.dispose(); + } + + void _cleanupTempFile() { + if (widget.content.type == 'pdf' || widget.content.type == 'image') { + try { + final file = File(widget.content.data); + if (file.existsSync()) { + file.deleteSync(); + debugLog("Cleaned up temporary cache file: ${widget.content.data}"); + } + } catch (e) { + debugLog("Error cleaning up temporary cache file: $e"); + } + } + } + + Future _loadPreferences() async { + try { + final prefs = await SharedPreferences.getInstance(); + final pinned = prefs.getStringList('pinned_group_ids') ?? []; + final lastDest = prefs.getString('last_save_destination'); + + if (mounted) { + setState(() { + _pinnedGroupIds = pinned; + _isLoadingPrefs = false; + + if (lastDest != null) { + if (lastDest == 'vault') { + _isVaultSelected = true; + } else if (lastDest.startsWith('group_')) { + _isVaultSelected = false; + _selectedGroupId = lastDest.substring(6); + } + } + }); + } + } catch (e) { + debugLog("Error loading preferences: $e"); + if (mounted) { + setState(() => _isLoadingPrefs = false); + } + } + } + + Future _togglePin(String groupId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final updated = List.from(_pinnedGroupIds); + if (updated.contains(groupId)) { + updated.remove(groupId); + } else { + updated.add(groupId); + } + await prefs.setStringList('pinned_group_ids', updated); + if (mounted) { + setState(() { + _pinnedGroupIds = updated; + }); + } + HapticFeedback.lightImpact(); + } catch (e) { + debugLog("Error saving pinned groups: $e"); + } + } + + Future _saveDestinationPreference(String dest) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_save_destination', dest); + } catch (_) {} + } + + Future _getOrCreatePrivateVaultGroup() async { + final groupsAsync = ref.read(groupsProvider); + final groups = groupsAsync.value ?? []; + + // Check if "Study Vault (Private)" already exists + final privateGroup = groups.firstWhere( + (g) => g.name == 'Study Vault (Private)', + orElse: () => StudyGroup( + id: '', + name: '', + description: '', + members: const [], + fileCount: 0, + lastActivity: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + + if (privateGroup.id.isNotEmpty) { + return privateGroup.id; + } + + // Create a new private group + final currentUser = ref.read(authRepositoryProvider).currentUser; + final userId = currentUser?.id ?? 'me'; + final userEmail = currentUser?.email ?? 'You'; + final cleanEmailPrefix = userEmail.contains('@') + ? userEmail.split('@').first + : ''; + final userInitials = cleanEmailPrefix.isNotEmpty + ? cleanEmailPrefix + .substring( + 0, + cleanEmailPrefix.length >= 2 ? 2 : cleanEmailPrefix.length, + ) + .toUpperCase() + : 'ME'; + + final groupId = 'g_private_${DateTime.now().millisecondsSinceEpoch}'; + final newGroup = StudyGroup( + id: groupId, + name: 'Study Vault (Private)', + description: 'Your private secure vault for personal documents.', + members: [ + GroupMember( + id: userId, + name: userEmail, + initials: userInitials, + isAdmin: true, + ), + ], + fileCount: 0, + lastActivity: DateTime.now(), + isWatermarkEnabled: true, + inviteCode: null, + ); + + await ref.read(groupsProvider.notifier).createGroup(newGroup); + return groupId; + } + + Future _handleSave() async { + final uploadNotifier = ref.read(uploadProvider.notifier); + + // Resolve Title + final contentType = widget.content.type; + String title = ''; + if (contentType == 'pdf' || contentType == 'image') { + title = + widget.content.name ?? + File(widget.content.data).uri.pathSegments.last; + } else if (contentType == 'text') { + title = _textSaveType == 'private' ? 'Private Note' : 'Group Note'; + } else if (contentType == 'url') { + final trimmed = widget.content.data.trim(); + final isDrive = + trimmed.contains('drive.google.com') || + trimmed.contains('docs.google.com'); + title = isDrive ? 'Linked Drive File' : 'Reference Link'; + } + + // Resolve Destination Type & Name + final destType = _isVaultSelected ? 'vault' : 'group'; + String destName = 'Private Vault'; + String? targetGroupId; + + final groupsAsync = ref.read(groupsProvider); + final groups = groupsAsync.value ?? []; + + String? loggedItemId; + + try { + if (_isVaultSelected) { + targetGroupId = await _getOrCreatePrivateVaultGroup(); + await _saveDestinationPreference('vault'); + } else { + if (_selectedGroupId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select a Study Group')), + ); + return; + } + targetGroupId = _selectedGroupId!; + await _saveDestinationPreference('group_$_selectedGroupId'); + + final matchedGroup = groups.firstWhere( + (g) => g.id == targetGroupId, + orElse: () => StudyGroup( + id: '', + name: 'Study Group', + description: '', + members: const [], + fileCount: 0, + lastActivity: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + destName = matchedGroup.name; + } + + // 1. Log to history as uploading + final itemId = 'saved_${DateTime.now().millisecondsSinceEpoch}'; + loggedItemId = itemId; + final newItem = RecentlySavedItem( + id: itemId, + title: title, + type: contentType, + destinationType: destType, + destinationName: destName, + destinationId: destType == 'group' ? targetGroupId : null, + timestamp: DateTime.now(), + status: 'uploading', + localPath: (contentType == 'pdf' || contentType == 'image') + ? widget.content.data + : null, + extraData: widget.content.data, + ); + ref.read(recentlySavedProvider.notifier).addItem(newItem); + + // 2. Perform Save + if (contentType == 'pdf' || contentType == 'image') { + final filePath = widget.content.data; + final file = File(filePath); + if (!await file.exists()) { + throw Exception("File does not exist on disk: $filePath"); + } + final bytes = await file.readAsBytes(); + final name = widget.content.name ?? file.uri.pathSegments.last; + final type = contentType == 'pdf' ? FileType.pdf : FileType.image; + + await uploadNotifier.uploadDocument(name, type, targetGroupId, bytes); + } else if (contentType == 'text') { + if (_textSaveType == 'private') { + final user = ref.read(authRepositoryProvider).currentUser; + if (user == null) throw Exception("User not authenticated"); + + final existing = await SupabaseService.instance.fetchUserNote( + user.id, + ); + final divider = + '\n\n---\n### 📥 Shared Note (${DateTime.now().toLocal().toString().substring(0, 16)})\n${widget.content.data}\n'; + final newContent = existing.isEmpty + ? widget.content.data + : '$existing$divider'; + + await uploadNotifier.savePrivateNote( + userId: user.id, + content: newContent, + ); + } else { + final name = + 'Shared Note ${DateTime.now().millisecondsSinceEpoch % 1000}'; + final bytes = Uint8List.fromList(utf8.encode(widget.content.data)); + + await uploadNotifier.uploadDocument( + name, + FileType.markdown, + targetGroupId, + bytes, + ); + } + } else if (contentType == 'url') { + final trimmed = widget.content.data.trim(); + final isDrive = + trimmed.contains('drive.google.com') || + trimmed.contains('docs.google.com'); + + if (isDrive) { + final name = + 'Linked Drive File ${DateTime.now().millisecondsSinceEpoch % 1000}'; + await uploadNotifier.linkGoogleDriveDocument( + name, + targetGroupId, + trimmed, + ); + } else { + final name = + 'Reference Link ${DateTime.now().millisecondsSinceEpoch % 1000}'; + final mdContent = + '# Shared Reference Link\n\nURL: [${widget.content.data}](${widget.content.data})\n\nShared on: ${DateTime.now().toLocal()}\n'; + final bytes = Uint8List.fromList(utf8.encode(mdContent)); + + await uploadNotifier.uploadDocument( + name, + FileType.markdown, + targetGroupId, + bytes, + ); + } + } + + // 3. Trigger checkmark success animation and dismiss on success + final uploadState = ref.read(uploadProvider); + if (uploadState.stage == UploadStage.complete) { + ref + .read(recentlySavedProvider.notifier) + .updateStatus(itemId, 'completed'); + HapticFeedback.mediumImpact(); + _checkController.forward(); + await Future.delayed(const Duration(milliseconds: 1600)); + if (mounted) { + ref.read(shareIntentProvider.notifier).clear(); + Navigator.of(context).pop(); + } + } else if (uploadState.stage == UploadStage.error) { + ref.read(recentlySavedProvider.notifier).updateStatus(itemId, 'failed'); + } + } catch (e) { + if (loggedItemId != null) { + ref + .read(recentlySavedProvider.notifier) + .updateStatus(loggedItemId, 'failed'); + } + debugLog("Error in SaveToNoSusDialog _handleSave: $e"); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText; + final bg = isDark ? const Color(0xFF141414) : Colors.white; + final cardBg = isDark ? const Color(0xFF1C1C1C) : Colors.grey[100]; + final subtle = isDark + ? NoSusTheme.dTextSecondary + : NoSusTheme.lTextSecondary; + + final groupsAsync = ref.watch(groupsProvider); + final uploadState = ref.watch(uploadProvider); + + // Filter/Order Groups: Recent (sorted by lastActivity desc), Pinned (in _pinnedGroupIds), then All + final groups = groupsAsync.value ?? []; + + // Sort groups for destination list + final pinnedGroups = groups + .where( + (g) => + _pinnedGroupIds.contains(g.id) && + g.name != 'Study Vault (Private)', + ) + .toList(); + final recentGroups = List.from(groups) + ..where((g) => g.name != 'Study Vault (Private)') + ..sort((a, b) => b.lastActivity.compareTo(a.lastActivity)); + final recent3 = recentGroups.take(3).toList(); + + final allGroupsSorted = List.from(groups) + ..where((g) => g.name != 'Study Vault (Private)') + ..sort((a, b) => a.name.compareTo(b.name)); + + // Preselect group if only 1 group exists + final nonPrivateGroups = groups + .where((g) => g.name != 'Study Vault (Private)') + .toList(); + if (nonPrivateGroups.length == 1 && + _selectedGroupId == null && + !_isVaultSelected) { + _selectedGroupId = nonPrivateGroups.first.id; + } + + if (uploadState.stage == UploadStage.processing || + uploadState.stage == UploadStage.complete) { + return Dialog( + backgroundColor: bg, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (uploadState.stage == UploadStage.processing) ...[ + const SizedBox(height: 12), + CircularProgressIndicator( + value: uploadState.progress, + color: fg, + backgroundColor: fg.withValues(alpha: 0.1), + strokeWidth: 5, + ), + const SizedBox(height: 24), + Text( + 'IMPORTING TO NO SUS...', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 8), + Text( + '${(uploadState.progress * 100).toInt()}%', + style: TextStyle( + fontSize: 14, + color: subtle, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: () { + ref.read(uploadProvider.notifier).cancelUpload(); + }, + style: OutlinedButton.styleFrom( + foregroundColor: fg, + side: BorderSide(color: fg.withValues(alpha: 0.2)), + ), + child: const Text('CANCEL'), + ), + ] else if (uploadState.stage == UploadStage.complete) ...[ + ScaleTransition( + scale: _checkProgress, + child: Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 48, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'SAVE COMPLETE!', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 8), + Text( + 'Content imported securely to NO SUS.', + style: TextStyle(fontSize: 13, color: subtle), + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ); + } + + return Dialog( + backgroundColor: bg, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 12), + child: Row( + children: [ + Icon( + widget.content.type == 'pdf' + ? Icons.picture_as_pdf_outlined + : widget.content.type == 'image' + ? Icons.image_outlined + : widget.content.type == 'url' + ? Icons.link_outlined + : Icons.text_snippet_outlined, + color: fg, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SAVE TO NO SUS', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + Text( + widget.content.type.toUpperCase(), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: subtle, + letterSpacing: 1.0, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + tooltip: 'Close', + onPressed: () { + ref.read(shareIntentProvider.notifier).clear(); + Navigator.of(context).pop(); + }, + ), + ], + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Content Scroll Area + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Shared Content Preview Card + Text( + 'SHARED CONTENT PREVIEW', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: subtle, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: fg.withValues(alpha: 0.08), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.content.type == 'image') ...[ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(widget.content.data), + height: 110, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + const SizedBox(height: 8), + ], + if (widget.content.name != null) ...[ + Text( + widget.content.name!, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + ], + Text( + widget.content.type == 'image' || + widget.content.type == 'pdf' + ? 'Local Temp File: ${widget.content.data}' + : widget.content.data, + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: fg.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // If Text: Ask to save as Private Note or Group Note + if (widget.content.type == 'text') ...[ + Text( + 'SAVE TEXT AS', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: subtle, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Semantics( + button: true, + selected: _textSaveType == 'private', + label: 'Save as private note', + child: InkWell( + onTap: () => + setState(() => _textSaveType = 'private'), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 8, + ), + decoration: BoxDecoration( + color: _textSaveType == 'private' + ? fg.withValues(alpha: 0.05) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _textSaveType == 'private' + ? fg + : fg.withValues(alpha: 0.1), + width: _textSaveType == 'private' + ? 1.5 + : 1.0, + ), + ), + child: Column( + children: [ + Icon( + Icons.lock_outline, + color: _textSaveType == 'private' + ? fg + : subtle, + size: 20, + ), + const SizedBox(height: 6), + Text( + 'Private Note', + style: TextStyle( + fontSize: 12, + fontWeight: _textSaveType == 'private' + ? FontWeight.bold + : FontWeight.normal, + color: _textSaveType == 'private' + ? fg + : subtle, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Semantics( + button: true, + selected: _textSaveType == 'group', + label: 'Save as group note', + child: InkWell( + onTap: () => + setState(() => _textSaveType = 'group'), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 8, + ), + decoration: BoxDecoration( + color: _textSaveType == 'group' + ? fg.withValues(alpha: 0.05) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _textSaveType == 'group' + ? fg + : fg.withValues(alpha: 0.1), + width: _textSaveType == 'group' ? 1.5 : 1.0, + ), + ), + child: Column( + children: [ + Icon( + Icons.people_outline, + color: _textSaveType == 'group' + ? fg + : subtle, + size: 20, + ), + const SizedBox(height: 6), + Text( + 'Group Note', + style: TextStyle( + fontSize: 12, + fontWeight: _textSaveType == 'group' + ? FontWeight.bold + : FontWeight.normal, + color: _textSaveType == 'group' + ? fg + : subtle, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 20), + ], + + // Choose Destination + if (widget.content.type != 'text' || + _textSaveType == 'group') ...[ + Text( + 'CHOOSE DESTINATION', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: subtle, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 8), + // Destination Cards + Semantics( + button: true, + selected: _isVaultSelected, + label: 'Destination: Study Vault, private', + child: InkWell( + onTap: () => setState(() => _isVaultSelected = true), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _isVaultSelected + ? fg.withValues(alpha: 0.04) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _isVaultSelected + ? fg + : fg.withValues(alpha: 0.1), + width: _isVaultSelected ? 1.5 : 1.0, + ), + ), + child: Row( + children: [ + Icon( + Icons.folder_shared_outlined, + color: _isVaultSelected ? fg : subtle, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Study Vault (Private)', + style: TextStyle( + fontSize: 13, + fontWeight: _isVaultSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + Text( + 'Secure, personal-only vault storage.', + style: TextStyle( + fontSize: 11, + color: subtle, + ), + ), + ], + ), + ), + if (_isVaultSelected) + Icon(Icons.check_circle, color: fg, size: 18), + ], + ), + ), + ), + ), + const SizedBox(height: 10), + Semantics( + button: true, + selected: !_isVaultSelected, + label: 'Destination: Study Group', + child: InkWell( + onTap: () => setState(() => _isVaultSelected = false), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: !_isVaultSelected + ? fg.withValues(alpha: 0.04) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: !_isVaultSelected + ? fg + : fg.withValues(alpha: 0.1), + width: !_isVaultSelected ? 1.5 : 1.0, + ), + ), + child: Row( + children: [ + Icon( + Icons.groups_outlined, + color: !_isVaultSelected ? fg : subtle, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Study Group', + style: TextStyle( + fontSize: 13, + fontWeight: !_isVaultSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + Text( + 'Share securely with verified team members.', + style: TextStyle( + fontSize: 11, + color: subtle, + ), + ), + ], + ), + ), + if (!_isVaultSelected) + Icon(Icons.check_circle, color: fg, size: 18), + ], + ), + ), + ), + ), + + // Group List Sub-section + if (!_isVaultSelected) ...[ + const SizedBox(height: 16), + if (groupsAsync.isLoading || _isLoadingPrefs) + const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (nonPrivateGroups.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + 'You haven\'t joined any study groups yet.', + style: TextStyle(fontSize: 12, color: subtle), + textAlign: TextAlign.center, + ), + ) + else ...[ + Text( + 'SELECT STUDY GROUP', + style: TextStyle( + fontSize: 8.5, + fontWeight: FontWeight.bold, + color: subtle, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 8), + Container( + constraints: const BoxConstraints(maxHeight: 180), + decoration: BoxDecoration( + border: Border.all( + color: fg.withValues(alpha: 0.08), + ), + borderRadius: BorderRadius.circular(10), + ), + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: [ + // 1. Recent Groups + if (recent3.isNotEmpty) ...[ + _buildGroupSectionHeader( + 'RECENT GROUPS', + subtle, + ), + ...recent3.map( + (g) => _buildGroupRow(g, fg, subtle), + ), + ], + + // 2. Pinned Groups + if (pinnedGroups.isNotEmpty) ...[ + _buildGroupSectionHeader( + 'PINNED GROUPS', + subtle, + ), + ...pinnedGroups.map( + (g) => _buildGroupRow(g, fg, subtle), + ), + ], + + // 3. All Groups + _buildGroupSectionHeader('ALL GROUPS', subtle), + ...allGroupsSorted.map( + (g) => _buildGroupRow(g, fg, subtle), + ), + ], + ), + ), + ], + ], + ], + + // Text Save Type Note + if (widget.content.type == 'text' && + _textSaveType == 'private') ...[ + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Icon(Icons.info_outline, size: 14, color: subtle), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Private notes are appended to your Private Notepad.', + style: TextStyle(fontSize: 11, color: subtle), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Action Buttons + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + ref.read(shareIntentProvider.notifier).clear(); + Navigator.of(context).pop(); + }, + child: Text( + 'CANCEL', + style: TextStyle( + color: fg.withValues(alpha: 0.6), + fontWeight: FontWeight.bold, + fontSize: 12, + letterSpacing: 1.0, + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: (uploadState.stage == UploadStage.processing) + ? null + : _handleSave, + style: ElevatedButton.styleFrom( + backgroundColor: fg, + foregroundColor: bg, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + 'IMPORT CONTENT', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + letterSpacing: 1.0, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildGroupSectionHeader(String title, Color color) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 4), + child: Text( + title, + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w800, + color: color, + letterSpacing: 0.5, + ), + ), + ); + } + + Widget _buildGroupRow(StudyGroup group, Color fg, Color subtle) { + final isSelected = _selectedGroupId == group.id; + final isPinned = _pinnedGroupIds.contains(group.id); + + return Semantics( + button: true, + selected: isSelected, + label: 'Save to ${group.name}', + child: InkWell( + onTap: () { + setState(() { + _selectedGroupId = group.id; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + color: isSelected ? fg.withValues(alpha: 0.04) : Colors.transparent, + child: Row( + children: [ + Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: isSelected ? fg : subtle, + size: 16, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + group.name, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected ? fg : fg.withValues(alpha: 0.8), + ), + ), + Text( + '${group.memberCount} members · ${group.fileCount} files', + style: TextStyle(fontSize: 10, color: subtle), + ), + ], + ), + ), + IconButton( + icon: Icon( + isPinned ? Icons.push_pin : Icons.push_pin_outlined, + color: isPinned ? Colors.amber : subtle, + size: 14, + ), + tooltip: isPinned ? 'Unpin ${group.name}' : 'Pin ${group.name}', + onPressed: () => _togglePin(group.id), + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/workspace/providers/recently_saved_provider.dart b/app/lib/features/workspace/providers/recently_saved_provider.dart new file mode 100644 index 0000000..8b667b5 --- /dev/null +++ b/app/lib/features/workspace/providers/recently_saved_provider.dart @@ -0,0 +1,409 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../files/presentation/providers/upload_provider.dart'; +import '../../groups/models/group_file.dart'; +import '../../auth/presentation/providers/auth_providers.dart'; +import '../../groups/providers/groups_provider.dart'; +import '../../groups/domain/models/study_group.dart'; +import '../../groups/screens/group_detail_screen.dart'; +import '../../../../components/spyglass_viewer.dart'; +import '../../../../core/utils/debug_logger.dart'; +import '../../../../main.dart'; + +class RecentlySavedItem { + final String id; + final String title; + final String type; // 'pdf', 'image', 'text', 'url' + final String destinationType; // 'vault' or 'group' + final String destinationName; // 'Private Vault' or group name + final String? destinationId; // group ID if group + final DateTime timestamp; + final String status; // 'uploading', 'completed', 'failed' + final String? localPath; // temp cache path for retry + final String? extraData; // extra context (shared content) + final String? fileId; // resolved file ID from Supabase + + RecentlySavedItem({ + required this.id, + required this.title, + required this.type, + required this.destinationType, + required this.destinationName, + this.destinationId, + required this.timestamp, + required this.status, + this.localPath, + this.extraData, + this.fileId, + }); + + RecentlySavedItem copyWith({ + String? id, + String? title, + String? type, + String? destinationType, + String? destinationName, + String? destinationId, + DateTime? timestamp, + String? status, + String? localPath, + String? extraData, + String? fileId, + }) { + return RecentlySavedItem( + id: id ?? this.id, + title: title ?? this.title, + type: type ?? this.type, + destinationType: destinationType ?? this.destinationType, + destinationName: destinationName ?? this.destinationName, + destinationId: destinationId ?? this.destinationId, + timestamp: timestamp ?? this.timestamp, + status: status ?? this.status, + localPath: localPath ?? this.localPath, + extraData: extraData ?? this.extraData, + fileId: fileId ?? this.fileId, + ); + } + + Map toMap() { + return { + 'id': id, + 'title': title, + 'type': type, + 'destinationType': destinationType, + 'destinationName': destinationName, + 'destinationId': destinationId, + 'timestamp': timestamp.toIso8601String(), + 'status': status, + 'localPath': localPath, + 'extraData': extraData, + 'fileId': fileId, + }; + } + + factory RecentlySavedItem.fromMap(Map map) { + return RecentlySavedItem( + id: map['id'] as String, + title: map['title'] as String, + type: map['type'] as String, + destinationType: map['destinationType'] as String, + destinationName: map['destinationName'] as String, + destinationId: map['destinationId'] as String?, + timestamp: DateTime.parse(map['timestamp'] as String), + status: map['status'] as String, + localPath: map['localPath'] as String?, + extraData: map['extraData'] as String?, + fileId: map['fileId'] as String?, + ); + } +} + +class RecentlySavedState { + final List items; + final String? activeUploadId; + + const RecentlySavedState({ + this.items = const [], + this.activeUploadId, + }); + + RecentlySavedState copyWith({ + List? items, + String? activeUploadId, + }) { + return RecentlySavedState( + items: items ?? this.items, + activeUploadId: activeUploadId ?? this.activeUploadId, + ); + } +} + +class RecentlySavedNotifier extends Notifier { + static const _keyPrefix = 'recently_saved_history_v2_'; + + @override + RecentlySavedState build() { + final user = ref.watch(authStateProvider).value; + + if (user != null) { + _loadFromPrefs(user.id); + } else { + state = const RecentlySavedState(); + } + return const RecentlySavedState(); + } + + Future _loadFromPrefs(String userId) async { + try { + final prefs = await SharedPreferences.getInstance(); + if (!ref.mounted) return; + final jsonStr = prefs.getString('$_keyPrefix$userId'); + if (jsonStr != null) { + final List decoded = json.decode(jsonStr); + var loadedItems = decoded.map((x) => RecentlySavedItem.fromMap(Map.from(x))).toList(); + + loadedItems = loadedItems.map((item) { + if (item.status == 'uploading') { + return item.copyWith(status: 'failed'); + } + return item; + }).toList(); + + state = state.copyWith(items: loadedItems); + } else { + state = const RecentlySavedState(); + } + } catch (e) { + debugLog("Error loading recently saved history: $e"); + } + } + + Future _saveToPrefs() async { + final user = ref.read(authStateProvider).value; + if (user == null) return; + + try { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = json.encode(state.items.map((x) => x.toMap()).toList()); + await prefs.setString('$_keyPrefix${user.id}', jsonStr); + } catch (e) { + debugLog("Error saving recently saved history: $e"); + } + } + + void addItem(RecentlySavedItem item) { + var items = List.from(state.items); + items.removeWhere((x) => x.id == item.id); + items.removeWhere((x) => x.extraData == item.extraData && x.destinationId == item.destinationId && x.destinationType == item.destinationType); + + items.insert(0, item); + if (items.length > 20) { + final toRemove = items.sublist(20); + for (final oldItem in toRemove) { + _deleteLocalFile(oldItem.localPath); + } + items = items.sublist(0, 20); + } + state = state.copyWith( + items: items, + activeUploadId: item.status == 'uploading' ? item.id : state.activeUploadId, + ); + _saveToPrefs(); + } + + void updateStatus(String id, String status) { + final items = state.items.map((x) { + if (x.id == id) { + if (status == 'completed') { + _deleteLocalFile(x.localPath); + } + return x.copyWith(status: status); + } + return x; + }).toList(); + + state = state.copyWith( + items: items, + activeUploadId: status == 'uploading' ? id : (state.activeUploadId == id ? null : state.activeUploadId), + ); + _saveToPrefs(); + } + + void updateItemFileId(String id, String? fileId) { + final items = state.items.map((x) { + if (x.id == id) { + return x.copyWith(fileId: fileId); + } + return x; + }).toList(); + state = state.copyWith(items: items); + _saveToPrefs(); + } + + void removeItem(String id) { + final item = state.items.firstWhere((x) => x.id == id, orElse: () => RecentlySavedItem( + id: '', title: '', type: '', destinationType: '', destinationName: '', timestamp: DateTime.now(), status: '' + )); + if (item.id.isNotEmpty) { + _deleteLocalFile(item.localPath); + } + final items = state.items.where((x) => x.id != id).toList(); + state = state.copyWith( + items: items, + activeUploadId: state.activeUploadId == id ? null : state.activeUploadId, + ); + _saveToPrefs(); + } + + void _deleteLocalFile(String? path) { + if (path == null) return; + try { + final file = File(path); + if (file.existsSync()) { + file.deleteSync(); + debugLog("Deleted cache file: $path"); + } + } catch (e) { + debugLog("Error deleting cache file: $e"); + } + } + + Future retryUpload(String itemId, WidgetRef ref) async { + final item = state.items.firstWhere((x) => x.id == itemId, orElse: () => RecentlySavedItem( + id: '', title: '', type: '', destinationType: '', destinationName: '', timestamp: DateTime.now(), status: '' + )); + if (item.id.isEmpty) return; + + updateStatus(itemId, 'uploading'); + + try { + final uploadNotifier = ref.read(uploadProvider.notifier); + final targetGroupId = item.destinationId ?? ''; + + if (item.type == 'pdf' || item.type == 'image') { + final filePath = item.localPath; + if (filePath == null) throw Exception("Cached file path is missing"); + final file = File(filePath); + if (!await file.exists()) { + throw Exception("Cached file no longer exists"); + } + final bytes = await file.readAsBytes(); + final type = item.type == 'pdf' ? FileType.pdf : FileType.image; + await uploadNotifier.uploadDocument(item.title, type, targetGroupId, bytes); + } else if (item.type == 'text') { + if (item.destinationType == 'vault') { + final user = ref.read(authRepositoryProvider).currentUser; + if (user == null) throw Exception("User not authenticated"); + + await uploadNotifier.savePrivateNote(userId: user.id, content: item.extraData ?? ''); + } else { + final bytes = Uint8List.fromList(utf8.encode(item.extraData ?? '')); + await uploadNotifier.uploadDocument(item.title, FileType.markdown, targetGroupId, bytes); + } + } else if (item.type == 'url') { + final trimmed = (item.extraData ?? '').trim(); + final isDrive = trimmed.contains('drive.google.com') || trimmed.contains('docs.google.com'); + if (isDrive) { + await uploadNotifier.linkGoogleDriveDocument(item.title, targetGroupId, trimmed); + } else { + final mdContent = '# Shared Reference Link\n\nURL: [$trimmed]($trimmed)\n\nShared on: ${DateTime.now().toLocal()}\n'; + final bytes = Uint8List.fromList(utf8.encode(mdContent)); + await uploadNotifier.uploadDocument(item.title, FileType.markdown, targetGroupId, bytes); + } + } + + final uploadState = ref.read(uploadProvider); + if (uploadState.stage == UploadStage.complete) { + updateStatus(itemId, 'completed'); + } else { + updateStatus(itemId, 'failed'); + } + } catch (e) { + debugLog("Retry upload failed: $e"); + updateStatus(itemId, 'failed'); + } + } + + /// Whether the signed-in user is still a member of [groupId], per the + /// server-backed group list rather than anything cached in this history + /// entry. Returns false while the list is still loading — deny-by-default is + /// the right side to err on, and the user can tap again. + bool _isStillMember(String groupId, WidgetRef ref) { + final groups = ref.read(groupsProvider).value ?? []; + return groups.any((g) => g.id == groupId); + } + + void navigateToItem(RecentlySavedItem item, BuildContext context, WidgetRef ref) async { + if (item.status != 'completed') return; + + if (item.type == 'url') { + final trimmed = (item.extraData ?? '').trim(); + final isDrive = trimmed.contains('drive.google.com') || trimmed.contains('docs.google.com'); + + if (isDrive && item.fileId != null && item.destinationId != null) { + // Same membership gate the non-Drive branch below applies. This entry + // is replayed from SharedPreferences, so it outlives leaving the group + // — without the check, a stale history row is a live door into a group + // the user is no longer in. The server refuses the fetch either way + // (drive-proxy resolves the file to its group and requires + // membership), so this is the fast, honest failure rather than the + // only barrier. + if (!_isStillMember(item.destinationId!, ref)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Study Group not found or left.')), + ); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SpyglassViewer( + fileId: item.fileId!, + groupId: item.destinationId!, + documentTitle: item.title, + documentCategory: 'pdf', + ), + ), + ); + } else { + const channel = MethodChannel('co.nosus.app/share'); + try { + await channel.invokeMethod('openUrl', {'url': trimmed}); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open link: $e')), + ); + } + } + } + } else if (item.type == 'text' && item.destinationType == 'vault') { + ref.read(activeTabProvider.notifier).changeTab(0); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Private notepad opened.'), + duration: Duration(seconds: 2), + ), + ); + } else { + if (item.destinationId != null) { + final groupsAsync = ref.read(groupsProvider); + final groups = groupsAsync.value ?? []; + final matchedGroup = groups.firstWhere( + (g) => g.id == item.destinationId, + orElse: () => StudyGroup( + id: '', + name: '', + description: '', + members: const [], + fileCount: 0, + lastActivity: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + + if (matchedGroup.id.isNotEmpty && context.mounted) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GroupDetailScreen( + group: matchedGroup, + highlightedFileId: item.fileId, + ), + ), + ); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Study Group not found or left.')), + ); + } + } + } + } +} + +final recentlySavedProvider = NotifierProvider(() { + return RecentlySavedNotifier(); +}); diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..84cb73a --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,1298 @@ +import 'dart:async'; +import 'components/experiment_frame.dart'; +import 'features/canary/domain/canary_link.dart'; +import 'features/canary/presentation/canary_app.dart'; +import 'features/canary/presentation/canary_home_screen.dart'; +import 'features/canary/presentation/canary_reader_screen.dart'; +import 'config/app_mode.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:universal_html/html.dart' as html; + +import 'config/crash_reporting_config.dart'; +import 'theme.dart'; +import 'features/profile/presentation/widgets/profile_avatar.dart'; +import 'core/providers/theme_provider.dart'; +import 'components/floating_nav.dart'; +import 'features/profile/providers/profile_provider.dart'; +import 'features/profile/presentation/screens/profile_screen.dart'; +import 'features/auth/presentation/widgets/auth_gate.dart'; +import 'features/groups/screens/groups_screen.dart'; +import 'features/groups/screens/group_invite_landing_screen.dart'; +import 'features/workspace/presentation/pages/workspace_tab.dart'; +import 'features/vault/presentation/pages/vault_tab.dart'; +import 'features/vault/presentation/pages/study_desk_tab.dart'; +import 'features/audit/presentation/pages/audit_tab.dart'; + +import 'services/supabase_service.dart'; +import 'services/measure_reporting.dart'; +import 'services/screenshot_guard.dart'; +import 'services/audit_service.dart'; +import 'services/device_integrity_service.dart'; +import 'services/share_intent_service.dart'; +import 'features/workspace/presentation/widgets/save_to_no_sus_dialog.dart'; +import 'features/focus/providers/focus_provider.dart'; +import 'core/utils/debug_logger.dart'; +import 'features/share/presentation/screens/anonymous_share_viewer_screen.dart'; +import 'features/share/presentation/screens/in_app_share_viewer_screen.dart'; +import 'features/share/presentation/screens/share_analytics_screen.dart'; +import 'features/share/presentation/providers/share_providers.dart'; +import 'features/share/domain/entities/share_link.dart'; + +import 'package:app_links/app_links.dart'; +import 'features/analytics/data/analytics_service.dart'; +import 'features/auth/presentation/providers/pending_intent_provider.dart'; +import 'features/notifications/data/push_service.dart'; +import 'features/notifications/presentation/notification_router.dart'; +import 'features/notifications/presentation/providers/notification_providers.dart'; +import 'features/notifications/presentation/screens/notification_inbox_screen.dart'; +import 'features/config/data/remote_config_service.dart'; +import 'features/config/presentation/providers/config_provider.dart'; +import 'core/mascot/mascot_state.dart'; +import 'core/mascot/mascot_view.dart'; + +import 'features/share/presentation/screens/burn_note_viewer_screen.dart'; +import 'features/share/presentation/screens/burn_file_viewer_screen.dart'; +import 'features/share/presentation/screens/redeem_code_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class BurnNoteToken { + final String id; + final String keyHex; + final String ivHex; + BurnNoteToken({required this.id, required this.keyHex, required this.ivHex}); +} + +/// Public (rather than private) so the URL contract has regression tests — +/// see test/unit/deep_link_parsing_test.dart. Burn links are shared into the +/// wild; silently changing what parses is a production outage. +BurnNoteToken? extractBurnNoteToken(Uri uri) { + final fullUrl = kIsWeb ? html.window.location.href : uri.toString(); + debugLog('NO SUS: Extracting Burn Note Token from: $fullUrl'); + + // ── Format 1 (new): #/burn/?k=&v= ───────────────── + // Extract the hash fragment, then parse query params from within it. + // e.g. https://nosus.foo/#/burn/abc-123?k=aaa...&v=bbb... + final hashIdx = fullUrl.indexOf('#'); + if (hashIdx != -1) { + final fragment = fullUrl.substring(hashIdx + 1); // /burn/?k=...&v=... + final qIdx = fragment.indexOf('?'); + if (qIdx != -1) { + final path = fragment.substring(0, qIdx); // /burn/ + final query = fragment.substring(qIdx + 1); // k=...&v=... + final params = Uri.splitQueryString(query); + final burnMatch = RegExp( + r'burn/([a-f0-9\-]{36})', + caseSensitive: false, + ).firstMatch(path); + final k = params['k']; + final v = params['v']; + if (burnMatch != null && + k != null && + v != null && + k.length == 64 && + v.length == 32) { + debugLog( + 'NO SUS: Burn Note matched (new format) id=${burnMatch.group(1)}', + ); + return BurnNoteToken(id: burnMatch.group(1)!, keyHex: k, ivHex: v); + } + } + } + + // ── Format 2 (legacy): burn/#. ───────────────────── + final legacyRegExp = RegExp( + r'burn/([a-f0-9\-]{36})(?:#|%23|/)([a-f0-9]{64})\.([a-f0-9]{32})', + caseSensitive: false, + ); + final match = legacyRegExp.firstMatch(fullUrl); + if (match != null) { + debugLog('NO SUS: Burn Note matched (legacy format) id=${match.group(1)}'); + return BurnNoteToken( + id: match.group(1)!, + keyHex: match.group(2)!, + ivHex: match.group(3)!, + ); + } + + debugLog('NO SUS: No Burn Note Token match in: $fullUrl'); + return null; +} + +class BurnFileToken { + final String id; + final String keyHex; + final String ivHex; + BurnFileToken({required this.id, required this.keyHex, required this.ivHex}); +} + +/// Public for the same reason as [extractBurnNoteToken] — a regression here +/// silently breaks every Burn Files link already shared into the wild. Only +/// one URL format exists (no legacy format to carry, unlike burn notes). +/// e.g. https://nosus.foo/#/burnfile/abc-123?k=aaa...&v=bbb... +BurnFileToken? extractBurnFileToken(Uri uri) { + final fullUrl = kIsWeb ? html.window.location.href : uri.toString(); + debugLog('NO SUS: Extracting Burn File Token from: $fullUrl'); + + final hashIdx = fullUrl.indexOf('#'); + if (hashIdx == -1) return null; + + final fragment = fullUrl.substring( + hashIdx + 1, + ); // /burnfile/?k=...&v=... + final qIdx = fragment.indexOf('?'); + if (qIdx == -1) return null; + + final path = fragment.substring(0, qIdx); // /burnfile/ + final query = fragment.substring(qIdx + 1); // k=...&v=... + final params = Uri.splitQueryString(query); + final match = RegExp( + r'burnfile/([a-f0-9\-]{36})', + caseSensitive: false, + ).firstMatch(path); + final k = params['k']; + final v = params['v']; + if (match != null && + k != null && + v != null && + k.length == 64 && + v.length == 32) { + debugLog('NO SUS: Burn File matched id=${match.group(1)}'); + return BurnFileToken(id: match.group(1)!, keyHex: k, ivHex: v); + } + + debugLog('NO SUS: No Burn File Token match in: $fullUrl'); + return null; +} + +/// Multi-file Burn Files share — a NEW, additive link shape living on its +/// own `burnfiles` (plural) path so it can never collide with or change the +/// parsing of `burnfile/` links already shared into the wild. Each +/// file keeps its own independent key/IV (the exact same per-file crypto as +/// [extractBurnFileToken] — no new crypto primitive, just N of them), so the +/// only new thing here is the list-shaped URL, not the encryption scheme. +/// e.g. https://app.nosus.foo/#/burnfiles/id1,id2?k=key1,key2&v=iv1,iv2 +List? extractBurnFilesToken(Uri uri) { + final fullUrl = kIsWeb ? html.window.location.href : uri.toString(); + + final hashIdx = fullUrl.indexOf('#'); + if (hashIdx == -1) return null; + + final fragment = fullUrl.substring( + hashIdx + 1, + ); // /burnfiles/?k=...&v=... + final qIdx = fragment.indexOf('?'); + if (qIdx == -1) return null; + + final path = fragment.substring(0, qIdx); + final query = fragment.substring(qIdx + 1); + final params = Uri.splitQueryString(query); + + final match = RegExp( + r'burnfiles/([a-f0-9,\-]+)', + caseSensitive: false, + ).firstMatch(path); + final ids = match?.group(1)?.split(',') ?? const []; + final keys = params['k']?.split(',') ?? const []; + final ivs = params['v']?.split(',') ?? const []; + + if (ids.isEmpty || ids.length != keys.length || ids.length != ivs.length) { + return null; + } + final tokens = []; + for (var i = 0; i < ids.length; i++) { + final id = ids[i]; + final k = keys[i]; + final v = ivs[i]; + if (id.length != 36 || k.length != 64 || v.length != 32) return null; + tokens.add(BurnFileToken(id: id, keyHex: k, ivHex: v)); + } + debugLog('NO SUS: Burn Files batch matched, count=${tokens.length}'); + return tokens; +} + +/// NO SUS Canary reader link: `#/canary/?k=<64 hex key>`. Public so the +/// URL contract has regression tests (test/unit/canary_link_test.dart). +CanaryLinkToken? extractCanaryToken(Uri uri) { + final fullUrl = kIsWeb ? html.window.location.href : uri.toString(); + return parseCanaryReaderLink(fullUrl); +} + +/// Extracts the opaque token carried by a two-digit redemption pairing link. +/// The token is deliberately long and unguessable; the two visible digits are +/// only a human confirmation step and must never be accepted on their own. +String? extractRedemptionToken(Uri uri) { + for (final raw in [uri.fragment, uri.path]) { + final cleaned = raw.startsWith('/') ? raw.substring(1) : raw; + final match = RegExp( + r'^redeem/([a-f0-9]{64})(?:\?.*)?$', + caseSensitive: false, + ).firstMatch(cleaned); + if (match != null) return match.group(1)!.toLowerCase(); + } + return null; +} + +/// Extracts a SecureSend share token from a `/v/` URL, checking both +/// the fragment (default hash-based web routing, e.g. `#/v/abc123`) and the +/// path, so the link works regardless of URL strategy. Returns null on any +/// other platform/URL shape — this only ever matches an intentional share link. +String? extractShareToken(Uri uri) { + for (final raw in [uri.fragment, uri.path]) { + final cleaned = raw.startsWith('/') ? raw.substring(1) : raw; + final parts = cleaned.split('/'); + if (parts.length >= 2 && parts[0] == 'v' && parts[1].isNotEmpty) { + return parts[1]; + } + } + return null; +} + +/// Extracts a group invite code from a URL/deep link, supporting hash routing +/// (#/join/abc123xyz), path routing (/join/abc123xyz), path segments, and custom scheme formats. +String? extractInviteToken(Uri uri) { + for (final raw in [uri.fragment, uri.path]) { + final cleaned = raw.startsWith('/') ? raw.substring(1) : raw; + final parts = cleaned.split('/'); + if (parts.length >= 2 && parts[0] == 'join' && parts[1].isNotEmpty) { + return parts[1]; + } + } + if (uri.pathSegments.length >= 2 && uri.pathSegments[0] == 'join') { + return uri.pathSegments[1]; + } + if (uri.host == 'join' && uri.pathSegments.isNotEmpty) { + return uri.pathSegments.first; + } + return null; +} + +void main() async { + await runZonedGuarded( + () async { + WidgetsFlutterBinding.ensureInitialized(); + + // Crash reporting — no-op unless SENTRY_DSN is supplied via + // --dart-define (see lib/config/crash_reporting_config.dart). Used at + // the low-level `SentryFlutter.init(configure)` form (no `appRunner`) + // so it slots into this existing bootstrap sequence instead of + // requiring runApp() to move inside a callback. sendDefaultPii is + // explicitly off and tracing defaults to 0% — this product's premise + // is zero-knowledge/no-tracking, so crash capture stays scoped to + // errors, not analytics. + if (CrashReportingConfig.isEnabled) { + await SentryFlutter.init((options) { + options.dsn = CrashReportingConfig.sentryDsn; + options.tracesSampleRate = CrashReportingConfig.tracesSampleRate; + options.sendDefaultPii = false; + }); + } + FlutterError.onError = (details) { + FlutterError.presentError(details); + if (CrashReportingConfig.isEnabled) { + Sentry.captureException(details.exception, stackTrace: details.stack); + } + }; + + // Measure (measure.sh) — Android-only, and a no-op unless both + // MEASURE_API_KEY and MEASURE_API_URL are supplied via --dart-define + // (see lib/config/measure_reporting_config.dart, which also documents + // what this SDK collects and the two behaviours that are hostile to this + // product). + // + // Order is load-bearing: this must stay AFTER the FlutterError.onError + // assignment above. Measure's init captures whatever handler is + // installed at that instant and calls it after its own, so initialising + // here chains Measure → Sentry → presentError. Move it earlier and the + // assignment above silently overwrites Measure's handler, leaving the + // SDK configured, reporting nothing, and looking fine. + // + // The empty callback is deliberate. Measure's documented form runs + // runApp() inside init(), which this bootstrap cannot do — there are + // several early-return runApp() branches below for share/burn deep + // links. init() only awaits its own setup and then invokes the callback, + // so a no-op is equivalent; same reasoning as the low-level + // SentryFlutter.init form above. + // + // start() is explicit because both MeasureConfigs are set to + // autoStart: false — one place where collection begins, and the point a + // consent gate would attach. + await initializeMeasureReporting(); + + // Pre-load SharedPreferences synchronously before routing and app run + final prefs = await SharedPreferences.getInstance(); + // Hand the warm instance to the analytics service so its "first ever" + // milestones can be deduped without awaiting a plugin channel per call. + AnalyticsService.instance.attachPreferences(prefs); + + // Initialize Supabase immediately so all early routing screens can access the client + await SupabaseService.instance.initialize(); + + // Funnel entry point. Fire-and-forget by construction — see + // AnalyticsService; it no-ops entirely in mock fallback mode. + AnalyticsService.instance.log(AnalyticsEvent.appOpened); + + // Do not probe `getUser()` and sign out during startup. Supabase Flutter + // v2 restores a persisted session first and refreshes it asynchronously; + // an expired access token or a transient network failure is normal here. + // Treating either as proof of a deleted account was logging real users + // out whenever they relaunched at the wrong moment. + // + // A genuinely invalid session emits the SDK's signed-out state, which + // AuthGate already handles. This keeps restore fast and non-destructive. + if (SupabaseService.instance.isConfigured) { + unawaited(() async { + final cachedSession = Supabase.instance.client.auth.currentSession; + if (cachedSession?.isExpired ?? false) { + debugLog( + 'NO SUS: Restoring an expired cached session; awaiting SDK refresh.', + ); + } + }()); + } + + // SecureSend anonymous path: a share-link recipient may have no NO SUS + // account at all, so this branch skips Supabase/auth entirely and never + // rejoins the normal app below. + final shareToken = extractShareToken(Uri.base); + if (shareToken != null) { + // ProviderScope here is only so the mascot system (Riverpod) works on + // this standalone entrypoint — it has no Supabase session and never + // will; nothing mascot-related depends on one. + runApp( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: AnonymousShareViewerScreen(token: shareToken), + ), + ); + return; + } + + final burnNoteToken = extractBurnNoteToken(Uri.base); + if (burnNoteToken != null) { + runApp( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: BurnNoteViewerScreen( + noteId: burnNoteToken.id, + keyHex: burnNoteToken.keyHex, + ivHex: burnNoteToken.ivHex, + ), + ), + ); + return; + } + + // Burn Files anonymous path — same "no session, never rejoins the + // normal app" pattern as burn notes/SecureSend above. The recipient + // never has a NO SUS account (product decision — see + // supabase/migrations/20260710050000_burn_files.sql). + final burnFileToken = extractBurnFileToken(Uri.base); + if (burnFileToken != null) { + runApp( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: BurnFileViewerScreen( + files: [ + ( + id: burnFileToken.id, + keyHex: burnFileToken.keyHex, + ivHex: burnFileToken.ivHex, + ), + ], + ), + ), + ); + return; + } + + // Multi-file share — checked after the single-file path so an old + // `burnfile/` link (no trailing 's') is never re-parsed here; + // the two regexes are structurally disjoint (see extractBurnFilesToken). + final burnFilesToken = extractBurnFilesToken(Uri.base); + if (burnFilesToken != null) { + runApp( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: BurnFileViewerScreen( + files: burnFilesToken + .map((t) => (id: t.id, keyHex: t.keyHex, ivHex: t.ivHex)) + .toList(), + ), + ), + ); + return; + } + + // NO SUS Canary reader path: anonymous and standalone, like Burn links. + final canaryToken = extractCanaryToken(Uri.base); + if (canaryToken != null) { + runApp( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: CanaryReaderApp( + noteId: canaryToken.noteId, + keyHex: canaryToken.keyHex, + ), + ), + ); + return; + } + + final redemptionToken = extractRedemptionToken(Uri.base); + if (redemptionToken != null) { + runApp( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: RedeemCodeScreen(redeemToken: redemptionToken), + ), + ); + return; + } + + // supabase_flutter runs its own app-link listener and consumes + // `login-callback` itself. The PKCE code is single-use, so calling + // getSessionFromUrl here unconditionally made both handlers race for it + // and whichever lost reported `flow_state_not_found` — non-deterministic, + // and it surfaced as an auth error on a login that had actually + // succeeded. Wait for the SDK, and only recover manually if it never + // establishes a session. + Future handleOAuthCallback(Uri uri) async { + final auth = Supabase.instance.client.auth; + if (auth.currentSession != null) return; + + try { + await auth.onAuthStateChange + .firstWhere((state) => state.session != null) + .timeout(const Duration(seconds: 8)); + debugLog('NO SUS: OAuth callback completed by the Supabase SDK.'); + return; + } catch (_) { + debugLog( + 'NO SUS: SDK established no session for $uri — recovering manually.', + ); + } + + try { + await auth.getSessionFromUrl(uri); + debugLog('NO SUS: Session recovered successfully!'); + } catch (e) { + debugLog('NO SUS: Error recovering session from deep link: $e'); + } + } + + // Listen to deep links for manual session recovery and invite codes + final appLinks = AppLinks(); + appLinks.uriLinkStream.listen((uri) async { + debugLog('NO SUS: Received Deep Link: $uri'); + final inviteCode = extractInviteToken(uri); + if (inviteCode != null) { + _handleInAppInviteLink(inviteCode); + return; + } + + if (uri.scheme == 'io.supabase.nosus') { + await handleOAuthCallback(uri); + } else if (uri.scheme == 'foo.nosus.app' && uri.host == 'v') { + final token = uri.pathSegments.firstOrNull; + if (token != null && token.isNotEmpty) { + _handleInAppShareView(token); + } + } else if (uri.scheme == 'https') { + // Android App Links hand us every https link to app.nosus.foo — + // see _routeIncomingWebLink for why this catch-all is mandatory. + _routeIncomingWebLink(uri); + } + }); + + // Handle initial link if app was closed + try { + final initialUri = await appLinks.getInitialLink(); + if (initialUri != null) { + final inviteCode = extractInviteToken(initialUri); + if (inviteCode != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleInAppInviteLink(inviteCode); + }); + } else if (initialUri.scheme == 'io.supabase.nosus') { + await handleOAuthCallback(initialUri); + } else if (initialUri.scheme == 'foo.nosus.app' && + initialUri.host == 'v') { + final token = initialUri.pathSegments.firstOrNull; + if (token != null && token.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleInAppShareView(token); + }); + } + } else if (initialUri.scheme == 'https') { + WidgetsBinding.instance.addPostFrameCallback((_) { + _routeIncomingWebLink(initialUri); + }); + } + } + } catch (e) { + debugLog('NO SUS: Error reading initial deep link: $e'); + } + + // Initialize security audit logging service + AuditService.instance.init(); + // Screen protection does not need to hold the first useful frame. + unawaited(ScreenshotGuard.instance.initialize()); + // 3. Device-integrity scan (root/Frida/Xposed) — fire-and-forget so a + // 150ms socket probe never delays first paint; findings land in the + // device_integrity_events ledger asynchronously. + unawaited(DeviceIntegrityService.instance.runStartupChecks()); + + // Initialize remote config and feature flags. Fire-and-forget — flags/ + // configs are mutated in place on the same instance handed to + // remoteConfigServiceProvider below, and every consumer already falls + // back to a default value until the fetch lands, so first paint never + // needs to wait on this network round-trip. + // In mock fallback mode there is no Supabase client at all — a null + // client makes the service serve defaults (touching Supabase.instance + // uninitalized would throw and kill boot before runApp). + final remoteConfig = RemoteConfigService( + SupabaseService.instance.isConfigured ? Supabase.instance.client : null, + ); + if (SupabaseService.instance.isConfigured && + SupabaseService.instance.isReachable) { + unawaited(remoteConfig.ensureInitialized()); + } + + runApp( + ProviderScope( + overrides: [ + remoteConfigServiceProvider.overrideWithValue(remoteConfig), + sharedPreferencesProvider.overrideWithValue(prefs), + ], + child: kCanaryOnly ? const CanaryApp() : const MyApp(), + ), + ); + }, + (error, stack) { + // Catch all unhandled async errors (e.g. Supabase realtime WebSocket failures) + // These are logged but do NOT crash the app — the mock fallback data remains active. + debugLog('NO SUS: Caught unhandled async error: $error'); + if (CrashReportingConfig.isEnabled) { + Sentry.captureException(error, stackTrace: stack); + } + }, + ); +} + +class MyApp extends ConsumerWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Initialize share intent listener early + ref.watch(shareIntentProvider); + final themeMode = ref.watch(themeModeProvider); + + final inviteToken = extractInviteToken(Uri.base); + + return MaterialApp( + title: 'NO SUS', + builder: (context, child) => ExperimentFrame(child: child ?? const SizedBox.shrink()), + debugShowCheckedModeBanner: false, + navigatorKey: ScreenshotGuard.instance.navigatorKey, + theme: NoSusTheme.lightTheme, + darkTheme: NoSusTheme.darkTheme, + themeMode: themeMode, + home: inviteToken != null + ? GroupInviteLandingScreen(inviteCode: inviteToken) + : const AuthGate(child: WorkspaceHome()), + onGenerateRoute: (settings) { + final route = settings.name ?? ''; + if (route == '/canary') { + return MaterialPageRoute( + settings: settings, + builder: (_) => const CanaryHomeScreen(), + ); + } + // Web OAuth (Google/GitHub) redirects land on e.g. "/?code=..." — not + // exactly "/", so Flutter treats it as a distinct route instead of + // falling back to `home`. By the time this builds, Supabase has + // already exchanged the code for a session (handled in main() before + // runApp), so just show the same real entry point `home` would — + // AuthGate reacts to the now-signed-in state normally. + if (settings.name != null && settings.name!.contains('code=')) { + return PageRouteBuilder( + pageBuilder: (context, _, _) => + const AuthGate(child: WorkspaceHome()), + transitionDuration: Duration.zero, + ); + } + return null; + }, + ); + } +} + +class WorkspaceHome extends ConsumerStatefulWidget { + const WorkspaceHome({super.key}); + + @override + ConsumerState createState() => _WorkspaceHomeState(); +} + +class ActiveTabNotifier extends Notifier { + @override + int build() => 0; + + void changeTab(int index) { + state = index; + } +} + +final activeTabProvider = NotifierProvider(() { + return ActiveTabNotifier(); +}); + +class _WorkspaceHomeState extends ConsumerState { + int _currentTab = 0; + String? _deskFileId; + + late final PageController _pageController; + + @override + void initState() { + super.initState(); + _currentTab = ref.read(activeTabProvider); + _pageController = PageController(initialPage: _currentTab); + WidgetsBinding.instance.addPostFrameCallback((_) async { + final shared = ref.read(shareIntentProvider); + if (shared != null) { + _showSaveToNoSusModal(shared); + } + + _resumePendingIntent(); + _initPush(); + }); + } + + StreamSubscription? _pushTapSub; + + /// Brings up the push transport and wires notification taps to navigation. + /// + /// Deliberately does **not** request the notification permission: that is a + /// contextual decision made after the user does something worth being + /// notified about (see maybePrimeNotifications). Registering a token for an + /// account that already granted permission on another install is separate + /// from asking, and only the former happens here. + /// + /// With no Firebase project configured — the current state — initialize() + /// returns false and everything below is a no-op. The in-app inbox still + /// works; it reads Postgres directly. + Future _initPush() async { + final available = await PushService.instance.initialize(); + if (!available || !mounted) return; + + if (await PushService.instance.hasPermission()) { + await PushService.instance.registerCurrentDevice(); + } + + _pushTapSub = PushService.instance.onNotificationTap.listen((message) { + if (!mounted) return; + final navContext = ScreenshotGuard.instance.navigatorKey.currentContext; + if (navContext == null || !navContext.mounted) return; + NotificationRouter.open( + navContext, + ref, + message.data['deep_link'] as String?, + ); + }); + + // A tap that launched the app from terminated does not arrive on the + // stream above — it has to be collected once, here. + final launchMessage = await PushService.instance.initialMessage(); + if (launchMessage != null && mounted) { + NotificationRouter.open( + context, + ref, + launchMessage.data['deep_link'] as String?, + ); + } + } + + /// Replays whatever the user was trying to do before an auth wall stopped + /// them. + /// + /// This is the landing point of the "preserve intent" contract: tap Join + /// Community → sign up → arrive back at the join flow, rather than on Home + /// having to find it again. Runs after the shell's first frame because two of + /// the four intents are tab switches on this very widget. + void _resumePendingIntent() { + final intent = ref.read(pendingIntentProvider.notifier).take(); + if (intent == null || !mounted) return; + + AnalyticsService.instance.log( + AnalyticsEvent.intentResumed, + properties: {'kind': intent.kind.name}, + ); + + switch (intent.kind) { + case PendingIntentKind.joinGroup: + final code = intent.payload; + if (code == null || code.isEmpty) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GroupInviteLandingScreen(inviteCode: code), + ), + ); + case PendingIntentKind.openShare: + final token = intent.payload; + if (token == null || token.isEmpty) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => InAppShareViewerScreen(token: token), + ), + ); + case PendingIntentKind.browseGroups: + ref.read(activeTabProvider.notifier).changeTab(4); + case PendingIntentKind.shareDocument: + ref.read(activeTabProvider.notifier).changeTab(1); + } + } + + bool _isShareModalOpen = false; + + void _showSaveToNoSusModal(SharedContent content) { + if (_isShareModalOpen) return; + _isShareModalOpen = true; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => SaveToNoSusDialog(content: content), + ).then((_) { + _isShareModalOpen = false; + }); + } + + @override + void dispose() { + _pushTapSub?.cancel(); + _pageController.dispose(); + super.dispose(); + } + + void _onTabTapped(int index) { + ref.read(activeTabProvider.notifier).changeTab(index); + } + + void _navigateToDesk(String fileId) { + setState(() { + _deskFileId = fileId; + }); + ref.read(activeTabProvider.notifier).changeTab(2); // Jump to Study Desk + } + + void _toggleTheme() { + ref.read(themeModeProvider.notifier).toggle(); + } + + /// Runs [action] once the in-flight frame is done, skipping it if this state + /// is gone by then. Provider listeners registered in [build] can fire during + /// the build phase, where touching UI throws. + void _afterFrame(VoidCallback action) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) action(); + }); + } + + Widget _buildHeader(BuildContext context, bool isDark) { + final theme = Theme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // The wordmark is laid out against a fixed-width action row, so it has + // to yield the leftover space rather than claim its natural width — + // the tagline is wide enough to overflow narrow phones otherwise. + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'NO SUS', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: 2.0, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'SILENT SECURITY WORKSPACE', + style: theme.textTheme.labelLarge?.copyWith( + fontSize: 10, + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + letterSpacing: 1.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Row( + children: [ + // Minimalist Outlined Theme Selector Toggle + Semantics( + button: true, + label: isDark ? 'Switch to light theme' : 'Switch to dark theme', + child: GestureDetector( + onTap: _toggleTheme, + child: Container( + padding: const EdgeInsets.all(NoSusTheme.s12), + decoration: NoSusTheme.buttonDecoration(context, radius: 14), + child: Icon( + isDark + ? Icons.wb_sunny_outlined + : Icons.nightlight_round_outlined, + size: 18, + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + const SizedBox(width: NoSusTheme.s12), + // Notification inbox. Present regardless of push: the inbox reads + // Postgres directly, so it is the reliable delivery channel and + // push is the optimisation on top. + _NotificationBell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const NotificationInboxScreen(), + ), + ), + ), + const SizedBox(width: NoSusTheme.s12), + // Glowing Gradient Profile Avatar Button + (() { + final profileAsync = ref.watch(profileProvider); + return profileAsync.maybeWhen( + data: (profile) { + return Semantics( + button: true, + label: 'Your profile', + child: GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ProfileScreen(), + ), + ); + }, + child: Container( + width: 42, + height: 42, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: NoSusTheme.lBorder, + border: Border.all( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.1, + ), + width: 1.0, + ), + ), + child: ClipOval( + child: ExcludeSemantics( + child: ProfileAvatar(profile: profile, size: 42), + ), + ), + ), + ), + ); + }, + orElse: () => Semantics( + button: true, + label: 'Your profile', + child: GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ProfileScreen(), + ), + ); + }, + child: Container( + width: 42, + height: 42, + decoration: NoSusTheme.buttonDecoration( + context, + radius: 21, + ), + child: Icon( + Icons.person_outline, + color: theme.colorScheme.onSurface, + size: 18, + ), + ), + ), + ), + ); + })(), + ], + ), + ], + ); + } + + void _showNotificationBanner(ShareViewEvent event, String fileName) { + final isDark = Theme.of(context).brightness == Brightness.dark; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).size.height - 100, + left: 16, + right: 16, + ), + duration: const Duration(seconds: 6), + // A dimmer blue in dark mode avoids an oversaturated highlight + // against the near-black background; white text/icon keeps + // sufficient contrast against either shade. + backgroundColor: isDark ? Colors.blue.shade700 : Colors.blueAccent, + content: Row( + children: [ + const MascotView( + character: MascotCharacter.nox, + size: 20, + fallback: Icon( + Icons.notifications_active, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '${event.viewerEmail} opened "$fileName"', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + action: SnackBarAction( + label: 'VIEW', + textColor: Colors.white, + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ShareAnalyticsScreen( + linkId: event.linkId, + fileName: fileName, + ), + ), + ); + }, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // These three listeners all perform work that is illegal mid-build — + // showing a snackbar or dialog, and jumping a PageController all call + // setState/markNeedsBuild. A provider that changes while this tree is + // building (a sign-out resetting the tab, for instance) delivered them + // synchronously and tripped "setState() called during build", so each is + // deferred to after the frame. + ref.listen(shareNotificationProvider, (prev, next) { + final event = next.latestEvent; + if (event != null && next.fileName != null) { + _afterFrame(() { + _showNotificationBanner(event, next.fileName!); + ref.read(shareNotificationProvider.notifier).dismiss(); + }); + } + }); + + ref.listen(shareIntentProvider, (previous, next) { + if (next != null) { + _afterFrame(() => _showSaveToNoSusModal(next)); + } + }); + + ref.listen(activeTabProvider, (previous, next) { + if (next != previous) { + _afterFrame(() { + if (_pageController.hasClients) _pageController.jumpToPage(next); + }); + } + }); + + _currentTab = ref.watch(activeTabProvider); + + ref.watch(focusSessionProvider); + final themeMode = ref.watch(themeModeProvider); + final isDark = themeMode == ThemeMode.dark; + + return Scaffold( + body: SafeArea( + // On desktop/web the phone-first layout would stretch edge to edge; + // constraining the whole shell keeps content (and the floating nav, + // which lives in the same Stack) in a centered readable column. + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Stack( + children: [ + // Main Content Area with thin border framing + Padding( + padding: EdgeInsets.only( + left: NoSusTheme.s24, + right: NoSusTheme.s24, + top: NoSusTheme.s16, + // Space for floating bottom nav. The compact phone nav + // uses icons only, so it needs less reserved height. + bottom: FloatingNav.contentClearance(context), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App Bar (Calm Monochrome UI Style) + _buildHeader(context, isDark), + const SizedBox(height: NoSusTheme.s24), + + // Animated Screen Content — using PageView to preserve states properly + Expanded( + child: PageView( + controller: _pageController, + physics: + const NeverScrollableScrollPhysics(), // Prevent swipe + children: [ + const WorkspaceTab(), + VaultTab(onRevealRequested: _navigateToDesk), + StudyDeskTab(initialFileId: _deskFileId), + const AuditTab(), + const GroupsScreen(key: ValueKey('groups_tab')), + ], + ), + ), + ], + ), + ), + + // Floating bottom navigation + FloatingNav(currentIndex: _currentTab, onTap: _onTabTapped), + ], + ), + ), + ), + ), + ); + } +} + +/// Header bell with an unread badge. +/// +/// The count is announced in the semantics label, not conveyed by the dot +/// alone — a red circle is invisible to a screen reader and to anyone who +/// cannot distinguish it against the header. +class _NotificationBell extends ConsumerWidget { + final VoidCallback onTap; + const _NotificationBell({required this.onTap}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final unread = ref.watch(unreadNotificationCountProvider); + + return Semantics( + button: true, + label: unread == 0 ? 'Notifications' : 'Notifications, $unread unread', + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(NoSusTheme.s12), + decoration: NoSusTheme.buttonDecoration(context, radius: 14), + child: ExcludeSemantics( + child: Stack( + clipBehavior: Clip.none, + children: [ + Icon( + unread > 0 + ? Icons.notifications_active_outlined + : Icons.notifications_none_outlined, + size: 18, + color: theme.colorScheme.onSurface, + ), + if (unread > 0) + Positioned( + top: -3, + right: -3, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + constraints: const BoxConstraints(minWidth: 14), + decoration: BoxDecoration( + color: Colors.redAccent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.scaffoldBackgroundColor, + width: 1.0, + ), + ), + child: Text( + unread > 9 ? '9+' : '$unread', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + height: 1.3, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +void _handleInAppShareView(String token) { + final context = ScreenshotGuard.instance.navigatorKey.currentContext; + if (context != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InAppShareViewerScreen(token: token), + ), + ); + } +} + +void _handleInAppInviteLink(String inviteCode) { + final context = ScreenshotGuard.instance.navigatorKey.currentContext; + if (context != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GroupInviteLandingScreen(inviteCode: inviteCode), + ), + ); + } +} + +/// Routes an incoming `https://app.nosus.foo/...` link to the viewer it names. +/// Returns true if the link was recognised and handled. +/// +/// **Why every shape has to be handled here.** The Android App Links filter for +/// `app.nosus.foo` is necessarily host-wide: an intent filter cannot match on a +/// URL fragment, and every link this app mints is fragment-shaped at path `/` +/// (`/#/burn/?k=…`, `/#/burnfile/`, `/#/burnfiles/`, +/// `/#/redeem/`, `/?cb=…#/v/…`, `/#/join/`). So once the app is +/// installed it intercepts *all* of them. +/// Anything not routed here is silently swallowed — the recipient lands on the +/// home screen and a single-use link looks broken, with no browser fallback, +/// because the system already chose the app over the web page. +/// +/// On web these same shapes are handled off `Uri.base` in [main] before the +/// normal app boots, each in its own standalone `runApp`. Native cannot do that +/// (the app may already be running), so they become pushed routes instead — +/// same screens, same anonymous fetch path, no session required. +/// +/// Order mirrors the web branches deliberately: single-file burn is checked +/// before multi-file so an old `burnfile/` link is never re-parsed by the +/// plural matcher. +bool _routeIncomingWebLink(Uri uri) { + final context = ScreenshotGuard.instance.navigatorKey.currentContext; + if (context == null) return false; + + final shareToken = extractShareToken(uri); + if (shareToken != null) { + _handleInAppShareView(shareToken); + return true; + } + + final canary = extractCanaryToken(uri); + if (canary != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CanaryReaderScreen( + noteId: canary.noteId, + keyHex: canary.keyHex, + ), + ), + ); + return true; + } + + final redemptionToken = extractRedemptionToken(uri); + if (redemptionToken != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => RedeemCodeScreen(redeemToken: redemptionToken), + ), + ); + return true; + } + + final burnNote = extractBurnNoteToken(uri); + if (burnNote != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BurnNoteViewerScreen( + noteId: burnNote.id, + keyHex: burnNote.keyHex, + ivHex: burnNote.ivHex, + ), + ), + ); + return true; + } + + final burnFile = extractBurnFileToken(uri); + if (burnFile != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BurnFileViewerScreen( + files: [ + (id: burnFile.id, keyHex: burnFile.keyHex, ivHex: burnFile.ivHex), + ], + ), + ), + ); + return true; + } + + final burnFiles = extractBurnFilesToken(uri); + if (burnFiles != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BurnFileViewerScreen( + files: burnFiles + .map((t) => (id: t.id, keyHex: t.keyHex, ivHex: t.ivHex)) + .toList(), + ), + ), + ); + return true; + } + + return false; +} diff --git a/app/lib/screens/splash_screen.dart b/app/lib/screens/splash_screen.dart new file mode 100644 index 0000000..fa0aac8 --- /dev/null +++ b/app/lib/screens/splash_screen.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +/// The brief, branded hold shown only while auth state is actually resolving. +/// +/// Startup work begins before this widget is built; it never adds a timer, +/// request, or minimum duration of its own. The square stop is the sole motion so +/// the canonical wordmark remains the visual focus. +class BrandSplash extends StatefulWidget { + const BrandSplash({super.key}); + + @override + State createState() => _BrandSplashState(); +} + +class _BrandSplashState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _stopController; + bool _motionConfigured = false; + + @override + void initState() { + super.initState(); + _stopController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_motionConfigured) return; + _motionConfigured = true; + + if (MediaQuery.disableAnimationsOf(context)) { + _stopController.value = 1; + } else { + _stopController.forward(); + } + } + + @override + void dispose() { + _stopController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final fontSize = constraints.maxWidth < 360 ? 34.0 : 42.0; + + return Center( + child: Semantics( + container: true, + liveRegion: true, + label: 'NO SUS opening application', + child: RichText( + text: TextSpan( + style: TextStyle( + color: Colors.white, + fontSize: fontSize, + fontWeight: FontWeight.w800, + height: 1, + letterSpacing: -0.5, + ), + children: [ + const TextSpan(text: 'NO SUS'), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: FadeTransition( + opacity: CurvedAnimation( + parent: _stopController, + curve: Curves.easeOut, + ), + child: Transform.translate( + offset: const Offset(0, -1), + child: Container( + key: const ValueKey('brand-square-stop'), + width: fontSize * 0.21, + height: fontSize * 0.21, + margin: EdgeInsets.only( + left: fontSize * 0.08, + bottom: fontSize * 0.02, + ), + color: const Color(0xFF808080), + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/services/audit_service.dart b/app/lib/services/audit_service.dart new file mode 100644 index 0000000..bfbecf0 --- /dev/null +++ b/app/lib/services/audit_service.dart @@ -0,0 +1,341 @@ +import 'dart:async'; +import '../features/analytics/data/analytics_service.dart'; +import 'supabase_service.dart'; +import '../core/utils/debug_logger.dart'; + +class AuditService { + static final AuditService instance = AuditService._internal(); + AuditService._internal(); + + final List> _auditLogs = [ + {'time': 'Now', 'event': 'Workspace session initiated', 'status': 'INFO'}, + ]; + + final StreamController>> _auditLogsStream = + StreamController>>.broadcast(); + + StreamSubscription? _auditLogsSub; + + void init() { + _auditLogsSub?.cancel(); + _auditLogsSub = null; + + if (SupabaseService.instance.isReachable) { + _auditLogsSub = SupabaseService.instance.watchAuditLogs().listen( + (data) { + _auditLogs.clear(); + _auditLogs.addAll(data); + _auditLogsStream.add(_auditLogs); + }, + onError: (e) { + debugLog( + "AuditService: watchAuditLogs stream error, offline fallback active. $e", + ); + }, + ); + } + } + + void reset() { + _auditLogsSub?.cancel(); + _auditLogsSub = null; + _auditLogs.clear(); + _auditLogs.add({'time': 'Now', 'event': 'Workspace session initiated', 'status': 'INFO'}); + _auditLogsStream.add(_auditLogs); + } + + void logEvent( + String eventTypeOrDescription, + String statusOrInfo, { + String? groupId, + String? fileId, + Map? metadata, + }) { + final now = DateTime.now(); + final timeStr = '${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + + // Activation milestones. Hooked here rather than at each call site because + // this method is the one funnel every upload and every document open + // already passes through — instrumenting the callers individually would + // guarantee one gets missed. logOnce() is backed by SharedPreferences, so + // "first" means the user's first, not this session's. + switch (eventTypeOrDescription) { + case 'file_uploaded': + unawaited( + AnalyticsService.instance.logOnce( + AnalyticsEvent.firstDocumentUploaded, + ), + ); + case 'file_viewed': + unawaited( + AnalyticsService.instance.logOnce(AnalyticsEvent.firstDocumentViewed), + ); + } + + if (SupabaseService.instance.isReachable) { + final allowedEventTypes = [ + 'file_uploaded', + 'file_downloaded', + 'file_viewed', + 'file_deleted', + 'file_renamed', + 'file_pinned', + 'member_joined', + 'member_left', + 'member_removed', + 'member_banned', + 'member_unbanned', + 'member_promoted', + 'member_demoted', + 'group_created', + 'group_updated', + 'invite_generated', + 'invite_used', + 'screenshot_attempt', + 'recording_attempt', + 'unauthorized_access', + 'overlay_detected', + 'accessibility_detected', + 'display_mirroring_detected', + ]; + + String eventType = eventTypeOrDescription; + Map meta = metadata ?? {}; + if (!allowedEventTypes.contains(eventType)) { + if (eventTypeOrDescription.contains('uploaded')) { + eventType = 'file_uploaded'; + meta['file_name'] = _extractFileName(eventTypeOrDescription); + } else if (eventTypeOrDescription.contains('Deleted') || + eventTypeOrDescription.contains('deleted')) { + eventType = 'file_deleted'; + meta['file_id'] = fileId ?? _extractFileId(eventTypeOrDescription); + } else if (eventTypeOrDescription.contains('Created') || + eventTypeOrDescription.contains('created')) { + eventType = 'group_created'; + meta['group_name'] = _extractGroupName(eventTypeOrDescription); + } else { + eventType = 'group_updated'; + meta['description'] = eventTypeOrDescription; + } + } + + _auditLogs.insert(0, { + 'time': timeStr, + 'event': formatEventDescription(eventType, meta), + 'status': getEventStatus(eventType, meta), + }); + _auditLogsStream.add(_auditLogs); + + // Without a group there is nothing log_group_event would accept: the old + // 'default_group' placeholder was never a real group id, so every such + // call was rejected server-side. The entry above still reaches the local + // audit list, so the user-visible trail is unchanged. + if (groupId != null) { + SupabaseService.instance.logEvent( + eventType, + groupId: groupId, + fileId: fileId, + metadata: meta, + ); + } + return; + } + + final allowedEventTypes = [ + 'file_uploaded', + 'file_downloaded', + 'file_viewed', + 'file_deleted', + 'file_renamed', + 'file_pinned', + 'member_joined', + 'member_left', + 'member_removed', + 'member_banned', + 'member_unbanned', + 'member_promoted', + 'member_demoted', + 'group_created', + 'group_updated', + 'invite_generated', + 'invite_used', + 'screenshot_attempt', + 'recording_attempt', + 'unauthorized_access', + 'overlay_detected', + 'accessibility_detected', + 'display_mirroring_detected', + ]; + + String displayEvent = eventTypeOrDescription; + String displayStatus = statusOrInfo; + + if (allowedEventTypes.contains(eventTypeOrDescription)) { + displayEvent = formatEventDescription( + eventTypeOrDescription, + metadata ?? {}, + ); + displayStatus = getEventStatus(eventTypeOrDescription, metadata ?? {}); + } + + _auditLogs.insert(0, { + 'time': timeStr, + 'event': displayEvent, + 'status': displayStatus, + }); + _auditLogsStream.add(_auditLogs); + } + + static String formatEventDescription( + String eventType, + Map metadata, + ) { + switch (eventType) { + case 'file_uploaded': + final name = metadata['file_name'] ?? 'document'; + final size = metadata['size_bytes']; + final sizeStr = size != null ? ' ($size bytes)' : ''; + return 'Uploaded "$name"$sizeStr'; + case 'file_downloaded': + final name = metadata['file_name'] ?? 'document'; + return 'Downloaded secure file "$name"'; + case 'file_viewed': + final name = metadata['file_name'] ?? 'document'; + return 'Opened secure file "$name" in Spyglass Viewer'; + case 'file_deleted': + final fileId = metadata['file_id'] ?? 'unknown'; + return 'Deleted secure file with ID "$fileId"'; + case 'file_renamed': + final oldName = metadata['old_name'] ?? 'old'; + final newName = metadata['new_name'] ?? 'new'; + return 'Renamed secure file "$oldName" to "$newName"'; + case 'file_pinned': + final name = metadata['file_name'] ?? 'document'; + final isPinned = metadata['is_pinned'] as bool? ?? true; + return '${isPinned ? "Pinned" : "Unpinned"} secure file "$name"'; + case 'member_joined': + final name = metadata['member_name'] ?? 'New user'; + return '$name joined the study group'; + case 'member_left': + final name = metadata['member_name'] ?? 'User'; + return '$name left the study group'; + case 'member_removed': + final name = metadata['member_name'] ?? 'A member'; + return '$name was removed from the group'; + case 'member_banned': + final name = metadata['member_name'] ?? 'A member'; + return '$name was banned from the group'; + case 'member_unbanned': + final name = metadata['member_name'] ?? 'A member'; + return "$name's ban was lifted"; + case 'member_promoted': + final name = metadata['member_name'] ?? 'User'; + return '$name was promoted to admin'; + case 'member_demoted': + final name = metadata['member_name'] ?? 'User'; + return '$name was demoted'; + case 'group_created': + final name = metadata['group_name'] ?? 'group'; + return 'Created study group "$name"'; + case 'group_updated': + final description = metadata['description']; + if (description is String && description.isNotEmpty) { + return description; + } + return 'Updated study group settings'; + case 'invite_generated': + return 'Generated invite code for group'; + case 'invite_used': + final name = metadata['member_name'] ?? 'User'; + return '$name joined group using invite code'; + case 'screenshot_attempt': + final name = metadata['file_name'] ?? 'document'; + return 'Blocked screenshot attempt of "$name"'; + case 'recording_attempt': + final name = metadata['file_name'] ?? 'document'; + return 'Blocked screen recording attempt of "$name"'; + case 'unauthorized_access': + return 'Unauthorized access attempt blocked'; + case 'overlay_detected': + final name = metadata['file_name'] ?? 'document'; + return 'Overlay/tapjacking attempt detected while viewing "$name"'; + case 'accessibility_detected': + final services = metadata['services']; + final count = services is List ? services.length : 0; + return 'Suspicious accessibility service(s) detected during viewing ($count)'; + case 'display_mirroring_detected': + return 'External display or screen mirroring detected during viewing'; + default: + return 'Security audit event triggered: $eventType'; + } + } + + static String getEventStatus( + String eventType, + Map metadata, + ) { + if (eventType == 'screenshot_attempt' || + eventType == 'recording_attempt' || + eventType == 'unauthorized_access' || + eventType == 'overlay_detected' || + eventType == 'accessibility_detected' || + eventType == 'display_mirroring_detected') { + return 'SECURITY'; + } + if (eventType.contains('viewed') || + eventType.contains('downloaded') || + eventType.contains('joined')) { + return 'INFO'; + } + return 'SUCCESS'; + } + + static String _extractFileName(String desc) { + final match = RegExp(r'"([^"]+)"').firstMatch(desc); + return match?.group(1) ?? 'document'; + } + + static String _extractFileId(String desc) { + final match = RegExp(r'ID "([^"]+)"').firstMatch(desc); + return match?.group(1) ?? 'unknown'; + } + + static String _extractGroupName(String desc) { + final match = RegExp(r'"([^"]+)"').firstMatch(desc); + return match?.group(1) ?? 'group'; + } + + Stream>> watchAuditLogs() { + return Stream.multi((controller) { + controller.add(List>.unmodifiable(_auditLogs)); + final sub = _auditLogsStream.stream.listen( + (data) => controller.add(List>.unmodifiable(data)), + onError: controller.addError, + onDone: controller.close, + ); + controller.onCancel = () => sub.cancel(); + }); + } + + List> get auditLogs => _auditLogs; + + Future> fetchAuditLogCounts() async { + if (SupabaseService.instance.isReachable) { + return await SupabaseService.instance.fetchAuditLogCounts(); + } + return {}; + } + + void handleRestPollLogs(List> logsParsed) { + _auditLogs.clear(); + _auditLogs.addAll(logsParsed); + _auditLogsStream.add(_auditLogs); + } + + void cancelStreams() { + _auditLogsSub?.cancel(); + _auditLogsSub = null; + } + + static String _pad(int n) => n.toString().padLeft(2, '0'); +} diff --git a/app/lib/services/automation_signal_stub.dart b/app/lib/services/automation_signal_stub.dart new file mode 100644 index 0000000..9e879da --- /dev/null +++ b/app/lib/services/automation_signal_stub.dart @@ -0,0 +1,4 @@ +/// Non-web fallback. Automation/headless-browser signals only exist in a +/// browser context — see automation_signal_web.dart for the real +/// implementation, selected via conditional import in web_security_guard.dart. +bool? readNavigatorWebdriverFlag() => null; diff --git a/app/lib/services/automation_signal_web.dart b/app/lib/services/automation_signal_web.dart new file mode 100644 index 0000000..9a0d764 --- /dev/null +++ b/app/lib/services/automation_signal_web.dart @@ -0,0 +1,19 @@ +// ignore_for_file: avoid_web_libraries_in_flutter +import 'dart:js_interop'; + +/// Reads the real, standardized `navigator.webdriver` property via static +/// JS interop. universal_html's own `Navigator.webdriver` getter is a stub +/// that always returns null regardless of platform — it does not forward to +/// the browser's actual property — so it's useless for real detection and +/// must be bypassed here. This file is only ever compiled in on web builds, +/// selected via the conditional import in web_security_guard.dart. +@JS('navigator.webdriver') +external JSBoolean? get _webdriverFlag; + +bool? readNavigatorWebdriverFlag() { + try { + return _webdriverFlag?.toDart; + } catch (_) { + return null; + } +} diff --git a/app/lib/services/burn_file_crypto.dart b/app/lib/services/burn_file_crypto.dart new file mode 100644 index 0000000..7d5d3fd --- /dev/null +++ b/app/lib/services/burn_file_crypto.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:encrypt/encrypt.dart' as enc; + +/// Zero-knowledge packing/encryption for Burn Files. +/// +/// Unlike burn_notes (text ciphertext stored inline in a DB column, so it +/// only ever needs base64 for the TEXT column type), a Burn File's ciphertext +/// goes straight to Storage as binary — no base64, avoiding ~33% bloat. +/// +/// The server never learns the original filename, mime type, or size +/// either: a length-prefixed JSON header is packed in front of the raw file +/// bytes, and the WHOLE packed buffer is encrypted as one blob. The +/// filename deliberately never appears in the share URL either (that would +/// leak via browser history) — it only exists inside the encrypted payload, +/// recovered by [unpackBurnFilePayload] after decryption. +class BurnFilePayload { + final String fileName; + final String mimeType; + final Uint8List bytes; + + const BurnFilePayload({ + required this.fileName, + required this.mimeType, + required this.bytes, + }); +} + +/// `[4-byte big-endian header length][JSON header {name,type,size}][raw file bytes]`. +/// The 4-byte length prefix (rather than a delimiter character) is required +/// because a delimiter could collide with arbitrary binary file content. +Uint8List packBurnFilePayload({ + required String fileName, + required String mimeType, + required Uint8List fileBytes, +}) { + final headerBytes = utf8.encode(jsonEncode({ + 'name': fileName, + 'type': mimeType, + 'size': fileBytes.length, + })); + final prefix = ByteData(4)..setUint32(0, headerBytes.length, Endian.big); + + return (BytesBuilder(copy: false) + ..add(prefix.buffer.asUint8List()) + ..add(headerBytes) + ..add(fileBytes)) + .toBytes(); +} + +/// Reverses [packBurnFilePayload] after decryption. +BurnFilePayload unpackBurnFilePayload(Uint8List packed) { + final headerLen = ByteData.sublistView(packed, 0, 4).getUint32(0, Endian.big); + final header = jsonDecode(utf8.decode(packed.sublist(4, 4 + headerLen))) + as Map; + return BurnFilePayload( + fileName: header['name'] as String, + mimeType: header['type'] as String, + bytes: packed.sublist(4 + headerLen), + ); +} + +/// Generates a fresh random 256-bit key + 128-bit IV, matching burn_notes' +/// CSPRNG approach (`fromSecureRandom`) — but explicit about AES-CBC rather +/// than relying on the `encrypt` package's default mode (SIC/CTR), since a +/// binary blob without burn_notes' string-oriented helpers should not rely +/// on an implicit default. +({enc.Key key, enc.IV iv}) generateBurnFileKeyMaterial() { + return (key: enc.Key.fromSecureRandom(32), iv: enc.IV.fromSecureRandom(16)); +} + +Uint8List encryptBurnFilePayload(Uint8List packed, enc.Key key, enc.IV iv) { + final encrypter = enc.Encrypter(enc.AES(key, mode: enc.AESMode.cbc)); + return encrypter.encryptBytes(packed, iv: iv).bytes; +} + +Uint8List decryptBurnFilePayload(Uint8List ciphertext, enc.Key key, enc.IV iv) { + final encrypter = enc.Encrypter(enc.AES(key, mode: enc.AESMode.cbc)); + return Uint8List.fromList( + encrypter.decryptBytes(enc.Encrypted(ciphertext), iv: iv), + ); +} + +/// Hex helpers matching burn_notes' URL-fragment key/IV encoding exactly +/// (`_bytesToHex`/`_hexToBytes` in burn_note_creator_screen.dart / +/// burn_note_viewer_screen.dart) so both features' links look consistent. +String bytesToHex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + +Uint8List hexToBytes(String hex) { + final bytes = Uint8List(hex.length ~/ 2); + for (var i = 0; i < hex.length; i += 2) { + bytes[i ~/ 2] = int.parse(hex.substring(i, i + 2), radix: 16); + } + return bytes; +} diff --git a/app/lib/services/device_integrity_service.dart b/app/lib/services/device_integrity_service.dart new file mode 100644 index 0000000..4c371c5 --- /dev/null +++ b/app/lib/services/device_integrity_service.dart @@ -0,0 +1,239 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; +import '../core/utils/debug_logger.dart'; +import 'audit_service.dart'; +import 'supabase_service.dart'; + +/// Aggregates device/session-level tamper signals from the native Android +/// scanner (root, Frida/Xposed/LSPosed instrumentation, display mirroring, +/// accessibility-service abuse). +/// +/// Two different findings get logged to two different ledgers, deliberately: +/// +/// - Root/instrumentation are properties of the *device*, not of any one +/// group action, so [runStartupChecks] writes them to the self-scoped +/// `device_integrity_events` hash chain (see [SupabaseService.logDeviceIntegrityEvent]). +/// - Display mirroring and accessibility abuse are checked per viewing +/// session in [runViewingChecks] and logged into the existing group-scoped +/// `audit_logs` chain via [AuditService], exactly like screenshot_attempt — +/// because "was someone mirroring my screen while I viewed this document" +/// is a fact about that document's audit trail, not about the device. +/// +/// Policy for this app (see project decision): findings are logged, not used +/// to block access — a false positive (e.g. a legitimately rooted power-user +/// phone) should never lock a user out of their own notes. +class DeviceIntegrityService { + DeviceIntegrityService._(); + static final DeviceIntegrityService instance = DeviceIntegrityService._(); + + static const _channel = MethodChannel('co.nosus.app/device_integrity'); + static const _deviceIdPrefsKey = 'nosus_device_id_v1'; + static const _deviceIdMigratedPrefsKey = 'nosus_device_id_migrated_v2'; + + String? _deviceId; + String? _deviceIdSecurityLevel; + bool _startupScanDone = false; + bool _deviceRegistered = false; + + /// How the current [deviceId] is backed: 'strongbox', 'tee', 'software', + /// 'unknown', or null on the legacy/non-Android path. Null or 'software' + /// means the id is not hardware-protected and carries no more weight than + /// the old random UUID did — don't present it as if it does. + String? get deviceIdSecurityLevel => _deviceIdSecurityLevel; + + /// Stable per-install identifier. Deliberately *not* a hardware ID + /// (IMEI/serial/etc.) — this app doesn't collect those, in keeping with the + /// no-third-party-tracking privacy stance (see web/privacy.html). It only + /// needs to be stable enough to notice "this same device flagged again" and + /// "multiple devices for one user". + /// + /// On Android this is the digest of a non-extractable Keystore key, so it + /// survives no better than the app's data but can't be edited into a + /// different value or copied to another device — which the previous + /// prefs-stored UUID could, defeating both of the detectors above on + /// exactly the rooted devices they target. Web, iOS, and any Android device + /// whose Keystore is unusable keep the original UUID behaviour. + Future get deviceId async { + if (_deviceId != null) return _deviceId!; + + final hardwareId = await _hardwareDeviceId(); + if (hardwareId != null) { + _deviceId = hardwareId; + return hardwareId; + } + + final prefs = await SharedPreferences.getInstance(); + var id = prefs.getString(_deviceIdPrefsKey); + if (id == null) { + id = const Uuid().v4(); + await prefs.setString(_deviceIdPrefsKey, id); + } + _deviceId = id; + return id; + } + + /// Null whenever a hardware-backed id can't be produced — non-Android, or + /// a Keystore that failed outright. Never throws: an unusable Keystore must + /// degrade to the legacy id, not break launch. + Future _hardwareDeviceId() async { + if (kIsWeb || !Platform.isAndroid) return null; + try { + final raw = await _channel.invokeMethod('getDeviceKeyId'); + if (raw is! Map) return null; + final result = Map.from(raw); + final id = result['deviceId']; + if (id is! String || id.isEmpty) return null; + _deviceIdSecurityLevel = result['securityLevel'] as String?; + // Level only, never the id itself — the id is a stable per-device + // identifier and logcat is readable by any debug tooling on the box. + debugLog('DeviceIntegrityService: device id backing=$_deviceIdSecurityLevel'); + return id; + } catch (e) { + debugLog('DeviceIntegrityService: hardware device id unavailable: $e'); + return null; + } + } + + /// One-time move of this device's server-side history from the legacy UUID + /// onto the hardware-backed id. Runs from [registerDeviceSeen] rather than + /// the [deviceId] getter because it needs an authenticated session, and + /// only sets the completion flag on success so a transient failure retries + /// on the next launch instead of silently orphaning the old row. + /// + /// Returns whether it's safe to register [currentId] now. False means a + /// migration was genuinely needed and failed — registering anyway would + /// make this already-known device look new and write a false + /// 'multiple_device_access' into an append-only hash chain that can't be + /// retracted. Skipping one session's `last_seen_at` refresh is the cheaper + /// error; the next launch retries. + Future _migrateLegacyDeviceId(String currentId) async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_deviceIdMigratedPrefsKey) == true) return true; + + final legacyId = prefs.getString(_deviceIdPrefsKey); + if (legacyId == null || legacyId == currentId) { + await prefs.setBool(_deviceIdMigratedPrefsKey, true); + return true; + } + + final migrated = await SupabaseService.instance.migrateDeviceId( + oldDeviceId: legacyId, + newDeviceId: currentId, + ); + if (migrated) { + await prefs.setBool(_deviceIdMigratedPrefsKey, true); + } + return migrated; + } + + Future?> _runNativeScan() async { + if (kIsWeb || !Platform.isAndroid) return null; + try { + final raw = await _channel.invokeMethod('runChecks'); + if (raw is! Map) return null; + return Map.from(raw); + } catch (e) { + debugLog('DeviceIntegrityService: native scan failed: $e'); + return null; + } + } + + /// Call once per app launch (see main.dart). Fire-and-forget — never + /// awaited by startup code, since file/socket probing shouldn't delay + /// first paint. + Future runStartupChecks() async { + if (_startupScanDone) return; + _startupScanDone = true; + + final result = await _runNativeScan(); + if (result == null) return; + + final id = await deviceId; + + final root = Map.from(result['root'] as Map? ?? {}); + if (root['rooted'] == true) { + await SupabaseService.instance.logDeviceIntegrityEvent( + eventType: 'root_detected', + severity: 'warning', + deviceId: id, + metadata: {'reasons': root['reasons']}, + ); + } + + final instrumentation = + Map.from(result['instrumentation'] as Map? ?? {}); + if (instrumentation['instrumented'] == true) { + await SupabaseService.instance.logDeviceIntegrityEvent( + eventType: 'tamper_detected', + severity: 'critical', + deviceId: id, + metadata: {'reasons': instrumentation['reasons']}, + ); + } + } + + /// Call once per app session, right after a user is confirmed signed in + /// (see AuthGate) — needs auth.uid() server-side, so it can't run before + /// that. Platform-agnostic (works on web too, unlike the native root/ + /// instrumentation scan): just a persisted device id + a Supabase RPC. + Future registerDeviceSeen() async { + if (_deviceRegistered) return; + _deviceRegistered = true; + try { + final id = await deviceId; + // Must precede the register call: it renames the existing row, so + // running it after would leave register_device_seen() to see the new id + // as unknown and log a spurious 'multiple_device_access'. If it was + // needed and failed, don't register at all this session — see the + // method's doc comment. + if (!await _migrateLegacyDeviceId(id)) { + _deviceRegistered = false; + return; + } + await SupabaseService.instance.registerDeviceSeen(id); + } catch (e) { + debugLog('DeviceIntegrityService: registerDeviceSeen failed: $e'); + } + } + + /// Call when a sensitive viewer (Spyglass, secure notes, etc.) opens. + /// Checks display mirroring + accessibility abuse *for this viewing + /// session* and logs into the group's tamper-evident audit trail. + Future runViewingChecks({ + required String? groupId, + String? fileId, + }) async { + if (groupId == null) return; + + final result = await _runNativeScan(); + if (result == null) return; + + final mirroring = + Map.from(result['displayMirroring'] as Map? ?? {}); + if (mirroring['mirroring'] == true) { + AuditService.instance.logEvent( + 'display_mirroring_detected', + 'SECURITY', + groupId: groupId, + fileId: fileId, + metadata: {'displays': mirroring['displays']}, + ); + } + + final accessibility = + Map.from(result['accessibility'] as Map? ?? {}); + if (accessibility['suspiciousServicesFound'] == true) { + AuditService.instance.logEvent( + 'accessibility_detected', + 'SECURITY', + groupId: groupId, + fileId: fileId, + metadata: {'services': accessibility['services']}, + ); + } + } +} diff --git a/app/lib/services/focus_service.dart b/app/lib/services/focus_service.dart new file mode 100644 index 0000000..6eda3d5 --- /dev/null +++ b/app/lib/services/focus_service.dart @@ -0,0 +1,83 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'supabase_service.dart'; + +class FocusService { + static final FocusService instance = FocusService._internal(); + FocusService._internal(); + + bool _isValidUuid(String id) { + return RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ).hasMatch(id); + } + + Future> fetchFocusLogs(String userId) async { + final Map localLogs = {}; + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + + // 1. Load local logs for the last 7 days + for (int i = 0; i < 7; i++) { + final date = now.subtract(Duration(days: i)); + final dateStr = date.toIso8601String().substring(0, 10); + final key = 'nosus_focus_log_${userId}_$dateStr'; + final minutes = prefs.getInt(key) ?? 0; + if (minutes > 0) { + localLogs[DateTime(date.year, date.month, date.day)] = minutes; + } + } + + // 2. If online and valid UUID, sync with Supabase + if (_isValidUuid(userId) && SupabaseService.instance.isConfigured && SupabaseService.instance.isReachable) { + try { + final serverLogs = await SupabaseService.instance.fetchFocusLogs(userId); + + // Merge server logs with local logs + // Rule: take the maximum of local vs server, and update local storage with server values. + for (final entry in serverLogs.entries) { + final dateStr = entry.key.toIso8601String().substring(0, 10); + final key = 'nosus_focus_log_${userId}_$dateStr'; + final localMinutes = prefs.getInt(key) ?? 0; + if (entry.value > localMinutes) { + await prefs.setInt(key, entry.value); + localLogs[entry.key] = entry.value; + } else if (localMinutes > entry.value) { + // Server has fewer minutes (local offline additions), we should update Supabase! + final diff = localMinutes - entry.value; + await SupabaseService.instance.incrementFocusMinutes(userId, diff); + } + } + + // Re-read local logs to ensure everything is perfectly aligned + for (int i = 0; i < 7; i++) { + final date = now.subtract(Duration(days: i)); + final dateStr = date.toIso8601String().substring(0, 10); + final key = 'nosus_focus_log_${userId}_$dateStr'; + final minutes = prefs.getInt(key) ?? 0; + if (minutes > 0) { + localLogs[DateTime(date.year, date.month, date.day)] = minutes; + } + } + } catch (e) { + // Safe to ignore, we fallback to local logs + } + } + + return localLogs; + } + + Future incrementFocusMinutes(String userId, int minutes) async { + final prefs = await SharedPreferences.getInstance(); + final todayStr = DateTime.now().toLocal().toIso8601String().substring(0, 10); + final key = 'nosus_focus_log_${userId}_$todayStr'; + final currentMinutes = prefs.getInt(key) ?? 0; + + // Save locally first + await prefs.setInt(key, currentMinutes + minutes); + + // If online & UUID is valid, sync with Supabase + if (_isValidUuid(userId) && SupabaseService.instance.isConfigured && SupabaseService.instance.isReachable) { + await SupabaseService.instance.incrementFocusMinutes(userId, minutes); + } + } +} diff --git a/app/lib/services/measure_reporting.dart b/app/lib/services/measure_reporting.dart new file mode 100644 index 0000000..cdcb9a1 --- /dev/null +++ b/app/lib/services/measure_reporting.dart @@ -0,0 +1,8 @@ +// Platform-safe entry point for the optional Android Measure integration. +// +// `measure_flutter` currently contains JavaScript-incompatible code, so a +// direct import would fail every Flutter web build even when reporting is +// disabled. Conditional export keeps the Android implementation out of the +// web program entirely. +export 'measure_reporting_stub.dart' + if (dart.library.io) 'measure_reporting_android.dart'; diff --git a/app/lib/services/measure_reporting_android.dart b/app/lib/services/measure_reporting_android.dart new file mode 100644 index 0000000..04919d5 --- /dev/null +++ b/app/lib/services/measure_reporting_android.dart @@ -0,0 +1,17 @@ +import 'package:measure_flutter/measure_flutter.dart'; + +import '../config/measure_reporting_config.dart'; + +/// Starts Measure only for a configured Android build. +/// +/// This stays after the app's FlutterError handler has been installed in +/// `main.dart`, so Measure can chain it rather than replacing it. +Future initializeMeasureReporting() async { + if (!MeasureReportingConfig.isEnabled) return; + + await Measure.instance.init( + () {}, + config: const MeasureConfig(autoStart: false), + ); + await Measure.instance.start(); +} diff --git a/app/lib/services/measure_reporting_stub.dart b/app/lib/services/measure_reporting_stub.dart new file mode 100644 index 0000000..83ec4fd --- /dev/null +++ b/app/lib/services/measure_reporting_stub.dart @@ -0,0 +1,5 @@ +/// Web and non-Android fallback for optional Measure reporting. +/// +/// Keeping this no-op in a separate conditional implementation means the web +/// compiler never resolves the Android-only `measure_flutter` package. +Future initializeMeasureReporting() async {} diff --git a/app/lib/services/play_integrity_service.dart b/app/lib/services/play_integrity_service.dart new file mode 100644 index 0000000..da054be --- /dev/null +++ b/app/lib/services/play_integrity_service.dart @@ -0,0 +1,49 @@ +import 'package:flutter/services.dart'; +import 'package:uuid/uuid.dart'; + +import '../config/app_integrity_config.dart'; +import '../core/utils/debug_logger.dart'; +import 'supabase_service.dart'; + +/// Requests a Play Integrity standard token from the native layer +/// (android/app/.../security/PlayIntegrityManager.kt) and hands it to +/// supabase/functions/verify-play-integrity for server-side verification. +/// +/// See [AppIntegrityConfig] for why this is a no-op until explicitly +/// configured and enabled — a Google-signed attestation is a much stronger +/// signal than the client-side heuristics in [DeviceIntegrityService], but +/// only once real setup (Cloud project linkage, real device testing) backs +/// it. Calling this before that setup is complete just returns null. +class PlayIntegrityService { + PlayIntegrityService._(); + static final PlayIntegrityService instance = PlayIntegrityService._(); + + static const _channel = MethodChannel('co.nosus.app/play_integrity'); + + /// Requests a token bound to a fresh random nonce and forwards it for + /// server-side verification. Returns the verdict from + /// verify-play-integrity, or null if integrity checks aren't + /// configured/enabled or the request failed for any reason — this is a + /// best-effort signal, never something that should block the user. + Future?> requestAndVerify() async { + if (!AppIntegrityConfig.enabled) return null; + + try { + // Bound into the attestation itself, so a captured token can't be + // replayed against a later verification call. Doesn't need to be + // cryptographically derived — just unique and unpredictable per call. + final nonce = const Uuid().v4().replaceAll('-', ''); + + final token = await _channel.invokeMethod('requestToken', {'nonce': nonce}); + if (token == null) return null; + + return await SupabaseService.instance.verifyPlayIntegrityToken( + token: token, + nonce: nonce, + ); + } catch (e) { + debugLog('NO SUS: Play Integrity check failed (non-fatal): $e'); + return null; + } + } +} diff --git a/app/lib/services/risk_engine_service.dart b/app/lib/services/risk_engine_service.dart new file mode 100644 index 0000000..1d75054 --- /dev/null +++ b/app/lib/services/risk_engine_service.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart'; +import '../core/utils/debug_logger.dart'; +import 'supabase_service.dart'; + +/// Mirrors a row of `public.user_risk_state` (see +/// supabase/migrations/20260710030000_risk_engine.sql). The score/tier are +/// computed entirely server-side by recompute_user_risk() — this class is +/// just a read model, never a place that decides risk on-device (a +/// compromised client could lie about its own risk otherwise). +@immutable +class RiskState { + final int score; + final String tier; // low | elevated | high | critical + final String watermarkIntensity; // normal | increased | maximum + final bool sessionLocked; + final bool requireReauth; + final Map breakdown; + + const RiskState({ + required this.score, + required this.tier, + required this.watermarkIntensity, + required this.sessionLocked, + required this.requireReauth, + required this.breakdown, + }); + + static const low = RiskState( + score: 0, + tier: 'low', + watermarkIntensity: 'normal', + sessionLocked: false, + requireReauth: false, + breakdown: {}, + ); + + factory RiskState.fromRow(Map row) { + return RiskState( + score: (row['score'] as num?)?.toInt() ?? 0, + tier: row['tier'] as String? ?? 'low', + watermarkIntensity: row['watermark_intensity'] as String? ?? 'normal', + sessionLocked: row['session_locked'] as bool? ?? false, + requireReauth: row['require_reauth'] as bool? ?? false, + breakdown: Map.from(row['breakdown'] as Map? ?? {}), + ); + } +} + +class RiskEngineService { + RiskEngineService._(); + static final RiskEngineService instance = RiskEngineService._(); + + /// Realtime stream of the given user's own risk state. RLS restricts the + /// underlying table to that user's row (plus super-admins), but this + /// method always filters to [userId] explicitly — this is "my risk + /// state", not an admin console. + Stream watch(String userId) { + return SupabaseService.instance.watchMyRiskState(userId).map( + (row) => row == null ? RiskState.low : RiskState.fromRow(row), + ); + } + + /// Clears require_reauth/session_locked for the caller after a fresh + /// sign-in. See acknowledge_reauth() in the migration for why this is + /// safe to expose without an admin check — it only ever touches the + /// caller's own row. + Future acknowledgeReauth() async { + try { + await SupabaseService.instance.acknowledgeReauth(); + } catch (e) { + debugLog('RiskEngineService: acknowledgeReauth failed: $e'); + } + } +} diff --git a/app/lib/services/screenshot_guard.dart b/app/lib/services/screenshot_guard.dart new file mode 100644 index 0000000..ebc61f6 --- /dev/null +++ b/app/lib/services/screenshot_guard.dart @@ -0,0 +1,192 @@ +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../theme.dart'; +import '../core/utils/debug_logger.dart'; +import 'audit_service.dart'; + +class ScreenshotGuard { + ScreenshotGuard._(); + static final ScreenshotGuard instance = ScreenshotGuard._(); + + static const _channel = MethodChannel('co.nosus.app/security'); + static const _eventChannel = EventChannel('co.nosus.app/screenshot'); + + final GlobalKey navigatorKey = GlobalKey(); + // Active secure context fields for security audit logging + String? activeGroupId; + String? activeFileId; + String? activeFileName; + bool _isDialogShowing = false; + + Future initialize() async { + if (kIsWeb) return; + if (!Platform.isAndroid) return; + + + // Demo builds pass --dart-define=NOSUS_ALLOW_SCREEN_CAPTURE=true so the + // phone can be mirrored to a projector. MainActivity sets FLAG_SECURE in + // onCreate, so this must actively clear it, and skip the popups. + const allowCapture = bool.fromEnvironment('NOSUS_ALLOW_SCREEN_CAPTURE'); + if (allowCapture) { + try { + await _channel.invokeMethod('disableSecure'); + } catch (_) {} + return; + } + + try { + await _channel.invokeMethod('enableSecure'); + } catch (_) {} + + // Listen to screenshot/recording/overlay events + _eventChannel.receiveBroadcastStream().listen((event) { + if (event is! Map) return; + final type = event['type'] as String?; + if (type == 'screenshot' || type == 'recording') { + _showFunnyPopup(type!); + } else if (type == 'overlay_detected') { + _logOverlayDetected(); + } + }, onError: (err) { + debugLog("ScreenshotGuard event error: $err"); + }); + } + + void _logOverlayDetected() { + if (activeGroupId == null) return; + AuditService.instance.logEvent( + 'overlay_detected', + 'SECURITY', + groupId: activeGroupId!, + fileId: activeFileId, + metadata: {'file_name': activeFileName ?? 'Vault Dashboard'}, + ); + } + + void _showFunnyPopup(String type) { + if (_isDialogShowing) return; + final context = navigatorKey.currentContext; + if (context == null) return; + + _isDialogShowing = true; + + // Log the screenshot or recording attempt to the secure audit logs if group ID is available + if (activeGroupId != null) { + AuditService.instance.logEvent( + type == 'recording' ? 'recording_attempt' : 'screenshot_attempt', + 'SECURITY', + groupId: activeGroupId!, + fileId: activeFileId, + metadata: {'file_name': activeFileName ?? 'Vault Dashboard'}, + ); + } + + final messages = type == 'recording' + ? [ + "Stop recording! 🎥 No bootleg tapes of NO SUS! 🙅‍♂️", + "Screen recording detected! 🚨 Our secrets are not for your movie! 🍿", + "Are you filming a documentary? 🕵️‍♂️ No recordings of the vault! 🔐", + "Nice try recording, but we've gone incognito! 🕶️", + ] + : [ + "Caught in 4K! 📸 Nice try, but NO SUS allowed here! 🙅‍♂️", + "Whoa there, inspector! 🕵️‍♂️ No screenshots of the vault! 🔐", + "Trying to leak the secret sauce? 🧪 Not on our watch! 🤫", + "Nice screenshot attempt! 📸 But we've blurred the evidence! 💨", + "Access Denied! 🚫 Did you think it was that easy? 😏", + "Selfie with the vault? 🤳 Not today, buddy! 🙅‍♀️", + ]; + + final randomMessage = messages[Random().nextInt(messages.length)]; + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + final isDark = Theme.of(dialogContext).brightness == Brightness.dark; + return Dialog( + backgroundColor: isDark ? NoSusTheme.dCard : NoSusTheme.lCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: isDark ? const Color(0x33FFFFFF) : const Color(0xFF1A1A1A), + width: 1.2, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withAlpha(25), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.security_rounded, + color: Colors.redAccent, + size: 40, + ), + ), + const SizedBox(height: 16), + Text( + type == 'recording' ? 'RECORDING DETECTED' : 'SCREENSHOT DETECTED', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 2.0, + color: Colors.redAccent, + ), + ), + const SizedBox(height: 12), + Text( + randomMessage, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: isDark ? NoSusTheme.dText : NoSusTheme.lText, + height: 1.4, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + onPressed: () { + _isDialogShowing = false; + Navigator.of(dialogContext).pop(); + }, + child: const Text( + "UNDERSTOOD", + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.1, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ).then((_) { + _isDialogShowing = false; + }); + } +} + diff --git a/app/lib/services/share_intent_service.dart b/app/lib/services/share_intent_service.dart new file mode 100644 index 0000000..411db4e --- /dev/null +++ b/app/lib/services/share_intent_service.dart @@ -0,0 +1,70 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class SharedContent { + final String type; // 'text', 'image', 'pdf', 'url' + final String data; // text content, URL, or local file path + final String? name; // file name if applicable + + const SharedContent({ + required this.type, + required this.data, + this.name, + }); + + factory SharedContent.fromMap(Map map) { + return SharedContent( + type: map['type'] as String, + data: map['data'] as String, + name: map['name'] as String?, + ); + } + + Map toMap() { + return { + 'type': type, + 'data': data, + 'name': name, + }; + } +} + +class ShareIntentNotifier extends Notifier { + static const _channel = MethodChannel('co.nosus.app/share'); + + @override + SharedContent? build() { + _init(); + ref.onDispose(() { + _channel.setMethodCallHandler(null); + }); + return null; + } + + void _init() async { + _channel.setMethodCallHandler((call) async { + if (call.method == 'onShareReceived') { + if (call.arguments != null) { + final data = Map.from(call.arguments as Map); + state = SharedContent.fromMap(data); + } + } + }); + + try { + final initial = await _channel.invokeMethod('getInitialShare'); + if (initial != null) { + final data = Map.from(initial); + state = SharedContent.fromMap(data); + } + } catch (_) {} + } + + void clear() { + state = null; + } +} + +final shareIntentProvider = NotifierProvider( + ShareIntentNotifier.new, +); diff --git a/app/lib/services/supabase_service.dart b/app/lib/services/supabase_service.dart new file mode 100644 index 0000000..42c7fca --- /dev/null +++ b/app/lib/services/supabase_service.dart @@ -0,0 +1,569 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:http/http.dart' as http; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../core/supabase/supabase_bootstrap.dart'; +import '../config/supabase_credentials.dart'; +import 'audit_service.dart'; +import '../core/utils/debug_logger.dart'; + +/// Service to handle all interactions with the live Supabase project. +/// +/// Automatically provides an [isConfigured] flag to check if live credentials +/// are available, falling back gracefully to mock storage if not. +/// +/// Also performs a DNS pre-flight check during [initialize] and exposes +/// [isReachable] — if the host can't be resolved, realtime streams are +/// skipped entirely to prevent infinite reconnect loops. +class SupabaseService { + static final SupabaseService instance = SupabaseService._internal(); + SupabaseService._internal(); + + /// True only if credentials are set AND the host was reachable at startup. + bool _isReachable = false; + + /// Flag indicating if valid Supabase credentials are configured + bool get isConfigured => SupabaseBootstrap.isConfigured; + + /// True if credentials are set AND a DNS lookup succeeded at startup. + /// Use this (instead of [isConfigured]) before opening any realtime streams. + bool get isReachable => _isReachable; + + /// Compatibility entry point while legacy services migrate to repositories. + Future initialize() async { + if (!isConfigured) { + debugLog( + "SupabaseService: Credentials empty. Operating in offline mock fallback mode.", + ); + return; + } + + try { + await SupabaseBootstrap.initialize(); + _isReachable = true; + debugLog("SupabaseService: Client successfully initialized."); + } catch (e) { + debugLog("SupabaseService: Initialization error: $e"); + _isReachable = false; + } + } + + // ─── Google Drive Proxy Operations ────────────────────────────────────────── + + String get _driveProxyUrl => + '${SupabaseCredentials.url}/functions/v1/drive-proxy'; + + String get _authHeader { + final token = Supabase.instance.client.auth.currentSession?.accessToken; + if (token != null && token.isNotEmpty) { + return 'Bearer $token'; + } + return 'Bearer ${SupabaseCredentials.anonKey}'; + } + + /// Deletes a secure file from Google Drive via Edge Function. + Future deleteGDriveFile(String fileId) async { + if (!isConfigured) return; + + try { + final response = await http.delete( + Uri.parse('$_driveProxyUrl/delete?fileId=$fileId'), + headers: {'Authorization': _authHeader}, + ); + if (response.statusCode != 200) { + debugLog( + "SupabaseService: Delete proxy failed: ${response.statusCode} - ${response.body}", + ); + } + } catch (e) { + debugLog("SupabaseService: Delete proxy exception: $e"); + } + } + + /// Downloads a file from the Google Drive proxy Edge Function. + Future downloadGDriveFile(String fileId) async { + if (!isConfigured) return null; + + try { + final response = await http.get( + Uri.parse('$_driveProxyUrl/download?fileId=$fileId'), + headers: {'Authorization': _authHeader}, + ); + if (response.statusCode != 200) { + debugLog( + "SupabaseService: Download proxy failed: ${response.statusCode} - ${response.body}", + ); + return null; + } + return response.bodyBytes; + } catch (e) { + debugLog("SupabaseService: Download proxy exception: $e"); + return null; + } + } + + /// Fetches the Google Drive Service Account Email dynamically from the proxy. + Future getServiceAccountEmail() async { + if (!isConfigured) return null; + + try { + final response = await http.get( + Uri.parse('$_driveProxyUrl/info'), + headers: {'Authorization': _authHeader}, + ); + if (response.statusCode == 200) { + final data = json.decode(response.body) as Map; + return data['serviceAccountEmail'] as String?; + } else { + debugLog("SupabaseService: getServiceAccountEmail failed: ${response.statusCode} - ${response.body}"); + } + } catch (e) { + debugLog("SupabaseService: getServiceAccountEmail exception: $e"); + } + return null; + } + + // ─── Audit Logging Operations ────────────────────────────────────────────── + + /// Streams the audit logs from Supabase ordered by creation time. + Stream>> watchAuditLogs() { + if (!isConfigured) return const Stream.empty(); + + return Supabase.instance.client + .from('audit_logs') + .stream(primaryKey: ['id']) + .order('created_at', ascending: false) + .map((list) { + return list.map((row) { + final timeStr = row['created_at'] != null + ? DateTime.parse( + row['created_at'] as String, + ).toLocal().toIso8601String().substring(11, 19) + : 'Now'; + final eventType = row['event_type'] as String? ?? 'group_updated'; + final metadata = row['metadata'] as Map? ?? {}; + + return { + 'time': timeStr, + 'event': AuditService.formatEventDescription(eventType, metadata), + 'status': AuditService.getEventStatus(eventType, metadata), + }; + }).toList(); + }) + .handleError((e) { + debugLog("SupabaseService: watchAuditLogs error: $e"); + }); + } + + // Group ids the signed-in user actually belongs to. `log_group_event` rejects + // writes for any other group, and a locally persisted history entry (see + // RecentlySavedItem.destinationId) keeps pointing at a group long after the + // user has left it — which produced a stream of "Not a member of the study + // group" failures on every document open. Cached per user with a short TTL so + // a fresh join is picked up without a round-trip per event. + Set? _memberGroupIds; + String? _memberGroupIdsUser; + DateTime? _memberGroupIdsAt; + static const _membershipTtl = Duration(minutes: 5); + + /// Group ids [userId] belongs to, or null when that could not be determined. + /// Callers must fail *open* on null rather than drop the event — a transient + /// network failure must never silently swallow a security audit record. + Future?> _memberGroupIdsFor(String userId) async { + final cached = _memberGroupIds; + final cachedAt = _memberGroupIdsAt; + if (cached != null && + _memberGroupIdsUser == userId && + cachedAt != null && + DateTime.now().difference(cachedAt) < _membershipTtl) { + return cached; + } + + try { + final rows = await Supabase.instance.client + .from('study_group_members') + .select('group_id') + .eq('user_id', userId); + final ids = { + for (final row in rows) + if (row['group_id'] != null) row['group_id'] as String, + }; + _memberGroupIds = ids; + _memberGroupIdsUser = userId; + _memberGroupIdsAt = DateTime.now(); + return ids; + } catch (e) { + debugLog("SupabaseService: group membership lookup failed: $e"); + return null; + } + } + + /// Drops the cached membership set. Call after joining or leaving a group so + /// the next audit event is evaluated against the new membership. + void invalidateGroupMembershipCache() { + _memberGroupIds = null; + _memberGroupIdsUser = null; + _memberGroupIdsAt = null; + } + + /// Inserts a new audit log record inside Supabase database via secure RPC. + Future logEvent( + String eventType, { + required String groupId, + String? fileId, + Map? metadata, + }) async { + if (!isConfigured) return; + + final userId = Supabase.instance.client.auth.currentUser?.id; + if (userId == null) return; + + final memberOf = await _memberGroupIdsFor(userId); + if (memberOf != null && !memberOf.contains(groupId)) { + debugLog( + "SupabaseService: skipping '$eventType' for group $groupId — " + "not a member, the RPC would reject it.", + ); + return; + } + + try { + await Supabase.instance.client.rpc('log_group_event', params: { + 'p_group_id': groupId, + 'p_file_id': fileId, + 'p_event_type': eventType, + 'p_metadata': metadata ?? {}, + }); + } catch (e) { + debugLog("SupabaseService: logEvent RPC failed: $e"); + } + } + + /// Forwards a Play Integrity token to supabase/functions/verify-play-integrity + /// for server-side verification against Google's API — a Postgres RPC can't + /// make the outbound HTTPS call to Google itself, hence an edge function + /// (via functions.invoke) rather than .rpc() like the other calls here. + /// See [PlayIntegrityService] — this is unreachable while + /// AppIntegrityConfig.enabled is false. + Future?> verifyPlayIntegrityToken({ + required String token, + required String nonce, + }) async { + if (!isConfigured) return null; + + try { + final response = await Supabase.instance.client.functions.invoke( + 'verify-play-integrity', + body: {'token': token, 'nonce': nonce}, + ); + return response.data as Map?; + } catch (e) { + debugLog("SupabaseService: verifyPlayIntegrityToken failed: $e"); + return null; + } + } + + /// Logs a device/session-level integrity finding (root, active + /// instrumentation, risk flag) to the self-scoped `device_integrity_events` + /// hash chain — separate from [logEvent] because these aren't tied to a + /// specific study group. + Future logDeviceIntegrityEvent({ + required String eventType, + required String deviceId, + String severity = 'info', + Map? metadata, + }) async { + if (!isConfigured) return; + + try { + await Supabase.instance.client.rpc('log_device_integrity_event', params: { + 'p_event_type': eventType, + 'p_severity': severity, + 'p_device_id': deviceId, + 'p_metadata': metadata ?? {}, + }); + } catch (e) { + debugLog("SupabaseService: logDeviceIntegrityEvent RPC failed: $e"); + } + } + + /// Realtime stream of a single user's `user_risk_state` row (see + /// [RiskEngineService]). Filters explicitly to [userId] rather than + /// relying on RLS alone, since a super-admin's RLS grant would otherwise + /// let this stream return every user's row. + Stream?> watchMyRiskState(String userId) { + if (!isConfigured) return Stream.value(null); + + return Supabase.instance.client + .from('user_risk_state') + .stream(primaryKey: ['user_id']) + .eq('user_id', userId) + .map((rows) => rows.isEmpty ? null : rows.first) + .handleError((e) { + debugLog("SupabaseService: watchMyRiskState error: $e"); + }); + } + + /// Clears the caller's own require_reauth/session_locked flags after a + /// fresh sign-in. See acknowledge_reauth() in + /// supabase/migrations/20260710030000_risk_engine.sql. + Future acknowledgeReauth() async { + if (!isConfigured) return; + await Supabase.instance.client.rpc('acknowledge_reauth'); + } + + /// Recent, unacknowledged security alerts visible to the caller under + /// RLS — group admins see alerts for their groups, super-admins see all, + /// everyone sees their own. Used by the admin-facing alerts list, not by + /// the flagged user's own device. + Future>> fetchSecurityAlerts() async { + if (!isConfigured) return []; + try { + final rows = await Supabase.instance.client + .from('security_alerts') + .select() + .eq('acknowledged', false) + .order('created_at', ascending: false) + .limit(50); + return List>.from(rows); + } catch (e) { + debugLog("SupabaseService: fetchSecurityAlerts failed: $e"); + return []; + } + } + + /// Dismisses an alert from the admin/owner queue. See + /// acknowledge_security_alert() in the migration for the server-side + /// authorization check (group admin or super-admin only). + Future acknowledgeSecurityAlert(String alertId) async { + if (!isConfigured) return; + await Supabase.instance.client.rpc('acknowledge_security_alert', params: { + 'p_alert_id': alertId, + }); + } + + /// Records that this device is active for the current user, and flags + /// 'multiple_device_access' in the device-integrity ledger the first time + /// a *new* device shows up for an account that already has another known + /// device (never on a user's first-ever device). See + /// register_device_seen() in 20260710040000_multi_device_detection.sql. + Future registerDeviceSeen(String deviceId) async { + if (!isConfigured) return; + await Supabase.instance.client.rpc('register_device_seen', params: { + 'p_device_id': deviceId, + }); + } + + /// Carries a device's history from its legacy random-UUID id onto the + /// hardware-backed one, so switching id formats doesn't make every + /// already-known device look new (which would fire a false + /// 'multiple_device_access' for every multi-device user on upgrade). See + /// migrate_device_id() in 20260725000000_hardware_backed_device_id.sql. + /// + /// Returns false on any failure so the caller can leave the migration flag + /// unset and retry on a later launch — never throws, since this runs on the + /// post-sign-in path and must not break it. + Future migrateDeviceId({ + required String oldDeviceId, + required String newDeviceId, + }) async { + if (!isConfigured) return false; + try { + await Supabase.instance.client.rpc('migrate_device_id', params: { + 'p_old_device_id': oldDeviceId, + 'p_new_device_id': newDeviceId, + }); + return true; + } catch (e) { + debugLog("SupabaseService: migrateDeviceId RPC failed: $e"); + return false; + } + } + + /// Fetches private notes for a user. + Future fetchUserNote(String userId) async { + if (!isConfigured) return ''; + try { + final response = await Supabase.instance.client + .from('user_notes') + .select('note_text') + .eq('user_id', userId) + .maybeSingle(); + return response?['note_text'] as String? ?? + "Welcome to the NO SUS Secure Workspace!\n\n" + "Quick Tutorial on Groups:\n" + "1. Open the 'Groups' tab from the bottom nav.\n" + "2. Tap the '+' icon to create a secure study group.\n" + "3. Share the invite code with classmates.\n" + "4. Upload notes — they are stored securely in the workspace.\n" + "5. Use 'REVEAL' to read in our screenshot-proof viewer."; + } catch (e) { + debugLog("SupabaseService: fetchUserNote error: $e"); + return ''; + } + } + + /// Saves private notes for a user. + Future saveUserNote(String userId, String noteText) async { + if (!isConfigured) return; + try { + await Supabase.instance.client.from('user_notes').upsert({ + 'user_id': userId, + 'note_text': noteText, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }); + } catch (e) { + debugLog("SupabaseService: saveUserNote error: $e"); + } + } + + /// Fetches focus logs from the last 7 days for a user. + Future> fetchFocusLogs(String userId) async { + if (!isConfigured) return {}; + try { + final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7)).toUtc().toIso8601String(); + final response = await Supabase.instance.client + .from('focus_logs') + .select('date, focus_minutes') + .eq('user_id', userId) + .gte('date', sevenDaysAgo); + + final Map logs = {}; + for (final row in response) { + final date = DateTime.parse(row['date'] as String); + final minutes = row['focus_minutes'] as int? ?? 0; + logs[DateTime(date.year, date.month, date.day)] = minutes; + } + return logs; + } catch (e) { + debugLog("SupabaseService: fetchFocusLogs error: $e"); + return {}; + } + } + + /// Increments daily study focus minutes for a user. + Future incrementFocusMinutes(String userId, int minutes) async { + if (!isConfigured) return; + try { + final todayStr = DateTime.now().toLocal().toIso8601String().substring(0, 10); + final existing = await Supabase.instance.client + .from('focus_logs') + .select('focus_minutes') + .eq('user_id', userId) + .eq('date', todayStr) + .maybeSingle(); + + final currentMinutes = existing?['focus_minutes'] as int? ?? 0; + await Supabase.instance.client.from('focus_logs').upsert({ + 'user_id': userId, + 'date': todayStr, + 'focus_minutes': currentMinutes + minutes, + }, onConflict: 'user_id,date'); + } catch (e) { + debugLog("SupabaseService: incrementFocusMinutes error: $e"); + } + } + + /// Fetches daily count of security checks / logs from the last 7 days. + Future> fetchAuditLogCounts() async { + if (!isConfigured) return {}; + try { + final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7)).toUtc().toIso8601String(); + final response = await Supabase.instance.client + .from('audit_logs') + .select('created_at') + .gte('created_at', sevenDaysAgo); + + final Map counts = {}; + for (final row in response) { + final date = DateTime.parse(row['created_at'] as String).toLocal(); + final dayKey = DateTime(date.year, date.month, date.day); + counts[dayKey] = (counts[dayKey] ?? 0) + 1; + } + return counts; + } catch (e) { + debugLog("SupabaseService: fetchAuditLogCounts error: $e"); + return {}; + } + } + + /// Fetches the profile of a user. If it doesn't exist, returns default values. + Future> fetchProfile(String userId) async { + if (!isConfigured) return {}; + try { + final response = await Supabase.instance.client + .from('profiles') + .select('display_name, avatar_color_start, avatar_color_end, onboarding_completed, survey_goals, survey_features, survey_user_type') + .eq('id', userId) + .maybeSingle(); + return response ?? {}; + } catch (e) { + debugLog("SupabaseService: fetchProfile error: $e"); + return {}; + } + } + + /// Updates or inserts a profile. + Future saveProfile({ + required String userId, + required String email, + required String displayName, + required String avatarColorStart, + required String avatarColorEnd, + bool onboardingCompleted = true, + }) async { + if (!isConfigured) return; + try { + await Supabase.instance.client.from('profiles').upsert({ + 'id': userId, + 'email': email, + 'display_name': displayName, + 'avatar_color_start': avatarColorStart, + 'avatar_color_end': avatarColorEnd, + 'onboarding_completed': onboardingCompleted, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }); + } catch (e) { + debugLog("SupabaseService: saveProfile error: $e"); + } + } + + /// Updates only the survey data fields in the user's profile. + Future updateSurveyData({ + required String userId, + required List goals, + required List features, + required String userType, + }) async { + if (!isConfigured) return; + try { + await Supabase.instance.client.from('profiles').update({ + 'survey_goals': goals, + 'survey_features': features, + 'survey_user_type': userType, + }).eq('id', userId); + } catch (e) { + debugLog("SupabaseService: updateSurveyData error: $e"); + } + } + + /// Resolves the group_id for a file by its storage/file ID. + /// Used by ZeroTrustGateway to check access for live Supabase files + /// that are not in the static mock note list. + Future getFileGroupId(String fileId) async { + if (!isConfigured) return null; + try { + final response = await Supabase.instance.client + .from('secure_files') + .select('group_id') + .eq('id', fileId) + .maybeSingle(); + return response?['group_id'] as String?; + } catch (e) { + debugLog("SupabaseService: getFileGroupId error: $e"); + return null; + } + } +} + diff --git a/app/lib/services/web_security_guard.dart b/app/lib/services/web_security_guard.dart new file mode 100644 index 0000000..c500c68 --- /dev/null +++ b/app/lib/services/web_security_guard.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:universal_html/html.dart' as html; +import 'automation_signal_stub.dart' + if (dart.library.js_interop) 'automation_signal_web.dart'; + +/// Browser-side deterrent layer for documents rendered in Flutter Web. +/// +/// HONESTY RULE (matches anonymous_share_viewer_screen.dart): none of this +/// *prevents* screenshots, and a determined user can still open real +/// browser DevTools and inspect network traffic. What this actually buys: +/// +/// - Removes the "right-click > Save As" / Ctrl+P / Ctrl+S paths a casual +/// user would otherwise reach for first. +/// - Surfaces a best-effort DevTools-open heuristic and repeated +/// tab-visibility switching as forensic signals (reported upstream via +/// [onSuspicious], not blocked — you cannot block a user from opening +/// DevTools from page JS). +/// - Never touches INPUT/TEXTAREA elements, so the email-gate form on the +/// anonymous share screen keeps working normally. +/// +/// Each protection is an independent flag so callers can opt out of ones +/// that conflict with an intentional UX choice — e.g. burn_note_viewer_screen +/// uses `SelectableText` on purpose (a recipient may need to copy a +/// password), so it enables blockRightClick/blockPrint/blockSave/blockDrag +/// but leaves blockCopy/blockSelect off. +/// +/// NOT covered here, and not coverable from page JS at all: OS-level +/// screenshot tools, OS/extension-level screen recording, a `` +/// pixel dump via DevTools console (`toDataURL()` — this app's CanvasKit +/// web build renders the whole UI to one canvas, so this is the single +/// most severe residual web vector once DevTools is open), and an external +/// camera. See the threat-model breakdown this class's callers were built +/// against for the full enumeration. +class WebSecurityGuard { + WebSecurityGuard({ + this.blockRightClick = true, + this.blockCopy = true, + this.blockCut = true, + this.blockPrint = true, + this.blockSave = true, + this.blockDrag = true, + this.blockSelect = true, + this.detectDevTools = true, + this.detectVisibilityChanges = true, + this.detectAutomation = true, + }); + + final bool blockRightClick; + final bool blockCopy; + final bool blockCut; + final bool blockPrint; + final bool blockSave; + final bool blockDrag; + final bool blockSelect; + final bool detectDevTools; + final bool detectVisibilityChanges; + final bool detectAutomation; + + /// DevTools' docked-panel width/height heuristic. Undocked (separate + /// window) DevTools defeats this entirely — that limitation is inherent + /// to any page-JS-based check, not specific to this implementation. + static const _devToolsThresholdPx = 160; + static const _devToolsPollInterval = Duration(seconds: 2); + + /// Reporting one repeated-visibility-switch event per this window avoids + /// spamming the ledger every time a user legitimately alt-tabs once. + static const _visibilityWindow = Duration(seconds: 30); + static const _visibilityThreshold = 3; + + final List _cleanups = []; + Timer? _devToolsTimer; + bool _devToolsReported = false; + final List _hiddenTimestamps = []; + bool _attached = false; + html.StyleElement? _printStyleEl; + + bool _isFormField(html.EventTarget? target) { + if (target is! html.Element) return false; + final tag = target.tagName.toUpperCase(); + return tag == 'INPUT' || tag == 'TEXTAREA'; + } + + /// Starts listening. [onSuspicious] is called with a stable event-type + /// string (matching the share-heartbeat allowlist) whenever a deterrent + /// fires or a detection heuristic trips. + void attach(void Function(String eventType) onSuspicious) { + if (!kIsWeb || _attached) return; + _attached = true; + + if (blockRightClick) { + _listen(html.document, 'contextmenu', (e) { + if (_isFormField(e.target)) return; + e.preventDefault(); + onSuspicious('right_click_attempted'); + }); + } + + if (blockCopy) { + _listen(html.document, 'copy', (e) { + if (_isFormField(e.target)) return; + e.preventDefault(); + onSuspicious('copy_attempted'); + }); + } + + if (blockCut) { + _listen(html.document, 'cut', (e) { + if (_isFormField(e.target)) return; + e.preventDefault(); + }); + } + + if (blockDrag) { + _listen(html.document, 'dragstart', (e) { + if (_isFormField(e.target)) return; + e.preventDefault(); + onSuspicious('drag_attempted'); + }); + } + + if (blockSelect) { + _listen(html.document, 'selectstart', (e) { + if (_isFormField(e.target)) return; + e.preventDefault(); + }); + } + + if (blockPrint) { + // The keydown listener below only catches Ctrl+P/Cmd+P. A print + // triggered from the browser's File menu (or a raw window.print() + // call) never fires a keydown at all — this print stylesheet closes + // that gap regardless of how printing was invoked, since the browser + // applies @media print rules independent of the trigger path. + final style = html.StyleElement() + ..id = 'nosus-print-block' + ..text = '@media print { html, body { visibility: hidden !important; } }'; + html.document.head?.append(style); + _printStyleEl = style; + } + + if (detectAutomation) { + // A one-time check, not polled — navigator.webdriver is fixed for the + // lifetime of the page. See automation_signal_web.dart for why this + // can't just use universal_html's own (non-functional) getter. + if (readNavigatorWebdriverFlag() == true) { + onSuspicious('automation_detected'); + } + } + + if (blockPrint || blockSave) { + _listen(html.document, 'keydown', (e) { + final ev = e as html.KeyboardEvent; + final ctrlOrMeta = ev.ctrlKey || ev.metaKey; + if (!ctrlOrMeta) return; + + final key = ev.key?.toLowerCase(); + if (blockPrint && key == 'p') { + e.preventDefault(); + onSuspicious('print_attempted'); + } else if (blockSave && key == 's') { + e.preventDefault(); + onSuspicious('save_attempted'); + } + }); + } + + if (detectVisibilityChanges) { + _listen(html.document, 'visibilitychange', (_) { + if (html.document.visibilityState != 'hidden') return; + final now = DateTime.now(); + _hiddenTimestamps.add(now); + _hiddenTimestamps.removeWhere( + (t) => now.difference(t) > _visibilityWindow, + ); + if (_hiddenTimestamps.length >= _visibilityThreshold) { + onSuspicious('visibility_hidden_repeated'); + _hiddenTimestamps.clear(); + } + }); + } + + if (detectDevTools) { + _devToolsTimer = Timer.periodic(_devToolsPollInterval, (_) { + if (_devToolsReported) return; + final widthDiff = html.window.outerWidth - (html.window.innerWidth ?? 0); + final heightDiff = html.window.outerHeight - (html.window.innerHeight ?? 0); + if (widthDiff > _devToolsThresholdPx || heightDiff > _devToolsThresholdPx) { + _devToolsReported = true; + onSuspicious('devtools_detected'); + } + }); + } + } + + void _listen( + html.EventTarget target, + String type, + void Function(html.Event) handler, + ) { + target.addEventListener(type, handler); + _cleanups.add(handler); + _listenerTargets.add(target); + _listenerTypes.add(type); + } + + final List _listenerTargets = []; + final List _listenerTypes = []; + + /// Removes every listener and stops the DevTools poll. Must be called + /// from the owning widget's dispose() — leaked document-level listeners + /// would otherwise keep firing (and holding a closure over disposed + /// state) for the lifetime of the page. + void detach() { + if (!_attached) return; + _attached = false; + + for (var i = 0; i < _cleanups.length; i++) { + _listenerTargets[i].removeEventListener(_listenerTypes[i], _cleanups[i]); + } + _cleanups.clear(); + _listenerTargets.clear(); + _listenerTypes.clear(); + + _devToolsTimer?.cancel(); + _devToolsTimer = null; + _devToolsReported = false; + _hiddenTimestamps.clear(); + + _printStyleEl?.remove(); + _printStyleEl = null; + } +} diff --git a/app/lib/theme.dart b/app/lib/theme.dart new file mode 100644 index 0000000..b8c0fc9 --- /dev/null +++ b/app/lib/theme.dart @@ -0,0 +1,415 @@ +import 'package:flutter/material.dart'; + +class NoSusTheme { + // Spacing Scale + static const double s4 = 4.0; + static const double s8 = 8.0; + static const double s12 = 12.0; + static const double s16 = 16.0; + static const double s24 = 24.0; + static const double s32 = 32.0; + static const double s48 = 48.0; + static const double s64 = 64.0; + + // Corner Radii + static const double r12 = 12.0; + static const double r16 = 16.0; + static const double r24 = 24.0; + + // Colors - Light + static const Color lBackground = Color(0xFFF5F5F3); + static const Color lCard = Color(0xFFFFFFFF); + static const Color lText = Color(0xFF111111); + static const Color lBorder = Color(0xFF1A1A1A); + static const Color lTextSecondary = Color(0xFF666666); + + // Colors - Dark + static const Color dBackground = Color(0xFF0D0D0D); + static const Color dCard = Color(0xFF151515); + static const Color dText = Color(0xFFF5F5F5); + static const Color dBorder = Color(0x33FFFFFF); // #FFFFFF20 + static const Color dTextSecondary = Color(0xFF999999); + + static TextTheme _textTheme(Color primary, Color secondary) { + return TextTheme( + displayLarge: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w800, + color: primary, + letterSpacing: -1.0, + ), + displayMedium: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: primary, + letterSpacing: -0.5, + ), + titleLarge: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: primary, + letterSpacing: -0.4, + ), + titleMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: primary, + ), + bodyLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + color: primary, + height: 1.5, + ), + bodyMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: secondary, + height: 1.4, + ), + labelLarge: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: primary, + letterSpacing: 0.5, + ), + ); + } + + // Light ThemeData + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: lBackground, + colorScheme: const ColorScheme.light( + surface: lCard, + onSurface: lText, + primary: lText, + onPrimary: lCard, + outline: lBorder, + outlineVariant: lBorder, + ), + textTheme: _textTheme(lText, lTextSecondary), + cardTheme: CardThemeData( + color: lCard, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(r16), + side: const BorderSide(color: lBorder, width: 1.2), + ), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: IconThemeData(color: lText), + centerTitle: false, + ), + pageTransitionsTheme: _fadeThroughTransitionsTheme, + ); + } + + // Dark ThemeData + static ThemeData get darkTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: dBackground, + colorScheme: const ColorScheme.dark( + surface: dCard, + onSurface: dText, + primary: dText, + onPrimary: dBackground, + outline: dBorder, + outlineVariant: dBorder, + ), + textTheme: _textTheme(dText, dTextSecondary), + cardTheme: CardThemeData( + color: dCard, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(r16), + side: const BorderSide(color: dBorder, width: 1.2), + ), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: IconThemeData(color: dText), + centerTitle: false, + ), + pageTransitionsTheme: _fadeThroughTransitionsTheme, + ); + } + + /// Shared fade-through transition for every platform, applied to all + /// pushed `MaterialPageRoute`s app-wide via [ThemeData.pageTransitionsTheme] + /// so no individual navigation call site needs to change. + static const PageTransitionsTheme _fadeThroughTransitionsTheme = + PageTransitionsTheme( + builders: { + TargetPlatform.android: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.iOS: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.macOS: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.linux: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.windows: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.fuchsia: FadeForwardsPageTransitionsBuilder(), + }, + ); + + // Common UI styles and decorators + static BoxDecoration cardDecoration(BuildContext context, {double radius = r16, Color? color}) { + final theme = Theme.of(context); + return BoxDecoration( + color: color ?? theme.colorScheme.surface, + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: theme.colorScheme.outline, + width: 1.2, + ), + ); + } + + static BoxDecoration buttonDecoration(BuildContext context, {double radius = r12, Color? color}) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return BoxDecoration( + color: color ?? (isDark ? dCard : lCard), + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: theme.colorScheme.outline, + width: 1.2, + ), + ); + } + + /// Returns platform-aware scroll physics (Clamping for Android/Web, Bouncing for iOS). + static ScrollPhysics getScrollPhysics(BuildContext context) { + final platform = Theme.of(context).platform; + if (platform == TargetPlatform.android || platform == TargetPlatform.fuchsia) { + return const ClampingScrollPhysics(); + } + return const BouncingScrollPhysics(); + } +} + +/// Canary-only tokens. Do not reuse these on the rest of NO SUS. +class CanaryTokens { + const CanaryTokens._(); + + static const Color bg = Color(0xFFFAF6EC); + static const Color surface = Color(0xFFFFFDF7); + static const Color surfaceHi = Color(0xFFF3ECDC); + static const Color border = Color(0xFF141414); + static const Color text = Color(0xFF141414); + static const Color textDim = Color(0xFF4A4740); + static const Color canary = Color(0xFFF28C28); + static const Color canaryHi = Color(0xFFFFB066); + static const Color canaryDeep = Color(0xFFC9690F); + static const Color onCanary = Color(0xFF141414); + static const Color monad = Color(0xFF141414); + static const Color ok = Color(0xFF141414); + static const Color bad = Color(0xFFB3261E); + static const Color warn = Color(0xFFC9690F); + + static const double rChip = 14; + static const double rCard = 20; + static const double rHero = 28; + + static const String displayFont = 'Outfit'; + static const String bodyFont = 'Inter'; + static const String monoFont = 'VT323'; + + static const LinearGradient brand = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [canary, canary], + ); + + static const LinearGradient chain = LinearGradient( + colors: [canary, monad], + ); + + static List get glow => const [ + BoxShadow(color: Color(0xFF141414), offset: Offset(5, 5)), + ]; + + static bool reduceMotion(BuildContext context) => + MediaQuery.disableAnimationsOf(context); + + static ThemeData theme() { + const scheme = ColorScheme.light( + surface: surface, + onSurface: text, + primary: canary, + onPrimary: onCanary, + secondary: monad, + onSecondary: bg, + error: bad, + onError: text, + outline: border, + outlineVariant: border, + ); + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: bg, + colorScheme: scheme, + fontFamily: bodyFont, + textTheme: const TextTheme( + displayLarge: TextStyle( + fontFamily: displayFont, + fontSize: 56, + fontWeight: FontWeight.w700, + color: text, + height: 1.05, + letterSpacing: -1.2, + ), + displayMedium: TextStyle( + fontFamily: displayFont, + fontSize: 40, + fontWeight: FontWeight.w700, + color: text, + height: 1.1, + letterSpacing: -0.8, + ), + headlineSmall: TextStyle( + fontFamily: displayFont, + fontSize: 28, + fontWeight: FontWeight.w600, + color: text, + height: 1.2, + ), + titleLarge: TextStyle( + fontFamily: displayFont, + fontSize: 22, + fontWeight: FontWeight.w600, + color: text, + ), + titleMedium: TextStyle( + fontFamily: displayFont, + fontSize: 16, + fontWeight: FontWeight.w600, + color: text, + ), + titleSmall: TextStyle( + fontFamily: displayFont, + fontSize: 14, + fontWeight: FontWeight.w600, + color: text, + ), + bodyLarge: TextStyle( + fontFamily: bodyFont, + fontSize: 16, + fontWeight: FontWeight.w400, + color: text, + height: 1.5, + ), + bodyMedium: TextStyle( + fontFamily: bodyFont, + fontSize: 16, + fontWeight: FontWeight.w400, + color: textDim, + height: 1.5, + ), + bodySmall: TextStyle( + fontFamily: bodyFont, + fontSize: 13, + fontWeight: FontWeight.w400, + color: textDim, + height: 1.45, + ), + labelLarge: TextStyle( + fontFamily: displayFont, + fontSize: 12, + fontWeight: FontWeight.w600, + color: text, + letterSpacing: 1.2, + ), + labelSmall: TextStyle( + fontFamily: monoFont, + fontSize: 14, + fontWeight: FontWeight.w400, + color: text, + letterSpacing: 0.4, + ), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: text, + iconTheme: IconThemeData(color: text), + centerTitle: false, + ), + cardTheme: CardThemeData( + color: surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(rCard), + side: const BorderSide(color: border), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surfaceHi, + labelStyle: const TextStyle(color: textDim), + hintStyle: const TextStyle(color: textDim), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(rCard), + borderSide: const BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(rCard), + borderSide: const BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(rCard), + borderSide: const BorderSide(color: canary, width: 2), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: canary, + foregroundColor: onCanary, + disabledBackgroundColor: canaryDeep, + disabledForegroundColor: onCanary.withValues(alpha: 0.5), + minimumSize: const Size(48, 48), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(rChip), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: text, + minimumSize: const Size(48, 48), + side: const BorderSide(color: border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(rChip), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: monad, + minimumSize: const Size(48, 48), + ), + ), + chipTheme: ChipThemeData( + backgroundColor: const Color(0x33836EF9), + labelStyle: const TextStyle( + fontFamily: monoFont, + color: text, + fontSize: 14, + ), + side: const BorderSide(color: monad), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(rChip), + ), + ), + dividerColor: border, + focusColor: canaryHi.withValues(alpha: 0.35), + ); + } +} diff --git a/app/linux/.gitignore b/app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt new file mode 100644 index 0000000..5193ddf --- /dev/null +++ b/app/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "no_sus") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.no_sus") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..456bc17 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,31 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) rive_common_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RivePlugin"); + rive_plugin_register_with_registrar(rive_common_registrar); + g_autoptr(FlPluginRegistrar) sentry_flutter_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin"); + sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..06f0c47 --- /dev/null +++ b/app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + gtk + rive_common + sentry_flutter + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/linux/runner/CMakeLists.txt b/app/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/app/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/app/linux/runner/main.cc b/app/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/app/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc new file mode 100644 index 0000000..f619b86 --- /dev/null +++ b/app/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "no_sus"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "no_sus"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/app/linux/runner/my_application.h b/app/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/app/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/macos/.gitignore b/app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/app/macos/Flutter/Flutter-Debug.xcconfig b/app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/Flutter-Release.xcconfig b/app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..b80bbdf --- /dev/null +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,34 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import file_picker +import file_selector_macos +import firebase_core +import firebase_messaging +import package_info_plus +import pdfium_flutter +import rive_common +import sentry_flutter +import share_plus +import shared_preferences_foundation +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PDFiumFlutterPlugin.register(with: registry.registrar(forPlugin: "PDFiumFlutterPlugin")) + RivePlugin.register(with: registry.registrar(forPlugin: "RivePlugin")) + SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c7783a3 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* no_sus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "no_sus.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* no_sus.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* no_sus.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/no_sus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/no_sus"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/no_sus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/no_sus"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/no_sus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/no_sus"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..1f5d946 --- /dev/null +++ b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/app/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..ffaa97e Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..3e4738a Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..84d7a82 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..b9abf42 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..8eaa94c Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/app/macos/Runner/Base.lproj/MainMenu.xib b/app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner/Configs/AppInfo.xcconfig b/app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..91eae3a --- /dev/null +++ b/app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = no_sus + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/app/macos/Runner/Configs/Debug.xcconfig b/app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Release.xcconfig b/app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Warnings.xcconfig b/app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/app/macos/RunnerTests/RunnerTests.swift b/app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/pubspec.lock b/app/pubspec.lock new file mode 100644 index 0000000..e72720d --- /dev/null +++ b/app/pubspec.lock @@ -0,0 +1,1474 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.dev" + source: hosted + version: "99.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: b268ff9bb87a16360a397f1accb50e7c781384e1119010b34f6d7c25820c44d8 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + encrypt: + dependency: "direct main" + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://pub.dev" + source: hosted + version: "11.0.2" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + url: "https://pub.dev" + source: hosted + version: "15.2.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + url: "https://pub.dev" + source: hosted + version: "4.6.10" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + url: "https://pub.dev" + source: hosted + version: "3.10.10" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: e9685e9ab852a8b8e0579c867f6c9155da27317b294b3df796a536b8b8e0d253 + url: "https://pub.dev" + source: hosted + version: "2.6.4" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "621e94de22fd0f1e902e593b892746d80ab7542b39c2552aa091d904eb82fc6f" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + google_mlkit_text_recognition: + dependency: "direct main" + description: + name: google_mlkit_text_recognition + sha256: e13685af5093c8be009a3555909b72a80e10cfe8f97752bcbdf99fa0ccbda810 + url: "https://pub.dev" + source: hosted + version: "0.17.1" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: "360bba9606e58d5acc59a033ab64ae3cf571a6868f7a7267cb8e9a204b7bf1b2" + url: "https://pub.dev" + source: hosted + version: "2.26.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" + url: "https://pub.dev" + source: hosted + version: "4.9.1" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + measure_flutter: + dependency: "direct main" + description: + name: measure_flutter + sha256: "23ed9dfa56faf0028a993fa974bc6ae57f841bae4d907db7f66dbc746061db3f" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + passkeys_platform_interface: + dependency: transitive + description: + name: passkeys_platform_interface + sha256: e810520c7b79dca629fdc266958564eedd77dc85a955f5e94430dfccb92eef68 + url: "https://pub.dev" + source: hosted + version: "2.9.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pdfium_dart: + dependency: transitive + description: + name: pdfium_dart + sha256: "86e95c66b09f3245b95c4924f2edce8d4f7c9786876e5c5ee8e36b104a94bbb0" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + pdfium_flutter: + dependency: transitive + description: + name: pdfium_flutter + sha256: "0b115c0917aef9cf6bb9c4d47d0efc61d97f44844cd76287c921f997c26cf15d" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + pdfrx: + dependency: "direct main" + description: + name: pdfrx + sha256: acce61944c31bd36f0c3031b1bbf41f59d0b0d4e5e27614fbdc74ff093d55464 + url: "https://pub.dev" + source: hosted + version: "2.4.7" + pdfrx_engine: + dependency: transitive + description: + name: pdfrx_engine + sha256: ef8f8cfa64255bfc91a559dd2d32d7cfe7a0f8003bac297bbf9a0aed1182fae5 + url: "https://pub.dev" + source: hosted + version: "0.4.6" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: "10e3f195e131eec944fa872644aed89b33b5be998f3fc77c87325db3927bc646" + url: "https://pub.dev" + source: hosted + version: "2.8.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "0cfbe0047e2591eba348938e9b4e620d5b1a8df6c374757e8eb8645252b8387f" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + rive: + dependency: "direct main" + description: + name: rive + sha256: "2551a44fa766a7ed3f52aa2b94feda6d18d00edc25dee5f66e72e9b365bb6d6c" + url: "https://pub.dev" + source: hosted + version: "0.13.20" + rive_common: + dependency: transitive + description: + name: rive_common + sha256: "2ba42f80d37a4efd0696fb715787c4785f8a13361e8aea9227c50f1e78cf763a" + url: "https://pub.dev" + source: hosted + version: "0.4.15" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + screen_protector: + dependency: "direct main" + description: + name: screen_protector + sha256: "66f2ffbfbe7acc26db49de893e0c49078c67b546c27a57958d9bd9901f77c38e" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + sentry: + dependency: transitive + description: + name: sentry + sha256: "599701ca0693a74da361bc780b0752e1abc98226cf5095f6b069648116c896bb" + url: "https://pub.dev" + source: hosted + version: "8.14.2" + sentry_flutter: + dependency: "direct main" + description: + name: sentry_flutter + sha256: "5ba2cf40646a77d113b37a07bd69f61bb3ec8a73cbabe5537b05a7c89d2656f8" + url: "https://pub.dev" + source: hosted + version: "8.14.2" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + smooth_page_indicator: + dependency: "direct main" + description: + name: smooth_page_indicator + sha256: "4b497e9898d095de40d246db943371183fa7482492a88391cfa8415ef94d57ba" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "221263cfbe0c01575b7d7fe7543e44abff380eb4d38d936372844a1caa85ba12" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: "3879996256325fcc9abb03b62b12c07e4eaae2e008e1bf043cc1525184159a43" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: c9916c1cd512ebf3107ec0f83c9dca13d2e1e789629c2a5df896586bff46ce5f + url: "https://pub.dev" + source: hosted + version: "2.16.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_html: + dependency: "direct main" + description: + name: universal_html + sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" +sdks: + dart: ">=3.12.1 <4.0.0" + flutter: ">=3.44.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..03ea46d --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,135 @@ +name: no_sus +description: "NO SUS — Secure study group workspace with file sharing, watermarking, and audit logging." +publish_to: 'none' + +version: 1.4.0+11 + +environment: + sdk: ^3.12.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + flutter_animate: ^4.5.2 + smooth_page_indicator: ^2.0.1 + flutter_riverpod: ^3.3.1 + supabase_flutter: ^2.6.0 + app_links: ^6.0.0 + file_picker: ^11.0.2 + shared_preferences: ^2.2.0 + uuid: ^4.5.3 + pdfrx: ^2.4.4 + screen_protector: ^1.5.2 + share_plus: ^12.0.2 + http: ^1.2.0 + universal_html: ^2.3.0 + encrypt: ^5.0.3 + crypto: ^3.0.6 + rive: ^0.13.20 + collection: ^1.19.0 + package_info_plus: ^9.0.1 + image: ^4.8.0 + url_launcher: ^6.3.2 + sentry_flutter: ^8.14.2 + + # Push notifications. google-services.json landed 2026-07-30 (Firebase + # project no-sus-isagi0011), so the Gradle plugin now applies for real — see + # android/app/build.gradle.kts, which keys off that file's presence. The file + # is git-ignored, so CI needs GOOGLE_SERVICES_JSON_BASE64 to reproduce it; + # without it a CI build still succeeds, PushService just catches the + # initialisation failure and sets _available = false. The inbox reads + # Postgres directly and works either way. + firebase_core: ^3.8.0 + firebase_messaging: ^15.1.5 + + # Measure (measure.sh) app monitoring. Android-only — the package registers + # plugin implementations for android/ios only and contains unsupported web + # code. services/measure_reporting.dart conditionally excludes it from web + # compilation. Inert unless MEASURE_API_KEY *and* MEASURE_API_URL are + # supplied; see AGENTS.md §8 for the two defaults that are hostile to this + # product (server-side screenshot-on-crash, and gesture labels) and why the + # app is deliberately not wrapped in MeasureWidget. + # + # Pulls image_picker transitively for a bug-report feature this app does not + # use. Checked: it adds a FileProvider and a Play-services module service to + # the merged manifest, no permissions. + measure_flutter: ^0.6.0 + qr_flutter: ^4.1.0 + image_picker: ^1.2.3 + google_mlkit_text_recognition: ^0.17.1 + + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.13.1 + mocktail: ^1.0.5 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # The Canary UI names these families directly (lib/theme.dart CanaryTokens). + fonts: + - family: Outfit + fonts: + - asset: assets/google_fonts/Outfit-Regular.ttf + - asset: assets/google_fonts/Outfit-SemiBold.ttf + weight: 600 + - asset: assets/google_fonts/Outfit-Bold.ttf + weight: 700 + - family: Inter + fonts: + - asset: assets/google_fonts/Inter-Regular.ttf + - asset: assets/google_fonts/Inter-Medium.ttf + weight: 500 + - asset: assets/google_fonts/Inter-SemiBold.ttf + weight: 600 + - asset: assets/google_fonts/Inter-Bold.ttf + weight: 700 + - family: VT323 + fonts: + - asset: assets/google_fonts/VT323-Regular.ttf + + assets: + - assets/images/ + - assets/icon/LuxandNox.png + - assets/mascot/ + # Bundled font binaries. google_fonts resolves these by filename + # ({Family}-{Variant}.ttf) out of the asset manifest, so with + # GoogleFonts.config.allowRuntimeFetching = false (main.dart) nothing is + # ever fetched from fonts.gstatic.com at runtime. Costs ~1.6 MB; buys a + # correct first paint on a cold or blocked network and removes a + # third-party call at startup. Only the weights the app actually renders + # are here — see lib/theme.dart (Inter 400/500/600/700, Outfit 400/600/700) + # and anonymous_share_viewer_screen.dart (VT323 400). + - assets/google_fonts/ + # Note: the rest of assets/icon/ (app_icon.png etc.) is used by + # flutter_launcher_icons at build time but is NOT included here as a + # runtime asset (reduces APK size). + +flutter_launcher_icons: + android: "launcher_icon" + ios: true + image_path: "assets/icon/app_icon.png" + remove_alpha_ios: true + adaptive_icon_background: "#000000" + adaptive_icon_foreground: "assets/icon/adaptive_foreground.png" + adaptive_icon_monochrome: "assets/icon/adaptive_monochrome.png" + diff --git a/supabase/config.toml b/app/supabase/config.toml similarity index 94% rename from supabase/config.toml rename to app/supabase/config.toml index b3ce41b..41866b3 100644 --- a/supabase/config.toml +++ b/app/supabase/config.toml @@ -2,7 +2,7 @@ # https://supabase.com/docs/guides/local-development/cli/config # A string used to distinguish different Supabase projects on the same host. Defaults to the # working directory name when running `supabase init`. -project_id = "nosus-monad" +project_id = "nosus-canary" [api] enabled = true @@ -156,11 +156,11 @@ max_indexes = 5 enabled = true # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # in emails. -site_url = "http://127.0.0.1:3000" +site_url = "https://shubham-sahu.me/nosus-canary/" # The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. # external_url = "" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000"] +additional_redirect_urls = ["https://shubham-sahu.me/nosus-canary/**", "http://127.0.0.1:5052/**"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 # JWT issuer URL. If not set, defaults to auth.external_url. @@ -223,7 +223,7 @@ enable_signup = true # addresses. If disabled, only the new email is required to confirm. double_confirm_changes = true # If enabled, users need to confirm their email address before signing in. -enable_confirmations = false +enable_confirmations = true # If enabled, users will need to reauthenticate or have logged in recently to change their password. secure_password_change = false # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. @@ -411,3 +411,36 @@ s3_secret_key = "env(S3_SECRET_KEY)" # declarative_schema_path = "./database" # JSON string passed through to pg-delta SQL formatting. # format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + +[functions.drive-proxy] +verify_jwt = false + +[functions.account-manager] +verify_jwt = true + +[functions.storage-router] +verify_jwt = true + +[functions.document-insights] +verify_jwt = true + +[functions.burn-file-init] +verify_jwt = false +[functions.burn-file-confirm] +verify_jwt = false +[functions.burn-file-fetch] +verify_jwt = false +[functions.share-fetch] +verify_jwt = false +[functions.share-heartbeat] +verify_jwt = false +[functions.create-redemption-code] +verify_jwt = false +[functions.redeem-code] +verify_jwt = false +[functions.cleanup-burn-files] +verify_jwt = false + +[functions.canary] +verify_jwt = false + diff --git a/app/supabase/functions/account-manager/index.ts b/app/supabase/functions/account-manager/index.ts new file mode 100644 index 0000000..741cec6 --- /dev/null +++ b/app/supabase/functions/account-manager/index.ts @@ -0,0 +1,186 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +function isObjectNotFound(err: { message: string }): boolean { + const m = err.message.toLowerCase(); + return m.includes("not found") || m.includes("does not exist") || m.includes("404"); +} + +Deno.serve(async (req: Request) => { + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!; + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + + const admin = createClient(supabaseUrl, serviceRoleKey); + + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response(JSON.stringify({ error: "Missing Authorization header" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const userClient = createClient(supabaseUrl, supabaseAnonKey, { + global: { headers: { Authorization: authHeader } }, + }); + const { data: { user }, error: authError } = await userClient.auth.getUser(); + if (authError || !user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const uid = user.id; + + const summary = { + groups: { processed: 0, deleted: 0, ownership_transferred: 0, admin_promoted: 0 }, + files: { database_records: 0, storage_objects_deleted: 0 }, + memberships_removed: 0, + notes_deleted: 0, + account: { profile_deleted: false, user_deleted: false }, + }; + + const errors: string[] = []; + const addError = (msg: string) => { errors.push(msg); }; + + const respond = (success: boolean, status: number) => + new Response(JSON.stringify({ success, summary, errors }), { + status, + headers: { "Content-Type": "application/json" }, + }); + + // ── 1. Find all groups where user is admin ────────────────────────────────── + const { data: adminEntries, error: adminQueryErr } = await admin + .from("study_group_members") + .select("group_id") + .eq("user_id", uid) + .eq("is_admin", true); + + if (adminQueryErr) { + addError(`Failed to query admin groups: ${adminQueryErr.message}`); + return respond(false, 500); + } + + // ── 2. Process each owned group ──────────────────────────────────────────── + for (const entry of adminEntries || []) { + const groupId = entry.group_id; + summary.groups.processed++; + + const { data: members, error: membersErr } = await admin + .from("study_group_members") + .select("user_id, joined_at, is_admin") + .eq("group_id", groupId) + .neq("user_id", uid); + + if (membersErr) { + addError(`Group ${groupId}: query members failed — ${membersErr.message}`); + continue; + } + + const otherAdmins = members.filter((m) => m.is_admin); + const otherNonAdmins = members.filter((m) => !m.is_admin); + + if (members.length === 0) { + // No remaining members → delete group + storage + all data + const { data: files, error: filesErr } = await admin + .from("secure_files") + .select("id, storage_path") + .eq("group_id", groupId); + + if (filesErr) { + addError(`Group ${groupId}: query files failed — ${filesErr.message}`); + } else { + summary.files.database_records += files.length; + + if (files.length > 0) { + const paths = files.map((f) => f.storage_path || f.id); + const { error: storageErr } = await admin.storage.from("secure-files").remove(paths); + + if (storageErr) { + if (isObjectNotFound(storageErr)) { + summary.files.storage_objects_deleted += paths.length; + } else { + addError(`Group ${groupId}: storage remove error — ${storageErr.message}`); + } + } else { + summary.files.storage_objects_deleted += paths.length; + } + } + } + + const { error: delErr } = await admin.from("study_groups").delete().eq("id", groupId); + if (delErr) { + addError(`Group ${groupId}: delete failed — ${delErr.message}`); + } else { + summary.groups.deleted++; + } + + } else if (otherAdmins.length > 0) { + // Another admin exists → group survives, ownership stays + summary.groups.ownership_transferred++; + + } else { + // Members exist but no other admin → promote earliest joined + const earliest = otherNonAdmins.reduce((a, b) => (a.joined_at < b.joined_at ? a : b)); + const { error: promoteErr } = await admin + .from("study_group_members") + .update({ is_admin: true }) + .eq("group_id", groupId) + .eq("user_id", earliest.user_id); + + if (promoteErr) { + addError(`Group ${groupId}: promote failed — ${promoteErr.message}`); + } else { + summary.groups.admin_promoted++; + } + } + } + + // ── 3. Remove remaining memberships (groups where user was a regular member) ── + const { count: removedCount, error: rmErr } = await admin + .from("study_group_members") + .delete({ count: "exact" }) + .eq("user_id", uid); + + if (rmErr) { + addError(`Remove memberships: ${rmErr.message}`); + } else { + summary.memberships_removed = removedCount ?? 0; + } + + // ── 4. Delete private notes ──────────────────────────────────────────────── + const { count: deletedCount, error: notesErr } = await admin + .from("user_notes") + .delete({ count: "exact" }) + .eq("user_id", uid); + + if (notesErr) { + addError(`Delete notes: ${notesErr.message}`); + } else { + summary.notes_deleted = deletedCount ?? 0; + } + + // ── 5. Delete profile (cascade removes devices) ──────────────────────────── + const { error: profileErr } = await admin.from("profiles").delete().eq("id", uid); + if (profileErr) { + addError(`Delete profile: ${profileErr.message}`); + } else { + summary.account.profile_deleted = true; + } + + // ── 6. Delete the auth user (last — after this, no more operations) ─────── + const { error: deleteUserErr } = await admin.auth.admin.deleteUser(uid); + if (deleteUserErr) { + if (isObjectNotFound(deleteUserErr)) { + summary.account.user_deleted = true; + } else { + addError(`Delete auth user: ${deleteUserErr.message}`); + } + } else { + summary.account.user_deleted = true; + } + + const success = errors.length === 0; + return respond(success, success ? 200 : 500); +}); diff --git a/app/supabase/functions/burn-file-confirm/index.ts b/app/supabase/functions/burn-file-confirm/index.ts new file mode 100644 index 0000000..e2997d8 --- /dev/null +++ b/app/supabase/functions/burn-file-confirm/index.ts @@ -0,0 +1,106 @@ +// Supabase Edge Function: burn-file-confirm +// +// Second step of the anonymous "Burn Files" upload flow. Called by the +// client immediately after its signed-URL PUT to Storage succeeds. +// +// POST { file_id } -> { ok: true } +// +// Closes a race identified during design review: burn-file-init creates the +// DB row (status: 'pending') and issues a signed upload URL BEFORE the +// client has actually uploaded any bytes. Without this confirm step, a +// burn-file-fetch call could land in that window and "burn" a row whose +// storage object doesn't exist yet. claim_burn_file's `WHERE status = 'ready'` +// gate makes this airtight — nothing is downloadable until this function +// verifies the object is really there. +// +// Also re-checks the REAL uploaded byte size against the size cap — the +// declared_size_bytes given to burn-file-init is client-asserted and could +// lie to dodge the cap; this is the actual enforcement point. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !serviceRoleKey) { + return json({ error: "burn-file-confirm is not configured" }, 503); + } + const admin = createClient(supabaseUrl, serviceRoleKey); + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const fileId = String(body.file_id ?? "").trim(); + if (!fileId) return json({ error: "Missing file_id" }, 400); + + const { data: row, error: rowErr } = await admin + .from("burn_files") + .select("id, storage_object_key, status, ciphertext_size_bytes, expires_at") + .eq("id", fileId) + .maybeSingle(); + if (rowErr || !row) return json({ error: "Unknown file_id" }, 404); + if (row.status !== "pending") return json({ error: "This upload was already confirmed" }, 409); + if (new Date(row.expires_at).getTime() < Date.now()) { + return json({ error: "This upload has expired" }, 410); + } + + // Confirm the object actually exists and get its REAL size — list() with + // an exact-name search avoids downloading the (possibly large) bytes just + // to check metadata. + const { data: listing, error: listErr } = await admin.storage + .from("burn-files") + .list("", { search: row.storage_object_key }); + const match = listing?.find((f) => f.name === row.storage_object_key); + if (listErr || !match) { + return json({ error: "Upload not found in storage — it may have failed" }, 404); + } + + const realSize = (match.metadata as { size?: number } | null)?.size ?? 0; + + const { data: configRow } = await admin + .from("remote_configs") + .select("config_value") + .eq("config_key", "burn_files_max_size_bytes") + .maybeSingle(); + const maxSizeBytes = configRow ? Number(configRow.config_value) : 52428800; + + if (realSize <= 0 || realSize > maxSizeBytes) { + // Lied about size (or something went wrong) — remove the object and + // the row rather than leaving an oversized/broken upload in place. + await admin.storage.from("burn-files").remove([row.storage_object_key]); + await admin.from("burn_files").delete().eq("id", fileId); + return json({ error: "Uploaded file exceeds the allowed size" }, 413); + } + + const { error: updateErr } = await admin + .from("burn_files") + .update({ status: "ready", ciphertext_size_bytes: realSize }) + .eq("id", fileId) + .eq("status", "pending"); // no-op if somehow already confirmed concurrently + if (updateErr) { + console.error("burn-file-confirm: failed to mark ready", updateErr); + return json({ error: "Could not confirm upload" }, 502); + } + + return json({ ok: true }); +}); diff --git a/app/supabase/functions/burn-file-fetch/index.ts b/app/supabase/functions/burn-file-fetch/index.ts new file mode 100644 index 0000000..d274870 --- /dev/null +++ b/app/supabase/functions/burn-file-fetch/index.ts @@ -0,0 +1,85 @@ +// Supabase Edge Function: burn-file-fetch +// +// The recipient's ONLY path to a Burn File's bytes. Public on purpose +// (verify_jwt: false) — the recipient never has an account, exactly like +// the SecureSend anonymous viewer, but with no email gate: single tap, +// zero friction, matching real file.io. +// +// POST { file_id } -> { signed_url } | 410 if already downloaded/expired +// +// Deletion of the file is a TWO-STAGE process, not something this function +// does directly: +// 1. claim_burn_file() atomically marks consumed_at the moment this +// resolves successfully — a second call with the same file_id always +// gets 410 from here on, even if the actual bytes aren't wiped yet. +// 2. The cleanup-burn-files cron sweep (every 5 minutes) removes the +// actual storage object after a grace window past consumed_at, so an +// already-issued signed URL isn't yanked out from under an in-flight +// download. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +const SIGNED_URL_TTL_SECONDS = 300; // 5 minutes — same TTL SecureSend's share-fetch uses + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !serviceRoleKey) { + return json({ error: "burn-file-fetch is not configured" }, 503); + } + const admin = createClient(supabaseUrl, serviceRoleKey); + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const fileId = String(body.file_id ?? "").trim(); + if (!fileId) return json({ error: "Missing file_id" }, 400); + + const { data: flag } = await admin + .from("feature_flags") + .select("is_active") + .eq("flag_key", "burn_files_enabled") + .maybeSingle(); + if (flag && flag.is_active === false) { + return json({ error: "Burn Files is temporarily disabled" }, 503); + } + + const { data: claimed, error: claimErr } = await admin.rpc("claim_burn_file", { p_id: fileId }); + if (claimErr) { + console.error("burn-file-fetch: claim RPC failed", claimErr); + return json({ error: "Could not process this link" }, 500); + } + if (!claimed?.id) { + return json({ error: "This file has already been downloaded, expired, or does not exist" }, 410); + } + + const { data: signed, error: signErr } = await admin.storage + .from("burn-files") + .createSignedUrl(claimed.storage_object_key, SIGNED_URL_TTL_SECONDS); + if (signErr || !signed?.signedUrl) { + console.error("burn-file-fetch: failed to sign download URL", signErr); + return json({ error: "The file could not be prepared for download" }, 502); + } + + return json({ signed_url: signed.signedUrl }); +}); diff --git a/app/supabase/functions/burn-file-init/index.ts b/app/supabase/functions/burn-file-init/index.ts new file mode 100644 index 0000000..bd26609 --- /dev/null +++ b/app/supabase/functions/burn-file-init/index.ts @@ -0,0 +1,161 @@ +// Supabase Edge Function: burn-file-init +// +// First step of the anonymous "Burn Files" upload flow (file.io clone — +// see supabase/migrations/20260710050000_burn_files.sql for the full +// design rationale). Public on purpose (verify_jwt: false) — upload is +// anonymous on both ends by product decision. +// +// POST { declared_size_bytes, expiry_hours? } -> +// { file_id, signed_upload_url, upload_token } +// +// Responsibilities: +// 1. Check the burn_files_enabled kill-switch. +// 2. Rate-limit by a SALTED HASH of the request IP — never the raw IP, +// consistent with this app's no-tracking privacy commitment. The hash +// only lives in burn_file_upload_counters, swept hourly. +// 3. Enforce the size cap (checked again for real in burn-file-confirm, +// since a client can lie about declared_size_bytes here). +// 4. Insert a `status: 'pending'` row and mint a signed UPLOAD url — the +// row only becomes usable once burn-file-confirm verifies the object +// actually landed in Storage. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +async function hmacHex(message: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)); + return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + const ipSalt = Deno.env.get("BURN_FILES_IP_SALT"); + if (!supabaseUrl || !serviceRoleKey || !ipSalt) { + return json({ error: "burn-file-init is not configured" }, 503); + } + const admin = createClient(supabaseUrl, serviceRoleKey); + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const declaredSize = Number(body.declared_size_bytes ?? 0); + const requestedExpiryHours = body.expiry_hours != null ? Number(body.expiry_hours) : null; + if (!Number.isFinite(declaredSize) || declaredSize <= 0) { + return json({ error: "Missing or invalid declared_size_bytes" }, 400); + } + + // Independent reads — run concurrently instead of paying for two sequential + // round trips. Order still matters for the rate-limit RPC below: that one + // stays after the kill-switch check so a disabled feature never burns a + // caller's quota. + const [{ data: flag }, { data: configRows }] = await Promise.all([ + admin + .from("feature_flags") + .select("is_active") + .eq("flag_key", "burn_files_enabled") + .maybeSingle(), + admin + .from("remote_configs") + .select("config_key, config_value") + .in("config_key", [ + "burn_files_max_size_bytes", + "burn_files_default_expiry_hours", + "burn_files_max_expiry_hours", + "burn_files_rate_limit_per_hour", + "burn_files_rate_limit_window_minutes", + ]), + ]); + if (flag && flag.is_active === false) { + return json({ error: "Burn Files is temporarily disabled" }, 503); + } + const cfg = (key: string, fallback: number): number => { + const row = configRows?.find((r: any) => r.config_key === key); + return row ? Number(row.config_value) : fallback; + }; + const maxSizeBytes = cfg("burn_files_max_size_bytes", 52428800); + const defaultExpiryHours = cfg("burn_files_default_expiry_hours", 24); + const maxExpiryHours = cfg("burn_files_max_expiry_hours", 168); + const rateLimitPerHour = cfg("burn_files_rate_limit_per_hour", 8); + const rateLimitWindowMinutes = cfg("burn_files_rate_limit_window_minutes", 60); + + if (declaredSize > maxSizeBytes) { + return json({ error: `File too large — max ${Math.floor(maxSizeBytes / 1048576)}MB` }, 413); + } + + const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const ipHash = await hmacHex(ip, ipSalt); + + const { data: allowed, error: rateErr } = await admin.rpc("check_and_increment_burn_upload_rate", { + p_ip_hash: ipHash, + p_limit: rateLimitPerHour, + p_window_minutes: rateLimitWindowMinutes, + }); + if (rateErr) { + console.error("burn-file-init: rate limit check failed", rateErr); + return json({ error: "Could not process upload request" }, 500); + } + if (!allowed) { + return json({ error: "Too many uploads from this network. Please try again later." }, 429); + } + + const expiryHours = Math.min( + requestedExpiryHours && requestedExpiryHours > 0 ? requestedExpiryHours : defaultExpiryHours, + maxExpiryHours, + ); + const expiresAt = new Date(Date.now() + expiryHours * 3600 * 1000).toISOString(); + + const fileId = crypto.randomUUID(); + + const { data: signedUpload, error: uploadErr } = await admin.storage + .from("burn-files") + .createSignedUploadUrl(fileId); + if (uploadErr || !signedUpload) { + console.error("burn-file-init: failed to create signed upload URL", uploadErr); + return json({ error: "Could not prepare upload" }, 502); + } + + const { error: insertErr } = await admin.from("burn_files").insert({ + id: fileId, + storage_object_key: fileId, + status: "pending", + ciphertext_size_bytes: declaredSize, + expires_at: expiresAt, + }); + if (insertErr) { + console.error("burn-file-init: failed to insert row", insertErr); + return json({ error: "Could not prepare upload" }, 502); + } + + return json({ + file_id: fileId, + signed_upload_url: signedUpload.signedUrl, + upload_token: signedUpload.token, + }); +}); diff --git a/app/supabase/functions/canary/gating.ts b/app/supabase/functions/canary/gating.ts new file mode 100644 index 0000000..24f5b3f --- /dev/null +++ b/app/supabase/functions/canary/gating.ts @@ -0,0 +1,61 @@ +// Which proof chains a canary request writes to. Pure functions only (no env, +// no network) so the rules can be checked with `deno test` offline. +// +// Solana devnet is the product's receipt. Monad testnet is the earlier +// prototype and is on only while CANARY_CONTRACT_ADDRESS is set. + +/** Monad is on only when a contract address is configured (blank = off). */ +export function monadEnabled(contractAddress: string | null | undefined): boolean { + return (contractAddress ?? "").trim() !== ""; +} + +type SealFields = { seal_tx_hash?: string | null; seal_solana_sig?: string | null }; + +/** A note is sealed once either chain holds its seal. */ +export function isSealed(note: SealFields | null | undefined): boolean { + return !!note && (!!note.seal_tx_hash || !!note.seal_solana_sig); +} + +export type SealPlan = { + /** Send sealNote on Monad (in parallel with Solana, Monad authoritative). */ + monad: boolean; + /** Send the seal memo on Solana. */ + solana: boolean; + /** A failed Solana memo must fail the request (Solana is the only receipt). */ + solanaRequired: boolean; +}; + +export function planSeal(p: { + monadOn: boolean; + solanaOn: boolean; + sealTx: string | null; + sealSolana: string | null; +}): SealPlan { + const sealed = isSealed({ seal_tx_hash: p.sealTx, seal_solana_sig: p.sealSolana }); + const solana = p.solanaOn && !p.sealSolana; + return { + monad: p.monadOn && !p.sealTx, + solana, + solanaRequired: solana && !p.monadOn && !sealed, + }; +} + +export type OpenPlan = { + /** Send openCopy on Monad (only for notes sealed there). */ + monad: boolean; + /** Send the open memo on Solana. A failure is stored as null and retried. */ + solana: boolean; +}; + +export function planOpen(p: { + monadOn: boolean; + solanaOn: boolean; + noteSealTx: string | null; + openTx: string | null; + openSolana: string | null; +}): OpenPlan { + return { + monad: p.monadOn && !!p.noteSealTx && !p.openTx, + solana: p.solanaOn && !p.openSolana, + }; +} diff --git a/app/supabase/functions/canary/gating_test.ts b/app/supabase/functions/canary/gating_test.ts new file mode 100644 index 0000000..3ae9be8 --- /dev/null +++ b/app/supabase/functions/canary/gating_test.ts @@ -0,0 +1,83 @@ +// Offline checks for the proof-chain rules. Run from the repo root: +// npx -y deno@2 test app/supabase/functions/canary/gating_test.ts +import { assertEquals } from "jsr:@std/assert@1"; +import { isSealed, monadEnabled, planOpen, planSeal } from "./gating.ts"; + +const TX = "0x" + "a".repeat(64); +const SIG = "5".repeat(88); + +Deno.test("monadEnabled: only a non-blank contract address turns Monad on", () => { + assertEquals(monadEnabled(undefined), false); + assertEquals(monadEnabled(null), false); + assertEquals(monadEnabled(""), false); + assertEquals(monadEnabled(" "), false); + assertEquals(monadEnabled("0x" + "1".repeat(40)), true); +}); + +Deno.test("isSealed: either chain's seal counts", () => { + assertEquals(isSealed(null), false); + assertEquals(isSealed({ seal_tx_hash: null, seal_solana_sig: null }), false); + assertEquals(isSealed({ seal_tx_hash: TX, seal_solana_sig: null }), true); + assertEquals(isSealed({ seal_tx_hash: null, seal_solana_sig: SIG }), true); +}); + +Deno.test("planSeal: Solana only, new note -> Solana required", () => { + assertEquals( + planSeal({ monadOn: false, solanaOn: true, sealTx: null, sealSolana: null }), + { monad: false, solana: true, solanaRequired: true }, + ); +}); + +Deno.test("planSeal: Solana only, already sealed -> nothing to send", () => { + assertEquals( + planSeal({ monadOn: false, solanaOn: true, sealTx: null, sealSolana: SIG }), + { monad: false, solana: false, solanaRequired: false }, + ); + // Sealed on Monad by an older build: fill the memo quietly. + assertEquals( + planSeal({ monadOn: false, solanaOn: true, sealTx: TX, sealSolana: null }), + { monad: false, solana: true, solanaRequired: false }, + ); +}); + +Deno.test("planSeal: both on behaves as before (Monad authoritative)", () => { + assertEquals( + planSeal({ monadOn: true, solanaOn: true, sealTx: null, sealSolana: null }), + { monad: true, solana: true, solanaRequired: false }, + ); + assertEquals( + planSeal({ monadOn: true, solanaOn: true, sealTx: TX, sealSolana: null }), + { monad: false, solana: true, solanaRequired: false }, + ); + assertEquals( + planSeal({ monadOn: true, solanaOn: false, sealTx: null, sealSolana: null }), + { monad: true, solana: false, solanaRequired: false }, + ); +}); + +Deno.test("planOpen: Solana only never touches Monad", () => { + assertEquals( + planOpen({ monadOn: false, solanaOn: true, noteSealTx: TX, openTx: null, openSolana: null }), + { monad: false, solana: true }, + ); + assertEquals( + planOpen({ monadOn: false, solanaOn: true, noteSealTx: null, openTx: null, openSolana: SIG }), + { monad: false, solana: false }, + ); +}); + +Deno.test("planOpen: both on behaves as before", () => { + assertEquals( + planOpen({ monadOn: true, solanaOn: true, noteSealTx: TX, openTx: null, openSolana: null }), + { monad: true, solana: true }, + ); + assertEquals( + planOpen({ monadOn: true, solanaOn: true, noteSealTx: TX, openTx: TX, openSolana: null }), + { monad: false, solana: true }, + ); + // A note sealed only on Solana is never opened on Monad (the contract would revert). + assertEquals( + planOpen({ monadOn: true, solanaOn: true, noteSealTx: null, openTx: null, openSolana: null }), + { monad: false, solana: true }, + ); +}); diff --git a/app/supabase/functions/canary/index.ts b/app/supabase/functions/canary/index.ts new file mode 100644 index 0000000..10bd987 --- /dev/null +++ b/app/supabase/functions/canary/index.ts @@ -0,0 +1,534 @@ +// Supabase Edge Function: canary (NO SUS Canary) +// +// One function, three actions, all POST JSON. Deployed with --no-verify-jwt +// and called with the publishable key, like create-redemption-code. +// +// { action: "create", note_id, owner_hash, copy_count, copies_hash, +// expires_in_hours, copies: [{ index, ciphertext, iv }] } +// -> { seal_tx_hash, seal_solana_sig, expires_at } +// { action: "open", note_id, device_id, reader_name } +// -> { copy_index, copy_count, ciphertext, iv, open_tx_hash, open_solana_sig, opened_at } +// { action: "status", note_id, owner_secret } +// -> { copy_count, seal_tx_hash, seal_solana_sig, expires_at, +// copies: [{ copy_index, reader_name, opened_at, open_tx_hash, open_solana_sig }] } +// +// *_solana_sig is the Solana devnet memo signature. *_tx_hash is the Monad +// testnet transaction hash and stays null whenever Monad is off. +// +// Errors: { error: "", retry?: true } with a 4xx/5xx status. +// +// The server never sees note text or keys: copies arrive already encrypted +// and the key stays in the link fragment. The chains receive only +// sha256-based ids and hashes (never names or text). +// +// Proof chains (rules in gating.ts): +// - Solana devnet (solana.ts) is the receipt: one SPL Memo per seal and per +// open. With Monad off a note is sealed only once its Solana memo lands, so +// a failed seal memo fails `create` with retry: true. A failed open memo is +// stored as null and sent again on the next open of that copy. +// - Monad testnet is the earlier prototype, kept behind a switch: on only +// while CANARY_CONTRACT_ADDRESS is set. When on, it runs in parallel with +// Solana exactly as before (Monad authoritative; a Solana failure only +// leaves *_solana_sig null). When off: no viem calls, no relayer leases. +// - A note counts as sealed when seal_tx_hash OR seal_solana_sig is set. +// - With neither chain configured, create and open return 503. +// +// Secrets (supabase secrets set ...): +// CANARY_TAG_SALT 64 hex chars, random, never shared +// SOLANA_RELAYER_KEYS comma-separated devnet keypairs (base58 64-byte +// secret key or solana-keygen JSON array). Required +// while Monad is off; unset = Solana proof off and +// responses carry null. +// SOLANA_RPC_URL optional, default https://api.devnet.solana.com +// SOLANA_MEMO_PROGRAM optional, default MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr +// CANARY_CONTRACT_ADDRESS optional; 0x… NoSusCanary on Monad testnet. +// Unset or empty = Monad off. +// CANARY_RELAYER_KEYS Monad only: comma-separated 0x… private keys (testnet) +// MONAD_RPC_URL Monad only, default https://testnet-rpc.monad.xyz +// SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected automatically. + +import { createClient, type SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; +import { + createPublicClient, + createWalletClient, + defineChain, + http, + type Hex, +} from "npm:viem@2.56.5"; +import { privateKeyToAccount } from "npm:viem@2.56.5/accounts"; +import { loadSolana, openMemo, sealMemo, sendSolanaMemo, type SolanaConfig } from "./solana.ts"; +import { isSealed, monadEnabled, planOpen, planSeal } from "./gating.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +class HttpError extends Error { + constructor(public status: number, message: string, public retry = false) { + super(message); + } +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const HEX64_RE = /^[0-9a-f]{64}$/; +const BYTES32_RE = /^0x[0-9a-f]{64}$/; +const IV_RE = /^[0-9a-f]{32}$/; +const PRIVATE_KEY_RE = /^0x[0-9a-fA-F]{64}$/; +const ALLOWED_HOURS = new Set([1, 24, 168]); +const MAX_CIPHERTEXT = 12000; + +// Measured in the Hardhat tests: sealNote 73,833 gas, openCopy 78,403 gas. +// Monad charges the gas LIMIT, so keep it explicit and modest. +const GAS_LIMIT = 150_000n; + +const CANARY_ABI = [ + { + type: "function", + name: "sealNote", + stateMutability: "nonpayable", + inputs: [ + { name: "noteId", type: "bytes32" }, + { name: "copyCount", type: "uint16" }, + { name: "copiesHash", type: "bytes32" }, + { name: "expiresAt", type: "uint64" }, + ], + outputs: [], + }, + { + type: "function", + name: "openCopy", + stateMutability: "nonpayable", + inputs: [ + { name: "noteId", type: "bytes32" }, + { name: "copyIndex", type: "uint16" }, + { name: "readerTag", type: "bytes32" }, + ], + outputs: [], + }, +] as const; + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function requireEnv(name: string): string { + const value = Deno.env.get(name); + if (!value) throw new HttpError(503, `canary is not configured (${name})`); + return value; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type Chain = { + contract: Hex; + keys: Hex[]; + rpcUrl: string; +}; + +// Null when Monad is off (CANARY_CONTRACT_ADDRESS unset or empty). Once an +// address is set, a bad Monad config is a 503 as before. +function loadChainOrNull(): Chain | null { + const contract = (Deno.env.get("CANARY_CONTRACT_ADDRESS") ?? "").trim(); + if (!monadEnabled(contract)) return null; + if (!/^0x[0-9a-fA-F]{40}$/.test(contract)) { + throw new HttpError(503, "canary is not configured (contract address)"); + } + const keys = requireEnv("CANARY_RELAYER_KEYS") + .split(",") + .map((k) => k.trim()) + .filter((k) => k.length > 0); + if (keys.length === 0 || keys.length > 32 || !keys.every((k) => PRIVATE_KEY_RE.test(k))) { + throw new HttpError(503, "canary is not configured (relayer keys)"); + } + return { + contract: contract as Hex, + keys: keys as Hex[], + rpcUrl: Deno.env.get("MONAD_RPC_URL") ?? "https://testnet-rpc.monad.xyz", + }; +} + +// A bad Solana config is logged and treated as "off". With Monad on, a failed +// memo is logged and stored as null; with Monad off, see handleCreate. +function loadSolanaOrNull(): SolanaConfig | null { + try { + return loadSolana(); + } catch (e) { + console.error("canary: solana is misconfigured", e); + return null; + } +} + +function requireProofChain(chain: Chain | null, solana: SolanaConfig | null) { + if (!chain && !solana) { + throw new HttpError(503, "canary is not configured (no proof chain)"); + } +} + +function solanaSig(result: PromiseSettledResult, label: string): string | null { + if (result.status === "fulfilled") return result.value; + console.error(`${label}: solana memo failed`, result.reason); + return null; +} + +// Sends one contract call from a leased relayer key and waits for success. +// A key is leased for the whole send+confirm so its nonce is never shared. +async function sendCanaryTx( + admin: SupabaseClient, + chain: Chain, + functionName: "sealNote" | "openCopy", + args: readonly unknown[], +): Promise { + let index: number | null = null; + for (let attempt = 0; attempt < 40 && index === null; attempt++) { + const { data, error } = await admin.rpc("canary_lease_relayer", { + p_count: chain.keys.length, + p_seconds: 30, + }); + if (error) throw error; + if (typeof data === "number") { + index = data; + } else { + await sleep(250); + } + } + if (index === null) { + throw new HttpError(503, "NO SUS is busy. Try again in a moment.", true); + } + + try { + const monadTestnet = defineChain({ + id: 10143, + name: "Monad Testnet", + nativeCurrency: { name: "MON", symbol: "MON", decimals: 18 }, + rpcUrls: { default: { http: [chain.rpcUrl] } }, + }); + const account = privateKeyToAccount(chain.keys[index]); + const wallet = createWalletClient({ account, chain: monadTestnet, transport: http(chain.rpcUrl) }); + const reader = createPublicClient({ chain: monadTestnet, transport: http(chain.rpcUrl) }); + const hash = await wallet.writeContract({ + address: chain.contract, + abi: CANARY_ABI, + // deno-lint-ignore no-explicit-any + functionName: functionName as any, + // deno-lint-ignore no-explicit-any + args: args as any, + gas: GAS_LIMIT, + }); + const receipt = await reader.waitForTransactionReceipt({ + hash, + timeout: 20_000, + pollingInterval: 250, + }); + if (receipt.status !== "success") { + throw new Error(`${functionName} reverted in ${hash}`); + } + return hash; + } finally { + await admin.rpc("canary_release_relayer", { p_index: index }); + } +} + +async function handleCreate(admin: SupabaseClient, body: Record) { + const noteId = String(body.note_id ?? ""); + const ownerHash = String(body.owner_hash ?? ""); + const copyCount = Number(body.copy_count); + const copiesHash = String(body.copies_hash ?? ""); + const hours = Number(body.expires_in_hours); + const copies = Array.isArray(body.copies) ? body.copies : []; + + if (!UUID_RE.test(noteId) || !HEX64_RE.test(ownerHash) || !BYTES32_RE.test(copiesHash)) { + throw new HttpError(400, "Invalid note."); + } + if (!Number.isInteger(copyCount) || copyCount < 2 || copyCount > 100) { + throw new HttpError(400, "Copy count must be between 2 and 100."); + } + if (!ALLOWED_HOURS.has(hours)) throw new HttpError(400, "Invalid expiry."); + if (copies.length !== copyCount) throw new HttpError(400, "Copy count does not match."); + + const rows = copies.map((raw, position) => { + const c = raw as Record; + const index = Number(c.index); + const ciphertext = String(c.ciphertext ?? ""); + const iv = String(c.iv ?? ""); + if ( + index !== position || ciphertext.length === 0 || ciphertext.length > MAX_CIPHERTEXT || + !IV_RE.test(iv) + ) { + throw new HttpError(400, "Invalid copy."); + } + return { note_id: noteId, copy_index: index, ciphertext, iv }; + }); + + const chain = loadChainOrNull(); + const solana = loadSolanaOrNull(); + requireProofChain(chain, solana); + const chainNoteId = `0x${await sha256Hex(`nosus-canary:${noteId}`)}`; + + const { data: existing, error: readError } = await admin + .from("canary_notes") + .select("owner_hash, seal_tx_hash, seal_solana_sig, expires_at") + .eq("id", noteId) + .maybeSingle(); + if (readError) throw readError; + + let expiresAt: string; + if (existing) { + if (existing.owner_hash !== ownerHash) throw new HttpError(409, "This note id is taken."); + expiresAt = existing.expires_at; + } else { + expiresAt = new Date(Date.now() + hours * 3600 * 1000).toISOString(); + const { error: noteError } = await admin.from("canary_notes").insert({ + id: noteId, + owner_hash: ownerHash, + copy_count: copyCount, + copies_hash: copiesHash, + chain_note_id: chainNoteId, + expires_at: expiresAt, + }); + if (noteError) throw noteError; + const { error: copiesError } = await admin.from("canary_copies").insert(rows); + if (copiesError) { + await admin.from("canary_notes").delete().eq("id", noteId); + throw copiesError; + } + } + + const expiresAtSeconds = Math.floor(new Date(expiresAt).getTime() / 1000); + const memo = sealMemo({ note: chainNoteId, copies: copyCount, hash: copiesHash, exp: expiresAtSeconds }); + const sealTxBefore: string | null = existing?.seal_tx_hash ?? null; + let sealSolana: string | null = existing?.seal_solana_sig ?? null; + const plan = planSeal({ + monadOn: chain !== null, + solanaOn: solana !== null, + sealTx: sealTxBefore, + sealSolana, + }); + + if (!chain || !plan.monad) { + // Nothing to send on Monad (Monad is off, or the note is sealed there + // already); at most the Solana memo is missing. + if (solana && plan.solana) { + const [sol] = await Promise.allSettled([sendSolanaMemo(solana, memo)]); + if (sol.status === "rejected" && plan.solanaRequired) { + // Solana is the only receipt: no memo means no seal. The row stays, and + // a retry with the same note id takes the existing-note path. + console.error("canary create: solana seal failed", sol.reason); + throw new HttpError(503, "Could not seal the note on Solana. Try again.", true); + } + sealSolana = solanaSig(sol, existing ? "canary create (retry)" : "canary create"); + if (sealSolana) { + await admin.from("canary_notes").update({ seal_solana_sig: sealSolana }).eq("id", noteId); + } + } + if (!isSealed({ seal_tx_hash: sealTxBefore, seal_solana_sig: sealSolana })) { + // Not reachable while requireProofChain holds; never hand out an unsealed link. + throw new HttpError(503, "Could not seal the note. Try again.", true); + } + return json({ seal_tx_hash: sealTxBefore, seal_solana_sig: sealSolana, expires_at: expiresAt }); + } + + // Monad and Solana in parallel. Monad is authoritative; Solana may fail quietly. + const [monad, sol] = await Promise.allSettled([ + sendCanaryTx(admin, chain, "sealNote", [chainNoteId, copyCount, copiesHash, BigInt(expiresAtSeconds)]), + solana && plan.solana ? sendSolanaMemo(solana, memo) : Promise.resolve(sealSolana), + ]); + sealSolana = solanaSig(sol, "canary create"); + if (monad.status === "rejected") { + const e = monad.reason; + if (e instanceof HttpError) throw e; + console.error("canary create: seal failed", e); + throw new HttpError(503, "Could not seal the note on Monad. Try again.", true); + } + const sealTx: Hex = monad.value; + const { error: updateError } = await admin + .from("canary_notes") + .update({ seal_tx_hash: sealTx, seal_solana_sig: sealSolana }) + .eq("id", noteId); + if (updateError) throw updateError; + return json({ seal_tx_hash: sealTx, seal_solana_sig: sealSolana, expires_at: expiresAt }); +} + +async function handleOpen(admin: SupabaseClient, body: Record) { + const noteId = String(body.note_id ?? ""); + const deviceId = String(body.device_id ?? ""); + const readerName = String(body.reader_name ?? "") + .replace(/[\x00-\x1F\x7F]/g, "") + .replace(/\s+/g, " ") + .trim(); + if (!UUID_RE.test(noteId) || !UUID_RE.test(deviceId)) throw new HttpError(400, "Invalid link."); + if (readerName.length < 1 || readerName.length > 40) { + throw new HttpError(400, "Type a name between 1 and 40 characters."); + } + + const { data: note, error: noteError } = await admin + .from("canary_notes") + .select("copy_count, chain_note_id, seal_tx_hash, seal_solana_sig, expires_at") + .eq("id", noteId) + .maybeSingle(); + if (noteError) throw noteError; + if (!note || !isSealed(note)) throw new HttpError(404, "This Canary link is not valid."); + if (new Date(note.expires_at) <= new Date()) throw new HttpError(410, "This Canary link has expired."); + + const tagSalt = requireEnv("CANARY_TAG_SALT"); + const chain = loadChainOrNull(); + const solana = loadSolanaOrNull(); + requireProofChain(chain, solana); + const deviceHash = await sha256Hex(`device:${deviceId}`); + + let assigned: Record | null = null; + for (let attempt = 0; attempt < 2 && assigned === null; attempt++) { + const { data, error } = await admin.rpc("canary_assign_copy", { + p_note_id: noteId, + p_device_hash: deviceHash, + p_reader_name: readerName, + }); + if (error) { + // 23505 = unique violation: a double tap from the same device raced. + if (error.code === "23505" && attempt === 0) continue; + throw error; + } + assigned = Array.isArray(data) && data.length > 0 ? data[0] : null; + if (assigned === null) break; + } + if (assigned === null) throw new HttpError(409, "Every copy of this note has been opened."); + + const copyIndex = Number(assigned.copy_index); + const readerTag = `0x${await sha256Hex(`${tagSalt}:${noteId}:${copyIndex}:${deviceHash}`)}`; + const memo = openMemo({ note: note.chain_note_id, copy: copyIndex, tag: readerTag }); + let openTx = (assigned.open_tx_hash as string | null) ?? null; + let openSolana = (assigned.open_solana_sig as string | null) ?? null; + const plan = planOpen({ + monadOn: chain !== null, + solanaOn: solana !== null, + noteSealTx: note.seal_tx_hash ?? null, + openTx, + openSolana, + }); + if (chain && plan.monad) { + // Monad and Solana in parallel. Monad is authoritative; Solana may fail quietly. + const [monad, sol] = await Promise.allSettled([ + sendCanaryTx(admin, chain, "openCopy", [note.chain_note_id, copyIndex, readerTag]), + solana && plan.solana ? sendSolanaMemo(solana, memo) : Promise.resolve(openSolana), + ]); + openSolana = solanaSig(sol, "canary open"); + if (monad.status === "fulfilled") { + openTx = monad.value; + } else { + const e = monad.reason; + if (e instanceof HttpError) throw e; + const message = e instanceof Error ? e.message : String(e); + if (!message.includes("CopyTaken")) { + console.error("canary open: chain write failed", e); + throw new HttpError(503, "Could not record your copy on Monad. Tap to try again.", true); + } + // Already on-chain from an earlier attempt whose hash was not stored. + openTx = null; + } + await admin + .from("canary_copies") + .update({ reader_tag: readerTag, open_tx_hash: openTx, open_solana_sig: openSolana }) + .eq("note_id", noteId) + .eq("copy_index", copyIndex); + } else if (solana && plan.solana) { + // Nothing to send on Monad (Monad is off, or this copy is recorded there + // already); only the Solana memo is missing. A failure stays null and is + // sent again on the next open of this copy. + const firstOpen = !assigned.reader_tag; + const [sol] = await Promise.allSettled([sendSolanaMemo(solana, memo)]); + openSolana = solanaSig(sol, firstOpen ? "canary open" : "canary open (retry)"); + const patch: Record = {}; + if (firstOpen) patch.reader_tag = readerTag; + if (openSolana) patch.open_solana_sig = openSolana; + if (Object.keys(patch).length > 0) { + await admin + .from("canary_copies") + .update(patch) + .eq("note_id", noteId) + .eq("copy_index", copyIndex); + } + } + + return json({ + copy_index: copyIndex, + copy_count: note.copy_count, + ciphertext: assigned.ciphertext, + iv: assigned.iv, + open_tx_hash: openTx, + open_solana_sig: openSolana, + opened_at: assigned.opened_at, + }); +} + +async function handleStatus(admin: SupabaseClient, body: Record) { + const noteId = String(body.note_id ?? ""); + const ownerSecret = String(body.owner_secret ?? ""); + if (!UUID_RE.test(noteId) || !HEX64_RE.test(ownerSecret)) throw new HttpError(400, "Invalid request."); + + const { data: note, error: noteError } = await admin + .from("canary_notes") + .select("owner_hash, copy_count, seal_tx_hash, seal_solana_sig, expires_at") + .eq("id", noteId) + .maybeSingle(); + if (noteError) throw noteError; + if (!note) throw new HttpError(404, "Note not found."); + if ((await sha256Hex(ownerSecret)) !== note.owner_hash) throw new HttpError(403, "Not your note."); + + const { data: copies, error: copiesError } = await admin + .from("canary_copies") + .select("copy_index, reader_name, opened_at, open_tx_hash, open_solana_sig") + .eq("note_id", noteId) + .not("opened_at", "is", null) + .order("copy_index"); + if (copiesError) throw copiesError; + + return json({ + copy_count: note.copy_count, + seal_tx_hash: note.seal_tx_hash, + seal_solana_sig: note.seal_solana_sig, + expires_at: note.expires_at, + copies: copies ?? [], + }); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + try { + const admin = createClient(requireEnv("SUPABASE_URL"), requireEnv("SUPABASE_SERVICE_ROLE_KEY")); + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + switch (body.action) { + case "create": + return await handleCreate(admin, body); + case "open": + return await handleOpen(admin, body); + case "status": + return await handleStatus(admin, body); + default: + return json({ error: "Unknown action" }, 400); + } + } catch (e) { + if (e instanceof HttpError) { + return json(e.retry ? { error: e.message, retry: true } : { error: e.message }, e.status); + } + console.error("canary: unexpected error", e); + return json({ error: "Something went wrong. Try again.", retry: true }, 500); + } +}); diff --git a/app/supabase/functions/canary/solana.ts b/app/supabase/functions/canary/solana.ts new file mode 100644 index 0000000..55db5e6 --- /dev/null +++ b/app/supabase/functions/canary/solana.ts @@ -0,0 +1,64 @@ +// Solana devnet receipts for NO SUS Canary. +// +// Every seal and every open is written as one SPL Memo on Solana devnet. +// The memo carries sha256-based ids and hashes and nothing else: never names, +// note text or keys. While SOLANA_RELAYER_KEYS is unset, loadSolana() returns +// null and nothing here runs (create/open then return 503 unless the legacy +// path in index.ts is configured). +// +// The memo format, key parsing and sending live in packages/receipts-ts (spec: +// docs/RECEIPTS_SPEC.md). This file only reads the env and keeps the names +// index.ts imports. +// +// Env (all optional here): +// SOLANA_RELAYER_KEYS comma-separated devnet keypairs, each either a base58 +// 64-byte secret key or a solana-keygen JSON array +// SOLANA_RPC_URL default https://api.devnet.solana.com +// SOLANA_MEMO_PROGRAM default MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr +// (SPL Memo: Solana Explorer shows the memo text). +// Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH (p-memo) +// is accepted too. + +import { + DEFAULT_MEMO_PROGRAM, + encodeOpenMemo, + encodeSealMemo, + MEMO_PROGRAM_IDS, + parseRelayerKeys, + type SendConfig, + sendMemo, +} from "../../../../packages/receipts-ts/mod.ts"; + +export type SolanaConfig = SendConfig; + +// Null when Solana is off. Throws on a bad config (the message never includes +// key material) so the caller can log it and carry on without Solana. +export function loadSolana(): SolanaConfig | null { + const raw = (Deno.env.get("SOLANA_RELAYER_KEYS") ?? "").trim(); + if (raw === "") return null; + const keys = parseRelayerKeys(raw); + + const memoProgram = (Deno.env.get("SOLANA_MEMO_PROGRAM") ?? "").trim() || DEFAULT_MEMO_PROGRAM; + if (!MEMO_PROGRAM_IDS.includes(memoProgram)) { + throw new Error("SOLANA_MEMO_PROGRAM is not a known Memo program address"); + } + + return { + rpcUrl: Deno.env.get("SOLANA_RPC_URL") ?? "https://api.devnet.solana.com", + keys, + memoProgram, + }; +} + +// {"p":"nosus-canary","v":1,"t":"seal","note":"0x…","copies":N,"hash":"0x…","exp":E} +// exp is unix seconds, 0 when the note never expires. +export const sealMemo = encodeSealMemo; + +// {"p":"nosus-canary","v":1,"t":"open","note":"0x…","copy":N,"tag":"0x…"} +export const openMemo = encodeOpenMemo; + +// Sends one memo from a random relayer key and waits until it is confirmed. +// Returns the base58 signature. +export function sendSolanaMemo(cfg: SolanaConfig, memo: string): Promise { + return sendMemo(cfg, { memo }); +} diff --git a/app/supabase/functions/canary/solana_test.ts b/app/supabase/functions/canary/solana_test.ts new file mode 100644 index 0000000..f310c6e --- /dev/null +++ b/app/supabase/functions/canary/solana_test.ts @@ -0,0 +1,126 @@ +// Offline checks that solana.ts still writes the exact memos already on +// devnet, and that loadSolana keeps its defaults and messages. Run from the +// repo root (env access: loadSolana reads SOLANA_*, and @solana/kit's `ws` +// dependency reads WS_* at import): +// npx -y deno@2 test --allow-env app/supabase/functions/canary/solana_test.ts +import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.19"; +import { + LEGACY_MEMO_PROGRAM_ADDRESS_V3, + SUPPORTED_MEMO_PROGRAM_ADDRESSES, +} from "npm:@solana-program/memo@0.15.0"; +import { MEMO_PROGRAM_IDS } from "../../../../packages/receipts-ts/mod.ts"; +import { loadSolana, openMemo, sealMemo } from "./solana.ts"; + +const NOTE_A = "0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40"; +const NOTE_B = "0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d"; + +Deno.test("sealMemo writes the exact devnet seal memos", () => { + // 2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ + assertEquals( + sealMemo({ + note: NOTE_A, + copies: 3, + hash: "0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8", + exp: 1791634855, + }), + '{"p":"nosus-canary","v":1,"t":"seal","note":"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40","copies":3,"hash":"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8","exp":1791634855}', + ); + // 62Frr29RyPXhhUkAaxDLQHDKinMztPhrsYeokzvoiAAUjp9JBXbNUn3S6D4gX7QEqt2yfurqG54PAERb7fLKCskF + assertEquals( + sealMemo({ + note: NOTE_B, + copies: 10, + hash: "0x40846d626dd4beb151497162ca5c1de105281a20d076223b76a44ab1d47962df", + exp: 1791719237, + }), + '{"p":"nosus-canary","v":1,"t":"seal","note":"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d","copies":10,"hash":"0x40846d626dd4beb151497162ca5c1de105281a20d076223b76a44ab1d47962df","exp":1791719237}', + ); +}); + +Deno.test("openMemo writes the exact devnet open memos", () => { + // NwoJauec9otkicaGf8yD6UechYgUnXRdYPqnuc5Kpn6uMYym6XVJcMmWdbVnwnRVncKKVgEyuZB2mR3T5REvdSr + assertEquals( + openMemo({ + note: NOTE_A, + copy: 0, + tag: "0x10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6", + }), + '{"p":"nosus-canary","v":1,"t":"open","note":"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40","copy":0,"tag":"0x10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6"}', + ); + // 2b9SS9RhwPvMrsDR5upY5a9w3gk7E2zgV4jmC69QTtZseBMmguXxNdBDqSFTZXW23cgLgAs8L9kFV39LyiU4bnTn + assertEquals( + openMemo({ + note: NOTE_B, + copy: 0, + tag: "0xe52417df0e5f0b104525ec1a583ec0bb64a71c2358a3e93d855b121172a4d10e", + }), + '{"p":"nosus-canary","v":1,"t":"open","note":"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d","copy":0,"tag":"0xe52417df0e5f0b104525ec1a583ec0bb64a71c2358a3e93d855b121172a4d10e"}', + ); + // 4NXs89eDP5hm2GyAkuA5rRDBQUNB5e6ssAChQJRCFGTYimyMDa3LmQRbcEVmaezQSXCx8kgt5GRkpUje59WqH2rc + assertEquals( + openMemo({ + note: NOTE_B, + copy: 1, + tag: "0x6ec7e19683ff70ce1d03ebf3df42242872f98407b47baada27b8cb50d934306e", + }), + '{"p":"nosus-canary","v":1,"t":"open","note":"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d","copy":1,"tag":"0x6ec7e19683ff70ce1d03ebf3df42242872f98407b47baada27b8cb50d934306e"}', + ); +}); + +Deno.test("memo builders keep their validation messages", () => { + assertThrows( + () => sealMemo({ note: "0x12", copies: 2, hash: NOTE_A, exp: 0 }), + Error, + "memo note must be 0x + 64 hex chars", + ); + assertThrows( + () => openMemo({ note: NOTE_A, copy: -1, tag: NOTE_A }), + Error, + "memo copy must be a non-negative integer", + ); +}); + +Deno.test("the library accepts the same memo programs as @solana-program/memo", () => { + assertEquals(MEMO_PROGRAM_IDS, [...SUPPORTED_MEMO_PROGRAM_ADDRESSES]); +}); + +const ENV = ["SOLANA_RELAYER_KEYS", "SOLANA_RPC_URL", "SOLANA_MEMO_PROGRAM"]; + +function withEnv(env: Record, fn: () => void) { + const saved = ENV.map((k) => [k, Deno.env.get(k)] as const); + try { + for (const k of ENV) Deno.env.delete(k); + for (const [k, v] of Object.entries(env)) Deno.env.set(k, v); + fn(); + } finally { + for (const [k, v] of saved) v === undefined ? Deno.env.delete(k) : Deno.env.set(k, v); + } +} + +// Test bytes 1..64 only; not a funded key. +const KEY = JSON.stringify(Array.from({ length: 64 }, (_, i) => i + 1)); + +Deno.test("loadSolana: defaults, overrides and messages are unchanged", () => { + withEnv({}, () => assertEquals(loadSolana(), null)); + withEnv({ SOLANA_RELAYER_KEYS: " " }, () => assertEquals(loadSolana(), null)); + withEnv({ SOLANA_RELAYER_KEYS: KEY }, () => { + const cfg = loadSolana()!; + assertEquals(cfg.rpcUrl, "https://api.devnet.solana.com"); + assertEquals(cfg.memoProgram, LEGACY_MEMO_PROGRAM_ADDRESS_V3); + assertEquals(cfg.keys.length, 1); + }); + withEnv({ + SOLANA_RELAYER_KEYS: `${KEY},${KEY}`, + SOLANA_RPC_URL: "http://localhost:8899", + SOLANA_MEMO_PROGRAM: " Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH ", + }, () => { + const cfg = loadSolana()!; + assertEquals(cfg.rpcUrl, "http://localhost:8899"); + assertEquals(cfg.memoProgram, "Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH"); + assertEquals(cfg.keys.length, 2); + }); + withEnv({ SOLANA_RELAYER_KEYS: KEY, SOLANA_MEMO_PROGRAM: "11111111111111111111111111111111" }, () => + assertThrows(loadSolana, Error, "SOLANA_MEMO_PROGRAM is not a known Memo program address")); + withEnv({ SOLANA_RELAYER_KEYS: "[1,2,3]" }, () => + assertThrows(loadSolana, Error, "SOLANA_RELAYER_KEYS: a key must be 64 bytes")); +}); diff --git a/app/supabase/functions/cleanup-burn-files/index.ts b/app/supabase/functions/cleanup-burn-files/index.ts new file mode 100644 index 0000000..1fa98e6 --- /dev/null +++ b/app/supabase/functions/cleanup-burn-files/index.ts @@ -0,0 +1,94 @@ +// Supabase Edge Function: cleanup-burn-files +// +// Invoked every 5 minutes by pg_cron via pg_net (see the +// 'burn-files-cleanup-sweep' job in +// supabase/migrations/20260710050000_burn_files.sql) — the actual mechanism +// that makes "permanently deleted" true. pg_cron/raw SQL cannot call the +// Storage REST API directly; this function is the bridge. +// +// Not a public-facing endpoint: gated by a shared secret (x-cron-secret) +// checked against BURN_FILES_CRON_SECRET, since without that check this +// would otherwise be an unauthenticated endpoint anyone could hammer to +// force repeated Storage API calls. +// +// Three deletion cases, matching the migration's design: +// 1. consumed_at more than 5 minutes old — grace window so an +// already-issued signed download URL isn't cut off mid-transfer. +// 2. expires_at passed and never consumed — the time-based trigger. +// 3. status = 'pending' for 2+ hours — the client never called +// burn-file-confirm (upload abandoned or failed). + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +Deno.serve(async (req: Request) => { + const cronSecret = Deno.env.get("BURN_FILES_CRON_SECRET"); + if (!cronSecret || req.headers.get("x-cron-secret") !== cronSecret) { + return json({ error: "Unauthorized" }, 401); + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !serviceRoleKey) { + return json({ error: "cleanup-burn-files is not configured" }, 503); + } + const admin = createClient(supabaseUrl, serviceRoleKey); + + const nowIso = new Date().toISOString(); + const fiveMinAgoIso = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const twoHoursAgoIso = new Date(Date.now() - 2 * 3600 * 1000).toISOString(); + + const rowsById = new Map(); + + const [consumedGrace, expiredUnconsumed, abandonedPending] = await Promise.all([ + admin.from("burn_files").select("id, storage_object_key") + .not("consumed_at", "is", null).lt("consumed_at", fiveMinAgoIso), + admin.from("burn_files").select("id, storage_object_key") + .eq("status", "ready").is("consumed_at", null).lt("expires_at", nowIso), + admin.from("burn_files").select("id, storage_object_key") + .eq("status", "pending").lt("created_at", twoHoursAgoIso), + ]); + + for (const result of [consumedGrace, expiredUnconsumed, abandonedPending]) { + if (result.error) { + console.error("cleanup-burn-files: query failed", result.error); + continue; + } + for (const row of result.data ?? []) rowsById.set(row.id, row); + } + + const rows = Array.from(rowsById.values()); + let deletedCount = 0; + + for (const batch of chunk(rows, 100)) { + const keys = batch.map((r) => r.storage_object_key); + const { error: removeErr } = await admin.storage.from("burn-files").remove(keys); + if (removeErr) { + console.error("cleanup-burn-files: storage removal failed for batch", removeErr); + continue; // don't delete the DB rows if we're not sure the objects are gone + } + const { error: deleteErr } = await admin.from("burn_files").delete().in( + "id", + batch.map((r) => r.id), + ); + if (deleteErr) { + console.error("cleanup-burn-files: row deletion failed for batch", deleteErr); + continue; + } + deletedCount += batch.length; + } + + return json({ deleted: deletedCount, candidates: rows.length }); +}); diff --git a/app/supabase/functions/create-redemption-code/index.ts b/app/supabase/functions/create-redemption-code/index.ts new file mode 100644 index 0000000..9eb2116 --- /dev/null +++ b/app/supabase/functions/create-redemption-code/index.ts @@ -0,0 +1,171 @@ +// Supabase Edge Function: create-redemption-code +// +// Mints a short, human-typeable code as an ALTERNATE way to retrieve the +// key/IV for an existing Burn Note or Burn File — the link-based path (key/ +// IV in the URL fragment, never touching the server) is unaffected and +// keeps its zero-knowledge guarantee. This is a deliberate, informed +// exception for this one path: the server temporarily holds the key, +// indexed by a hash of the code, for a short window. +// See supabase/migrations/20260713000000_burn_redemption_codes.sql for the +// full design rationale. +// +// POST { target_kind: 'note'|'file', target_id, key_hex, iv_hex } -> +// { code, redeem_token, expires_at } +// +// Responsibilities: +// 1. Check the burn_redemption_codes_enabled kill-switch. +// 2. Verify the target actually exists and hasn't already been +// consumed/expired — no point minting a code for dead content. +// 3. Generate a two-digit human confirmation code AND a 256-bit redeem +// token. The token is the actual credential; the code is intentionally +// only a convenient pairing check. +// 4. Hash both values and store them with expiry capped to the underlying +// content's own expiry. Return plaintext values once. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +const TOKEN_BYTES = 32; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +async function hmacHex(message: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)); + return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +function generatePairingCode(): string { + // Rejection sampling avoids modulo bias: every value 00–99 has equal odds. + const byte = new Uint8Array(1); + do crypto.getRandomValues(byte); while (byte[0] >= 200); + return String(byte[0] % 100).padStart(2, "0"); +} + +function generateRedeemToken(): string { + const bytes = new Uint8Array(TOKEN_BYTES); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + const codeSalt = Deno.env.get("REDEMPTION_CODE_SALT"); + if (!supabaseUrl || !serviceRoleKey || !codeSalt) { + return json({ error: "create-redemption-code is not configured" }, 503); + } + const admin = createClient(supabaseUrl, serviceRoleKey); + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const targetKind = String(body.target_kind ?? ""); + const targetId = String(body.target_id ?? "").trim(); + const keyHex = String(body.key_hex ?? "").trim(); + const ivHex = String(body.iv_hex ?? "").trim(); + if (targetKind !== "note" && targetKind !== "file") { + return json({ error: "target_kind must be 'note' or 'file'" }, 400); + } + if (!targetId || !keyHex || !ivHex) { + return json({ error: "Missing target_id, key_hex, or iv_hex" }, 400); + } + if ( + !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(targetId) || + !/^[0-9a-f]{64}$/i.test(keyHex) || + !/^[0-9a-f]{32}$/i.test(ivHex) + ) { + return json({ error: "Invalid redemption target" }, 400); + } + + const { data: flag } = await admin + .from("feature_flags") + .select("is_active") + .eq("flag_key", "burn_redemption_codes_enabled") + .maybeSingle(); + if (flag && flag.is_active === false) { + return json({ error: "Redemption codes are temporarily disabled" }, 503); + } + + const { data: configRows } = await admin + .from("remote_configs") + .select("config_key, config_value") + .eq("config_key", "redeem_code_ttl_minutes"); + const ttlMinutes = configRows?.length ? Number(configRows[0].config_value) : 20; + + // Verify the target still exists and hasn't already been consumed/expired + // — a code for dead content would just be a confusing dead end. + let targetExpiresAt: string | null = null; + if (targetKind === "file") { + const { data: fileRow } = await admin + .from("burn_files") + .select("expires_at, consumed_at") + .eq("id", targetId) + .maybeSingle(); + if (!fileRow || fileRow.consumed_at || new Date(fileRow.expires_at) <= new Date()) { + return json({ error: "This file is no longer available" }, 404); + } + targetExpiresAt = fileRow.expires_at; + } else { + const { data: noteRow } = await admin + .from("burn_notes") + .select("expires_at") + .eq("id", targetId) + .maybeSingle(); + if (!noteRow || new Date(noteRow.expires_at) <= new Date()) { + return json({ error: "This note is no longer available" }, 404); + } + targetExpiresAt = noteRow.expires_at; + } + + const ttlExpiry = new Date(Date.now() + ttlMinutes * 60 * 1000); + const targetExpiry = new Date(targetExpiresAt); + const expiresAt = (ttlExpiry < targetExpiry ? ttlExpiry : targetExpiry).toISOString(); + + const code = generatePairingCode(); + const redeemToken = generateRedeemToken(); + const [codeHash, redeemTokenHash] = await Promise.all([ + hmacHex(code, codeSalt), + hmacHex(redeemToken, codeSalt), + ]); + + const { error: insertErr } = await admin.from("burn_redemption_codes").insert({ + code_hash: codeHash, + redeem_token_hash: redeemTokenHash, + target_kind: targetKind, + target_id: targetId, + key_hex: keyHex, + iv_hex: ivHex, + expires_at: expiresAt, + }); + + if (insertErr) { + console.error("create-redemption-code: failed to insert row", insertErr); + return json({ error: "Could not create a redemption pairing" }, 502); + } + + return json({ code, redeem_token: redeemToken, expires_at: expiresAt }); +}); diff --git a/app/supabase/functions/document-insights/index.ts b/app/supabase/functions/document-insights/index.ts new file mode 100644 index 0000000..167432a --- /dev/null +++ b/app/supabase/functions/document-insights/index.ts @@ -0,0 +1,78 @@ +// Optional Gemini document insights. Core sharing never calls this. +// Secrets: GEMINI_API_KEY (Supabase Edge Function secret). Never a client key. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const apiKey = Deno.env.get("GEMINI_API_KEY"); + if (!apiKey) return json({ error: "Gemini is not configured" }, 503); + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const anonKey = Deno.env.get("SUPABASE_ANON_KEY"); + if (!supabaseUrl || !anonKey) return json({ error: "Not configured" }, 503); + + const authHeader = req.headers.get("Authorization") ?? ""; + const userClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } }, + }); + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData.user) return json({ error: "Sign in required" }, 401); + + let body: { text?: string; question?: string }; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const text = String(body.text ?? "").trim(); + if (!text) return json({ error: "Missing text" }, 400); + if (text.length > 20000) return json({ error: "Text too long" }, 413); + + const question = String(body.question ?? "").trim(); + const prompt = question + ? `Answer this question about the authorized document. Be brief.\nQuestion: ${question}\n\nDocument:\n${text}` + : `Summarize this authorized document in 3 short sentences. Then list up to 6 topics as a JSON array.\nReturn JSON: {"summary":"...","topics":["..."]}\n\nDocument:\n${text}`; + + const geminiRes = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.2, maxOutputTokens: 400 }, + }), + }, + ); + if (!geminiRes.ok) { + return json({ error: "Insight provider unavailable" }, 502); + } + const gemini = await geminiRes.json(); + const raw = gemini?.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; + try { + const parsed = JSON.parse(String(raw).replace(/^```json\s*|\s*```$/g, "")); + return json({ + summary: String(parsed.summary ?? raw).trim(), + topics: Array.isArray(parsed.topics) ? parsed.topics.map(String) : [], + }); + } catch { + return json({ summary: String(raw).trim(), topics: [] }); + } +}); diff --git a/app/supabase/functions/document-intelligence/index.ts b/app/supabase/functions/document-intelligence/index.ts new file mode 100644 index 0000000..cf40770 --- /dev/null +++ b/app/supabase/functions/document-intelligence/index.ts @@ -0,0 +1,86 @@ +// Optional Gemini document insights. Core sharing does not call this. +// Secret: GEMINI_API_KEY (never ship it in the Flutter client). +// Enable the client with --dart-define=INTELLIGENCE_PROVIDER=gemini + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const geminiKey = Deno.env.get("GEMINI_API_KEY"); + if (!geminiKey) return json({ error: "Gemini is not configured" }, 503); + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const anonKey = Deno.env.get("SUPABASE_ANON_KEY"); + const authHeader = req.headers.get("Authorization") ?? ""; + if (!supabaseUrl || !anonKey || !authHeader.startsWith("Bearer ")) { + return json({ error: "Unauthorized" }, 401); + } + const userClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } }, + }); + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData.user) return json({ error: "Unauthorized" }, 401); + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const title = String(body.title ?? "").slice(0, 200); + const mimeType = String(body.mime_type ?? "").slice(0, 100); + const text = String(body.text ?? "").slice(0, 12000); + if (!title && !text) return json({ error: "Nothing to inspect" }, 400); + + const prompt = + `Classify and summarize this document the user already has access to.\n` + + `Title: ${title}\nType: ${mimeType}\n\n${text}\n\n` + + `Reply as JSON: {"summary": string, "classification": string, "highlights": string[]}`; + + const geminiRes = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.2, maxOutputTokens: 512 }, + }), + }, + ); + if (!geminiRes.ok) { + return json({ error: "Insight provider failed" }, 502); + } + const geminiJson = await geminiRes.json(); + const raw = geminiJson?.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; + try { + const parsed = JSON.parse(raw.replace(/```json|```/g, "").trim()); + return json({ + summary: String(parsed.summary ?? ""), + classification: String(parsed.classification ?? "Document"), + highlights: Array.isArray(parsed.highlights) ? parsed.highlights.slice(0, 8) : [], + }); + } catch { + return json({ + summary: String(raw).slice(0, 600), + classification: "Document", + highlights: [], + }); + } +}); diff --git a/app/supabase/functions/drive-proxy/index.ts b/app/supabase/functions/drive-proxy/index.ts new file mode 100644 index 0000000..f286167 --- /dev/null +++ b/app/supabase/functions/drive-proxy/index.ts @@ -0,0 +1,363 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; +import { SignJWT, importPKCS8 } from "https://deno.land/x/jose@v4.13.1/index.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", +}; + +// ─── Google OAuth2 Access Token Exchange ───────────────────────────────────── +async function getGoogleAccessToken(email: string, privateKey: string): Promise { + const cleanedKey = privateKey.replace(/\\n/g, "\n"); + + const jwt = await new SignJWT({ + scope: "https://www.googleapis.com/auth/drive", + }) + .setProtectedHeader({ alg: "RS256", typ: "JWT" }) + .setIssuer(email) + .setSubject(email) + .setAudience("https://oauth2.googleapis.com/token") + .setIssuedAt() + .setExpirationTime("1h") + .sign(await importPKCS8(cleanedKey, "RS256")); + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Google OAuth exchange failed: ${errorText}`); + } + + const data = await response.json(); + return data.access_token; +} + +// ─── Authorization: may this caller touch this Drive file? ─────────────────── +// The bytes for a Drive-backed file never pass through Postgres, so RLS on +// `secure_files` does not protect them — this function fetches with a service +// account that can read every file in the parent folder. Authentication alone +// (any signed-in user) is therefore not enough: without this check, one user's +// session could download or delete another group's file by id. The id is not +// guessable, but it is durable and it leaks the ordinary way — it is stored in +// `secure_files`, cached client-side, and survives leaving the group. +// +// Resolves the Drive id back to its owning group and requires live membership. +// Deny-by-default: an unregistered id, an ambiguous one, or any lookup error +// all fail closed. +async function callerMayAccessDriveFile( + supabaseUrl: string, + serviceRoleKey: string | undefined, + driveFileId: string, + userId: string, +): Promise { + if (!serviceRoleKey) return false; + + const admin = createClient(supabaseUrl, serviceRoleKey, { + auth: { persistSession: false }, + }); + + // Where the Drive id actually lives: addGoogleDriveLink() inserts it as the + // row's primary key (`id`), and never writes the `gdrive_file_id` column — + // which is why that column is empty in every row. Match `id` first, then fall + // back to `gdrive_file_id` so this keeps working if the schema's nominal + // column is ever populated. Two `.eq()` queries rather than one `.or()` + // string, because `.or()` takes a filter expression and this id comes + // straight off the query string. + let groupId: string | null = null; + + for (const column of ["id", "gdrive_file_id"]) { + const { data: files, error: fileError } = await admin + .from("secure_files") + .select("group_id") + .eq(column, driveFileId) + .limit(2); + + if (fileError) return false; + if (files && files.length === 1) { + groupId = files[0].group_id; + break; + } + // More than one row for the same id is ambiguous — fail closed. + if (files && files.length > 1) return false; + } + + if (!groupId) return false; + + const { data: membership, error: memberError } = await admin + .from("study_group_members") + .select("user_id") + .eq("group_id", groupId) + .eq("user_id", userId) + .maybeSingle(); + + if (memberError || !membership) return false; + + // A ban is enforced separately from membership — the row may still exist + // while the user is banned, so check both rather than trusting either alone. + const { data: ban, error: banError } = await admin + .from("group_bans") + .select("user_id") + .eq("group_id", groupId) + .eq("user_id", userId) + .maybeSingle(); + + if (banError) return false; + return !ban; +} + +// ─── Main Deno Serve Handler ────────────────────────────────────────────────── +Deno.serve(async (req: Request) => { + // 1. Handle CORS Preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + const url = new URL(req.url); + const path = url.pathname.split("/").pop(); + + try { + // 2. Authenticate the incoming client JWT using Supabase auth + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response(JSON.stringify({ error: "Missing Authorization header" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!; + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + + const token = authHeader.replace(/^Bearer\s+/i, ""); + let isAuthorized = false; + // Who the caller is, not merely that they are someone — the per-file + // authorization check below needs the id. Null for the service-role and + // anon-key paths, which are not user sessions. + let authedUserId: string | null = null; + let isServiceRole = false; + + if (serviceRoleKey && token === serviceRoleKey) { + isAuthorized = true; + isServiceRole = true; + } else if (path === "info" && token === supabaseAnonKey) { + // Public info endpoint can be checked using the Anon Key + isAuthorized = true; + } else { + // Fallback: verify as a user session JWT (requires actual signed-in user) + const supabase = createClient(supabaseUrl, supabaseAnonKey, { + global: { headers: { Authorization: authHeader } }, + }); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (!authError && user) { + isAuthorized = true; + authedUserId = user.id; + } + } + + if (!isAuthorized) { + return new Response(JSON.stringify({ error: "Unauthorized session JWT" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // 3. Load Google Drive Service Account Secrets + const serviceAccountEmail = Deno.env.get("GD_SERVICE_ACCOUNT_EMAIL"); + const serviceAccountKey = Deno.env.get("GD_PRIVATE_KEY"); + const parentFolderId = Deno.env.get("GD_PARENT_FOLDER_ID"); + + if (!serviceAccountEmail || !serviceAccountKey) { + return new Response( + JSON.stringify({ + error: "Google Drive proxy secrets are not configured in your Supabase dashboard.", + setupInstructions: "Set 'GD_SERVICE_ACCOUNT_EMAIL' and 'GD_PRIVATE_KEY' in your Supabase project secrets.", + }), + { + status: 503, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Handle Route: GET /info (returns service account email for client sharing) + if (req.method === "GET" && path === "info") { + return new Response(JSON.stringify({ serviceAccountEmail }), { + status: 200, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // 4. Handle Route: POST /upload + if (req.method === "POST" && path === "upload") { + const filename = url.searchParams.get("name") || `file_${Date.now()}.enc`; + const token = await getGoogleAccessToken(serviceAccountEmail, serviceAccountKey); + + // Construct a raw multipart/related body to attach folder parent and file name + const boundary = "boundary_nosus_gdrive_upload"; + const metadata = { + name: filename, + parents: parentFolderId ? [parentFolderId] : undefined, + }; + + const metadataPart = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n`; + const mediaHeader = `--${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`; + const mediaFooter = `\r\n--${boundary}--`; + + const encoder = new TextEncoder(); + const metadataBytes = encoder.encode(metadataPart); + const mediaHeaderBytes = encoder.encode(mediaHeader); + const mediaFooterBytes = encoder.encode(mediaFooter); + const fileBytes = new Uint8Array(await req.arrayBuffer()); + + // Combine parts + const body = new Uint8Array( + metadataBytes.length + mediaHeaderBytes.length + fileBytes.length + mediaFooterBytes.length + ); + let offset = 0; + body.set(metadataBytes, offset); offset += metadataBytes.length; + body.set(mediaHeaderBytes, offset); offset += mediaHeaderBytes.length; + body.set(fileBytes, offset); offset += fileBytes.length; + body.set(mediaFooterBytes, offset); + + const uploadResponse = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": `multipart/related; boundary=${boundary}`, + }, + body, + }); + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text(); + return new Response(JSON.stringify({ error: `Google Drive upload failed: ${errorText}` }), { + status: uploadResponse.status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const data = await uploadResponse.json(); + return new Response(JSON.stringify({ fileId: data.id }), { + status: 200, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // 5. Handle Route: GET /download + if (req.method === "GET" && path === "download") { + const fileId = url.searchParams.get("fileId"); + if (!fileId) { + return new Response(JSON.stringify({ error: "Missing fileId parameter" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + if (!isServiceRole) { + const allowed = authedUserId !== null && + await callerMayAccessDriveFile(supabaseUrl, serviceRoleKey, fileId, authedUserId); + if (!allowed) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + } + + const token = await getGoogleAccessToken(serviceAccountEmail, serviceAccountKey); + const driveResponse = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, { + headers: { + "Authorization": `Bearer ${token}`, + }, + }); + + if (!driveResponse.ok) { + const errorText = await driveResponse.text(); + return new Response(JSON.stringify({ error: `Google Drive file retrieval failed: ${errorText}` }), { + status: driveResponse.status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Pipe the Google Drive stream directly back to the client response + return new Response(driveResponse.body, { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": driveResponse.headers.get("Content-Type") || "application/octet-stream", + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + }, + }); + } + + // 6. Handle Route: DELETE /delete + if (req.method === "DELETE" && path === "delete") { + const fileId = url.searchParams.get("fileId"); + if (!fileId) { + return new Response(JSON.stringify({ error: "Missing fileId parameter" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Ordering note: this runs while the `secure_files` row still exists, so + // callers must delete the Drive blob first and the row second. Reversing + // it makes the id unresolvable and this check fails closed — the blob + // would be orphaned in Drive rather than deleted. + if (!isServiceRole) { + const allowed = authedUserId !== null && + await callerMayAccessDriveFile(supabaseUrl, serviceRoleKey, fileId, authedUserId); + if (!allowed) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + } + + const token = await getGoogleAccessToken(serviceAccountEmail, serviceAccountKey); + const deleteResponse = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}`, { + method: "DELETE", + headers: { + "Authorization": `Bearer ${token}`, + }, + }); + + if (!deleteResponse.ok && deleteResponse.status !== 404) { + const errorText = await deleteResponse.text(); + return new Response(JSON.stringify({ error: `Google Drive file deletion failed: ${errorText}` }), { + status: deleteResponse.status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ error: "Route not found" }), { + status: 404, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/app/supabase/functions/redeem-code/index.ts b/app/supabase/functions/redeem-code/index.ts new file mode 100644 index 0000000..ee84997 --- /dev/null +++ b/app/supabase/functions/redeem-code/index.ts @@ -0,0 +1,143 @@ +// Supabase Edge Function: redeem-code +// +// The recipient's entry point for the short-code retrieval path — see +// create-redemption-code and +// supabase/migrations/20260713000000_burn_redemption_codes.sql for the full +// design. Public on purpose (verify_jwt: false), same anonymous posture as +// burn-file-fetch/read_and_burn_note — a recipient never needs an account. +// +// POST { code, redeem_token? } -> { target_kind, target_id, key_hex, iv_hex } +// | 410 if invalid/expired/already-used (one generic error — +// deliberately not distinguishing wrong-vs-expired-vs-used, +// so a guesser gets no oracle signal) +// +// Rate-limited by a salted hash of the request IP (never the raw IP), +// mirroring burn-file-init's limiter — defense-in-depth against a +// scripted/distributed brute-force attempt. New two-digit values are never a +// standalone credential: they must be paired with an unguessable 256-bit +// redeem token from the secure link. Legacy eight-character codes remain +// redeemable briefly for backwards compatibility. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +async function hmacHex(message: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)); + return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + const codeSalt = Deno.env.get("REDEMPTION_CODE_SALT"); + const ipSalt = Deno.env.get("BURN_FILES_IP_SALT"); + if (!supabaseUrl || !serviceRoleKey || !codeSalt || !ipSalt) { + return json({ error: "redeem-code is not configured" }, 503); + } + const admin = createClient(supabaseUrl, serviceRoleKey); + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const code = String(body.code ?? "").trim().toUpperCase(); + const redeemToken = String(body.redeem_token ?? "").trim().toLowerCase(); + if (!code) return json({ error: "Missing code" }, 400); + const isPairingCode = /^\d{2}$/.test(code); + const hasRedeemToken = /^[0-9a-f]{64}$/.test(redeemToken); + if (isPairingCode && !hasRedeemToken) { + return json({ error: "Open the secure pairing link before entering this two-digit code" }, 400); + } + if (redeemToken && !hasRedeemToken) { + return json({ error: "Invalid pairing link" }, 400); + } + if (!isPairingCode && !/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/.test(code)) { + return json({ error: "Invalid or expired code" }, 410); + } + + const { data: flag } = await admin + .from("feature_flags") + .select("is_active") + .eq("flag_key", "burn_redemption_codes_enabled") + .maybeSingle(); + if (flag && flag.is_active === false) { + return json({ error: "Redemption codes are temporarily disabled" }, 503); + } + + const { data: configRows } = await admin + .from("remote_configs") + .select("config_key, config_value") + .in("config_key", ["redeem_rate_limit_per_hour", "redeem_rate_limit_window_minutes"]); + const cfg = (key: string, fallback: number): number => { + const row = configRows?.find((r: any) => r.config_key === key); + return row ? Number(row.config_value) : fallback; + }; + const rateLimitPerHour = cfg("redeem_rate_limit_per_hour", 10); + const rateLimitWindowMinutes = cfg("redeem_rate_limit_window_minutes", 60); + + const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const ipHash = await hmacHex(ip, ipSalt); + + const { data: allowed, error: rateErr } = await admin.rpc("check_and_increment_redeem_rate", { + p_ip_hash: ipHash, + p_limit: rateLimitPerHour, + p_window_minutes: rateLimitWindowMinutes, + }); + if (rateErr) { + console.error("redeem-code: rate limit check failed", rateErr); + return json({ error: "Could not process this code" }, 500); + } + if (!allowed) { + return json({ error: "Too many attempts from this network. Please try again later." }, 429); + } + + const codeHash = await hmacHex(code, codeSalt); + const tokenHash = hasRedeemToken + ? await hmacHex(redeemToken, codeSalt) + : null; + const { data: claimed, error: claimErr } = hasRedeemToken + ? await admin.rpc("claim_redemption_code", { + p_code_hash: codeHash, + p_redeem_token_hash: tokenHash, + }) + : await admin.rpc("claim_redemption_code", { p_code_hash: codeHash }); + if (claimErr) { + console.error("redeem-code: claim RPC failed", claimErr); + return json({ error: "Could not process this code" }, 500); + } + if (!claimed?.id) { + return json({ error: "Invalid or expired code" }, 410); + } + + return json({ + target_kind: claimed.target_kind, + target_id: claimed.target_id, + key_hex: claimed.key_hex, + iv_hex: claimed.iv_hex, + }); +}); diff --git a/app/supabase/functions/send-push/index.ts b/app/supabase/functions/send-push/index.ts new file mode 100644 index 0000000..6447a31 --- /dev/null +++ b/app/supabase/functions/send-push/index.ts @@ -0,0 +1,236 @@ +// Delivers queued rows from `public.notifications` to FCM. +// +// Invoked as a sweep (cron or manual) rather than per-insert: a push provider +// is the least reliable dependency in the stack, and a sweep that re-reads +// `pushed_at IS NULL` is naturally idempotent, whereas a per-row webhook drops +// the notification permanently the one time FCM 503s. +// +// STATUS: structurally complete, never exercised against a real FCM project. +// It needs FCM_SERVICE_ACCOUNT_EMAIL / FCM_PRIVATE_KEY / FCM_PROJECT_ID, which +// are not set — see AGENTS.md §8. Do NOT assume the Play publishing service +// account (GOOGLE_PLAY_SERVICE_ACCOUNT_JSON) covers this: FCM needs the +// firebase.messaging scope, so it takes its own service account, same +// convention as drive-proxy's GD_SERVICE_ACCOUNT_EMAIL. +// +// Until those exist this returns a 503 describing exactly what is missing, +// which is the honest response — not a 200 that implies delivery happened. +// +// Deployed with verify_jwt disabled but guarded by PUSH_SWEEP_SECRET: the +// caller is a scheduler, not a user session. Without that secret set, the +// function refuses to run rather than defaulting open. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; +import { SignJWT, importPKCS8 } from "https://deno.land/x/jose@v4.13.1/index.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type, x-sweep-secret", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +/** Max rows per sweep. Bounds both the FCM fan-out and the function's runtime. */ +const BATCH_SIZE = 200; + +async function getFcmAccessToken( + email: string, + privateKey: string, +): Promise { + const cleanedKey = privateKey.replace(/\\n/g, "\n"); + + const jwt = await new SignJWT({ + scope: "https://www.googleapis.com/auth/firebase.messaging", + }) + .setProtectedHeader({ alg: "RS256", typ: "JWT" }) + .setIssuer(email) + .setSubject(email) + .setAudience("https://oauth2.googleapis.com/token") + .setIssuedAt() + .setExpirationTime("1h") + .sign(await importPKCS8(cleanedKey, "RS256")); + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }), + }); + + if (!response.ok) { + throw new Error(`FCM OAuth exchange failed: ${await response.text()}`); + } + return (await response.json()).access_token; +} + +interface QueuedNotification { + id: string; + user_id: string; + category: string; + title: string; + body: string; + deep_link: string | null; +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + const json = (payload: unknown, status: number) => + new Response(JSON.stringify(payload), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + + const sweepSecret = Deno.env.get("PUSH_SWEEP_SECRET"); + if (!sweepSecret) { + return json( + { + error: "Push delivery is not configured.", + setupInstructions: + "Set PUSH_SWEEP_SECRET, FCM_SERVICE_ACCOUNT_EMAIL, FCM_PRIVATE_KEY and " + + "FCM_PROJECT_ID in Supabase project secrets. See AGENTS.md §8.", + }, + 503, + ); + } + if (req.headers.get("x-sweep-secret") !== sweepSecret) { + return json({ error: "Forbidden" }, 403); + } + + const fcmEmail = Deno.env.get("FCM_SERVICE_ACCOUNT_EMAIL"); + const fcmKey = Deno.env.get("FCM_PRIVATE_KEY"); + const fcmProject = Deno.env.get("FCM_PROJECT_ID"); + if (!fcmEmail || !fcmKey || !fcmProject) { + return json( + { + error: "FCM credentials are not configured.", + setupInstructions: + "Set FCM_SERVICE_ACCOUNT_EMAIL, FCM_PRIVATE_KEY and FCM_PROJECT_ID.", + }, + 503, + ); + } + + // Service role: this reads rows belonging to every user by design, which is + // exactly why the function is secret-gated above and never user-invocable. + const supabase = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "", + ); + + const { data: pending, error: fetchError } = await supabase + .from("notifications") + .select("id, user_id, category, title, body, deep_link") + .is("pushed_at", null) + .order("created_at", { ascending: true }) + .limit(BATCH_SIZE); + + if (fetchError) { + return json({ error: fetchError.message }, 500); + } + if (!pending || pending.length === 0) { + return json({ delivered: 0, message: "Nothing queued." }, 200); + } + + const notifications = pending as QueuedNotification[]; + const userIds = [...new Set(notifications.map((n) => n.user_id))]; + + const { data: tokenRows } = await supabase + .from("device_tokens") + .select("token, user_id") + .in("user_id", userIds); + + const tokensByUser = new Map(); + for (const row of tokenRows ?? []) { + const list = tokensByUser.get(row.user_id) ?? []; + list.push(row.token); + tokensByUser.set(row.user_id, list); + } + + const accessToken = await getFcmAccessToken(fcmEmail, fcmKey); + const endpoint = + `https://fcm.googleapis.com/v1/projects/${fcmProject}/messages:send`; + + const deliveredIds: string[] = []; + const staleTokens: string[] = []; + + for (const notification of notifications) { + const tokens = tokensByUser.get(notification.user_id) ?? []; + + // No registered device is not a failure — the user may only ever use the + // web app. Mark it pushed so the sweep does not reconsider it forever; the + // row is still in their in-app inbox. + if (tokens.length === 0) { + deliveredIds.push(notification.id); + continue; + } + + for (const token of tokens) { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message: { + token, + // title/body come straight from the DB, where enqueue_notification + // composed them from fixed templates that name no document and no + // group. Nothing sensitive is added here, because this payload is + // what renders on a locked screen. + notification: { + title: notification.title, + body: notification.body, + }, + data: { + category: notification.category, + deep_link: notification.deep_link ?? "", + notification_id: notification.id, + }, + android: { + priority: notification.category === "security" ? "HIGH" : "NORMAL", + notification: { + channel_id: `nosus_${notification.category}`, + // Collapse noisy categories so ten new documents are one line, + // not ten. Security alerts never collapse. + tag: notification.category === "security" + ? undefined + : notification.category, + }, + }, + }, + }), + }); + + if (response.ok) continue; + + // 404 UNREGISTERED / 400 INVALID_ARGUMENT on the token mean the install + // is gone. Reap it so the list does not grow forever with dead handsets. + if (response.status === 404 || response.status === 400) { + staleTokens.push(token); + } + } + + deliveredIds.push(notification.id); + } + + if (deliveredIds.length > 0) { + await supabase + .from("notifications") + .update({ pushed_at: new Date().toISOString() }) + .in("id", deliveredIds); + } + + if (staleTokens.length > 0) { + await supabase.from("device_tokens").delete().in("token", staleTokens); + } + + return json( + { delivered: deliveredIds.length, reapedTokens: staleTokens.length }, + 200, + ); +}); diff --git a/app/supabase/functions/share-fetch/index.ts b/app/supabase/functions/share-fetch/index.ts new file mode 100644 index 0000000..b34cc61 --- /dev/null +++ b/app/supabase/functions/share-fetch/index.ts @@ -0,0 +1,181 @@ +// Supabase Edge Function: share-fetch +// +// SecureSend's anonymous door. The ONLY way a share-link recipient (no +// account, no session) ever reaches a file's bytes. Public on purpose +// (verify_jwt: false, same trust model already used by this project's +// drive-proxy function) — the token itself is the credential. +// +// POST { token, viewer_email } -> +// { file_name, type, signed_url } (signed_url expires in 5 minutes) +// +// Responsibilities: +// 1. Look up the token (service role — share_links has no public SELECT +// policy; only this function ever resolves a token to a file). +// 2. Reject revoked / expired / view-limit-exceeded links. +// 3. Reject Google-Drive-linked files (out of scope for v1 — only +// Supabase-Storage-backed secure_files rows are shareable this way). +// 4. Log the view (viewer_email + timestamp) and bump view_count. +// 5. Mint a short-lived Storage signed URL (service role bypasses RLS for +// this one call) so the client fetches bytes directly from Supabase's +// storage CDN — never through this function's response body. +// +// Never logs the token or viewer_email in error output. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +const SIGNED_URL_TTL_SECONDS = 300; // 5 minutes + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +function isPlausibleEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +/// A GDrive-linked secure_files row uses its Drive file id as `id`, which +/// never carries the app's own id prefixes (mirrors the check already used in +/// supabase_secure_file_repository.dart's downloadFile/deleteFile). +function isGoogleDriveFile(fileId: string): boolean { + return !fileId.startsWith("file_") && !fileId.startsWith("sec_gen_"); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !serviceRoleKey) { + return json({ error: "share-fetch is not configured" }, 503); + } + const admin = createClient(supabaseUrl, serviceRoleKey); + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const token = String(body.token ?? "").trim(); + const viewerEmail = String(body.viewer_email ?? "").trim().toLowerCase(); + const deviceType = String(body.device_type ?? "web").trim().toLowerCase(); + if (!token) return json({ error: "Missing token" }, 400); + if (!viewerEmail || !isPlausibleEmail(viewerEmail)) { + return json({ error: "A valid email is required to view this document" }, 400); + } + + // 1. Fetch feature flags to check if SecureSend is enabled and get settings + const { data: flags, error: flagsErr } = await admin + .from("feature_flags") + .select("flag_key, is_active") + .in("flag_key", [ + "securesend_enabled", + "securesend_watermark_enforced", + "securesend_blur_enforced", + ]); + + if (flagsErr) { + console.error("share-fetch: Failed to query feature flags", flagsErr); + } + + const getFlag = (key: string, defaultValue = true): boolean => { + if (!flags) return defaultValue; + const f = flags.find((item: any) => item.flag_key === key); + return f ? f.is_active : defaultValue; + }; + + if (!getFlag("securesend_enabled", true)) { + return json({ error: "SecureSend sharing is temporarily disabled by administrators" }, 503); + } + + const watermarkEnforced = getFlag("securesend_watermark_enforced", true); + const platformAllowsBlur = getFlag("securesend_blur_enforced", true); + + const { data: link, error: linkErr } = await admin + .from("share_links") + .select("id, file_id, revoked, expires_at, max_views, view_count, require_touch_reveal") + .eq("token", token) + .maybeSingle(); + + if (linkErr) return json({ error: "Lookup failed" }, 500); + if (!link) return json({ error: "This link is invalid" }, 404); + if (link.revoked) return json({ error: "This link has been revoked" }, 410); + if (link.expires_at && new Date(link.expires_at).getTime() < Date.now()) { + return json({ error: "This link has expired" }, 410); + } + if (link.max_views != null && link.view_count >= link.max_views) { + return json({ error: "This link has reached its view limit" }, 410); + } + + const fileId = link.file_id as string; + if (isGoogleDriveFile(fileId)) { + return json({ error: "This file isn't shareable via link yet" }, 400); + } + + // The sender's own per-link choice AND the platform-wide admin flag both + // have to allow blur for it to apply — the admin flag is a kill-switch, + // never a way to force blur ON against what a sender explicitly turned off. + const blurEnforced = platformAllowsBlur && (link.require_touch_reveal as boolean); + + + const { data: file, error: fileErr } = await admin + .from("secure_files") + .select("name, type") + .eq("id", fileId) + .maybeSingle(); + if (fileErr || !file) return json({ error: "The document could not be found" }, 404); + + const { data: signed, error: signErr } = await admin.storage + .from("secure-files") + .createSignedUrl(fileId, SIGNED_URL_TTL_SECONDS); + if (signErr || !signed?.signedUrl) { + return json({ error: "The document could not be prepared for viewing" }, 502); + } + + let viewEventId = null; + // Log the view + bump the counter. Best-effort: a failure here must not + // block a legitimate view (the recipient already has a valid signed URL). + try { + const { data: eventData } = await admin + .from("share_view_events") + .insert({ + link_id: link.id, + viewer_email: viewerEmail, + device_type: deviceType === "app" ? "app" : "web", + }) + .select("id") + .single(); + + if (eventData) { + viewEventId = eventData.id; + } + + await admin + .from("share_links") + .update({ view_count: (link.view_count as number) + 1 }) + .eq("id", link.id); + } catch (_e) { + // swallow — see comment above + } + + return json({ + file_name: file.name, + type: file.type, + signed_url: signed.signedUrl, + watermark_enforced: watermarkEnforced, + blur_enforced: blurEnforced, + view_event_id: viewEventId, + }); +}); + diff --git a/app/supabase/functions/share-heartbeat/index.ts b/app/supabase/functions/share-heartbeat/index.ts new file mode 100644 index 0000000..90a6b11 --- /dev/null +++ b/app/supabase/functions/share-heartbeat/index.ts @@ -0,0 +1,107 @@ +// Supabase Edge Function: share-heartbeat +// +// Receives periodic heartbeats from recipients viewing documents to update +// their last active timestamp or mark the session as ended. +// +// POST { view_event_id, close, event_type? } -> { ok: true } +// +// event_type (optional): a web-viewer deterrent signal — devtools opened, +// repeated tab-visibility switches, or a blocked copy/print/save/right-click +// attempt (see lib/services/web_security_guard.dart). Appended to +// share_view_events.suspicious_events, capped at MAX_SUSPICIOUS_EVENTS so a +// hostile client spamming this endpoint can't grow the row unbounded. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const ALLOWED_EVENT_TYPES = new Set([ + "devtools_detected", + "visibility_hidden_repeated", + "print_attempted", + "copy_attempted", + "right_click_attempted", + "save_attempted", + "drag_attempted", + "automation_detected", +]); + +const MAX_SUSPICIOUS_EVENTS = 50; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !serviceRoleKey) { + return json({ error: "share-heartbeat is not configured" }, 503); + } + const admin = createClient(supabaseUrl, serviceRoleKey); + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const eventId = String(body.view_event_id ?? "").trim(); + const isClose = !!body.close; + const eventType = body.event_type != null ? String(body.event_type).trim() : null; + + if (!eventId) return json({ error: "Missing view_event_id" }, 400); + if (eventType && !ALLOWED_EVENT_TYPES.has(eventType)) { + return json({ error: "Unknown event_type" }, 400); + } + + try { + if (isClose) { + await admin + .from("share_view_events") + .update({ ended_at: new Date().toISOString() }) + .eq("id", eventId); + } else { + await admin + .from("share_view_events") + .update({ last_heartbeat: new Date().toISOString() }) + .eq("id", eventId); + } + + if (eventType) { + // Read-modify-write rather than a raw jsonb-append expression: this + // endpoint runs at most a few times per minute per viewer, so the + // extra round trip is cheap and keeps the capping logic simple. + const { data: row } = await admin + .from("share_view_events") + .select("suspicious_events") + .eq("id", eventId) + .maybeSingle(); + + const existing = Array.isArray(row?.suspicious_events) ? row!.suspicious_events : []; + const next = [...existing, { type: eventType, at: new Date().toISOString() }] + .slice(-MAX_SUSPICIOUS_EVENTS); + + await admin + .from("share_view_events") + .update({ suspicious_events: next }) + .eq("id", eventId); + } + } catch (e) { + console.error("share-heartbeat: Failed to update heartbeat", e); + return json({ error: "Failed to update heartbeat" }, 500); + } + + return json({ ok: true }); +}); diff --git a/app/supabase/functions/storage-router/index.ts b/app/supabase/functions/storage-router/index.ts new file mode 100644 index 0000000..4c057dc --- /dev/null +++ b/app/supabase/functions/storage-router/index.ts @@ -0,0 +1,553 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", +}; + +type ProviderConfig = { + name: string; + endpoint: string; + region?: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; + freeBytes?: number; + priority?: number; +}; + +type StorageObjectRow = { + user_id: string; + group_id: string; + file_id: string; + provider: string; + bucket: string; + object_key: string; + content_type: string; +}; + +type AdminClient = any; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + const url = new URL(req.url); + const path = url.pathname.split("/").pop(); + + try { + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return json({ error: "Missing Authorization header" }, 401); + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const anonKey = Deno.env.get("SUPABASE_ANON_KEY"); + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !anonKey || !serviceRoleKey) { + return json({ + error: "Storage router Supabase secrets are not configured", + }, 503); + } + + const userClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } }, + }); + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData?.user) { + return json({ error: "Invalid or expired session" }, 401); + } + + const admin = createClient(supabaseUrl, serviceRoleKey); + const userId = userData.user.id; + + if (req.method === "POST" && path === "upload") { + return await handleUpload(req, url, admin, userId); + } + if (req.method === "GET" && path === "download") { + return await handleDownload(url, admin, userId); + } + if (req.method === "DELETE" && path === "delete") { + return await handleDelete(url, admin, userId); + } + + return json({ error: "Route not found" }, 404); + } catch (error) { + if (error instanceof HttpError) { + return json({ error: error.message }, error.status); + } + return json({ + error: error instanceof Error ? error.message : String(error), + }, 500); + } +}); + +async function handleUpload( + req: Request, + url: URL, + admin: AdminClient, + userId: string, +): Promise { + const providers = loadProviders(); + const groupId = requiredParam(url, "groupId"); + const fileId = requiredParam(url, "fileId"); + const rawName = requiredParam(url, "name"); + const type = url.searchParams.get("type") || "pdf"; + const contentType = url.searchParams.get("contentType") || + req.headers.get("content-type") || + "application/octet-stream"; + + await assertGroupMember(admin, groupId, userId); + + const bytes = new Uint8Array(await req.arrayBuffer()); + if (bytes.byteLength === 0) return json({ error: "Empty upload body" }, 400); + + const provider = await chooseProvider(admin, providers, bytes.byteLength); + const objectKey = `${userId}/${groupId}/${fileId}`; + const checksum = await sha256Hex(bytes); + const logicalPath = `${groupId}/${fileId}`; + + const { error: fileErr } = await admin.from("secure_files").upsert( + { + id: fileId, + group_id: groupId, + name: rawName, + type, + size_bytes: bytes.byteLength, + is_watermarked: true, + is_pinned: false, + security_status: "secured", + storage_path: fileId, + uploaded_by: userId, + owner_id: userId, + }, + { onConflict: "id" }, + ); + if (fileErr) { + return json({ error: "Failed to create secure file metadata" }, 500); + } + + const upload = await s3Fetch(provider, "PUT", objectKey, bytes, contentType); + if (!upload.ok) { + const detail = await upload.text(); + await admin.from("secure_files").delete().eq("id", fileId).eq( + "owner_id", + userId, + ); + return json({ + error: "Provider upload failed", + provider: provider.name, + detail, + }, 502); + } + + const { error: objectErr } = await admin.from("storage_objects").upsert( + { + user_id: userId, + group_id: groupId, + file_id: fileId, + logical_path: logicalPath, + provider: provider.name, + bucket: provider.bucket, + object_key: objectKey, + size_bytes: bytes.byteLength, + content_type: contentType, + checksum_sha256: checksum, + status: "active", + }, + { onConflict: "file_id" }, + ); + if (objectErr) { + await s3Fetch(provider, "DELETE", objectKey); + await admin.from("secure_files").delete().eq("id", fileId).eq( + "owner_id", + userId, + ); + return json({ error: "Failed to create storage index" }, 500); + } + + return json({ + file_id: fileId, + provider: provider.name, + bucket: provider.bucket, + object_key: objectKey, + size_bytes: bytes.byteLength, + checksum_sha256: checksum, + }); +} + +async function handleDownload( + url: URL, + admin: AdminClient, + userId: string, +): Promise { + const fileId = requiredParam(url, "fileId"); + const row = await loadObjectForAccess(admin, fileId, userId); + const provider = findProvider(row.provider); + + const upstream = await s3Fetch(provider, "GET", row.object_key); + if (!upstream.ok) { + return json( + { error: "Provider download failed", provider: provider.name }, + 502, + ); + } + + return new Response(upstream.body, { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": row.content_type || "application/octet-stream", + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + }, + }); +} + +async function handleDelete( + url: URL, + admin: AdminClient, + userId: string, +): Promise { + const fileId = requiredParam(url, "fileId"); + const row = await loadObjectForMutation(admin, fileId, userId); + const provider = findProvider(row.provider); + + const upstream = await s3Fetch(provider, "DELETE", row.object_key); + if (!upstream.ok && upstream.status !== 404) { + return json( + { error: "Provider delete failed", provider: provider.name }, + 502, + ); + } + + await admin + .from("storage_objects") + .update({ status: "deleted" }) + .eq("file_id", fileId); + + return json({ success: true }); +} + +async function assertGroupMember( + admin: AdminClient, + groupId: string, + userId: string, +) { + const { data, error } = await admin + .from("study_group_members") + .select("user_id") + .eq("group_id", groupId) + .eq("user_id", userId) + .maybeSingle(); + if (error || !data) throw new HttpError("Not a group member", 403); +} + +async function loadObjectForAccess( + admin: AdminClient, + fileId: string, + userId: string, +): Promise { + const { data: file, error: fileErr } = await admin + .from("secure_files") + .select("group_id") + .eq("id", fileId) + .maybeSingle(); + if (fileErr || !file) throw new HttpError("File not found", 404); + await assertGroupMember(admin, file.group_id as string, userId); + + const { data, error } = await admin + .from("storage_objects") + .select( + "user_id, group_id, file_id, provider, bucket, object_key, content_type", + ) + .eq("file_id", fileId) + .eq("status", "active") + .maybeSingle(); + if (error || !data) throw new HttpError("Storage object not found", 404); + return data as StorageObjectRow; +} + +async function loadObjectForMutation( + admin: AdminClient, + fileId: string, + userId: string, +): Promise { + const { data: file, error: fileErr } = await admin + .from("secure_files") + .select("group_id, owner_id") + .eq("id", fileId) + .maybeSingle(); + if (fileErr || !file) throw new HttpError("File not found", 404); + + const groupId = file.group_id as string; + const isOwner = file.owner_id === userId; + const { data: adminRow } = await admin + .from("study_group_members") + .select("user_id") + .eq("group_id", groupId) + .eq("user_id", userId) + .eq("is_admin", true) + .maybeSingle(); + if (!isOwner && !adminRow) { + throw new HttpError("Not allowed to delete object", 403); + } + + const { data, error } = await admin + .from("storage_objects") + .select( + "user_id, group_id, file_id, provider, bucket, object_key, content_type", + ) + .eq("file_id", fileId) + .eq("status", "active") + .maybeSingle(); + if (error || !data) throw new HttpError("Storage object not found", 404); + return data as StorageObjectRow; +} + +async function chooseProvider( + admin: AdminClient, + providers: ProviderConfig[], + incomingBytes: number, +): Promise { + const ordered = [...providers].sort((a, b) => + (b.priority ?? 0) - (a.priority ?? 0) + ); + + for (const provider of ordered) { + const used = await usedBytes(admin, provider.name); + const freeBytes = provider.freeBytes ?? Number.MAX_SAFE_INTEGER; + if (used + incomingBytes <= freeBytes * 0.8) return provider; + } + + return ordered[0]; +} + +async function usedBytes( + admin: AdminClient, + providerName: string, +): Promise { + const { data, error } = await admin + .from("storage_objects") + .select("size_bytes") + .eq("provider", providerName) + .eq("status", "active"); + if (error || !data) return 0; + return (data as Array<{ size_bytes?: number | string }>).reduce( + (total: number, row: { size_bytes?: number | string }) => + total + Number(row.size_bytes ?? 0), + 0, + ); +} + +function loadProviders(): ProviderConfig[] { + const raw = Deno.env.get("STORAGE_ROUTER_PROVIDERS"); + if (!raw) { + throw new HttpError("STORAGE_ROUTER_PROVIDERS is not configured", 503); + } + const providers = JSON.parse(raw) as ProviderConfig[]; + if (!Array.isArray(providers) || providers.length === 0) { + throw new HttpError("No storage providers configured", 503); + } + for (const provider of providers) { + if ( + !provider.name || + !provider.endpoint || + !provider.bucket || + !provider.accessKeyId || + !provider.secretAccessKey + ) { + throw new HttpError( + `Invalid provider config: ${provider.name ?? "unknown"}`, + 503, + ); + } + } + return providers; +} + +function findProvider(name: string): ProviderConfig { + const provider = loadProviders().find((item) => item.name === name); + if (!provider) throw new HttpError(`Provider not configured: ${name}`, 503); + return provider; +} + +async function s3Fetch( + provider: ProviderConfig, + method: string, + objectKey: string, + body?: Uint8Array, + contentType = "application/octet-stream", +): Promise { + const endpoint = provider.endpoint.replace(/\/+$/, ""); + const url = new URL(`${endpoint}/${provider.bucket}/${encodeKey(objectKey)}`); + const payloadHash = body ? await sha256Hex(body) : EMPTY_SHA256; + const headers = await signAwsRequest({ + method, + url, + provider, + payloadHash, + contentType, + }); + + return await fetch(url, { + method, + headers, + body: method === "PUT" && body ? toArrayBuffer(body) : undefined, + }); +} + +async function signAwsRequest(params: { + method: string; + url: URL; + provider: ProviderConfig; + payloadHash: string; + contentType: string; +}): Promise> { + const now = new Date(); + const amzDate = toAmzDate(now); + const dateStamp = amzDate.slice(0, 8); + const region = params.provider.region || "auto"; + const service = "s3"; + const host = params.url.host; + + const headers: Record = { + "host": host, + "x-amz-content-sha256": params.payloadHash, + "x-amz-date": amzDate, + }; + if (params.method === "PUT") headers["content-type"] = params.contentType; + + const signedHeaders = Object.keys(headers).sort().join(";"); + const canonicalHeaders = Object.keys(headers) + .sort() + .map((name) => `${name}:${headers[name]}\n`) + .join(""); + const canonicalRequest = [ + params.method, + params.url.pathname, + "", + canonicalHeaders, + signedHeaders, + params.payloadHash, + ].join("\n"); + + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; + const stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + await sha256Hex(new TextEncoder().encode(canonicalRequest)), + ].join("\n"); + + const signingKey = await getSignatureKey( + params.provider.secretAccessKey, + dateStamp, + region, + service, + ); + const signature = bytesToHex(await hmacBytes(signingKey, stringToSign)); + + return { + ...Object.fromEntries( + Object.entries(headers).map(([key, value]) => [headerCase(key), value]), + ), + "Authorization": + `AWS4-HMAC-SHA256 Credential=${params.provider.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, + }; +} + +async function getSignatureKey( + secret: string, + dateStamp: string, + region: string, + service: string, +): Promise { + const kDate = await hmacBytes( + new TextEncoder().encode(`AWS4${secret}`), + dateStamp, + ); + const kRegion = await hmacBytes(kDate, region); + const kService = await hmacBytes(kRegion, service); + return await hmacBytes(kService, "aws4_request"); +} + +async function hmacBytes( + key: Uint8Array, + message: string, +): Promise { + const cryptoKey = await crypto.subtle.importKey( + "raw", + toArrayBuffer(key), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array( + await crypto.subtle.sign( + "HMAC", + cryptoKey, + new TextEncoder().encode(message), + ), + ); +} + +async function sha256Hex(bytes: Uint8Array): Promise { + return bytesToHex( + new Uint8Array(await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes))), + ); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; +} + +function bytesToHex(bytes: Uint8Array): string { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function toAmzDate(date: Date): string { + return date.toISOString().replace(/[:-]|\.\d{3}/g, ""); +} + +function encodeKey(key: string): string { + return key.split("/").map(encodeURIComponent).join("/"); +} + +function headerCase(name: string): string { + if (name === "host") return "Host"; + if (name === "content-type") return "Content-Type"; + return name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)) + .join("-"); +} + +function requiredParam(url: URL, name: string): string { + const value = url.searchParams.get(name); + if (!value) throw new HttpError(`Missing ${name}`, 400); + return value; +} + +class HttpError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +const EMPTY_SHA256 = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; diff --git a/app/supabase/functions/verify-play-integrity/index.ts b/app/supabase/functions/verify-play-integrity/index.ts new file mode 100644 index 0000000..5b850ef --- /dev/null +++ b/app/supabase/functions/verify-play-integrity/index.ts @@ -0,0 +1,145 @@ +// Supabase Edge Function: verify-play-integrity +// +// SCAFFOLDED, NOT VERIFIED END-TO-END. Written to the documented Play +// Integrity API request/response shape and reusing this repo's own proven +// Google service-account JWT pattern (see drive-proxy/index.ts's +// getGoogleAccessToken, which is the same flow against a different Google +// API) — but never exercised against a real device-generated token in this +// environment. Before relying on this: +// 1. Enable the Play Integrity API for the Google Cloud project linked to +// this app in Play Console (App integrity → Play Integrity API). +// 2. Create a dedicated service account with Play Integrity API access +// (don't assume GOOGLE_PLAY_SERVICE_ACCOUNT_JSON — used elsewhere for +// Play publishing — has this scope; verify or create a separate one, +// matching this codebase's own convention of per-integration service +// accounts, e.g. drive-proxy's GD_SERVICE_ACCOUNT_EMAIL/GD_PRIVATE_KEY). +// 3. Set PLAY_INTEGRITY_SERVICE_ACCOUNT_EMAIL / PLAY_INTEGRITY_PRIVATE_KEY +// as Supabase project secrets. +// 4. Fill in the real Cloud project number in PlayIntegrityManager.kt and +// flip AppIntegrityConfig.enabled on. +// 5. Test against a real device build installed via Play (internal +// testing track is enough) — sideloaded/debug builds can't mint +// real tokens. +// +// POST { token, nonce } -> { verdict: {...} } | 4xx/5xx on failure. +// Never blocks the caller on failure — see PlayIntegrityService.requestAndVerify, +// which treats any error here as "no signal", not a hard failure. + +import { SignJWT, importPKCS8 } from "https://deno.land/x/jose@v4.13.1/index.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +// Must match android/app/build.gradle.kts' applicationId exactly. +const PACKAGE_NAME = "foo.nosus.app"; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +// Same shape as drive-proxy/index.ts's getGoogleAccessToken, scoped to +// Play Integrity instead of Drive. +async function getPlayIntegrityAccessToken(email: string, privateKey: string): Promise { + const cleanedKey = privateKey.replace(/\\n/g, "\n"); + + const jwt = await new SignJWT({ + scope: "https://www.googleapis.com/auth/playintegrity", + }) + .setProtectedHeader({ alg: "RS256", typ: "JWT" }) + .setIssuer(email) + .setSubject(email) + .setAudience("https://oauth2.googleapis.com/token") + .setIssuedAt() + .setExpirationTime("1h") + .sign(await importPKCS8(cleanedKey, "RS256")); + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }), + }); + + if (!response.ok) { + throw new Error(`Google OAuth exchange failed: ${await response.text()}`); + } + const data = await response.json(); + return data.access_token; +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const serviceAccountEmail = Deno.env.get("PLAY_INTEGRITY_SERVICE_ACCOUNT_EMAIL"); + const serviceAccountKey = Deno.env.get("PLAY_INTEGRITY_PRIVATE_KEY"); + if (!serviceAccountEmail || !serviceAccountKey) { + return json({ error: "verify-play-integrity is not configured" }, 503); + } + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + + const token = String(body.token ?? "").trim(); + const expectedNonce = String(body.nonce ?? "").trim(); + if (!token || !expectedNonce) { + return json({ error: "Missing token or nonce" }, 400); + } + + try { + const accessToken = await getPlayIntegrityAccessToken(serviceAccountEmail, serviceAccountKey); + + const decodeRes = await fetch( + `https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ integrity_token: token }), + }, + ); + + if (!decodeRes.ok) { + return json({ error: `Play Integrity decode failed: ${await decodeRes.text()}` }, 502); + } + + const decoded = await decodeRes.json(); + const payload = decoded.tokenPayloadExternal ?? {}; + const requestDetails = payload.requestDetails ?? {}; + + // Reject if the nonce doesn't match what THIS request minted, or the + // token was issued for a different app — both prevent a captured token + // from a different session/app being replayed here. + if (requestDetails.nonce !== expectedNonce) { + return json({ error: "Nonce mismatch — possible replay" }, 400); + } + if (requestDetails.requestPackageName !== PACKAGE_NAME) { + return json({ error: "Package name mismatch" }, 400); + } + + return json({ + verdict: { + appIntegrity: payload.appIntegrity ?? null, + deviceIntegrity: payload.deviceIntegrity ?? null, + accountDetails: payload.accountDetails ?? null, + }, + }); + } catch (error) { + console.error("verify-play-integrity: verification failed", error); + return json({ error: "Could not verify integrity token" }, 500); + } +}); diff --git a/app/supabase/migrations/20260629000000_baseline.sql b/app/supabase/migrations/20260629000000_baseline.sql new file mode 100644 index 0000000..a86d505 --- /dev/null +++ b/app/supabase/migrations/20260629000000_baseline.sql @@ -0,0 +1,718 @@ +-- ============================================================================= +-- NO SUS — CONSOLIDATED BASELINE SCHEMA MIGRATION +-- ============================================================================= + +-- ─── EXTENSIONS ────────────────────────────────────────────────────────────── +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ============================================================================= +-- SECTION 1: DATABASE TABLES +-- ============================================================================= + +-- 1. PROFILES +CREATE TABLE IF NOT EXISTS public.profiles ( + id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + email text UNIQUE, + display_name text, + avatar_color_start text DEFAULT 'FF0072FF', + avatar_color_end text DEFAULT 'FF00F2FE', + onboarding_completed boolean DEFAULT false, + avatar_id integer DEFAULT 0, + survey_goals text[], + survey_features text[], + survey_user_type text, + updated_at timestamptz DEFAULT now() +); + +-- 2. STUDY GROUPS +CREATE TABLE IF NOT EXISTS public.study_groups ( + id text PRIMARY KEY, + name text NOT NULL, + description text, + is_watermark_enabled boolean NOT NULL DEFAULT true, + invite_code text, + file_count integer NOT NULL DEFAULT 0, + last_activity timestamptz DEFAULT now(), + created_at timestamptz DEFAULT now() +); + +-- 3. STUDY GROUP MEMBERS +CREATE TABLE IF NOT EXISTS public.study_group_members ( + group_id text REFERENCES public.study_groups(id) ON DELETE CASCADE, + user_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE, + is_admin boolean NOT NULL DEFAULT false, + joined_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (group_id, user_id) +); + +-- 4. SECURE FILES +CREATE TABLE IF NOT EXISTS public.secure_files ( + id text PRIMARY KEY, + group_id text NOT NULL REFERENCES public.study_groups(id) ON DELETE CASCADE, + name text NOT NULL, + type text NOT NULL DEFAULT 'pdf', + size_bytes integer NOT NULL DEFAULT 0, + is_watermarked boolean DEFAULT true, + is_pinned boolean DEFAULT false, + security_status text DEFAULT 'secured', + uploaded_at timestamptz NOT NULL DEFAULT now(), + storage_path text, + gdrive_file_id text, + key_id text, + uploaded_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE DEFAULT auth.uid(), + owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE DEFAULT auth.uid(), + CONSTRAINT secure_files_security_status_check CHECK (security_status IN ('secured', 'pending', 'compromised', 'revoked')) +); + +-- 5. AUDIT LOGS +CREATE TABLE IF NOT EXISTS public.audit_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + group_id text NOT NULL REFERENCES public.study_groups(id) ON DELETE CASCADE, + actor_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + file_id text REFERENCES public.secure_files(id) ON DELETE SET NULL, + event_type text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + previous_hash text NOT NULL, + entry_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chk_audit_metadata_object CHECK (jsonb_typeof(metadata) = 'object'), + CONSTRAINT chk_audit_event_type CHECK ( + event_type IN ( + 'file_uploaded', + 'file_downloaded', + 'file_viewed', + 'file_deleted', + 'file_renamed', + 'file_pinned', + 'member_joined', + 'member_left', + 'member_promoted', + 'member_demoted', + 'group_created', + 'group_updated', + 'invite_generated', + 'invite_used', + 'screenshot_attempt', + 'recording_attempt', + 'unauthorized_access' + ) + ) +); + +-- Indexes for Audit Logs +CREATE INDEX IF NOT EXISTS idx_audit_group_created ON public.audit_logs(group_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_actor ON public.audit_logs(actor_id); +CREATE INDEX IF NOT EXISTS idx_audit_file ON public.audit_logs(file_id); + +-- Performance and RLS Indexes +CREATE INDEX IF NOT EXISTS idx_secure_files_group_id ON public.secure_files(group_id); +CREATE INDEX IF NOT EXISTS idx_secure_files_owner_id ON public.secure_files(owner_id); +CREATE INDEX IF NOT EXISTS idx_secure_files_uploaded_at ON public.secure_files(uploaded_at DESC); +CREATE INDEX IF NOT EXISTS idx_study_group_members_user_id ON public.study_group_members(user_id); +CREATE INDEX IF NOT EXISTS idx_study_groups_invite_code ON public.study_groups(invite_code) WHERE invite_code IS NOT NULL; + +-- 6. FOCUS LOGS +CREATE TABLE IF NOT EXISTS public.focus_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + date date NOT NULL, + focus_minutes integer NOT NULL DEFAULT 0, + UNIQUE (user_id, date) +); + +-- 7. USER NOTES +CREATE TABLE IF NOT EXISTS public.user_notes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + note_text text NOT NULL DEFAULT '', + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- ============================================================================= +-- SECTION 2: SECURITY DEFINER HELPERS & RPCs +-- ============================================================================= + +-- 1. GROUP MEMBERSHIP QUERY BYPASS +CREATE OR REPLACE FUNCTION public.is_group_member(p_group_id text) +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.study_group_members + WHERE group_id = p_group_id AND user_id = auth.uid() + ); +END; +$$; + +-- 2. GROUP ADMIN QUERY BYPASS +CREATE OR REPLACE FUNCTION public.is_group_admin(p_group_id text) +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.study_group_members + WHERE group_id = p_group_id AND user_id = auth.uid() AND is_admin = true + ); +END; +$$; + +-- 3. SIGNUP AUTH AUTOMATION +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_global_id text; + v_tsec_id text; +BEGIN + -- 1. Insert Profile + INSERT INTO public.profiles (id, email, display_name) + VALUES ( + NEW.id, + NEW.email, + COALESCE(NEW.raw_user_meta_data->>'display_name', split_part(NEW.email, '@', 1)) + ) + ON CONFLICT (id) DO NOTHING; + + -- 2. Ensure Global Community group exists and get its ID + SELECT id INTO v_global_id FROM public.study_groups WHERE name = 'Global Community'; + IF v_global_id IS NULL THEN + v_global_id := '8055b43a-6d07-4588-a39f-159e6154a019'; + INSERT INTO public.study_groups (id, name, description, is_watermark_enabled) + VALUES (v_global_id, 'Global Community', 'A home for all No Sus users.', true) + ON CONFLICT (id) DO NOTHING; + END IF; + + -- 3. Ensure TSEC, Kandivali group exists and get its ID + SELECT id INTO v_tsec_id FROM public.study_groups WHERE name = 'TSEC, Kandivali'; + IF v_tsec_id IS NULL THEN + v_tsec_id := '12792cd4-9f89-4331-a4af-1b86a72f8f47'; + INSERT INTO public.study_groups (id, name, description, is_watermark_enabled) + VALUES (v_tsec_id, 'TSEC, Kandivali', 'TSEC Kandivali Students Community', true) + ON CONFLICT (id) DO NOTHING; + END IF; + + -- 4. Auto-join user to both groups + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES + (v_global_id, NEW.id, false), + (v_tsec_id, NEW.id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + -- 5. Write audit logs for these joins + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES + (v_global_id, NEW.id, NULL, 'member_joined', '{}'::jsonb), + (v_tsec_id, NEW.id, NULL, 'member_joined', '{}'::jsonb) + ON CONFLICT DO NOTHING; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- 4. FILE COUNT SYNCHRONIZATION +CREATE OR REPLACE FUNCTION public.update_study_group_file_count() +RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE public.study_groups + SET file_count = file_count + 1 + WHERE id = NEW.group_id; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + UPDATE public.study_groups + SET file_count = file_count - 1 + WHERE id = OLD.group_id; + RETURN OLD; + END IF; + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS trg_update_study_group_file_count ON public.secure_files; +CREATE TRIGGER trg_update_study_group_file_count + AFTER INSERT OR DELETE ON public.secure_files + FOR EACH ROW EXECUTE FUNCTION public.update_study_group_file_count(); + +-- 5. AUDIT CHAIN HASH TRIGGERS +CREATE OR REPLACE FUNCTION public.audit_logs_hash_trigger() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$ +DECLARE + prev_hash text; +BEGIN + SELECT entry_hash INTO prev_hash + FROM public.audit_logs + WHERE group_id = NEW.group_id AND id != NEW.id + ORDER BY created_at DESC, id DESC + LIMIT 1; + + NEW.previous_hash := COALESCE(prev_hash, 'GENESIS'); + + NEW.entry_hash := encode(digest( + NEW.actor_id::text || NEW.event_type || NEW.created_at::text || NEW.previous_hash, + 'sha256' + ), 'hex'); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_audit_logs_hash ON public.audit_logs; +CREATE TRIGGER trg_audit_logs_hash + BEFORE INSERT ON public.audit_logs + FOR EACH ROW EXECUTE FUNCTION public.audit_logs_hash_trigger(); + +-- 6. RPC: VERIFY LEDGER TIMELINE +CREATE OR REPLACE FUNCTION public.verify_audit_chain(p_group_id text) +RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$ +DECLARE + rec record; + computed_hash text; + expected_prev text := 'GENESIS'; +BEGIN + FOR rec IN + SELECT * FROM public.audit_logs + WHERE group_id = p_group_id + ORDER BY created_at ASC, id ASC + LOOP + IF rec.previous_hash IS DISTINCT FROM expected_prev THEN + RETURN jsonb_build_object('status', 'invalid', 'first_broken_record', rec.id, 'reason', 'previous_hash mismatch'); + END IF; + + computed_hash := encode(digest( + rec.actor_id::text || rec.event_type || rec.created_at::text || rec.previous_hash, + 'sha256' + ), 'hex'); + + IF rec.entry_hash IS DISTINCT FROM computed_hash THEN + RETURN jsonb_build_object('status', 'invalid', 'first_broken_record', rec.id, 'reason', 'entry_hash mismatch'); + END IF; + + expected_prev := rec.entry_hash; + END LOOP; + + RETURN jsonb_build_object('status', 'valid'); +END; +$$; + +-- 7. RPC: LOG SECURITY EVENTS +CREATE OR REPLACE FUNCTION public.log_group_event( + p_group_id text, + p_file_id text, + p_event_type text, + p_metadata jsonb DEFAULT '{}'::jsonb +) +RETURNS boolean +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + IF NOT public.is_group_member(p_group_id) THEN + RAISE EXCEPTION 'Not a member of the study group'; + END IF; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (p_group_id, auth.uid(), p_file_id, p_event_type, p_metadata); + + RETURN true; +END; +$$; + +-- 8. RPC: ATOMIC GROUP CREATION +CREATE OR REPLACE FUNCTION public.create_study_group_with_creator( + p_group_id text, + p_name text, + p_description text, + p_is_watermark_enabled boolean, + p_invite_code text +) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_user_id uuid; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + INSERT INTO public.study_groups (id, name, description, is_watermark_enabled, invite_code) + VALUES (p_group_id, p_name, p_description, p_is_watermark_enabled, p_invite_code); + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (p_group_id, v_user_id, true) + ON CONFLICT (group_id, user_id) DO NOTHING; + + PERFORM public.log_group_event( + p_group_id, + NULL, + 'group_created', + jsonb_build_object('group_name', p_name) + ); + + RETURN p_group_id; +END; +$$; + +-- 9. RPC: ATOMIC INVITE CODE JOIN +CREATE OR REPLACE FUNCTION public.join_group_by_invite_code( + p_invite_code text +) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; + v_user_id uuid; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT id INTO v_group_id + FROM public.study_groups + WHERE invite_code = p_invite_code; + + IF v_group_id IS NULL THEN + RAISE EXCEPTION 'Invalid invite code'; + END IF; + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_group_id, v_user_id, NULL, 'member_joined', '{}'::jsonb); + + RETURN v_group_id; +END; +$$; + +-- 10. RPC: ATOMIC COMMUNITY JOIN +CREATE OR REPLACE FUNCTION public.join_public_group_by_name( + p_group_name text +) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; + v_user_id uuid; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT id INTO v_group_id + FROM public.study_groups + WHERE name = p_group_name; + + IF v_group_id IS NULL THEN + RAISE EXCEPTION 'Group not found'; + END IF; + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_group_id, v_user_id, NULL, 'member_joined', '{}'::jsonb); + + RETURN v_group_id; +END; +$$; + +-- 11. RPC: COMMUNITY ASSURANCE +CREATE OR REPLACE FUNCTION public.ensure_community_exists( + p_name text, + p_description text +) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; +BEGIN + SELECT id INTO v_group_id + FROM public.study_groups + WHERE name = p_name; + + IF v_group_id IS NULL THEN + v_group_id := gen_random_uuid()::text; + INSERT INTO public.study_groups (id, name, description, is_watermark_enabled) + VALUES (v_group_id, p_name, p_description, true); + END IF; + + RETURN v_group_id; +END; +$$; + +-- 12. RPC: STORAGE ACCESS AUTHORIZATION BYPASS +CREATE OR REPLACE FUNCTION public.can_access_storage_object( + p_bucket_id text, + p_object_name text, + p_user_id uuid +) +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF p_bucket_id != 'secure-files' THEN + RETURN false; + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM public.secure_files sf + JOIN public.study_group_members sgm ON sgm.group_id = sf.group_id + WHERE (sf.storage_path = p_object_name OR sf.id = p_object_name) + AND sgm.user_id = p_user_id + ); +END; +$$; + +-- 13. RPC: STORAGE DELETE AUTHORIZATION BYPASS +CREATE OR REPLACE FUNCTION public.can_delete_storage_object( + p_bucket_id text, + p_object_name text, + p_user_id uuid, + p_owner_id text +) +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF p_bucket_id != 'secure-files' THEN + RETURN false; + END IF; + + -- Owner can delete + IF p_user_id::text = p_owner_id THEN + RETURN true; + END IF; + + -- Group admin can delete + RETURN EXISTS ( + SELECT 1 + FROM public.secure_files sf + JOIN public.study_group_members sgm ON sgm.group_id = sf.group_id + WHERE (sf.storage_path = p_object_name OR sf.id = p_object_name) + AND sgm.user_id = p_user_id + AND sgm.is_admin = true + ); +END; +$$; + +-- ============================================================================= +-- SECTION 3: ROW LEVEL SECURITY (RLS) POLICIES +-- ============================================================================= + +-- Enable RLS +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.study_groups ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.study_group_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.secure_files ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.focus_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.user_notes ENABLE ROW LEVEL SECURITY; + +-- 1. PROFILES POLICIES +CREATE POLICY "Users can view their own profile" + ON public.profiles FOR SELECT + TO authenticated + USING (true); + +CREATE POLICY "Users can update their own profile" + ON public.profiles FOR UPDATE + TO authenticated + USING (auth.uid() = id) + WITH CHECK (auth.uid() = id); + +CREATE POLICY "Users can insert their own profile" + ON public.profiles FOR INSERT + TO authenticated + WITH CHECK (auth.uid() = id); + +-- 2. STUDY GROUPS POLICIES +CREATE POLICY "groups: member select" + ON public.study_groups FOR SELECT + TO authenticated + USING (public.is_group_member(id)); + +CREATE POLICY "groups: invite code lookup" + ON public.study_groups FOR SELECT + TO authenticated + USING (invite_code IS NOT NULL); + +CREATE POLICY "groups: authenticated insert" + ON public.study_groups FOR INSERT + TO authenticated + WITH CHECK (true); + +CREATE POLICY "groups: admin update" + ON public.study_groups FOR UPDATE + TO authenticated + USING (public.is_group_admin(id)); + +CREATE POLICY "groups: admin delete" + ON public.study_groups FOR DELETE + TO authenticated + USING (public.is_group_admin(id)); + +-- 3. STUDY GROUP MEMBERS POLICIES +CREATE POLICY "members: group member select" + ON public.study_group_members FOR SELECT + TO authenticated + USING (public.is_group_member(group_id)); + +CREATE POLICY "members: self insert" + ON public.study_group_members FOR INSERT + TO authenticated + WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "members: admin update" + ON public.study_group_members FOR UPDATE + TO authenticated + USING (public.is_group_admin(group_id)) + WITH CHECK (public.is_group_admin(group_id)); + +CREATE POLICY "members: admin delete" + ON public.study_group_members FOR DELETE + TO authenticated + USING (public.is_group_admin(group_id)); + +CREATE POLICY "members: self delete" + ON public.study_group_members FOR DELETE + TO authenticated + USING (user_id = auth.uid()); + +-- 4. SECURE FILES POLICIES +CREATE POLICY "files: member select" + ON public.secure_files FOR SELECT + TO authenticated + USING (public.is_group_member(group_id)); + +CREATE POLICY "files: member insert" + ON public.secure_files FOR INSERT + TO authenticated + WITH CHECK (public.is_group_member(group_id)); + +CREATE POLICY "files: owner or admin update" + ON public.secure_files FOR UPDATE + TO authenticated + USING (owner_id = auth.uid() OR public.is_group_admin(group_id)); + +CREATE POLICY "files: owner or admin delete" + ON public.secure_files FOR DELETE + TO authenticated + USING (owner_id = auth.uid() OR public.is_group_admin(group_id)); + +-- 5. AUDIT LOGS POLICIES +CREATE POLICY "audit: member select" + ON public.audit_logs FOR SELECT + TO authenticated + USING (public.is_group_member(group_id)); + +-- 6. FOCUS LOGS POLICIES +CREATE POLICY "focus: self all" + ON public.focus_logs FOR ALL + TO authenticated + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); + +-- 7. USER NOTES POLICIES +CREATE POLICY "notes: self all" + ON public.user_notes FOR ALL + TO authenticated + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); + +-- ============================================================================= +-- SECTION 4: STORAGE CONFIGURATIONS & POLICIES +-- ============================================================================= + +-- Create private bucket 'secure-files' +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'secure-files', + 'secure-files', + false, + 26214400, + ARRAY['application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'application/octet-stream'] +) +ON CONFLICT (id) DO UPDATE +SET file_size_limit = EXCLUDED.file_size_limit, + allowed_mime_types = EXCLUDED.allowed_mime_types; + +-- Policies on storage.objects +CREATE POLICY "secure-files: group member download" + ON storage.objects FOR SELECT + TO authenticated + USING ( + bucket_id = 'secure-files' + AND public.can_access_storage_object(bucket_id, name, auth.uid()) + ); + +CREATE POLICY "secure-files: group member upload" + ON storage.objects FOR INSERT + TO authenticated + WITH CHECK ( + bucket_id = 'secure-files' + AND (auth.uid())::text = owner_id::text + ); + +CREATE POLICY "Allow owners to update secure-files" + ON storage.objects FOR UPDATE + TO authenticated + USING ( bucket_id = 'secure-files' AND owner_id::text = auth.uid()::text ); + +CREATE POLICY "secure-files: owner or admin delete" + ON storage.objects FOR DELETE + TO authenticated + USING ( + bucket_id = 'secure-files' + AND public.can_delete_storage_object(bucket_id, name, auth.uid(), owner_id) + ); + +-- ============================================================================= +-- SECTION 5: REALTIME PUBLICATION +-- ============================================================================= + +DO $pub$ +DECLARE tbl text; +BEGIN + FOREACH tbl IN ARRAY ARRAY['study_groups','study_group_members','secure_files','audit_logs','focus_logs','user_notes'] + LOOP + IF NOT EXISTS ( + SELECT 1 FROM pg_publication p + JOIN pg_publication_rel pr ON pr.prpubid = p.oid + JOIN pg_class c ON c.oid = pr.prrelid + WHERE p.pubname = 'supabase_realtime' AND c.relname = tbl + ) THEN + EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE public.%I', tbl); + END IF; + END LOOP; +END $pub$; diff --git a/app/supabase/migrations/20260701000000_fhe_replay_protection.sql b/app/supabase/migrations/20260701000000_fhe_replay_protection.sql new file mode 100644 index 0000000..86348e4 --- /dev/null +++ b/app/supabase/migrations/20260701000000_fhe_replay_protection.sql @@ -0,0 +1,27 @@ +-- Migration: 20260701000000_fhe_replay_protection.sql +-- Description: Creates the Isolated Replay Protection Nonces table. + +CREATE TABLE IF NOT EXISTS public.fhe_nonces ( + nonce TEXT PRIMARY KEY, + request_id UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Enable RLS (Row Level Security) +ALTER TABLE public.fhe_nonces ENABLE ROW LEVEL SECURITY; + +-- Define RLS policies: Server side only operations +CREATE POLICY "Enable insert access for authenticated server roles" + ON public.fhe_nonces + FOR INSERT + TO authenticated + WITH CHECK (true); + +CREATE POLICY "Enable read access for authenticated server roles" + ON public.fhe_nonces + FOR SELECT + TO authenticated + USING (true); + +-- Index for auto-eviction sweeping queries +CREATE INDEX IF NOT EXISTS idx_fhe_nonces_created_at ON public.fhe_nonces (created_at); diff --git a/app/supabase/migrations/20260704000000_fhe_subsystem.sql b/app/supabase/migrations/20260704000000_fhe_subsystem.sql new file mode 100644 index 0000000..afc08af --- /dev/null +++ b/app/supabase/migrations/20260704000000_fhe_subsystem.sql @@ -0,0 +1,174 @@ +-- Migration: 20260704000000_fhe_subsystem.sql +-- Description: Isolated FHE subsystem metadata tables. +-- +-- SAFETY / ARCHITECTURE NOTES +-- * This migration is PURELY ADDITIVE. It touches no existing table, policy, +-- RPC, or storage bucket. Existing AES storage, sharing, viewing, auth and +-- RLS are untouched. +-- * NO secret key material is ever stored here. Supabase holds METADATA ONLY. +-- ClientKeys never leave the device; Server/Evaluation keys live only in +-- the Rust microservice's protected memory. +-- * Every table is tenant-isolated by user_id = auth.uid() via RLS. +-- * fhe_events is append-only (no UPDATE/DELETE policy for end users). + +-- =========================================================================== +-- 1. KEY METADATA (fingerprints + lifecycle only, never the keys themselves) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.fhe_key_metadata ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + key_id TEXT NOT NULL, + device_id TEXT, + algorithm TEXT NOT NULL DEFAULT 'TFHE-rs', + param_version TEXT NOT NULL DEFAULT 'v1', + tfhe_version TEXT NOT NULL DEFAULT '0.6', + protocol_version TEXT NOT NULL DEFAULT 'v1', + -- SHA-256 fingerprint of the public/evaluation key for integrity checks. + -- A hash is NOT secret and cannot reconstruct the key. + public_fingerprint TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active','rotated','revoked','expired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + expires_at TIMESTAMPTZ, + rotated_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + UNIQUE (user_id, key_id) +); + +CREATE INDEX IF NOT EXISTS idx_fhe_key_metadata_user ON public.fhe_key_metadata (user_id); +CREATE INDEX IF NOT EXISTS idx_fhe_key_metadata_status ON public.fhe_key_metadata (status); + +ALTER TABLE public.fhe_key_metadata ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "fhe_key_metadata_select_own" ON public.fhe_key_metadata; +CREATE POLICY "fhe_key_metadata_select_own" + ON public.fhe_key_metadata FOR SELECT TO authenticated + USING (user_id = auth.uid()); + +DROP POLICY IF EXISTS "fhe_key_metadata_insert_own" ON public.fhe_key_metadata; +CREATE POLICY "fhe_key_metadata_insert_own" + ON public.fhe_key_metadata FOR INSERT TO authenticated + WITH CHECK (user_id = auth.uid()); + +DROP POLICY IF EXISTS "fhe_key_metadata_update_own" ON public.fhe_key_metadata; +CREATE POLICY "fhe_key_metadata_update_own" + ON public.fhe_key_metadata FOR UPDATE TO authenticated + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); + +-- =========================================================================== +-- 2. COMPUTE JOBS (async job queue mirror for realtime status) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.fhe_compute_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + key_id TEXT NOT NULL, + operation TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','queued','running','completed','failed','cancelled','dead_letter')), + priority SMALLINT NOT NULL DEFAULT 1, + progress REAL NOT NULL DEFAULT 0.0, + -- Homomorphic RESULT ciphertext only (still encrypted; decryptable solely + -- by the client's ClientKey). Nullable until the job completes. + result_ciphertext TEXT, + error TEXT, + attempts SMALLINT NOT NULL DEFAULT 0, + timeout_seconds INTEGER NOT NULL DEFAULT 30, + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_fhe_jobs_user ON public.fhe_compute_jobs (user_id); +CREATE INDEX IF NOT EXISTS idx_fhe_jobs_status ON public.fhe_compute_jobs (status); + +ALTER TABLE public.fhe_compute_jobs ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "fhe_jobs_select_own" ON public.fhe_compute_jobs; +CREATE POLICY "fhe_jobs_select_own" + ON public.fhe_compute_jobs FOR SELECT TO authenticated + USING (user_id = auth.uid()); + +DROP POLICY IF EXISTS "fhe_jobs_insert_own" ON public.fhe_compute_jobs; +CREATE POLICY "fhe_jobs_insert_own" + ON public.fhe_compute_jobs FOR INSERT TO authenticated + WITH CHECK (user_id = auth.uid()); + +-- Users may only cancel their own jobs (status -> cancelled). The worker +-- (service_role, which bypasses RLS) performs all other status transitions. +DROP POLICY IF EXISTS "fhe_jobs_cancel_own" ON public.fhe_compute_jobs; +CREATE POLICY "fhe_jobs_cancel_own" + ON public.fhe_compute_jobs FOR UPDATE TO authenticated + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); + +-- =========================================================================== +-- 3. EVENT LEDGER (append-only; integrates with the NO SUS audit philosophy) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.fhe_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + job_id UUID, + event_type TEXT NOT NULL + CHECK (event_type IN ( + 'fhe_key_generated','fhe_key_rotated','fhe_key_revoked', + 'fhe_compute_started','fhe_compute_completed','fhe_compute_failed', + 'fhe_budget_exceeded','fhe_job_cancelled','fhe_replay_detected', + 'fhe_discovery_started','fhe_discovery_completed' + )), + -- Aggregate / non-sensitive metadata ONLY. Never plaintext, ciphertext, + -- key material, or key fingerprints. + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()) +); + +CREATE INDEX IF NOT EXISTS idx_fhe_events_user ON public.fhe_events (user_id); +CREATE INDEX IF NOT EXISTS idx_fhe_events_type ON public.fhe_events (event_type); +CREATE INDEX IF NOT EXISTS idx_fhe_events_time ON public.fhe_events (created_at); + +ALTER TABLE public.fhe_events ENABLE ROW LEVEL SECURITY; + +-- Users can read their own audit trail. Inserts happen from the Edge Function +-- (service_role) so the ledger cannot be forged by clients. No UPDATE/DELETE +-- policy exists => the ledger is append-only for everyone but service_role. +DROP POLICY IF EXISTS "fhe_events_select_own" ON public.fhe_events; +CREATE POLICY "fhe_events_select_own" + ON public.fhe_events FOR SELECT TO authenticated + USING (user_id = auth.uid()); + +-- Supabase projects created after April 28, 2026 may not expose new public +-- tables to the Data API automatically. Explicit grants keep the Flutter client +-- able to read its RLS-scoped metadata while RLS remains the row-level guard. +GRANT SELECT, INSERT, UPDATE ON public.fhe_key_metadata TO authenticated; +GRANT SELECT, INSERT, UPDATE ON public.fhe_compute_jobs TO authenticated; +GRANT SELECT ON public.fhe_events TO authenticated; + +-- =========================================================================== +-- 4. AUTO-UPDATE updated_at ON JOBS +-- =========================================================================== +CREATE OR REPLACE FUNCTION public.fhe_touch_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at := timezone('utc', now()); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_fhe_jobs_touch ON public.fhe_compute_jobs; +CREATE TRIGGER trg_fhe_jobs_touch + BEFORE UPDATE ON public.fhe_compute_jobs + FOR EACH ROW EXECUTE FUNCTION public.fhe_touch_updated_at(); + +-- =========================================================================== +-- 5. REALTIME (client subscribes to its own job rows for push notifications) +-- =========================================================================== +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN + BEGIN + ALTER PUBLICATION supabase_realtime ADD TABLE public.fhe_compute_jobs; + EXCEPTION WHEN duplicate_object THEN + NULL; -- already added + END; + END IF; +END $$; diff --git a/app/supabase/migrations/20260704105829_storage_router.sql b/app/supabase/migrations/20260704105829_storage_router.sql new file mode 100644 index 0000000..90f3df7 --- /dev/null +++ b/app/supabase/migrations/20260704105829_storage_router.sql @@ -0,0 +1,91 @@ +-- Migration: storage_router +-- Description: Add a metadata index for optional multi-cloud object storage. +-- +-- This is purely additive. Existing Supabase Storage buckets, upload flows, +-- download flows, sharing rules, and secure_files policies remain intact. +-- Provider credentials are never stored here; they live only as Edge Function +-- secrets consumed by supabase/functions/storage-router. + +CREATE TABLE IF NOT EXISTS public.storage_objects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + group_id text NOT NULL REFERENCES public.study_groups(id) ON DELETE CASCADE, + file_id text NOT NULL REFERENCES public.secure_files(id) ON DELETE CASCADE, + logical_path text NOT NULL, + provider text NOT NULL, + bucket text NOT NULL, + object_key text NOT NULL, + size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0), + content_type text NOT NULL DEFAULT 'application/octet-stream', + checksum_sha256 text, + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'deleted', 'orphaned')), + created_at timestamptz NOT NULL DEFAULT timezone('utc', now()), + updated_at timestamptz NOT NULL DEFAULT timezone('utc', now()), + UNIQUE (file_id), + UNIQUE (user_id, logical_path) +); + +CREATE INDEX IF NOT EXISTS idx_storage_objects_user + ON public.storage_objects(user_id); +CREATE INDEX IF NOT EXISTS idx_storage_objects_group + ON public.storage_objects(group_id); +CREATE INDEX IF NOT EXISTS idx_storage_objects_provider + ON public.storage_objects(provider, status); + +ALTER TABLE public.storage_objects ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "storage_objects: group member select" ON public.storage_objects; +CREATE POLICY "storage_objects: group member select" + ON public.storage_objects FOR SELECT + TO authenticated + USING (public.is_group_member(group_id)); + +DROP POLICY IF EXISTS "storage_objects: owner insert" ON public.storage_objects; +CREATE POLICY "storage_objects: owner insert" + ON public.storage_objects FOR INSERT + TO authenticated + WITH CHECK (user_id = auth.uid() AND public.is_group_member(group_id)); + +DROP POLICY IF EXISTS "storage_objects: owner or admin update" ON public.storage_objects; +CREATE POLICY "storage_objects: owner or admin update" + ON public.storage_objects FOR UPDATE + TO authenticated + USING (user_id = auth.uid() OR public.is_group_admin(group_id)) + WITH CHECK (user_id = auth.uid() OR public.is_group_admin(group_id)); + +DROP POLICY IF EXISTS "storage_objects: owner or admin delete" ON public.storage_objects; +CREATE POLICY "storage_objects: owner or admin delete" + ON public.storage_objects FOR DELETE + TO authenticated + USING (user_id = auth.uid() OR public.is_group_admin(group_id)); + +CREATE OR REPLACE FUNCTION public.storage_objects_touch_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at := timezone('utc', now()); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_storage_objects_touch ON public.storage_objects; +CREATE TRIGGER trg_storage_objects_touch + BEFORE UPDATE ON public.storage_objects + FOR EACH ROW EXECUTE FUNCTION public.storage_objects_touch_updated_at(); + +-- Supabase projects created after April 28, 2026 may not expose new public +-- tables automatically. Keep permissions explicit while RLS enforces rows. +GRANT SELECT, INSERT, UPDATE, DELETE ON public.storage_objects TO authenticated; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN + BEGIN + ALTER PUBLICATION supabase_realtime ADD TABLE public.storage_objects; + EXCEPTION WHEN duplicate_object THEN + NULL; + END; + END IF; +END $$; diff --git a/app/supabase/migrations/20260705000000_sealed_core.sql b/app/supabase/migrations/20260705000000_sealed_core.sql new file mode 100644 index 0000000..2d7505f --- /dev/null +++ b/app/supabase/migrations/20260705000000_sealed_core.sql @@ -0,0 +1,265 @@ +-- Migration: 20260705000000_sealed_core.sql +-- Sealed — reciprocity-gated intent graph. Core schema (M0). +-- +-- ARCHITECTURE / TRUST NOTES (read before changing) +-- * Sealed's unit is an ARENA (a community/room). Each member gets a small, +-- PUBLIC integer id (`arena_public_id`). A SEAL is a member's ENCRYPTED +-- choice: the ciphertext of the arena_public_id of the ONE person they +-- picked (or a sentinel 0 = "no pick"). A MATCH exists for a pair (A,B) iff +-- (A's choice == B.public_id) AND (B's choice == A.public_id) +-- which the FHE pact evaluator computes over ciphertext +-- (services/fhe-compute/src/compute/pact.rs). The predicate needs BOTH +-- choices sealed under the SAME arena key — hence a per-arena key, NOT the +-- per-user FHE keys used elsewhere. +-- * `arenas.public_key` holds the arena's non-secret CompactPublicKey so +-- members can encrypt their choice ON-DEVICE (the trustless M10 path). The +-- interim MVP may encrypt server-side; either way this schema is stable. +-- * PRIVACY (load-bearing): a seal's target is NEVER stored in plaintext — it +-- lives only inside `sealed_choice` (ciphertext). RLS makes a member's seal +-- selectable ONLY by that member. The matcher runs as service_role. +-- * HONESTY RULE: until M10 the pact key may be held server-side, so copy says +-- "encrypted, revealed only on mutual match" — NOT "we cannot see it". +-- * Purely ADDITIVE: touches no existing NO SUS table, policy, or RPC. + +-- =========================================================================== +-- 1. SEALED PROFILES (consumer identity layer over auth.users) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.sealed_profiles ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + handle TEXT UNIQUE, -- public @handle for discovery + display_name TEXT, + avatar_url TEXT, + -- SHA-256 of a normalized phone/email, for contact-based discovery WITHOUT + -- storing raw contacts. Not secret; not reversible to the contact. + contact_hash TEXT, + age_ok BOOLEAN NOT NULL DEFAULT false, -- age-gate (M5) + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()) +); +CREATE INDEX IF NOT EXISTS idx_sealed_profiles_handle ON public.sealed_profiles (handle); +CREATE INDEX IF NOT EXISTS idx_sealed_profiles_contact_hash ON public.sealed_profiles (contact_hash); + +ALTER TABLE public.sealed_profiles ENABLE ROW LEVEL SECURITY; +-- Profiles are discoverable (you must be able to find who to seal), but only +-- the owner may write their own row. +DROP POLICY IF EXISTS "sealed_profiles_select_all" ON public.sealed_profiles; +CREATE POLICY "sealed_profiles_select_all" + ON public.sealed_profiles FOR SELECT TO authenticated USING (true); +DROP POLICY IF EXISTS "sealed_profiles_upsert_own" ON public.sealed_profiles; +CREATE POLICY "sealed_profiles_insert_own" + ON public.sealed_profiles FOR INSERT TO authenticated + WITH CHECK (user_id = auth.uid()); +DROP POLICY IF EXISTS "sealed_profiles_update_own" ON public.sealed_profiles; +CREATE POLICY "sealed_profiles_update_own" + ON public.sealed_profiles FOR UPDATE TO authenticated + USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); + +-- =========================================================================== +-- 2. ARENAS (matching contexts; shared pact key per arena) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.arenas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'community' + CHECK (kind IN ('community','pairwise')), + -- Non-secret CompactPublicKey (base64) enabling on-device encryption. + -- Nullable while a key is being provisioned / in interim server-side mode. + public_key TEXT, + key_fingerprint TEXT, -- SHA-256 of the eval key + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()) +); +ALTER TABLE public.arenas ENABLE ROW LEVEL SECURITY; +-- Members (and, for open communities, any authenticated user who can join) +-- may read the arena. Writes go through RPCs / service_role. +DROP POLICY IF EXISTS "arenas_select_member" ON public.arenas; +CREATE POLICY "arenas_select_member" + ON public.arenas FOR SELECT TO authenticated USING (true); + +-- =========================================================================== +-- 3. ARENA MEMBERS (public integer id per member, unique within an arena) +-- =========================================================================== +-- Helper must exist BEFORE the arena_members policy that references it +-- (Postgres validates policy expressions at creation time). +CREATE OR REPLACE FUNCTION public.is_arena_member(p_arena_id uuid) +RETURNS boolean +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.arena_members + WHERE arena_id = p_arena_id AND user_id = auth.uid() + ); +END; +$$; + +CREATE TABLE IF NOT EXISTS public.arena_members ( + arena_id UUID NOT NULL REFERENCES public.arenas(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + arena_public_id INTEGER NOT NULL, -- PUBLIC; used by the predicate + joined_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + PRIMARY KEY (arena_id, user_id), + UNIQUE (arena_id, arena_public_id) +); +CREATE INDEX IF NOT EXISTS idx_arena_members_user ON public.arena_members (user_id); + +ALTER TABLE public.arena_members ENABLE ROW LEVEL SECURITY; +-- Membership + public ids are not secret (the predicate uses public ids), so +-- members of an arena can see the roster to know who to seal. +DROP POLICY IF EXISTS "arena_members_select" ON public.arena_members; +CREATE POLICY "arena_members_select" + ON public.arena_members FOR SELECT TO authenticated + USING (public.is_arena_member(arena_id)); + +-- =========================================================================== +-- 4. SEALS (encrypted one-directional choices) ***RLS is load-bearing*** +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.seals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + arena_id UUID NOT NULL REFERENCES public.arenas(id) ON DELETE CASCADE, + sealer_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + -- The pick, ENCRYPTED (base64 TFHE ciphertext under the arena key). The + -- target is NEVER stored in plaintext anywhere. + sealed_choice TEXT NOT NULL, + intent_kind TEXT NOT NULL DEFAULT 'crush' + CHECK (intent_kind IN + ('crush','friend','reconnect','work_with','hire','invest','partner')), + -- Someday List (M4): seals do not expire and re-evaluate against future + -- counter-seals unless explicitly withdrawn. + never_expires BOOLEAN NOT NULL DEFAULT true, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','matched','withdrawn')), + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + -- one active seal per (sealer, arena, intent); re-sealing updates it + UNIQUE (arena_id, sealer_id, intent_kind) +); +CREATE INDEX IF NOT EXISTS idx_seals_arena ON public.seals (arena_id); +CREATE INDEX IF NOT EXISTS idx_seals_sealer ON public.seals (sealer_id); + +ALTER TABLE public.seals ENABLE ROW LEVEL SECURITY; +-- CRITICAL: a member can see and manage ONLY their own seals. No policy lets +-- anyone read a counterparty's seal. The matcher reads seals as service_role +-- (which bypasses RLS) and never exposes a non-mutual choice to a client. +DROP POLICY IF EXISTS "seals_select_own" ON public.seals; +CREATE POLICY "seals_select_own" + ON public.seals FOR SELECT TO authenticated USING (sealer_id = auth.uid()); +DROP POLICY IF EXISTS "seals_insert_own" ON public.seals; +CREATE POLICY "seals_insert_own" + ON public.seals FOR INSERT TO authenticated WITH CHECK (sealer_id = auth.uid()); +DROP POLICY IF EXISTS "seals_update_own" ON public.seals; +CREATE POLICY "seals_update_own" + ON public.seals FOR UPDATE TO authenticated + USING (sealer_id = auth.uid()) WITH CHECK (sealer_id = auth.uid()); + +-- =========================================================================== +-- 5. MATCHES (created only on a true FHE decrypt; visible to both parties) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.matches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + arena_id UUID NOT NULL REFERENCES public.arenas(id) ON DELETE CASCADE, + user_a UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + user_b UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + intent_kind TEXT NOT NULL, + matched_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + -- exactly one match per unordered pair per arena+intent + CONSTRAINT matches_pair_unique UNIQUE (arena_id, intent_kind, user_a, user_b), + CONSTRAINT matches_ordered CHECK (user_a < user_b) +); +CREATE INDEX IF NOT EXISTS idx_matches_user_a ON public.matches (user_a); +CREATE INDEX IF NOT EXISTS idx_matches_user_b ON public.matches (user_b); + +ALTER TABLE public.matches ENABLE ROW LEVEL SECURITY; +-- Only the two matched parties can see the match. Inserts are service_role only +-- (the matcher); no client INSERT/UPDATE/DELETE policy exists. +DROP POLICY IF EXISTS "matches_select_participant" ON public.matches; +CREATE POLICY "matches_select_participant" + ON public.matches FOR SELECT TO authenticated + USING (auth.uid() = user_a OR auth.uid() = user_b); + +-- =========================================================================== +-- 6. INVITES (the viral loop: seal toward a non-user -> shareable claim link) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.invites ( + code TEXT PRIMARY KEY, -- random, unguessable + inviter_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + arena_id UUID REFERENCES public.arenas(id) ON DELETE SET NULL, + -- optional contact_hash of the intended recipient (never the raw contact) + target_hint TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + claimed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + claimed_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_invites_inviter ON public.invites (inviter_id); + +ALTER TABLE public.invites ENABLE ROW LEVEL SECURITY; +-- Inviter manages own invites; claiming happens via an RPC (M3) so we never +-- expose the whole invite table. A public claim-by-code path is added in M3. +DROP POLICY IF EXISTS "invites_select_own" ON public.invites; +CREATE POLICY "invites_select_own" + ON public.invites FOR SELECT TO authenticated USING (inviter_id = auth.uid()); +DROP POLICY IF EXISTS "invites_insert_own" ON public.invites; +CREATE POLICY "invites_insert_own" + ON public.invites FOR INSERT TO authenticated WITH CHECK (inviter_id = auth.uid()); + +-- =========================================================================== +-- 7. HELPERS & RPCs +-- =========================================================================== +-- (is_arena_member is defined in section 3, before the policy that uses it.) + +-- Atomically join an arena, assigning the next free public id. Idempotent: +-- returns the caller's existing public id if already a member. +CREATE OR REPLACE FUNCTION public.join_arena(p_arena_id uuid) +RETURNS integer +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_user uuid := auth.uid(); + v_pub integer; +BEGIN + IF v_user IS NULL THEN RAISE EXCEPTION 'Not authenticated'; END IF; + + SELECT arena_public_id INTO v_pub + FROM public.arena_members WHERE arena_id = p_arena_id AND user_id = v_user; + IF v_pub IS NOT NULL THEN RETURN v_pub; END IF; + + -- next public id starts at 1 (0 is reserved as the "no pick" sentinel) + SELECT COALESCE(MAX(arena_public_id), 0) + 1 INTO v_pub + FROM public.arena_members WHERE arena_id = p_arena_id; + + INSERT INTO public.arena_members (arena_id, user_id, arena_public_id) + VALUES (p_arena_id, v_user, v_pub) + ON CONFLICT (arena_id, user_id) DO NOTHING; + + SELECT arena_public_id INTO v_pub + FROM public.arena_members WHERE arena_id = p_arena_id AND user_id = v_user; + RETURN v_pub; +END; +$$; + +-- Keep updated_at fresh on seals (reuses the existing fhe_touch pattern). +DROP TRIGGER IF EXISTS trg_seals_touch ON public.seals; +CREATE TRIGGER trg_seals_touch + BEFORE UPDATE ON public.seals + FOR EACH ROW EXECUTE FUNCTION public.fhe_touch_updated_at(); + +-- =========================================================================== +-- 8. GRANTS + REALTIME +-- =========================================================================== +-- Explicit grants (post-Apr-2026 projects don't auto-expose new tables). +GRANT SELECT, INSERT, UPDATE ON public.sealed_profiles TO authenticated; +GRANT SELECT ON public.arenas TO authenticated; +GRANT SELECT ON public.arena_members TO authenticated; +GRANT SELECT, INSERT, UPDATE ON public.seals TO authenticated; +GRANT SELECT ON public.matches TO authenticated; +GRANT SELECT, INSERT ON public.invites TO authenticated; + +-- The reveal moment: clients subscribe to matches (RLS-scoped to participants) +-- and their own seals (status flips to 'matched'). +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN + BEGIN ALTER PUBLICATION supabase_realtime ADD TABLE public.matches; + EXCEPTION WHEN duplicate_object THEN NULL; END; + BEGIN ALTER PUBLICATION supabase_realtime ADD TABLE public.seals; + EXCEPTION WHEN duplicate_object THEN NULL; END; + END IF; +END $$; diff --git a/app/supabase/migrations/20260705010000_sealed_seals_membership_check.sql b/app/supabase/migrations/20260705010000_sealed_seals_membership_check.sql new file mode 100644 index 0000000..e32e882 --- /dev/null +++ b/app/supabase/migrations/20260705010000_sealed_seals_membership_check.sql @@ -0,0 +1,18 @@ +-- Migration: 20260705010000_sealed_seals_membership_check.sql +-- Hardens seals_insert_own: a seal write now also requires arena membership. +-- +-- WHY: sealChoice moved to a direct client write (Flutter inserts the +-- ciphertext straight into `seals` after encrypting via the arena pact key), +-- bypassing the membership/target-existence checks a trusted orchestrator +-- would otherwise perform. RLS previously only checked `sealer_id = auth.uid()` +-- for INSERT, so any authenticated user could insert a seal row into an arena +-- they never joined (a dead, permanently-unmatchable row — not a data leak, +-- but wasted state worth closing off at the RLS layer, defense-in-depth). +-- +-- Purely additive: replaces one policy on `public.seals`; no other table, +-- policy, or RPC is touched. + +DROP POLICY IF EXISTS "seals_insert_own" ON public.seals; +CREATE POLICY "seals_insert_own" + ON public.seals FOR INSERT TO authenticated + WITH CHECK (sealer_id = auth.uid() AND public.is_arena_member(arena_id)); diff --git a/app/supabase/migrations/20260705020000_securesend_share_links.sql b/app/supabase/migrations/20260705020000_securesend_share_links.sql new file mode 100644 index 0000000..bf3d82f --- /dev/null +++ b/app/supabase/migrations/20260705020000_securesend_share_links.sql @@ -0,0 +1,94 @@ +-- Migration: 20260705020000_securesend_share_links.sql +-- SecureSend: anonymous, tracked, watermarked document share links. +-- +-- WHY: today only an authenticated group member can read a file (RLS in +-- baseline.sql requires group membership via can_access_storage_object). A +-- share-link recipient has no account and no session. Rather than weaken the +-- existing storage RLS, this is a narrow, additive side door: a random token +-- maps to a file; only the trusted `share-fetch` Edge Function (service role) +-- ever resolves a token to bytes (via a short-lived signed URL). No policy +-- here grants public SELECT on the token table itself. +-- +-- Purely additive: touches no existing table, policy, or bucket. + +-- =========================================================================== +-- 1. SHARE LINKS +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.share_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Unguessable token embedded in the public URL (…/v/). 32 hex chars + -- of real randomness — never derived from file_id or any sequential value. + token TEXT UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(16), 'hex'), + file_id TEXT NOT NULL REFERENCES public.secure_files(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + -- Nullable = never expires; default gives new links a sane 30-day life. + expires_at TIMESTAMPTZ DEFAULT (timezone('utc', now()) + interval '30 days'), + revoked BOOLEAN NOT NULL DEFAULT false, + -- Nullable = unlimited views. + max_views INTEGER, + view_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_share_links_file_id ON public.share_links (file_id); +CREATE INDEX IF NOT EXISTS idx_share_links_created_by ON public.share_links (created_by); + +ALTER TABLE public.share_links ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "share_links_select_own" ON public.share_links; +CREATE POLICY "share_links_select_own" + ON public.share_links FOR SELECT TO authenticated + USING (created_by = auth.uid()); + +-- Only the file's OWNER may create a share link for it (not just any group +-- member) — the simplest, least-surprising v1 rule. +DROP POLICY IF EXISTS "share_links_insert_owner" ON public.share_links; +CREATE POLICY "share_links_insert_owner" + ON public.share_links FOR INSERT TO authenticated + WITH CHECK ( + created_by = auth.uid() + AND EXISTS ( + SELECT 1 FROM public.secure_files sf + WHERE sf.id = file_id AND sf.owner_id = auth.uid() + ) + ); + +-- Creator can revoke (and only revoke — no other field is client-mutable). +DROP POLICY IF EXISTS "share_links_update_own" ON public.share_links; +CREATE POLICY "share_links_update_own" + ON public.share_links FOR UPDATE TO authenticated + USING (created_by = auth.uid()) + WITH CHECK (created_by = auth.uid()); + +-- =========================================================================== +-- 2. SHARE LINK VIEWS (append-only view log; the tracking half of the ask) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.share_link_views ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + share_link_id UUID NOT NULL REFERENCES public.share_links(id) ON DELETE CASCADE, + viewer_email TEXT NOT NULL, + viewed_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()) +); + +CREATE INDEX IF NOT EXISTS idx_share_link_views_link ON public.share_link_views (share_link_id); + +ALTER TABLE public.share_link_views ENABLE ROW LEVEL SECURITY; + +-- Only the parent link's creator may read the view log. Inserts happen only +-- from share-fetch (service role, bypasses RLS) — never from a client, so +-- viewers can't forge or read each other's view history. +DROP POLICY IF EXISTS "share_link_views_select_owner" ON public.share_link_views; +CREATE POLICY "share_link_views_select_owner" + ON public.share_link_views FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.share_links sl + WHERE sl.id = share_link_id AND sl.created_by = auth.uid() + ) + ); + +-- =========================================================================== +-- 3. GRANTS (post-Apr-2026 Supabase projects don't auto-expose new tables) +-- =========================================================================== +GRANT SELECT, INSERT, UPDATE ON public.share_links TO authenticated; +GRANT SELECT ON public.share_link_views TO authenticated; diff --git a/app/supabase/migrations/20260706000000_drop_experiment_subsystems.sql b/app/supabase/migrations/20260706000000_drop_experiment_subsystems.sql new file mode 100644 index 0000000..6d887b4 --- /dev/null +++ b/app/supabase/migrations/20260706000000_drop_experiment_subsystems.sql @@ -0,0 +1,40 @@ +-- Migration: 20260706000000_drop_experiment_subsystems.sql +-- Deep cleanup (founder-approved "full nuke", 2026-07-06): removes the unused +-- experiment subsystems from the live database. The corresponding SOURCE CODE +-- (Sealed feature, FHE service, storage-router) stays in the repo, shelved — +-- a fresh environment replaying all migrations recreates then drops these, +-- landing in the same state. +-- +-- Drops (all tables were empty at execution time): +-- FHE subsystem: fhe_nonces, fhe_key_metadata, fhe_compute_jobs, fhe_events +-- Sealed subsystem: sealed_profiles, arenas, arena_members, seals, matches, invites +-- Multi-cloud: storage_objects +-- Orphan: devices (0 rows, unreferenced by any code) +-- Functions whose only consumers are dropped here: is_arena_member, join_arena, +-- fhe_touch_updated_at, storage_objects_touch_updated_at. +-- The PRODUCT tables (share_links, share_link_views) and core app tables are +-- NOT touched by this migration. + +-- Sealed (children before parent; CASCADE clears policies/triggers/FKs) +DROP TABLE IF EXISTS public.seals CASCADE; +DROP TABLE IF EXISTS public.matches CASCADE; +DROP TABLE IF EXISTS public.invites CASCADE; +DROP TABLE IF EXISTS public.arena_members CASCADE; +DROP TABLE IF EXISTS public.arenas CASCADE; +DROP TABLE IF EXISTS public.sealed_profiles CASCADE; +DROP FUNCTION IF EXISTS public.is_arena_member(uuid); +DROP FUNCTION IF EXISTS public.join_arena(uuid); + +-- FHE subsystem +DROP TABLE IF EXISTS public.fhe_nonces CASCADE; +DROP TABLE IF EXISTS public.fhe_key_metadata CASCADE; +DROP TABLE IF EXISTS public.fhe_compute_jobs CASCADE; +DROP TABLE IF EXISTS public.fhe_events CASCADE; +DROP FUNCTION IF EXISTS public.fhe_touch_updated_at(); + +-- Multi-cloud storage router +DROP TABLE IF EXISTS public.storage_objects CASCADE; +DROP FUNCTION IF EXISTS public.storage_objects_touch_updated_at(); + +-- Orphaned table (exists in live DB only; no repo migration or code references) +DROP TABLE IF EXISTS public.devices CASCADE; diff --git a/app/supabase/migrations/20260706010000_securesend_platform_operations.sql b/app/supabase/migrations/20260706010000_securesend_platform_operations.sql new file mode 100644 index 0000000..a57585d --- /dev/null +++ b/app/supabase/migrations/20260706010000_securesend_platform_operations.sql @@ -0,0 +1,188 @@ +-- Migration: 20260706010000_securesend_platform_operations.sql +-- Enables role-based access control, dynamic feature flags, remote configs, +-- and administrative logging for the SecureSend platform. + +-- =========================================================================== +-- 1. ROLES & PERMISSIONS +-- =========================================================================== + +CREATE TYPE public.user_role AS ENUM ('super_admin', 'admin', 'operator', 'user'); + +CREATE TABLE IF NOT EXISTS public.user_roles ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + role public.user_role NOT NULL DEFAULT 'user', + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()) +); + +CREATE INDEX IF NOT EXISTS idx_user_roles_role ON public.user_roles(role); + +ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY; + +-- Security Definer to avoid circular policy checks (RLS recursion) +CREATE OR REPLACE FUNCTION public.is_admin(check_user_id uuid) +RETURNS boolean AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.user_roles + WHERE user_id = check_user_id AND role IN ('super_admin', 'admin') + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION public.is_super_admin(check_user_id uuid) +RETURNS boolean AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.user_roles + WHERE user_id = check_user_id AND role = 'super_admin' + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Roles Policies +DROP POLICY IF EXISTS "user_roles_select_all" ON public.user_roles; +CREATE POLICY "user_roles_select_all" + ON public.user_roles FOR SELECT TO authenticated + USING (true); + +DROP POLICY IF EXISTS "user_roles_write_super" ON public.user_roles; +CREATE POLICY "user_roles_write_super" + ON public.user_roles FOR ALL TO authenticated + USING (public.is_super_admin(auth.uid())) + WITH CHECK (public.is_super_admin(auth.uid())); + +-- Trigger to auto-assign 'user' role on profile creation if not already present +CREATE OR REPLACE FUNCTION public.handle_user_role_setup() +RETURNS trigger AS $$ +BEGIN + INSERT INTO public.user_roles (user_id, role) + VALUES (new.id, 'user') + ON CONFLICT (user_id) DO NOTHING; + RETURN new; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS on_profile_created_role ON public.profiles; +CREATE TRIGGER on_profile_created_role + AFTER INSERT ON public.profiles + FOR EACH ROW EXECUTE FUNCTION public.handle_user_role_setup(); + +-- Backfill existing users (profiles) with 'user' role +INSERT INTO public.user_roles (user_id, role) +SELECT id, 'user'::public.user_role FROM public.profiles +ON CONFLICT (user_id) DO NOTHING; + +-- Auto-elevate the first profile to super_admin for bootstrap purposes +UPDATE public.user_roles +SET role = 'super_admin'::public.user_role +WHERE user_id = (SELECT id FROM public.profiles ORDER BY created_at ASC LIMIT 1); + +-- =========================================================================== +-- 2. FEATURE FLAGS +-- =========================================================================== + +CREATE TABLE IF NOT EXISTS public.feature_flags ( + flag_key TEXT PRIMARY KEY, + description TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT false, + rollout_percentage INTEGER NOT NULL DEFAULT 100 CONSTRAINT pct_limit CHECK (rollout_percentage BETWEEN 0 AND 100), + targeted_user_ids UUID[] NOT NULL DEFAULT '{}'::uuid[], + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()) +); + +ALTER TABLE public.feature_flags ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "feature_flags_select_authenticated" ON public.feature_flags; +CREATE POLICY "feature_flags_select_authenticated" + ON public.feature_flags FOR SELECT TO authenticated + USING (true); + +DROP POLICY IF EXISTS "feature_flags_write_admin" ON public.feature_flags; +CREATE POLICY "feature_flags_write_admin" + ON public.feature_flags FOR ALL TO authenticated + USING (public.is_admin(auth.uid())) + WITH CHECK (public.is_admin(auth.uid())); + +-- Insert Default SecureSend Flags +INSERT INTO public.feature_flags (flag_key, description, is_active, rollout_percentage) VALUES +('securesend_enabled', 'Master kill-switch for SecureSend link generation and viewing.', true, 100), +('securesend_watermark_enforced', 'Forces recipient email rendering as a visual watermark on documents.', true, 100), +('securesend_blur_enforced', 'Enforces touch-to-reveal blur protection on anonymous share views.', true, 100), +('securesend_custom_expiry', 'Allows creators to customize the expiration times of share links.', true, 100) +ON CONFLICT (flag_key) DO UPDATE +SET description = excluded.description, is_active = excluded.is_active, rollout_percentage = excluded.rollout_percentage; + +-- =========================================================================== +-- 3. REMOTE CONFIGURATIONS +-- =========================================================================== + +CREATE TABLE IF NOT EXISTS public.remote_configs ( + config_key TEXT PRIMARY KEY, + config_value JSONB NOT NULL, + description TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()) +); + +ALTER TABLE public.remote_configs ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "remote_configs_select_authenticated" ON public.remote_configs; +CREATE POLICY "remote_configs_select_authenticated" + ON public.remote_configs FOR SELECT TO authenticated + USING (true); + +DROP POLICY IF EXISTS "remote_configs_write_admin" ON public.remote_configs; +CREATE POLICY "remote_configs_write_admin" + ON public.remote_configs FOR ALL TO authenticated + USING (public.is_admin(auth.uid())) + WITH CHECK (public.is_admin(auth.uid())); + +-- Insert Default SecureSend Configs +INSERT INTO public.remote_configs (config_key, config_value, description) VALUES +('securesend_max_expiry_days', '30'::jsonb, 'The maximum number of days a share link can remain active before expiring.'), +('securesend_max_views_default', '10'::jsonb, 'The default max views limit when creating a new share link.'), +('min_supported_client_version', '"1.0.0"'::jsonb, 'The minimum supported client version required to prevent locking access.') +ON CONFLICT (config_key) DO UPDATE +SET config_value = excluded.config_value, description = excluded.description; + +-- =========================================================================== +-- 4. ADMINISTRATIVE AUDIT TRAIL +-- =========================================================================== + +CREATE TABLE IF NOT EXISTS public.admin_audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + admin_id UUID NOT NULL REFERENCES auth.users(id), + action TEXT NOT NULL, + target_table TEXT NOT NULL, + target_key TEXT NOT NULL, + previous_value JSONB, + new_value JSONB, + reason TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()) +); + +CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_admin ON public.admin_audit_logs(admin_id); +CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_action ON public.admin_audit_logs(action); + +ALTER TABLE public.admin_audit_logs ENABLE ROW LEVEL SECURITY; + +-- Admins and Super Admins can read the audit trail +DROP POLICY IF EXISTS "admin_audit_logs_select_admin" ON public.admin_audit_logs; +CREATE POLICY "admin_audit_logs_select_admin" + ON public.admin_audit_logs FOR SELECT TO authenticated + USING (public.is_admin(auth.uid())); + +-- System/service role can write logs, or admins can write logs (bypassed on internal RPC triggers) +DROP POLICY IF EXISTS "admin_audit_logs_insert_admin" ON public.admin_audit_logs; +CREATE POLICY "admin_audit_logs_insert_admin" + ON public.admin_audit_logs FOR INSERT TO authenticated + WITH CHECK (public.is_admin(auth.uid())); + +-- GRANTS FOR CLIENT APPLICATIONS +GRANT SELECT ON public.feature_flags TO authenticated; +GRANT SELECT ON public.remote_configs TO authenticated; +GRANT SELECT ON public.user_roles TO authenticated; +GRANT SELECT, INSERT ON public.admin_audit_logs TO authenticated; diff --git a/app/supabase/migrations/20260706050000_burn_notes.sql b/app/supabase/migrations/20260706050000_burn_notes.sql new file mode 100644 index 0000000..69c32fc --- /dev/null +++ b/app/supabase/migrations/20260706050000_burn_notes.sql @@ -0,0 +1,41 @@ +-- Migration: 20260706050000_burn_notes.sql +-- Zero-knowledge burn-on-read secrets exchange table +-- +-- Only INSERT is open to anonymous clients (senders may have no account). +-- Reads and deletes are never exposed directly to PostgREST — the only way +-- to retrieve (and atomically destroy) a note is the read_and_burn_note RPC +-- below, which runs SECURITY DEFINER and bypasses RLS as the function owner. +-- (An earlier draft of this migration briefly also opened SELECT/DELETE with +-- USING (true), which defeated the burn-on-read guarantee; fixed here so a +-- fresh environment never passes through that insecure intermediate state.) + +CREATE TABLE IF NOT EXISTS public.burn_notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ciphertext TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '7 days') +); + +-- RLS +ALTER TABLE public.burn_notes ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Anyone can insert burn notes" ON public.burn_notes + FOR INSERT WITH CHECK (true); + +-- RPC for atomic read-and-delete (burn on read) +CREATE OR REPLACE FUNCTION read_and_burn_note(note_id UUID) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + retrieved_ciphertext TEXT; +BEGIN + DELETE FROM public.burn_notes + WHERE id = note_id + RETURNING ciphertext INTO retrieved_ciphertext; + + RETURN retrieved_ciphertext; +END; +$$; diff --git a/app/supabase/migrations/20260706120000_burn_notes_security_hardening.sql b/app/supabase/migrations/20260706120000_burn_notes_security_hardening.sql new file mode 100644 index 0000000..ec27152 --- /dev/null +++ b/app/supabase/migrations/20260706120000_burn_notes_security_hardening.sql @@ -0,0 +1,37 @@ +-- Migration: 20260706120000_burn_notes_security_hardening.sql +-- Fixes a critical hole in burn_notes: the "Anyone can read/delete" RLS +-- policies let any anonymous client hit PostgREST directly +-- (GET/DELETE /rest/v1/burn_notes) and read or destroy every secret, +-- bypassing the burn-on-read RPC entirely. Only the RPC (SECURITY DEFINER, +-- which bypasses RLS as the function owner) should ever read/delete rows. +-- Insert stays open: anonymous senders must be able to create notes. + +DROP POLICY IF EXISTS "Anyone can read burn notes" ON public.burn_notes; +DROP POLICY IF EXISTS "Anyone can delete burn notes" ON public.burn_notes; + +-- Atomic burn-on-read: single DELETE...RETURNING instead of SELECT-then-DELETE, +-- which had a race window letting two concurrent readers both retrieve the +-- ciphertext before either delete landed. Also pins search_path per the +-- Supabase security linter (function_search_path_mutable). +CREATE OR REPLACE FUNCTION public.read_and_burn_note(note_id UUID) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + retrieved_ciphertext TEXT; +BEGIN + DELETE FROM public.burn_notes + WHERE id = note_id + RETURNING ciphertext INTO retrieved_ciphertext; + + RETURN retrieved_ciphertext; +END; +$$; + +ALTER FUNCTION public.is_admin(uuid) SET search_path = public; +ALTER FUNCTION public.is_super_admin(uuid) SET search_path = public; +ALTER FUNCTION public.handle_user_role_setup() SET search_path = public; +ALTER FUNCTION public.revoke_device(uuid) SET search_path = public; +ALTER FUNCTION public.update_study_group_file_count() SET search_path = public; diff --git a/app/supabase/migrations/20260706223526_restore_community_rpcs.sql b/app/supabase/migrations/20260706223526_restore_community_rpcs.sql new file mode 100644 index 0000000..0e25b35 --- /dev/null +++ b/app/supabase/migrations/20260706223526_restore_community_rpcs.sql @@ -0,0 +1,80 @@ +-- Migration: 20260706223526_restore_community_rpcs.sql +-- +-- CORRECTION 2026-08-01 — the original header (kept below) was wrong, and it +-- cost real debugging time. It claimed these functions were missing from the +-- live project. They are not: both `join_public_group_by_name` and +-- `ensure_community_exists` exist, this migration is in the ledger as +-- 20260706223526, and both test accounts are members of "Global Community". +-- The "Not a member of the study group" error it blames is unrelated — that +-- comes from opening a document belonging to a group you have left, via a +-- cached history entry. Do not use this header as a diagnostic lead. +-- +-- The file was also renamed from 20260707030000_* to match the ledger version, +-- so `supabase db push` treats it as already applied instead of re-running it. +-- The DDL below is unchanged and still correct (CREATE OR REPLACE, so a re-run +-- would have been harmless either way). +-- +-- ── Original header, retained for the record ────────────────────────────── +-- These two functions exist in the repo's baseline.sql but were never applied +-- to the live project (its schema evolved through an independent migration +-- chain). Without them, the app's community auto-join at startup fails +-- silently (PGRST202 "not found in schema cache"), the user never becomes a +-- member of "Global Community", and downstream audit events logged against +-- that group are rejected with "Not a member of the study group". +-- Definitions copied verbatim from 20260629000000_baseline.sql. + +CREATE OR REPLACE FUNCTION public.join_public_group_by_name( + p_group_name text +) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; + v_user_id uuid; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT id INTO v_group_id + FROM public.study_groups + WHERE name = p_group_name; + + IF v_group_id IS NULL THEN + RAISE EXCEPTION 'Group not found'; + END IF; + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_group_id, v_user_id, NULL, 'member_joined', '{}'::jsonb); + + RETURN v_group_id; +END; +$$; + +CREATE OR REPLACE FUNCTION public.ensure_community_exists( + p_name text, + p_description text +) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; +BEGIN + SELECT id INTO v_group_id + FROM public.study_groups + WHERE name = p_name; + + IF v_group_id IS NULL THEN + v_group_id := gen_random_uuid()::text; + INSERT INTO public.study_groups (id, name, description, is_watermark_enabled) + VALUES (v_group_id, p_name, p_description, true); + END IF; + + RETURN v_group_id; +END; +$$; diff --git a/app/supabase/migrations/20260707000000_drop_orphaned_revoke_device.sql b/app/supabase/migrations/20260707000000_drop_orphaned_revoke_device.sql new file mode 100644 index 0000000..7157bb5 --- /dev/null +++ b/app/supabase/migrations/20260707000000_drop_orphaned_revoke_device.sql @@ -0,0 +1,10 @@ +-- Migration: 20260707000000_drop_orphaned_revoke_device.sql +-- public.revoke_device() references public.devices, which was already dropped +-- as an orphaned table in 20260706000000_drop_experiment_subsystems.sql (that +-- migration dropped the table but missed this function). No client code calls +-- it (grep confirms zero references in lib/ or supabase/functions/), so it's +-- dead and currently broken (any call errors: relation "devices" does not +-- exist) while still being flagged by the security advisor as an +-- unrestricted anon-callable SECURITY DEFINER function. Safe to drop. + +DROP FUNCTION IF EXISTS public.revoke_device(uuid); diff --git a/app/supabase/migrations/20260707000001_share_links_touch_reveal_choice.sql b/app/supabase/migrations/20260707000001_share_links_touch_reveal_choice.sql new file mode 100644 index 0000000..55ffda1 --- /dev/null +++ b/app/supabase/migrations/20260707000001_share_links_touch_reveal_choice.sql @@ -0,0 +1,18 @@ +-- Migration: 20260707000001_share_links_touch_reveal_choice.sql +-- Lets the SENDER (the file's uploader, who creates the share link) choose +-- whether a recipient must touch-and-hold to reveal the document, instead of +-- this being forced on for every link. Defaults to true (unchanged behavior +-- for existing links). The existing `feature_flags.securesend_blur_enforced` +-- global admin flag still applies as a platform-wide override on top of this +-- per-link choice (share-fetch combines them with AND). +-- +-- Purely additive: one new column on an existing table. +-- +-- Renumbered from 20260707000000 (2026-08-01): that timestamp collided with +-- 20260707000000_drop_orphaned_revoke_device.sql — two files sharing one +-- version broke the remote ledger, since supabase_migrations.schema_migrations +-- has version as its primary key. This file's column was already live in +-- production under the old timestamp; the rename is bookkeeping only. + +ALTER TABLE public.share_links + ADD COLUMN IF NOT EXISTS require_touch_reveal BOOLEAN NOT NULL DEFAULT true; diff --git a/app/supabase/migrations/20260707010000_burn_notes_abuse_prevention.sql b/app/supabase/migrations/20260707010000_burn_notes_abuse_prevention.sql new file mode 100644 index 0000000..9f25b43 --- /dev/null +++ b/app/supabase/migrations/20260707010000_burn_notes_abuse_prevention.sql @@ -0,0 +1,45 @@ +-- Migration: 20260707010000_burn_notes_abuse_prevention.sql +-- burn_notes is the app's only anonymous-write surface (INSERT is open by +-- design — senders may have no account). Three gaps made it abusable: +-- +-- 1. No size limit: anyone could insert arbitrarily large ciphertext rows +-- and grow the database unboundedly. The composer is a short text note; +-- 100 KB of base64 (~75 KB plaintext) is far beyond any legitimate use. +-- 2. Nothing ever deletes unread notes: the RPC deletes on read, but a note +-- nobody opens lived forever despite the 7-day expires_at. +-- 3. read_and_burn_note ignored expires_at: an unswept note could still be +-- opened after its advertised expiry. + +-- 1. Cap ciphertext size (existing rows verified far below the cap). +ALTER TABLE public.burn_notes + ADD CONSTRAINT burn_notes_ciphertext_size + CHECK (length(ciphertext) <= 100000); + +-- 2. Honor expiry at read time: expired notes return NULL ("already burned, +-- read, or expired") instead of their content. +CREATE OR REPLACE FUNCTION public.read_and_burn_note(note_id UUID) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + retrieved_ciphertext TEXT; +BEGIN + DELETE FROM public.burn_notes + WHERE id = note_id + AND expires_at > now() + RETURNING ciphertext INTO retrieved_ciphertext; + + RETURN retrieved_ciphertext; +END; +$$; + +-- 3. Hourly sweep of expired rows via pg_cron (available on Supabase). +-- cron.schedule upserts by job name, so re-running is safe. +CREATE EXTENSION IF NOT EXISTS pg_cron; +SELECT cron.schedule( + 'burn-notes-expiry-sweep', + '17 * * * *', + $$DELETE FROM public.burn_notes WHERE expires_at < now()$$ +); diff --git a/app/supabase/migrations/20260707010001_index_secure_files_uploaded_by.sql b/app/supabase/migrations/20260707010001_index_secure_files_uploaded_by.sql new file mode 100644 index 0000000..5e922b7 --- /dev/null +++ b/app/supabase/migrations/20260707010001_index_secure_files_uploaded_by.sql @@ -0,0 +1,7 @@ +-- Migration: 20260707010001_index_secure_files_uploaded_by.sql +-- Covers the secure_files_uploaded_by_fkey foreign key, flagged by the +-- Supabase performance advisor as unindexed (matters for profile deletes, +-- which cascade through this column via account-manager). + +CREATE INDEX IF NOT EXISTS idx_secure_files_uploaded_by + ON public.secure_files (uploaded_by); diff --git a/app/supabase/migrations/20260707020000_feedback_table.sql b/app/supabase/migrations/20260707020000_feedback_table.sql new file mode 100644 index 0000000..59be609 --- /dev/null +++ b/app/supabase/migrations/20260707020000_feedback_table.sql @@ -0,0 +1,20 @@ +-- Migration: 20260707020000_feedback_table.sql +-- The profile screen's "Help & Feedback" form previously showed a success +-- toast without sending anything anywhere. This gives it a real destination. +-- Users can INSERT their own feedback; there is deliberately no SELECT/UPDATE/ +-- DELETE policy for regular users — submissions are read by the founder via +-- the dashboard (service role bypasses RLS). + +CREATE TABLE IF NOT EXISTS public.feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth.users (id) ON DELETE SET NULL, + message TEXT NOT NULL CHECK (length(message) BETWEEN 1 AND 2000), + app_version TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE public.feedback ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "feedback_insert_own" ON public.feedback + FOR INSERT TO authenticated + WITH CHECK (user_id = auth.uid()); diff --git a/app/supabase/migrations/20260707040000_restore_invite_code_rpc.sql b/app/supabase/migrations/20260707040000_restore_invite_code_rpc.sql new file mode 100644 index 0000000..68071e3 --- /dev/null +++ b/app/supabase/migrations/20260707040000_restore_invite_code_rpc.sql @@ -0,0 +1,38 @@ +-- Migration: 20260707040000_restore_invite_code_rpc.sql +-- Same bug class as 20260707030000: join_group_by_invite_code exists in the +-- repo's baseline.sql but was never applied to the live project, so the +-- entire invite-code join flow failed with PGRST202. Copied verbatim from +-- 20260629000000_baseline.sql. + +CREATE OR REPLACE FUNCTION public.join_group_by_invite_code( + p_invite_code text +) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; + v_user_id uuid; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT id INTO v_group_id + FROM public.study_groups + WHERE invite_code = p_invite_code; + + IF v_group_id IS NULL THEN + RAISE EXCEPTION 'Invalid invite code'; + END IF; + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_group_id, v_user_id, NULL, 'member_joined', '{}'::jsonb); + + RETURN v_group_id; +END; +$$; diff --git a/app/supabase/migrations/20260707050000_avatars_bucket.sql b/app/supabase/migrations/20260707050000_avatars_bucket.sql new file mode 100644 index 0000000..35b2fde --- /dev/null +++ b/app/supabase/migrations/20260707050000_avatars_bucket.sql @@ -0,0 +1,24 @@ +-- Migration: 20260707050000_avatars_bucket.sql +-- Public bucket for user profile pictures. Avatars are low-sensitivity, +-- displayed in headers/rosters, so public-read keeps rendering trivial +-- (plain URL, browser-cacheable — no signed-URL churn). Writes are locked +-- to the owner: each user manages exactly one object named .png. + +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ('avatars', 'avatars', true, 1048576, ARRAY['image/png']) +ON CONFLICT (id) DO NOTHING; + +CREATE POLICY "avatars_insert_own" ON storage.objects + FOR INSERT TO authenticated + WITH CHECK (bucket_id = 'avatars' AND name = auth.uid()::text || '.png'); + +CREATE POLICY "avatars_update_own" ON storage.objects + FOR UPDATE TO authenticated + USING (bucket_id = 'avatars' AND name = auth.uid()::text || '.png'); + +CREATE POLICY "avatars_delete_own" ON storage.objects + FOR DELETE TO authenticated + USING (bucket_id = 'avatars' AND name = auth.uid()::text || '.png'); + +CREATE POLICY "avatars_public_read" ON storage.objects + FOR SELECT USING (bucket_id = 'avatars'); diff --git a/app/supabase/migrations/20260708000000_share_view_events.sql b/app/supabase/migrations/20260708000000_share_view_events.sql new file mode 100644 index 0000000..8611360 --- /dev/null +++ b/app/supabase/migrations/20260708000000_share_view_events.sql @@ -0,0 +1,28 @@ +-- Migration: 20260708000000_share_view_events.sql + +CREATE TABLE IF NOT EXISTS public.share_view_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + link_id UUID NOT NULL REFERENCES public.share_links(id) ON DELETE CASCADE, + viewer_email TEXT NOT NULL, + device_type TEXT NOT NULL DEFAULT 'web', -- 'web' | 'app' + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_heartbeat TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, -- set by client on close (best-effort) + duration_seconds INTEGER GENERATED ALWAYS AS ( + EXTRACT(EPOCH FROM (COALESCE(ended_at, last_heartbeat) - started_at))::INTEGER + ) STORED +); + +-- RLS: only the link's creator (file owner) may read their own events +ALTER TABLE public.share_view_events ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "owner can read their link's events" ON public.share_view_events + FOR SELECT USING ( + link_id IN ( + SELECT id FROM public.share_links WHERE created_by = auth.uid() + ) + ); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS share_view_events_link_id_idx ON public.share_view_events(link_id); +CREATE INDEX IF NOT EXISTS share_view_events_started_at_idx ON public.share_view_events(started_at DESC); diff --git a/app/supabase/migrations/20260710000000_group_invites.sql b/app/supabase/migrations/20260710000000_group_invites.sql new file mode 100644 index 0000000..40bbfb8 --- /dev/null +++ b/app/supabase/migrations/20260710000000_group_invites.sql @@ -0,0 +1,190 @@ +-- Migration: 20260710000000_group_invites.sql +-- Setup table and RPCs for zoom/slack-style dynamic group invites + +CREATE TABLE IF NOT EXISTS public.group_invites ( + code TEXT PRIMARY KEY, + group_id TEXT NOT NULL REFERENCES public.study_groups(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, -- null means never expires + max_uses INTEGER, -- null means unlimited uses + use_count INTEGER NOT NULL DEFAULT 0, + is_revoked BOOLEAN NOT NULL DEFAULT false +); + +CREATE INDEX IF NOT EXISTS idx_group_invites_group ON public.group_invites (group_id); + +-- Enable RLS +ALTER TABLE public.group_invites ENABLE ROW LEVEL SECURITY; + +-- Select policy: Anyone can select (required to show details on web landing / pre-screen) +DROP POLICY IF EXISTS "group_invites_select_public" ON public.group_invites; +CREATE POLICY "group_invites_select_public" ON public.group_invites + FOR SELECT USING (true); + +-- Write policy: Only authenticated group members can create/manage invites +DROP POLICY IF EXISTS "group_invites_write_members" ON public.group_invites; +CREATE POLICY "group_invites_write_members" ON public.group_invites + FOR ALL TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.study_group_members + WHERE group_id = group_invites.group_id + AND user_id = auth.uid() + ) + ); + +-- RPC for previewing invite details safely +CREATE OR REPLACE FUNCTION public.get_invite_details( + p_invite_code text +) +RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; + v_group_name text; + v_group_desc text; + v_member_count bigint; + v_creator_name text; + v_invite record; +BEGIN + -- 1. Look up in group_invites + SELECT * INTO v_invite + FROM public.group_invites + WHERE code = p_invite_code; + + IF v_invite IS NOT NULL THEN + -- Validate constraints + IF v_invite.is_revoked THEN + RETURN jsonb_build_object('valid', false, 'reason', 'This invite link has been revoked'); + END IF; + IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN + RETURN jsonb_build_object('valid', false, 'reason', 'This invite link has expired'); + END IF; + IF v_invite.max_uses IS NOT NULL AND v_invite.use_count >= v_invite.max_uses THEN + RETURN jsonb_build_object('valid', false, 'reason', 'This invite link has reached its usage limit'); + END IF; + + v_group_id := v_invite.group_id; + + -- Fetch creator display name + SELECT display_name INTO v_creator_name + FROM public.profiles + WHERE id = v_invite.creator_id; + ELSE + -- Check legacy static code + SELECT id INTO v_group_id + FROM public.study_groups + WHERE invite_code = p_invite_code; + + IF v_group_id IS NULL THEN + RETURN jsonb_build_object('valid', false, 'reason', 'Invalid invite code or link'); + END IF; + + v_creator_name := 'Admin'; + END IF; + + -- 2. Fetch group details + SELECT name, description INTO v_group_name, v_group_desc + FROM public.study_groups + WHERE id = v_group_id; + + SELECT count(*) INTO v_member_count + FROM public.study_group_members + WHERE group_id = v_group_id; + + RETURN jsonb_build_object( + 'valid', true, + 'group_id', v_group_id, + 'group_name', v_group_name, + 'group_description', COALESCE(v_group_desc, ''), + 'member_count', v_member_count, + 'creator_name', COALESCE(v_creator_name, 'Group Administrator') + ); +END; +$$; + +-- RPC for joining a group via invite link safely +CREATE OR REPLACE FUNCTION public.join_group_by_invite_link( + p_invite_code text +) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; + v_user_id uuid; + v_invite record; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- 1. Fetch and lock invite row + SELECT * INTO v_invite + FROM public.group_invites + WHERE code = p_invite_code FOR UPDATE; + + IF v_invite IS NULL THEN + -- Fallback to the legacy static invite_code column on study_groups + SELECT id INTO v_group_id + FROM public.study_groups + WHERE invite_code = p_invite_code; + + IF v_group_id IS NULL THEN + RAISE EXCEPTION 'Invalid invite code or link'; + END IF; + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_group_id, v_user_id, NULL, 'member_joined', '{}'::jsonb); + + RETURN v_group_id; + END IF; + + -- 2. Validate invite constraints + IF v_invite.is_revoked THEN + RAISE EXCEPTION 'This invite link has been revoked'; + END IF; + + IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN + RAISE EXCEPTION 'This invite link has expired'; + END IF; + + IF v_invite.max_uses IS NOT NULL AND v_invite.use_count >= v_invite.max_uses THEN + RAISE EXCEPTION 'This invite link has reached its usage limit'; + END IF; + + -- 3. Add member + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_invite.group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + -- 4. Update invite metrics + UPDATE public.group_invites + SET use_count = use_count + 1 + WHERE code = p_invite_code; + + -- 5. Log activity + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_invite.group_id, v_user_id, NULL, 'member_joined', jsonb_build_object('invite_code', p_invite_code)); + + RETURN v_invite.group_id; +END; +$$; + +-- Insert app_download_url remote config if not already present +INSERT INTO public.remote_configs (config_key, config_value, description) +VALUES ( + 'app_download_url', + '"https://https-shubhamsahu.github.io/NON_SUS/app-release.apk"', + 'Mobile app download destination URL' +) ON CONFLICT (config_key) DO NOTHING; + +-- Grant select to public (anon/authenticated) on remote_configs +DROP POLICY IF EXISTS "remote_configs_select_anon" ON public.remote_configs; +CREATE POLICY "remote_configs_select_anon" ON public.remote_configs + FOR SELECT USING (true); diff --git a/app/supabase/migrations/20260710010000_security_hardening.sql b/app/supabase/migrations/20260710010000_security_hardening.sql new file mode 100644 index 0000000..f9b473b --- /dev/null +++ b/app/supabase/migrations/20260710010000_security_hardening.sql @@ -0,0 +1,166 @@ +-- Security hardening: device-integrity ledger + expanded audit event types +-- for overlay/mirroring/accessibility detection surfaced by the Android +-- device-integrity scanner (root, Frida/Xposed/LSPosed, display mirroring, +-- accessibility-service abuse). See lib/services/device_integrity_service.dart. + +-- 1. Expand the audit_logs event_type enum for viewing-context signals that +-- belong in the existing group-scoped, tamper-evident chain — they occur +-- while a specific document is open, same as screenshot_attempt/recording_attempt. +ALTER TABLE public.audit_logs DROP CONSTRAINT IF EXISTS chk_audit_event_type; +ALTER TABLE public.audit_logs ADD CONSTRAINT chk_audit_event_type CHECK ( + event_type IN ( + 'file_uploaded', + 'file_downloaded', + 'file_viewed', + 'file_deleted', + 'file_renamed', + 'file_pinned', + 'member_joined', + 'member_left', + 'member_promoted', + 'member_demoted', + 'group_created', + 'group_updated', + 'invite_generated', + 'invite_used', + 'screenshot_attempt', + 'recording_attempt', + 'unauthorized_access', + 'overlay_detected', + 'accessibility_detected', + 'display_mirroring_detected' + ) +); + +-- 2. Device-integrity ledger: root/instrumentation findings are a property +-- of the device+user, not of a specific group action, so they get their +-- own append-only, hash-chained table rather than being forced into the +-- group-scoped audit_logs chain. Chained per-user (not per-group). +CREATE TABLE IF NOT EXISTS public.device_integrity_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + device_id text NOT NULL, + event_type text NOT NULL, + severity text NOT NULL DEFAULT 'info', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + previous_hash text NOT NULL, + entry_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chk_dievents_metadata_object CHECK (jsonb_typeof(metadata) = 'object'), + CONSTRAINT chk_dievents_severity CHECK (severity IN ('info', 'warning', 'critical')), + CONSTRAINT chk_dievents_event_type CHECK ( + event_type IN ( + 'root_detected', + 'tamper_detected', + 'device_risk_flagged', + 'session_locked', + 'session_revoked', + 'multiple_device_access', + 'unusual_location_detected' + ) + ) +); + +CREATE INDEX IF NOT EXISTS idx_dievents_user_created ON public.device_integrity_events(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_dievents_device ON public.device_integrity_events(device_id); + +ALTER TABLE public.device_integrity_events ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "device_integrity: self select" + ON public.device_integrity_events FOR SELECT + TO authenticated + USING (user_id = auth.uid()); + +-- No UPDATE/DELETE policies — append-only by omission, matching audit_logs. +-- No direct INSERT policy either — all writes go through the SECURITY +-- DEFINER RPC below so the hash chain and auth binding can't be bypassed +-- by a client inserting arbitrary rows for another user_id. + +CREATE OR REPLACE FUNCTION public.device_integrity_events_hash_trigger() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$ +DECLARE + prev_hash text; +BEGIN + SELECT entry_hash INTO prev_hash + FROM public.device_integrity_events + WHERE user_id = NEW.user_id AND id != NEW.id + ORDER BY created_at DESC, id DESC + LIMIT 1; + + NEW.previous_hash := COALESCE(prev_hash, 'GENESIS'); + + NEW.entry_hash := encode(digest( + NEW.device_id || NEW.event_type || NEW.created_at::text || NEW.previous_hash, + 'sha256' + ), 'hex'); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_device_integrity_events_hash ON public.device_integrity_events; +CREATE TRIGGER trg_device_integrity_events_hash + BEFORE INSERT ON public.device_integrity_events + FOR EACH ROW EXECUTE FUNCTION public.device_integrity_events_hash_trigger(); + +-- 3. RPC: verify the per-user device-integrity hash chain (mirrors verify_audit_chain) +CREATE OR REPLACE FUNCTION public.verify_device_integrity_chain(p_user_id uuid) +RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$ +DECLARE + rec record; + computed_hash text; + expected_prev text := 'GENESIS'; +BEGIN + IF auth.uid() IS DISTINCT FROM p_user_id THEN + RAISE EXCEPTION 'Not authorized to verify another user''s device ledger'; + END IF; + + FOR rec IN + SELECT * FROM public.device_integrity_events + WHERE user_id = p_user_id + ORDER BY created_at ASC, id ASC + LOOP + IF rec.previous_hash IS DISTINCT FROM expected_prev THEN + RETURN jsonb_build_object('status', 'invalid', 'first_broken_record', rec.id, 'reason', 'previous_hash mismatch'); + END IF; + + computed_hash := encode(digest( + rec.device_id || rec.event_type || rec.created_at::text || rec.previous_hash, + 'sha256' + ), 'hex'); + + IF rec.entry_hash IS DISTINCT FROM computed_hash THEN + RETURN jsonb_build_object('status', 'invalid', 'first_broken_record', rec.id, 'reason', 'entry_hash mismatch'); + END IF; + + expected_prev := rec.entry_hash; + END LOOP; + + RETURN jsonb_build_object('status', 'valid'); +END; +$$; + +-- 4. RPC: log a device-integrity finding (root/instrumentation/risk flag) +-- for the calling user. SECURITY DEFINER so RLS never needs a direct +-- INSERT policy — auth.uid() binds the row server-side. +CREATE OR REPLACE FUNCTION public.log_device_integrity_event( + p_event_type text, + p_severity text DEFAULT 'info', + p_device_id text DEFAULT 'unknown', + p_metadata jsonb DEFAULT '{}'::jsonb +) +RETURNS boolean +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + INSERT INTO public.device_integrity_events (user_id, device_id, event_type, severity, metadata) + VALUES (auth.uid(), p_device_id, p_event_type, p_severity, p_metadata); + + RETURN true; +END; +$$; diff --git a/app/supabase/migrations/20260710020000_share_view_suspicious_events.sql b/app/supabase/migrations/20260710020000_share_view_suspicious_events.sql new file mode 100644 index 0000000..9c1b71b --- /dev/null +++ b/app/supabase/migrations/20260710020000_share_view_suspicious_events.sql @@ -0,0 +1,12 @@ +-- Web-viewer deterrent signals (Phase 2 hardening): the anonymous SecureSend +-- viewer can't prevent screenshots in a browser, but it can now report +-- devtools-open heuristics, repeated tab-visibility switches, and blocked +-- copy/print/save/right-click attempts. These get appended to the existing +-- share_view_events row for that viewing session by the share-heartbeat +-- Edge Function (service role — no client-side INSERT/UPDATE policy needed, +-- matching how heartbeats/close already work). +ALTER TABLE public.share_view_events + ADD COLUMN IF NOT EXISTS suspicious_events jsonb NOT NULL DEFAULT '[]'::jsonb; + +COMMENT ON COLUMN public.share_view_events.suspicious_events IS + 'Append-only array of {type, at} objects reported by the web viewer (devtools_detected, visibility_hidden_repeated, print_attempted, copy_attempted, right_click_attempted, save_attempted, drag_attempted). Capped at 50 entries server-side in share-heartbeat/index.ts.'; diff --git a/app/supabase/migrations/20260710030000_risk_engine.sql b/app/supabase/migrations/20260710030000_risk_engine.sql new file mode 100644 index 0000000..b23e7e8 --- /dev/null +++ b/app/supabase/migrations/20260710030000_risk_engine.sql @@ -0,0 +1,292 @@ +-- Phase 3 hardening: deterministic, rule-based risk scoring. +-- +-- Deliberately NOT machine learning — a fixed, auditable weight table that +-- an admin can retune in SQL (or a future admin console) without redeploying +-- app code. Every signal here has a real detector feeding it (Phase 1's +-- device_integrity_events, Phase 1/2's audit_logs event types). Two event +-- types already exist in device_integrity_events' CHECK constraint — +-- multiple_device_access and unusual_location_detected — as schema-ready +-- extensibility hooks, but NO detector populates them yet (would need a +-- concurrent-session tracker / IP geolocation lookup this app doesn't have). +-- They're included below so the engine picks them up automatically the day +-- someone builds that detector, but until then they will simply never fire. + +-- 1. Configurable signal weights --------------------------------------------- +CREATE TABLE IF NOT EXISTS public.risk_signal_weights ( + signal_type text PRIMARY KEY, + source text NOT NULL CHECK (source IN ('audit_logs', 'device_integrity_events')), + weight int NOT NULL CHECK (weight >= 0), + window_minutes int NOT NULL CHECK (window_minutes > 0), + enabled boolean NOT NULL DEFAULT true +); + +INSERT INTO public.risk_signal_weights (signal_type, source, weight, window_minutes) VALUES + ('screenshot_attempt', 'audit_logs', 15, 1440), + ('recording_attempt', 'audit_logs', 25, 1440), + ('overlay_detected', 'audit_logs', 20, 1440), + ('accessibility_detected', 'audit_logs', 15, 1440), + ('display_mirroring_detected', 'audit_logs', 10, 1440), + ('unauthorized_access', 'audit_logs', 30, 1440), + ('root_detected', 'device_integrity_events', 10, 43200), + ('tamper_detected', 'device_integrity_events', 40, 43200), + ('device_risk_flagged', 'device_integrity_events', 20, 1440), + ('multiple_device_access', 'device_integrity_events', 15, 1440), + ('unusual_location_detected', 'device_integrity_events', 20, 1440) +ON CONFLICT (signal_type) DO NOTHING; + +-- 2. Configurable tiers ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.risk_tiers ( + tier text PRIMARY KEY, + min_score int NOT NULL, + actions text[] NOT NULL DEFAULT '{}' +); + +INSERT INTO public.risk_tiers (tier, min_score, actions) VALUES + ('low', 0, '{}'), + ('elevated', 30, '{increase_watermark}'), + ('high', 60, '{increase_watermark,require_reauth,notify_admin}'), + ('critical', 100, '{increase_watermark,lock_session,require_reauth,notify_admin,notify_owner}') +ON CONFLICT (tier) DO NOTHING; + +-- 3. Per-user computed state -------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.user_risk_state ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + score int NOT NULL DEFAULT 0, + tier text NOT NULL DEFAULT 'low' REFERENCES public.risk_tiers(tier), + watermark_intensity text NOT NULL DEFAULT 'normal' + CHECK (watermark_intensity IN ('normal', 'increased', 'maximum')), + session_locked boolean NOT NULL DEFAULT false, + require_reauth boolean NOT NULL DEFAULT false, + breakdown jsonb NOT NULL DEFAULT '{}'::jsonb, + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.user_risk_state ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "risk_state: self or super admin select" + ON public.user_risk_state FOR SELECT + TO authenticated + USING (user_id = auth.uid() OR public.is_super_admin(auth.uid())); + +-- No INSERT/UPDATE/DELETE policies at all — every write goes through +-- recompute_user_risk() / acknowledge_reauth() below (SECURITY DEFINER), +-- so a client can never set its own risk score to "low" directly. + +-- 4. Alerts for admins / group owners ---------------------------------------- +CREATE TABLE IF NOT EXISTS public.security_alerts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + group_id text REFERENCES public.study_groups(id) ON DELETE SET NULL, + tier text NOT NULL, + score int NOT NULL, + breakdown jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + acknowledged boolean NOT NULL DEFAULT false +); + +CREATE INDEX IF NOT EXISTS idx_security_alerts_group ON public.security_alerts(group_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_security_alerts_user ON public.security_alerts(user_id, created_at DESC); + +ALTER TABLE public.security_alerts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "security_alerts: self, group admin, or super admin select" + ON public.security_alerts FOR SELECT + TO authenticated + USING ( + user_id = auth.uid() + OR public.is_super_admin(auth.uid()) + OR (group_id IS NOT NULL AND public.is_group_admin(group_id)) + ); + +-- No direct UPDATE policy — acknowledgement goes through +-- acknowledge_security_alert() so the admin-or-owner check is enforced +-- server-side rather than trusted to RLS on a mutable column. + +-- 5. Core scoring function ---------------------------------------------------- +-- p_context_group_id: the group the triggering event happened in, if any +-- (passed through purely so a resulting alert can be seen by that group's +-- admin — the score itself is always computed across ALL of the user's +-- groups and devices, not just this one). +CREATE OR REPLACE FUNCTION public.recompute_user_risk( + p_user_id uuid, + p_context_group_id text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$ +DECLARE + total_score int := 0; + chosen_tier text; + chosen_actions text[]; + prev_tier text; + breakdown jsonb := '{}'::jsonb; + rec record; +BEGIN + FOR rec IN + SELECT w.signal_type, w.weight, count(*) AS cnt + FROM public.device_integrity_events e + JOIN public.risk_signal_weights w + ON w.signal_type = e.event_type AND w.source = 'device_integrity_events' AND w.enabled + WHERE e.user_id = p_user_id + AND e.created_at > now() - (w.window_minutes || ' minutes')::interval + GROUP BY w.signal_type, w.weight + LOOP + total_score := total_score + (rec.weight * rec.cnt); + breakdown := breakdown || jsonb_build_object(rec.signal_type, rec.cnt); + END LOOP; + + FOR rec IN + SELECT w.signal_type, w.weight, count(*) AS cnt + FROM public.audit_logs a + JOIN public.risk_signal_weights w + ON w.signal_type = a.event_type AND w.source = 'audit_logs' AND w.enabled + WHERE a.actor_id = p_user_id + AND a.created_at > now() - (w.window_minutes || ' minutes')::interval + GROUP BY w.signal_type, w.weight + LOOP + total_score := total_score + (rec.weight * rec.cnt); + breakdown := breakdown || jsonb_build_object(rec.signal_type, rec.cnt); + END LOOP; + + SELECT tier, actions INTO chosen_tier, chosen_actions + FROM public.risk_tiers + WHERE min_score <= total_score + ORDER BY min_score DESC + LIMIT 1; + + IF chosen_tier IS NULL THEN + chosen_tier := 'low'; + chosen_actions := '{}'; + END IF; + + SELECT tier INTO prev_tier FROM public.user_risk_state WHERE user_id = p_user_id; + + INSERT INTO public.user_risk_state ( + user_id, score, tier, watermark_intensity, session_locked, require_reauth, breakdown, updated_at + ) + VALUES ( + p_user_id, + total_score, + chosen_tier, + CASE + WHEN chosen_tier = 'critical' THEN 'maximum' + WHEN 'increase_watermark' = ANY(chosen_actions) THEN 'increased' + ELSE 'normal' + END, + 'lock_session' = ANY(chosen_actions), + 'require_reauth' = ANY(chosen_actions), + breakdown, + now() + ) + ON CONFLICT (user_id) DO UPDATE SET + score = EXCLUDED.score, + tier = EXCLUDED.tier, + watermark_intensity = EXCLUDED.watermark_intensity, + session_locked = EXCLUDED.session_locked, + require_reauth = EXCLUDED.require_reauth, + breakdown = EXCLUDED.breakdown, + updated_at = now(); + + -- Alert on escalation INTO high/critical only — not every recompute, so a + -- user sitting at 'critical' doesn't generate a fresh alert on every + -- unrelated security event that happens to also be scored. + IF chosen_tier IN ('high', 'critical') AND prev_tier IS DISTINCT FROM chosen_tier THEN + INSERT INTO public.security_alerts (user_id, group_id, tier, score, breakdown) + VALUES (p_user_id, p_context_group_id, chosen_tier, total_score, breakdown); + END IF; + + -- Log the enforcement action itself into the device ledger — this is what + -- makes 'session_locked' a real, populated event type rather than a dead + -- CHECK-constraint value. Safe against re-triggering the risk recompute + -- below: 'session_locked' isn't itself a scored signal_type. + IF chosen_tier = 'critical' AND prev_tier IS DISTINCT FROM chosen_tier THEN + INSERT INTO public.device_integrity_events (user_id, device_id, event_type, severity, metadata) + VALUES (p_user_id, 'system', 'session_locked', 'critical', jsonb_build_object('score', total_score)); + END IF; + + RETURN jsonb_build_object('score', total_score, 'tier', chosen_tier, 'actions', to_jsonb(chosen_actions)); +END; +$$; + +-- 6. Triggers: recompute only when the inserted event is an actual scored +-- signal (skips the vast majority of ordinary audit_logs inserts like +-- file_uploaded/member_joined without a per-row lookup cost on every one). +CREATE OR REPLACE FUNCTION public.trg_recompute_risk_from_audit_logs() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM public.risk_signal_weights + WHERE signal_type = NEW.event_type AND source = 'audit_logs' AND enabled + ) THEN + PERFORM public.recompute_user_risk(NEW.actor_id, NEW.group_id); + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_risk_from_audit_logs ON public.audit_logs; +CREATE TRIGGER trg_risk_from_audit_logs + AFTER INSERT ON public.audit_logs + FOR EACH ROW EXECUTE FUNCTION public.trg_recompute_risk_from_audit_logs(); + +CREATE OR REPLACE FUNCTION public.trg_recompute_risk_from_device_integrity() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM public.risk_signal_weights + WHERE signal_type = NEW.event_type AND source = 'device_integrity_events' AND enabled + ) THEN + PERFORM public.recompute_user_risk(NEW.user_id, NULL); + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_risk_from_device_integrity ON public.device_integrity_events; +CREATE TRIGGER trg_risk_from_device_integrity + AFTER INSERT ON public.device_integrity_events + FOR EACH ROW EXECUTE FUNCTION public.trg_recompute_risk_from_device_integrity(); + +-- 7. RPC: the client calls this right after a fresh, successful sign-in to +-- satisfy a require_reauth / session_locked gate. Safe to expose broadly +-- because it only ever clears the CALLER's own flags — it cannot be used +-- to clear anyone else's lock, and a genuinely fresh authentication is +-- exactly what "require reauth" was asking for. +CREATE OR REPLACE FUNCTION public.acknowledge_reauth() +RETURNS boolean +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + UPDATE public.user_risk_state + SET require_reauth = false, session_locked = false, updated_at = now() + WHERE user_id = auth.uid(); + + RETURN true; +END; +$$; + +-- 8. RPC: admin/owner acknowledges an alert (dismisses it from their queue). +CREATE OR REPLACE FUNCTION public.acknowledge_security_alert(p_alert_id uuid) +RETURNS boolean +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + alert_group_id text; +BEGIN + SELECT group_id INTO alert_group_id FROM public.security_alerts WHERE id = p_alert_id; + + IF alert_group_id IS NULL THEN + IF NOT public.is_super_admin(auth.uid()) THEN + RAISE EXCEPTION 'Not authorized to acknowledge this alert'; + END IF; + ELSIF NOT (public.is_super_admin(auth.uid()) OR public.is_group_admin(alert_group_id)) THEN + RAISE EXCEPTION 'Not authorized to acknowledge this alert'; + END IF; + + UPDATE public.security_alerts SET acknowledged = true WHERE id = p_alert_id; + RETURN true; +END; +$$; diff --git a/app/supabase/migrations/20260710031500_risk_engine_lockdown.sql b/app/supabase/migrations/20260710031500_risk_engine_lockdown.sql new file mode 100644 index 0000000..f1c8157 --- /dev/null +++ b/app/supabase/migrations/20260710031500_risk_engine_lockdown.sql @@ -0,0 +1,35 @@ +-- Follow-up to 20260710030000_risk_engine.sql: the advisor caught two real +-- gaps missed in the original migration. +-- +-- 1. risk_signal_weights / risk_tiers were created WITHOUT +-- `ENABLE ROW LEVEL SECURITY` at all — not just missing a policy, RLS was +-- never turned on, so PostgREST's default grants would have controlled +-- access instead of anything this migration intended. +ALTER TABLE public.risk_signal_weights ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.risk_tiers ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "risk_signal_weights: authenticated read" + ON public.risk_signal_weights FOR SELECT + TO authenticated + USING (true); + +CREATE POLICY "risk_tiers: authenticated read" + ON public.risk_tiers FOR SELECT + TO authenticated + USING (true); + +-- No INSERT/UPDATE/DELETE policies — these are admin-tunable config tables, +-- changed via direct SQL (or a future admin console), never by the app. + +-- 2. recompute_user_risk() and the two trigger functions are meant to run +-- ONLY as part of the AFTER INSERT trigger chain, never as a directly +-- callable RPC — PostgREST exposes every SECURITY DEFINER function by +-- default, so without this REVOKE any authenticated user could call +-- recompute_user_risk('someone-elses-uuid', ...) directly, forcing +-- side effects (alert rows, device-ledger inserts) on another account. +-- Trigger invocation does not require EXECUTE privilege (it isn't a normal +-- SQL call), so this does not affect the triggers created in the prior +-- migration — only direct RPC/PostgREST access is blocked. +REVOKE EXECUTE ON FUNCTION public.recompute_user_risk(uuid, text) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.trg_recompute_risk_from_audit_logs() FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.trg_recompute_risk_from_device_integrity() FROM PUBLIC, anon, authenticated; diff --git a/app/supabase/migrations/20260710040000_multi_device_detection.sql b/app/supabase/migrations/20260710040000_multi_device_detection.sql new file mode 100644 index 0000000..0f1c710 --- /dev/null +++ b/app/supabase/migrations/20260710040000_multi_device_detection.sql @@ -0,0 +1,66 @@ +-- Closes a gap flagged (not silently dropped) in 20260710030000_risk_engine.sql: +-- 'multiple_device_access' had a risk_signal_weights row but no detector. +-- This is the detector — no external infra needed (no IP geolocation, no +-- session-tracking service), just a per-user table of devices seen before. +-- +-- 'unusual_location_detected' remains undetected on purpose: that one +-- genuinely needs an IP geolocation lookup, which means picking a provider +-- (and likely a paid tier) — a product decision, not something to wire up +-- silently. + +CREATE TABLE IF NOT EXISTS public.user_known_devices ( + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + device_id text NOT NULL, + first_seen_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, device_id) +); + +ALTER TABLE public.user_known_devices ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "user_known_devices: self select" + ON public.user_known_devices FOR SELECT + TO authenticated + USING (user_id = auth.uid()); + +-- No direct INSERT/UPDATE policy — writes go through register_device_seen() +-- so the "is this genuinely new" check and the resulting ledger entry can't +-- be spoofed by a client inserting rows directly. + +CREATE OR REPLACE FUNCTION public.register_device_seen(p_device_id text) +RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + is_new boolean; + known_count int; +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT NOT EXISTS ( + SELECT 1 FROM public.user_known_devices + WHERE user_id = auth.uid() AND device_id = p_device_id + ) INTO is_new; + + INSERT INTO public.user_known_devices (user_id, device_id, last_seen_at) + VALUES (auth.uid(), p_device_id, now()) + ON CONFLICT (user_id, device_id) DO UPDATE SET last_seen_at = now(); + + SELECT count(*) INTO known_count + FROM public.user_known_devices WHERE user_id = auth.uid(); + + -- Only flag when a NEW device shows up for an account that already has at + -- least one other known device — the user's very first device ever (the + -- common case, every signup) must never trigger this. + IF is_new AND known_count > 1 THEN + INSERT INTO public.device_integrity_events (user_id, device_id, event_type, severity, metadata) + VALUES ( + auth.uid(), p_device_id, 'multiple_device_access', 'info', + jsonb_build_object('known_device_count', known_count) + ); + END IF; + + RETURN jsonb_build_object('is_new_device', is_new, 'known_device_count', known_count); +END; +$$; diff --git a/app/supabase/migrations/20260710050000_burn_files.sql b/app/supabase/migrations/20260710050000_burn_files.sql new file mode 100644 index 0000000..1682819 --- /dev/null +++ b/app/supabase/migrations/20260710050000_burn_files.sql @@ -0,0 +1,195 @@ +-- Migration: 20260710050000_burn_files.sql +-- "Burn Files" — anonymous, no-login file drop. Upload a file, get a link, +-- the file is permanently deleted from storage after first download or a +-- time limit, whichever comes first (file.io's "dual deletion trigger"). +-- +-- Modeled on two existing features, neither of which alone solves this: +-- - burn_notes: zero-knowledge client-side encryption (key/IV never touch +-- the server) + atomic burn-on-read. Text-only, ciphertext inline in a +-- column, so a plain DELETE erases it. +-- - SecureSend (share_links / share-fetch): moves real file bytes through +-- Storage with signed URLs, but NOTHING in this codebase deletes the +-- actual storage object on expiry/exhaustion, and view-count enforcement +-- is non-atomic. This migration does not touch SecureSend at all — it's +-- a parallel, independent system. +-- +-- Since upload here is genuinely anonymous (no auth on either end, per +-- product decision), this migration also includes rate-limiting that +-- burn_notes never needed — an unthrottled anonymous upload endpoint is a +-- real exploitable resource, not just a feature gap. + +-- =========================================================================== +-- 1. STORAGE BUCKET +-- =========================================================================== +-- Private, separate from secure-files for clean lifecycle/RLS isolation. +-- 50MB — double the existing secure-files cap (25MB), still comfortably +-- under the ~100-150MB memory-safety heuristic from client-side AES-CBC +-- having no streaming API (peak memory is ~2-3x file size on both native +-- and web). Not file.io's 4GB — that's a deliberate, conservative choice. +INSERT INTO storage.buckets (id, name, public, file_size_limit) +VALUES ('burn-files', 'burn-files', false, 52428800) +ON CONFLICT (id) DO NOTHING; + +-- Deliberately NO storage.objects RLS policies for anon/authenticated — +-- every access path (upload via signed upload URL, download via signed +-- download URL, delete via the cleanup sweep) goes through a service-role +-- edge function. Nothing here is ever reachable directly by a client role. + +-- =========================================================================== +-- 2. CORE TABLE +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.burn_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + storage_object_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready')), + ciphertext_size_bytes BIGINT NOT NULL CHECK (ciphertext_size_bytes > 0 AND ciphertext_size_bytes <= 104857600), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_burn_files_cleanup + ON public.burn_files(status, expires_at, consumed_at); + +ALTER TABLE public.burn_files ENABLE ROW LEVEL SECURITY; +-- Deliberately ZERO policies — stronger than burn_notes (which still has an +-- open anon INSERT policy). Row creation here must pass through the rate +-- limiter first, which needs server-side IP hashing an RLS WITH CHECK +-- clause can't perform. Every access path is edge-function/service-role. + +-- =========================================================================== +-- 3. ATOMIC SINGLE-USE CLAIM +-- =========================================================================== +-- burn_notes needed a follow-up hardening migration to make its +-- read-and-burn RPC atomic (a prior check-then-delete had a race under +-- concurrent reads). This bakes the fix in from day one: a single +-- UPDATE ... RETURNING can only ever succeed once per row. +CREATE OR REPLACE FUNCTION public.claim_burn_file(p_id uuid) +RETURNS public.burn_files +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_row public.burn_files; +BEGIN + UPDATE public.burn_files + SET consumed_at = now() + WHERE id = p_id + AND status = 'ready' + AND consumed_at IS NULL + AND expires_at > now() + RETURNING * INTO v_row; + + RETURN v_row; -- all-NULL row if no match (0 rows updated) — caller checks v_row.id IS NULL +END; +$$; + +-- Locked to service_role only — unlike read_and_burn_note (which anon calls +-- directly via PostgREST), this RPC is only ever invoked from inside the +-- burn-file-fetch edge function, which also needs a service-role Storage +-- signed-URL call in the same breath. No reason to expose it over PostgREST. +REVOKE EXECUTE ON FUNCTION public.claim_burn_file(uuid) FROM PUBLIC, anon, authenticated; + +-- =========================================================================== +-- 4. ANONYMOUS-UPLOAD RATE LIMITING +-- =========================================================================== +-- Only a salted HMAC hash of the request IP is ever stored (computed in the +-- burn-file-init edge function) — never the raw IP — consistent with the +-- privacy policy's no-tracking commitment. Swept hourly so no hash outlives +-- its usefulness. +CREATE TABLE IF NOT EXISTS public.burn_file_upload_counters ( + ip_hash TEXT PRIMARY KEY, + window_start TIMESTAMPTZ NOT NULL DEFAULT now(), + upload_count INTEGER NOT NULL DEFAULT 0 +); + +ALTER TABLE public.burn_file_upload_counters ENABLE ROW LEVEL SECURITY; +-- Zero policies — only ever touched via the SECURITY DEFINER function below. + +CREATE OR REPLACE FUNCTION public.check_and_increment_burn_upload_rate( + p_ip_hash text, + p_limit int, + p_window_minutes int +) +RETURNS boolean +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_row public.burn_file_upload_counters; +BEGIN + SELECT * INTO v_row FROM public.burn_file_upload_counters WHERE ip_hash = p_ip_hash FOR UPDATE; + + IF NOT FOUND THEN + INSERT INTO public.burn_file_upload_counters (ip_hash, upload_count) VALUES (p_ip_hash, 1); + RETURN true; + END IF; + + IF v_row.window_start < now() - make_interval(mins => p_window_minutes) THEN + UPDATE public.burn_file_upload_counters + SET window_start = now(), upload_count = 1 + WHERE ip_hash = p_ip_hash; + RETURN true; + END IF; + + IF v_row.upload_count >= p_limit THEN + RETURN false; + END IF; + + UPDATE public.burn_file_upload_counters SET upload_count = upload_count + 1 WHERE ip_hash = p_ip_hash; + RETURN true; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.check_and_increment_burn_upload_rate(text, int, int) + FROM PUBLIC, anon, authenticated; + +-- =========================================================================== +-- 5. FEATURE FLAG + CONFIG (existing feature_flags/remote_configs convention) +-- =========================================================================== +INSERT INTO public.feature_flags (flag_key, description, is_active, rollout_percentage) VALUES +('burn_files_enabled', 'Master kill-switch for Burn Files upload/download.', true, 100) +ON CONFLICT (flag_key) DO UPDATE +SET description = excluded.description, is_active = excluded.is_active; + +INSERT INTO public.remote_configs (config_key, config_value, description) VALUES +('burn_files_max_size_bytes', '52428800'::jsonb, 'Max encrypted blob size for a Burn File upload (50MB default).'), +('burn_files_default_expiry_hours', '24'::jsonb, 'Default TTL for a new Burn File when the sender does not choose one.'), +('burn_files_max_expiry_hours', '168'::jsonb, 'Max TTL a sender may request (7 days).'), +('burn_files_rate_limit_per_hour', '8'::jsonb, 'Max uploads per hashed-IP per rolling window.'), +('burn_files_rate_limit_window_minutes', '60'::jsonb, 'Rolling window length for the upload rate limiter.') +ON CONFLICT (config_key) DO UPDATE +SET config_value = excluded.config_value, description = excluded.description; + +-- =========================================================================== +-- 6. SCHEDULED CLEANUP (first use of pg_net in this project) +-- =========================================================================== +-- pg_cron cannot call the Storage REST API directly — deleting an object +-- requires an actual HTTP call. pg_net is the async-HTTP bridge Supabase +-- provides for exactly this: cron -> pg_net -> edge function -> Storage API. +CREATE EXTENSION IF NOT EXISTS pg_net; + +-- Sweeps the rate-limit counter table hourly, same cadence/pattern as the +-- existing burn_notes expiry sweep. +SELECT cron.schedule( + 'burn-file-rate-limit-sweep', + '0 * * * *', + $$DELETE FROM public.burn_file_upload_counters WHERE window_start < now() - interval '24 hours'$$ +); + +-- The main cleanup sweep — every 5 minutes, tighter than burn_notes' hourly +-- sweep, since "permanently deleted" for real file bytes is a stated product +-- promise, not just row housekeeping. Authenticated via a shared secret in +-- Supabase Vault (created once, out-of-band — see deployment notes; never +-- committed as a literal value in this file). The cron job only looks the +-- secret up by name at execution time. +SELECT cron.schedule( + 'burn-files-cleanup-sweep', + '*/5 * * * *', + $$ + SELECT net.http_post( + url := 'https://rxfnazmusofikwaggntb.supabase.co/functions/v1/cleanup-burn-files', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-cron-secret', (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'burn_files_cron_secret') + ), + body := '{}'::jsonb + ); + $$ +); diff --git a/app/supabase/migrations/20260711000000_function_grants_least_privilege.sql b/app/supabase/migrations/20260711000000_function_grants_least_privilege.sql new file mode 100644 index 0000000..99881bd --- /dev/null +++ b/app/supabase/migrations/20260711000000_function_grants_least_privilege.sql @@ -0,0 +1,94 @@ +-- Function-grant least privilege +-- +-- Postgres grants EXECUTE on new functions to PUBLIC by default, so every +-- SECURITY DEFINER RPC created before the burn_files/risk_engine era was +-- callable by the `anon` role via /rest/v1/rpc/*. Newer migrations +-- (20260710050000_burn_files.sql, 20260710030000_risk_engine.sql) already +-- revoke default grants on their functions; this migration applies the same +-- pattern to the older ones, flagged by Supabase's security advisor +-- (anon_security_definer_function_executable). +-- +-- Grant tiers: +-- trigger-only → no client role may EXECUTE (triggers run as definer/owner) +-- authenticated → app RPCs that require a session (their bodies already +-- check auth.uid(); this adds a grant-level wall in front) +-- anon + authenticated → RPCs that are anonymous BY DESIGN: +-- read_and_burn_note (burn-note claim happens without an account) +-- get_invite_details (invite landing screen renders pre-login) + +-- ── Trigger functions: never client-callable ──────────────────────────────── +revoke execute on function public.audit_logs_hash_trigger() from public, anon, authenticated; +revoke execute on function public.device_integrity_events_hash_trigger() from public, anon, authenticated; +revoke execute on function public.handle_new_user() from public, anon, authenticated; +revoke execute on function public.handle_user_role_setup() from public, anon, authenticated; +revoke execute on function public.update_study_group_file_count() from public, anon, authenticated; + +-- ── Authenticated-only RPCs and RLS helper functions ──────────────────────── +revoke execute on function public.acknowledge_reauth() from public, anon; +grant execute on function public.acknowledge_reauth() to authenticated; + +revoke execute on function public.acknowledge_security_alert(uuid) from public, anon; +grant execute on function public.acknowledge_security_alert(uuid) to authenticated; + +revoke execute on function public.can_access_storage_object(text, text, uuid) from public, anon; +grant execute on function public.can_access_storage_object(text, text, uuid) to authenticated; + +revoke execute on function public.can_delete_storage_object(text, text, uuid, text) from public, anon; +grant execute on function public.can_delete_storage_object(text, text, uuid, text) to authenticated; + +revoke execute on function public.create_study_group_with_creator(text, text, text, boolean, text) from public, anon; +grant execute on function public.create_study_group_with_creator(text, text, text, boolean, text) to authenticated; + +revoke execute on function public.ensure_community_exists(text, text) from public, anon; +grant execute on function public.ensure_community_exists(text, text) to authenticated; + +revoke execute on function public.is_admin(uuid) from public, anon; +grant execute on function public.is_admin(uuid) to authenticated; + +revoke execute on function public.is_group_admin(text) from public, anon; +grant execute on function public.is_group_admin(text) to authenticated; + +revoke execute on function public.is_group_member(text) from public, anon; +grant execute on function public.is_group_member(text) to authenticated; + +revoke execute on function public.is_super_admin(uuid) from public, anon; +grant execute on function public.is_super_admin(uuid) to authenticated; + +revoke execute on function public.join_group_by_invite_code(text) from public, anon; +grant execute on function public.join_group_by_invite_code(text) to authenticated; + +revoke execute on function public.join_group_by_invite_link(text) from public, anon; +grant execute on function public.join_group_by_invite_link(text) to authenticated; + +revoke execute on function public.join_public_group_by_name(text) from public, anon; +grant execute on function public.join_public_group_by_name(text) to authenticated; + +revoke execute on function public.log_device_integrity_event(text, text, text, jsonb) from public, anon; +grant execute on function public.log_device_integrity_event(text, text, text, jsonb) to authenticated; + +revoke execute on function public.log_group_event(text, text, text, jsonb) from public, anon; +grant execute on function public.log_group_event(text, text, text, jsonb) to authenticated; + +revoke execute on function public.register_device_seen(text) from public, anon; +grant execute on function public.register_device_seen(text) to authenticated; + +revoke execute on function public.verify_audit_chain(text) from public, anon; +grant execute on function public.verify_audit_chain(text) to authenticated; + +revoke execute on function public.verify_device_integrity_chain(uuid) from public, anon; +grant execute on function public.verify_device_integrity_chain(uuid) to authenticated; + +-- ── Anonymous-by-design RPCs: replace implicit PUBLIC with explicit grants ── +revoke execute on function public.read_and_burn_note(uuid) from public; +grant execute on function public.read_and_burn_note(uuid) to anon, authenticated; + +revoke execute on function public.get_invite_details(text) from public; +grant execute on function public.get_invite_details(text) to anon, authenticated; + +-- ── Storage: avatars bucket listing ───────────────────────────────────────── +-- The app reads avatars exclusively through getPublicUrl() (public-bucket URL +-- endpoint, which does not consult RLS). The broad SELECT policy only enabled +-- authenticated/anon API clients to LIST every object in the bucket — file +-- names are user ids, so that leaked the user list. Flagged by the advisor +-- (public_bucket_allows_listing). Drop it; upload/update/delete stay owner-scoped. +drop policy if exists avatars_public_read on storage.objects; diff --git a/app/supabase/migrations/20260711010000_rls_initplan_and_fk_indexes.sql b/app/supabase/migrations/20260711010000_rls_initplan_and_fk_indexes.sql new file mode 100644 index 0000000..507d398 --- /dev/null +++ b/app/supabase/migrations/20260711010000_rls_initplan_and_fk_indexes.sql @@ -0,0 +1,45 @@ +-- RLS initplan optimization + covering FK indexes +-- +-- Supabase performance advisor (auth_rls_initplan): bare auth.uid() / +-- auth.role() calls in a policy are re-evaluated per row; wrapping them in a +-- scalar subquery `(select auth.uid())` lets the planner evaluate once per +-- statement. Statements below were generated from pg_policies with only that +-- wrap applied — quals are otherwise byte-identical to production. + +alter policy admin_audit_logs_insert_admin on public.admin_audit_logs with check (is_admin((select auth.uid()))); +alter policy admin_audit_logs_select_admin on public.admin_audit_logs using (is_admin((select auth.uid()))); +alter policy "device_integrity: self select" on public.device_integrity_events using ((user_id = (select auth.uid()))); +alter policy feature_flags_write_admin on public.feature_flags using (is_admin((select auth.uid()))) with check (is_admin((select auth.uid()))); +alter policy feedback_insert_own on public.feedback with check ((user_id = (select auth.uid()))); +alter policy "focus: self all" on public.focus_logs using ((user_id = (select auth.uid()))) with check ((user_id = (select auth.uid()))); +alter policy group_invites_write_members on public.group_invites using ((EXISTS ( SELECT 1 + FROM study_group_members + WHERE ((study_group_members.group_id = group_invites.group_id) AND (study_group_members.user_id = (select auth.uid())))))); +alter policy "Users can insert their own profile" on public.profiles with check (((select auth.uid()) = id)); +alter policy "Users can update their own profile" on public.profiles using (((select auth.uid()) = id)) with check (((select auth.uid()) = id)); +alter policy remote_configs_write_admin on public.remote_configs using (is_admin((select auth.uid()))) with check (is_admin((select auth.uid()))); +alter policy "files: owner or admin delete" on public.secure_files using (((owner_id = (select auth.uid())) OR is_group_admin(group_id))); +alter policy "files: owner or admin update" on public.secure_files using (((owner_id = (select auth.uid())) OR is_group_admin(group_id))); +alter policy "security_alerts: self, group admin, or super admin select" on public.security_alerts using (((user_id = (select auth.uid())) OR is_super_admin((select auth.uid())) OR ((group_id IS NOT NULL) AND is_group_admin(group_id)))); +alter policy share_link_views_select_owner on public.share_link_views using ((EXISTS ( SELECT 1 + FROM share_links sl + WHERE ((sl.id = share_link_views.share_link_id) AND (sl.created_by = (select auth.uid())))))); +alter policy share_links_insert_owner on public.share_links with check (((created_by = (select auth.uid())) AND (EXISTS ( SELECT 1 + FROM secure_files sf + WHERE ((sf.id = share_links.file_id) AND (sf.owner_id = (select auth.uid()))))))); +alter policy share_links_select_own on public.share_links using ((created_by = (select auth.uid()))); +alter policy share_links_update_own on public.share_links using ((created_by = (select auth.uid()))) with check ((created_by = (select auth.uid()))); +alter policy "owner can read their link's events" on public.share_view_events using ((link_id IN ( SELECT share_links.id + FROM share_links + WHERE (share_links.created_by = (select auth.uid()))))); +alter policy "members: self delete" on public.study_group_members using ((user_id = (select auth.uid()))); +alter policy "members: self insert" on public.study_group_members with check ((user_id = (select auth.uid()))); +alter policy "user_known_devices: self select" on public.user_known_devices using ((user_id = (select auth.uid()))); +alter policy "notes: self all" on public.user_notes using ((user_id = (select auth.uid()))) with check ((user_id = (select auth.uid()))); +alter policy "risk_state: self or super admin select" on public.user_risk_state using (((user_id = (select auth.uid())) OR is_super_admin((select auth.uid())))); +alter policy user_roles_write_super on public.user_roles using (is_super_admin((select auth.uid()))) with check (is_super_admin((select auth.uid()))); + +-- Covering indexes for FKs flagged by the advisor (unindexed_foreign_keys). +create index if not exists idx_feedback_user_id on public.feedback (user_id); +create index if not exists idx_group_invites_creator_id on public.group_invites (creator_id); +create index if not exists idx_user_risk_state_tier on public.user_risk_state (tier); diff --git a/app/supabase/migrations/20260713000000_burn_redemption_codes.sql b/app/supabase/migrations/20260713000000_burn_redemption_codes.sql new file mode 100644 index 0000000..462b6a4 --- /dev/null +++ b/app/supabase/migrations/20260713000000_burn_redemption_codes.sql @@ -0,0 +1,160 @@ +-- Migration: 20260713000000_burn_redemption_codes.sql +-- Short redemption codes — an alternate retrieval path for Burn Notes and +-- Burn Files, alongside the existing link (whose URL fragment carries the +-- AES key/IV and never touches the server). A recipient can instead type an +-- 8-character code; the app looks up the key/IV from the server and +-- proceeds through the exact same viewer/claim flow as a link would. +-- +-- This is a DELIBERATE, informed change to what "zero-knowledge" means for +-- this one path: the server temporarily holds the key material so a short, +-- human-typeable code can resolve it. Compensating controls: short expiry +-- (capped to the underlying content's own expiry), single-use, and a +-- rate limiter on redemption attempts mirroring burn_files' upload limiter. +-- The link-based path is completely untouched and keeps its original +-- guarantee — this table only ever holds an ADDITIONAL pointer to key +-- material that also exists (encrypted content + key) via the normal path. +-- +-- A code and its underlying burn_files/burn_notes row share one burn: since +-- claim_burn_file/read_and_burn_note are already atomic single-use, whichever +-- of {link, code} is used first consumes the content — the other simply +-- stops working. No new burn-semantics logic needed here. + +-- =========================================================================== +-- 1. CORE TABLE +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.burn_redemption_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code_hash TEXT NOT NULL UNIQUE, -- HMAC-SHA256(code, REDEMPTION_CODE_SALT), hex — never the plaintext code + target_kind TEXT NOT NULL CHECK (target_kind IN ('note', 'file')), + target_id UUID NOT NULL, + key_hex TEXT NOT NULL, + iv_hex TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_burn_redemption_codes_cleanup + ON public.burn_redemption_codes(expires_at, consumed_at); + +ALTER TABLE public.burn_redemption_codes ENABLE ROW LEVEL SECURITY; +-- Deliberately ZERO policies — same posture as burn_files. Every access path +-- (create, redeem, cleanup) is edge-function/service-role/cron only, never +-- reachable directly via PostgREST. + +-- =========================================================================== +-- 2. ATOMIC SINGLE-USE CLAIM +-- =========================================================================== +CREATE OR REPLACE FUNCTION public.claim_redemption_code(p_code_hash text) +RETURNS public.burn_redemption_codes +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_row public.burn_redemption_codes; +BEGIN + UPDATE public.burn_redemption_codes + SET consumed_at = now() + WHERE code_hash = p_code_hash + AND consumed_at IS NULL + AND expires_at > now() + RETURNING * INTO v_row; + + RETURN v_row; -- all-NULL row if no match — caller checks v_row.id IS NULL +END; +$$; + +-- Locked to service_role only — invoked exclusively from inside the +-- redeem-code edge function, which also needs to rate-limit by IP hash in +-- the same breath. No reason to expose this over PostgREST. +REVOKE EXECUTE ON FUNCTION public.claim_redemption_code(text) FROM PUBLIC, anon, authenticated; + +-- =========================================================================== +-- 3. REDEMPTION-ATTEMPT RATE LIMITING +-- =========================================================================== +-- Mirrors burn_file_upload_counters / check_and_increment_burn_upload_rate +-- exactly (20260710050000_burn_files.sql) — only a salted HMAC hash of the +-- request IP is ever stored, never the raw IP. This limits brute-force +-- guessing against the code space; the code's own entropy (8 chars from a +-- 32-symbol alphabet, ~1.1 trillion combinations) already makes offline +-- brute force impractical within a 20-minute window, so this is +-- defense-in-depth against a scripted/distributed attempt, not the sole +-- protection. +CREATE TABLE IF NOT EXISTS public.burn_redeem_attempt_counters ( + ip_hash TEXT PRIMARY KEY, + window_start TIMESTAMPTZ NOT NULL DEFAULT now(), + attempt_count INTEGER NOT NULL DEFAULT 0 +); + +ALTER TABLE public.burn_redeem_attempt_counters ENABLE ROW LEVEL SECURITY; +-- Zero policies — only ever touched via the SECURITY DEFINER function below. + +CREATE OR REPLACE FUNCTION public.check_and_increment_redeem_rate( + p_ip_hash text, + p_limit int, + p_window_minutes int +) +RETURNS boolean +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_row public.burn_redeem_attempt_counters; +BEGIN + SELECT * INTO v_row FROM public.burn_redeem_attempt_counters WHERE ip_hash = p_ip_hash FOR UPDATE; + + IF NOT FOUND THEN + INSERT INTO public.burn_redeem_attempt_counters (ip_hash, attempt_count) VALUES (p_ip_hash, 1); + RETURN true; + END IF; + + IF v_row.window_start < now() - make_interval(mins => p_window_minutes) THEN + UPDATE public.burn_redeem_attempt_counters + SET window_start = now(), attempt_count = 1 + WHERE ip_hash = p_ip_hash; + RETURN true; + END IF; + + IF v_row.attempt_count >= p_limit THEN + RETURN false; + END IF; + + UPDATE public.burn_redeem_attempt_counters SET attempt_count = attempt_count + 1 WHERE ip_hash = p_ip_hash; + RETURN true; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.check_and_increment_redeem_rate(text, int, int) + FROM PUBLIC, anon, authenticated; + +-- =========================================================================== +-- 4. FEATURE FLAG + CONFIG (existing feature_flags/remote_configs convention) +-- =========================================================================== +INSERT INTO public.feature_flags (flag_key, description, is_active, rollout_percentage) VALUES +('burn_redemption_codes_enabled', 'Master kill-switch for short-code redemption of Burn Notes/Files.', true, 100) +ON CONFLICT (flag_key) DO UPDATE +SET description = excluded.description, is_active = excluded.is_active; + +INSERT INTO public.remote_configs (config_key, config_value, description) VALUES +('redeem_code_ttl_minutes', '20'::jsonb, 'How long a redemption code stays valid, capped to the underlying content''s own expiry.'), +('redeem_rate_limit_per_hour', '10'::jsonb, 'Max redemption attempts per hashed-IP per rolling window.'), +('redeem_rate_limit_window_minutes', '60'::jsonb, 'Rolling window length for the redemption rate limiter.') +ON CONFLICT (config_key) DO UPDATE +SET config_value = excluded.config_value, description = excluded.description; + +-- =========================================================================== +-- 5. SCHEDULED CLEANUP +-- =========================================================================== +-- Unlike burn_files, redemption codes have no associated Storage object to +-- delete — this table is pure metadata (key/IV text), so a plain SQL DELETE +-- via pg_cron is sufficient, no pg_net/edge-function round trip needed. +-- Same cadence class as burn_notes' hourly sweep, tightened since the whole +-- point of this table is to hold sensitive key material only briefly. +SELECT cron.schedule( + 'burn-redemption-codes-cleanup', + '*/10 * * * *', + $$DELETE FROM public.burn_redemption_codes WHERE expires_at < now() OR consumed_at IS NOT NULL$$ +); + +-- Sweeps the rate-limit counter table hourly, same pattern as burn_files'. +SELECT cron.schedule( + 'burn-redeem-rate-limit-sweep', + '0 * * * *', + $$DELETE FROM public.burn_redeem_attempt_counters WHERE window_start < now() - interval '24 hours'$$ +); diff --git a/app/supabase/migrations/20260717000000_app_latest_version_config.sql b/app/supabase/migrations/20260717000000_app_latest_version_config.sql new file mode 100644 index 0000000..6303453 --- /dev/null +++ b/app/supabase/migrations/20260717000000_app_latest_version_config.sql @@ -0,0 +1,23 @@ +-- =========================================================================== +-- APP LATEST VERSION remote config +-- =========================================================================== +-- Source of truth for the in-app "update available" check +-- (lib/features/config/presentation/providers/app_update_provider.dart). +-- The app compares its own PackageInfo version against this row and shows a +-- dismissible update banner + a "GET UPDATE" action in the About modal, +-- opening `app_download_url` (the GitHub releases/latest page, where CI +-- attaches an APK on every v*.*.* tag). +-- +-- RELEASE FLOW: bump this row's value after pushing each release tag — +-- UPDATE public.remote_configs +-- SET config_value = '"X.Y.Z"'::jsonb +-- WHERE config_key = 'app_latest_version'; +-- +-- Unlike other config seeds in this schema history, this uses DO NOTHING +-- rather than DO UPDATE on conflict: the value is expected to move ahead of +-- what any migration says (bumped per release), so replaying migrations must +-- never regress it. + +INSERT INTO public.remote_configs (config_key, config_value, description) VALUES +('app_latest_version', '"1.2.0"'::jsonb, 'Latest released app version; drives the in-app update banner. Bump after each release tag.') +ON CONFLICT (config_key) DO NOTHING; diff --git a/app/supabase/migrations/20260721000000_security_hardening_and_burn_note_rate_limit.sql b/app/supabase/migrations/20260721000000_security_hardening_and_burn_note_rate_limit.sql new file mode 100644 index 0000000..cb6ac03 --- /dev/null +++ b/app/supabase/migrations/20260721000000_security_hardening_and_burn_note_rate_limit.sql @@ -0,0 +1,118 @@ +-- Security hardening + RLS performance consolidation +-- +-- Follow-up to the audit in Pass 2 of the production-readiness review. +-- Every change here is additive-safe or a straight tightening — nothing +-- edits data destructively. + +-- =========================================================================== +-- 1. study_groups.invite_code: close the direct-insert / collision gap +-- =========================================================================== +ALTER TABLE public.study_groups + ADD CONSTRAINT study_groups_invite_code_key UNIQUE (invite_code); + +DROP POLICY IF EXISTS "groups: authenticated insert" ON public.study_groups; + +-- =========================================================================== +-- 2. pg_net-in-public advisory: deliberately NOT fixed here (see report — +-- ALTER EXTENSION SET SCHEMA is unsupported for pg_net; drop+recreate is +-- too high-blast-radius for an automated pass). +-- =========================================================================== + +-- =========================================================================== +-- 3. Burn Notes: lightweight global insert-velocity guard +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.burn_note_velocity ( + bucket_start timestamptz PRIMARY KEY, + insert_count integer NOT NULL DEFAULT 0 +); + +ALTER TABLE public.burn_note_velocity ENABLE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION public.check_burn_note_velocity() +RETURNS trigger AS $$ +DECLARE + v_bucket timestamptz := date_trunc('minute', now()); + v_count integer; +BEGIN + INSERT INTO public.burn_note_velocity (bucket_start, insert_count) + VALUES (v_bucket, 1) + ON CONFLICT (bucket_start) DO UPDATE SET insert_count = burn_note_velocity.insert_count + 1 + RETURNING insert_count INTO v_count; + + DELETE FROM public.burn_note_velocity WHERE bucket_start < v_bucket - interval '10 minutes'; + + IF v_count > 120 THEN + RAISE EXCEPTION 'Too many burn notes created right now. Please try again in a moment.' + USING ERRCODE = 'P0001'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +REVOKE EXECUTE ON FUNCTION public.check_burn_note_velocity() FROM public, anon, authenticated; + +DROP TRIGGER IF EXISTS burn_notes_velocity_guard ON public.burn_notes; +CREATE TRIGGER burn_notes_velocity_guard + BEFORE INSERT ON public.burn_notes + FOR EACH ROW EXECUTE FUNCTION public.check_burn_note_velocity(); + +-- =========================================================================== +-- 4. Consolidate multiple permissive RLS policies (performance advisory) +-- =========================================================================== +DROP POLICY IF EXISTS feature_flags_write_admin ON public.feature_flags; +CREATE POLICY feature_flags_write_insert ON public.feature_flags + FOR INSERT TO authenticated WITH CHECK (is_admin((SELECT auth.uid()))); +CREATE POLICY feature_flags_write_update ON public.feature_flags + FOR UPDATE TO authenticated USING (is_admin((SELECT auth.uid()))) WITH CHECK (is_admin((SELECT auth.uid()))); +CREATE POLICY feature_flags_write_delete ON public.feature_flags + FOR DELETE TO authenticated USING (is_admin((SELECT auth.uid()))); + +DROP POLICY IF EXISTS remote_configs_select_authenticated ON public.remote_configs; +DROP POLICY IF EXISTS remote_configs_write_admin ON public.remote_configs; +CREATE POLICY remote_configs_write_insert ON public.remote_configs + FOR INSERT TO authenticated WITH CHECK (is_admin((SELECT auth.uid()))); +CREATE POLICY remote_configs_write_update ON public.remote_configs + FOR UPDATE TO authenticated USING (is_admin((SELECT auth.uid()))) WITH CHECK (is_admin((SELECT auth.uid()))); +CREATE POLICY remote_configs_write_delete ON public.remote_configs + FOR DELETE TO authenticated USING (is_admin((SELECT auth.uid()))); + +DROP POLICY IF EXISTS group_invites_write_members ON public.group_invites; +CREATE POLICY group_invites_write_insert ON public.group_invites + FOR INSERT TO authenticated WITH CHECK ( + EXISTS (SELECT 1 FROM public.study_group_members + WHERE study_group_members.group_id = group_invites.group_id + AND study_group_members.user_id = (SELECT auth.uid())) + ); +CREATE POLICY group_invites_write_update ON public.group_invites + FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.study_group_members + WHERE study_group_members.group_id = group_invites.group_id + AND study_group_members.user_id = (SELECT auth.uid())) + ); +CREATE POLICY group_invites_write_delete ON public.group_invites + FOR DELETE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.study_group_members + WHERE study_group_members.group_id = group_invites.group_id + AND study_group_members.user_id = (SELECT auth.uid())) + ); + +DROP POLICY IF EXISTS user_roles_write_super ON public.user_roles; +CREATE POLICY user_roles_write_insert ON public.user_roles + FOR INSERT TO authenticated WITH CHECK (is_super_admin((SELECT auth.uid()))); +CREATE POLICY user_roles_write_update ON public.user_roles + FOR UPDATE TO authenticated USING (is_super_admin((SELECT auth.uid()))) WITH CHECK (is_super_admin((SELECT auth.uid()))); +CREATE POLICY user_roles_write_delete ON public.user_roles + FOR DELETE TO authenticated USING (is_super_admin((SELECT auth.uid()))); + +DROP POLICY IF EXISTS "members: admin delete" ON public.study_group_members; +DROP POLICY IF EXISTS "members: self delete" ON public.study_group_members; +CREATE POLICY "members: admin or self delete" ON public.study_group_members + FOR DELETE TO authenticated + USING (is_group_admin(group_id) OR user_id = (SELECT auth.uid())); + +DROP POLICY IF EXISTS "groups: invite code lookup" ON public.study_groups; +DROP POLICY IF EXISTS "groups: member select" ON public.study_groups; +CREATE POLICY "groups: invite code or member select" ON public.study_groups + FOR SELECT TO authenticated + USING (invite_code IS NOT NULL OR is_group_member(id)); diff --git a/app/supabase/migrations/20260725000000_hardware_backed_device_id.sql b/app/supabase/migrations/20260725000000_hardware_backed_device_id.sql new file mode 100644 index 0000000..bfed128 --- /dev/null +++ b/app/supabase/migrations/20260725000000_hardware_backed_device_id.sql @@ -0,0 +1,69 @@ +-- Supports moving the client's device identity from a random UUID held in +-- SharedPreferences to a digest of a non-extractable Android Keystore key +-- (android/app/.../security/KeyAttestationManager.kt). +-- +-- Why this function has to exist at all: the id format changes, so on first +-- launch after the upgrade every already-known device would look brand new to +-- register_device_seen(). That fires 'multiple_device_access' for every user +-- who has ever signed in on more than one device — a flood of false findings +-- in the integrity ledger on upgrade day. This renames the row in place and +-- carries first_seen_at across so the device stays "known". +-- +-- Deliberately does NOT touch device_integrity_events. That table is an +-- append-only hash chain whose entry_hash is computed over device_id by a +-- BEFORE INSERT trigger (20260710010000_security_hardening.sql); an UPDATE +-- would not recompute it, so rewriting historical device_ids there would +-- silently break verify_device_integrity_chain(). Past findings correctly +-- stay recorded against the id that was current when they were observed. + +CREATE OR REPLACE FUNCTION public.migrate_device_id( + p_old_device_id text, + p_new_device_id text +) +RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_old_first_seen timestamptz; +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + IF p_old_device_id IS NULL + OR p_new_device_id IS NULL + OR p_old_device_id = p_new_device_id THEN + RETURN jsonb_build_object('migrated', false, 'reason', 'noop'); + END IF; + + SELECT first_seen_at INTO v_old_first_seen + FROM public.user_known_devices + WHERE user_id = auth.uid() AND device_id = p_old_device_id; + + -- Nothing to carry over: a fresh install, or already migrated. The caller + -- then just registers the new id normally, which is the correct outcome. + IF v_old_first_seen IS NULL THEN + RETURN jsonb_build_object('migrated', false, 'reason', 'no_legacy_row'); + END IF; + + -- Upsert rather than UPDATE ... SET device_id: the new id may already be + -- present (a retried migration, or the same hardware id arriving from a + -- reinstall), and that would collide with the (user_id, device_id) PK. + INSERT INTO public.user_known_devices (user_id, device_id, first_seen_at, last_seen_at) + VALUES (auth.uid(), p_new_device_id, v_old_first_seen, now()) + ON CONFLICT (user_id, device_id) DO UPDATE + SET first_seen_at = LEAST(user_known_devices.first_seen_at, EXCLUDED.first_seen_at), + last_seen_at = now(); + + DELETE FROM public.user_known_devices + WHERE user_id = auth.uid() AND device_id = p_old_device_id; + + RETURN jsonb_build_object('migrated', true); +END; +$$; + +-- Same least-privilege posture as every other RPC here +-- (20260711000000_function_grants_least_privilege.sql): authenticated only, +-- and the function derives the user from auth.uid() rather than trusting a +-- caller-supplied user_id, so one user can never rewrite another's devices. +revoke execute on function public.migrate_device_id(text, text) from public, anon; +grant execute on function public.migrate_device_id(text, text) to authenticated; diff --git a/app/supabase/migrations/20260730112222_private_group_boundary.sql b/app/supabase/migrations/20260730112222_private_group_boundary.sql new file mode 100644 index 0000000..bea25cf --- /dev/null +++ b/app/supabase/migrations/20260730112222_private_group_boundary.sql @@ -0,0 +1,152 @@ +-- Close the private-group boundary: membership is granted by RPC, not by the +-- client asserting it. +-- +-- Before this migration, two permissive policies combined into a full bypass of +-- the invite system: +-- +-- 1. "members: self insert" allowed ANY authenticated user to INSERT a +-- study_group_members row for themselves into ANY group — there was no +-- predicate on group_id at all. WITH CHECK (user_id = auth.uid()) only +-- proves you are inserting *yourself*, never that you were *invited*. +-- 2. "groups: invite code or member select" allowed ANY authenticated user to +-- SELECT every study_groups row whose invite_code IS NOT NULL — including +-- the invite_code column itself. So the codes protecting every private +-- group were readable by every account in the system. +-- +-- Chained: read every invite code (or skip that and just insert yourself by +-- group id), become a member, and "files: member select" then hands over every +-- document in that group. The invite flow was enforced only by the client +-- choosing to call the RPC. +-- +-- 20260721000000 already applied exactly this fix to study_groups INSERT +-- (dropping "groups: authenticated insert" so only +-- create_study_group_with_creator can create a group). This finishes the same +-- job for membership. +-- +-- No client change is needed: every join path already goes through a +-- SECURITY DEFINER routine, which is not subject to RLS. +-- +-- create_study_group_with_creator (creator becomes admin) +-- join_group_by_invite_code (legacy static study_groups.invite_code) +-- join_group_by_invite_link (group_invites, with expiry/use limits) +-- join_public_group_by_name (Global Community / named communities) +-- handle_new_user (signup trigger) +-- +-- =========================================================================== +-- 1. Membership is granted server-side only +-- =========================================================================== +DROP POLICY IF EXISTS "members: self insert" ON public.study_group_members; + +-- Deliberately NOT replaced with a narrower INSERT policy. There is no +-- client-side insert that should succeed: an "insert myself into a group I can +-- prove I may join" predicate would have to re-implement invite validation +-- (revocation, expiry, max_uses) in a policy expression, where +-- join_group_by_invite_link already does it correctly and atomically. + +-- =========================================================================== +-- 2. Stop publishing every private group's invite code +-- =========================================================================== +DROP POLICY IF EXISTS "groups: invite code or member select" ON public.study_groups; + +CREATE POLICY "groups: member select" ON public.study_groups + FOR SELECT TO authenticated + USING (public.is_group_member(id)); + +-- Pre-join invite previews are unaffected: get_invite_details() is +-- SECURITY DEFINER and returns only name/description/member count/creator — +-- never the invite_code, and never the group's file list. + +-- =========================================================================== +-- 3. profiles: stop exposing the whole user directory +-- =========================================================================== +-- "Users can view their own profile" was USING (true) — despite its name, it +-- let any authenticated user read every profile row (id, email, display_name). +-- Group member lists genuinely need co-members' names, and the super-admin +-- console needs the directory, so scope it to exactly those three cases. +-- +-- SECURITY DEFINER helper rather than an inline EXISTS: the subquery would +-- otherwise evaluate study_group_members' own RLS from inside a policy on +-- profiles, on every row of every profile read. +CREATE OR REPLACE FUNCTION public.shares_group_with(p_other_user_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.study_group_members mine + JOIN public.study_group_members theirs ON theirs.group_id = mine.group_id + WHERE mine.user_id = (SELECT auth.uid()) + AND theirs.user_id = p_other_user_id + ); +$$; + +REVOKE EXECUTE ON FUNCTION public.shares_group_with(uuid) FROM public, anon; +GRANT EXECUTE ON FUNCTION public.shares_group_with(uuid) TO authenticated; + +DROP POLICY IF EXISTS "Users can view their own profile" ON public.profiles; + +CREATE POLICY "profiles: self, co-member or admin select" ON public.profiles + FOR SELECT TO authenticated + USING ( + id = (SELECT auth.uid()) + OR public.is_admin((SELECT auth.uid())) + OR public.shares_group_with(id) + ); + +-- Honest scope note: every account is auto-joined to "Global Community" by +-- handle_new_user(), so today shares_group_with() is true for almost every +-- pair of users and this policy blocks little in practice. It is still the +-- correct shape — it stops being a no-op the moment a deployment does not +-- funnel everyone through one universal group, and it costs one indexed +-- lookup. Do not read it as "profiles are private now". + +-- =========================================================================== +-- 4. Signup no longer force-joins TSEC +-- =========================================================================== +-- handle_new_user() joined every new account to BOTH 'Global Community' and +-- 'TSEC, Kandivali' unconditionally. Onboarding asks "Are you a student at +-- TSEC, Kandivali?" and joins on yes — but the trigger had already joined +-- them, so the answer was decorative and every user worldwide landed in a +-- single college's group. Global Community stays (it is what a brand-new +-- account has to look at); TSEC becomes opt-in through the same +-- join_public_group_by_name RPC onboarding already calls. +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_global_id text; +BEGIN + INSERT INTO public.profiles (id, email, display_name) + VALUES ( + NEW.id, + NEW.email, + COALESCE(NEW.raw_user_meta_data->>'display_name', split_part(NEW.email, '@', 1)) + ) + ON CONFLICT (id) DO NOTHING; + + SELECT id INTO v_global_id FROM public.study_groups WHERE name = 'Global Community'; + IF v_global_id IS NULL THEN + v_global_id := '8055b43a-6d07-4588-a39f-159e6154a019'; + INSERT INTO public.study_groups (id, name, description, is_watermark_enabled) + VALUES (v_global_id, 'Global Community', 'A home for all No Sus users.', true) + ON CONFLICT (id) DO NOTHING; + END IF; + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_global_id, NEW.id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_global_id, NEW.id, NULL, 'member_joined', '{}'::jsonb) + ON CONFLICT DO NOTHING; + + RETURN NEW; +END; +$$; + +-- Existing TSEC memberships are left alone. Removing them would silently drop +-- real students out of a group they are actively using, and any member can +-- leave through the normal "members: admin or self delete" path. diff --git a/app/supabase/migrations/20260730112308_analytics_events.sql b/app/supabase/migrations/20260730112308_analytics_events.sql new file mode 100644 index 0000000..b17480e --- /dev/null +++ b/app/supabase/migrations/20260730112308_analytics_events.sql @@ -0,0 +1,97 @@ +-- Product analytics: the activation funnel, and nothing else. +-- +-- This is the first surface in NO SUS that collects anything for the team's +-- benefit rather than the user's, so the constraints are enforced by the +-- schema rather than left to client discipline: +-- +-- * `event` is checked against a fixed allowlist. A new event name requires a +-- migration, which is the point — it makes "just log one more thing" a +-- reviewed decision instead of a one-line client change. +-- * `properties` is capped at 2KB and must be a JSON object. It is for low- +-- cardinality context (which tab, which platform), never document names, +-- group names, file contents, invite codes or link tokens. +-- * Nobody can read their own rows back, let alone anyone else's. SELECT is +-- admin-only; the client is write-only. +-- +-- Turning this on changes the Play Data Safety answers — see +-- store_listing/data_safety_answers.md, updated in the same commit. + +CREATE TABLE IF NOT EXISTS public.analytics_events ( + id bigserial PRIMARY KEY, + -- Nullable: the most important events in an activation funnel happen before + -- there is an account. ON DELETE SET NULL so deleting an account detaches its + -- history rather than leaving a dangling reference or blocking the delete. + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + event text NOT NULL, + properties jsonb NOT NULL DEFAULT '{}'::jsonb, + app_version text, + platform text, + created_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT analytics_events_event_allowed CHECK (event IN ( + 'app_opened', + 'welcome_viewed', + 'guest_tool_opened', + 'auth_wall_hit', + 'signup_started', + 'signup_completed', + 'signin_completed', + 'onboarding_started', + 'onboarding_skipped', + 'onboarding_completed', + 'intent_resumed', + 'group_create_started', + 'group_create_completed', + 'group_join_started', + 'group_join_completed', + 'first_document_uploaded', + 'first_document_viewed', + 'burn_note_created', + 'burn_file_created', + 'notification_permission_prompted', + 'notification_permission_granted', + 'notification_permission_denied', + 'tour_step_shown', + 'tour_skipped', + 'help_topic_opened' + )), + CONSTRAINT analytics_events_properties_is_object + CHECK (jsonb_typeof(properties) = 'object'), + CONSTRAINT analytics_events_properties_small + CHECK (pg_column_size(properties) <= 2048), + CONSTRAINT analytics_events_platform_short + CHECK (platform IS NULL OR length(platform) <= 32), + CONSTRAINT analytics_events_version_short + CHECK (app_version IS NULL OR length(app_version) <= 32) +); + +CREATE INDEX IF NOT EXISTS idx_analytics_events_event_created + ON public.analytics_events (event, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_analytics_events_user + ON public.analytics_events (user_id) WHERE user_id IS NOT NULL; + +ALTER TABLE public.analytics_events ENABLE ROW LEVEL SECURITY; + +-- Signed-in writes must be attributed to the caller — a client cannot forge +-- another user's funnel. +CREATE POLICY analytics_events_insert_authenticated + ON public.analytics_events FOR INSERT TO authenticated + WITH CHECK (user_id = (SELECT auth.uid())); + +-- Signed-out writes must be anonymous. Without the null check, an unauthenticated +-- caller could attribute events to any user id it could guess. +CREATE POLICY analytics_events_insert_anon + ON public.analytics_events FOR INSERT TO anon + WITH CHECK (user_id IS NULL); + +CREATE POLICY analytics_events_select_admin + ON public.analytics_events FOR SELECT TO authenticated + USING (public.is_admin((SELECT auth.uid()))); + +-- No UPDATE or DELETE policy: events are append-only from every client role. + +COMMENT ON TABLE public.analytics_events IS + 'Activation-funnel events. Write-only from clients, admin-readable. Never ' + 'store document names, group names, file contents, invite codes or share ' + 'tokens in `properties` — see the allowlist constraint and ' + 'store_listing/data_safety_answers.md.'; diff --git a/app/supabase/migrations/20260730112424_group_moderation.sql b/app/supabase/migrations/20260730112424_group_moderation.sql new file mode 100644 index 0000000..283bdca --- /dev/null +++ b/app/supabase/migrations/20260730112424_group_moderation.sql @@ -0,0 +1,509 @@ +-- Community administration: roles, removals and bans as audited operations. +-- +-- Before this, the only moderation the product had was a direct +-- `DELETE FROM study_group_members` from the client, permitted by the +-- "members: admin or self delete" policy. That worked, but: +-- +-- * it wrote nothing to the audit ledger, so the one action an admin can take +-- against another person was the one action the group could not see; +-- * there was no way to change a role, despite `is_admin` existing and the +-- "members: admin update" policy already allowing it; +-- * there was no way to stop a removed person simply re-using the invite; +-- * an admin could remove or demote the last admin and strand the group. +-- +-- Everything below routes through SECURITY DEFINER RPCs that re-check +-- authorisation server-side and write the audit entry in the same transaction, +-- so a moderation action and its record cannot come apart. + +-- =========================================================================== +-- 1. New audit vocabulary +-- =========================================================================== +-- audit_logs.event_type is CHECK-constrained, so new events must be declared. +-- +-- This also repairs three event types the app has been *emitting* since +-- 20260710010000 but which the constraint never accepted: overlay_detected, +-- accessibility_detected and display_mirroring_detected. AuditService lists all +-- three as allowed and ScreenshotGuard._logOverlayDetected fires the first one +-- for real, but every such INSERT was rejected by this constraint and swallowed +-- by SupabaseService.logEvent's catch block. Three shipped detectors have been +-- writing to nothing. +ALTER TABLE public.audit_logs DROP CONSTRAINT IF EXISTS chk_audit_event_type; +ALTER TABLE public.audit_logs ADD CONSTRAINT chk_audit_event_type CHECK ( + event_type IN ( + 'file_uploaded', + 'file_downloaded', + 'file_viewed', + 'file_deleted', + 'file_renamed', + 'file_pinned', + 'member_joined', + 'member_left', + 'member_removed', + 'member_banned', + 'member_unbanned', + 'member_promoted', + 'member_demoted', + 'group_created', + 'group_updated', + 'invite_generated', + 'invite_used', + 'screenshot_attempt', + 'recording_attempt', + 'unauthorized_access', + 'overlay_detected', + 'accessibility_detected', + 'display_mirroring_detected' + ) +); + +-- =========================================================================== +-- 2. Bans +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.group_bans ( + group_id text NOT NULL REFERENCES public.study_groups(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + -- SET NULL rather than CASCADE: an admin deleting their own account must not + -- silently lift every ban they issued. + banned_by uuid REFERENCES public.profiles(id) ON DELETE SET NULL, + reason text, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (group_id, user_id), + CONSTRAINT group_bans_reason_len CHECK (reason IS NULL OR length(reason) <= 280) +); + +CREATE INDEX IF NOT EXISTS idx_group_bans_user ON public.group_bans(user_id); + +ALTER TABLE public.group_bans ENABLE ROW LEVEL SECURITY; + +-- Members can see who is banned from their own group — same reasoning as the +-- audit log being group-visible: moderation that only moderators can see is not +-- accountable. Writes are RPC-only (no INSERT/UPDATE/DELETE policy exists), so +-- a client cannot ban anyone by talking to the table directly. +CREATE POLICY "bans: group member select" ON public.group_bans + FOR SELECT TO authenticated + USING (public.is_group_member(group_id)); + +CREATE OR REPLACE FUNCTION public.is_group_banned(p_group_id text, p_user_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.group_bans + WHERE group_id = p_group_id AND user_id = p_user_id + ); +$$; + +REVOKE EXECUTE ON FUNCTION public.is_group_banned(text, uuid) FROM public, anon; +GRANT EXECUTE ON FUNCTION public.is_group_banned(text, uuid) TO authenticated; + +-- =========================================================================== +-- 3. Shared guards +-- =========================================================================== +-- Every mutating RPC below begins with these two checks, so the rules live in +-- one place rather than being re-typed (and eventually mis-typed) five times. +CREATE OR REPLACE FUNCTION public.assert_group_admin(p_group_id text) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'Not authenticated' USING ERRCODE = '42501'; + END IF; + IF NOT public.is_group_admin(p_group_id) THEN + RAISE EXCEPTION 'Only a group admin can do that' USING ERRCODE = '42501'; + END IF; + RETURN v_actor; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.assert_group_admin(text) FROM public, anon, authenticated; + +-- Refuses to leave a group with no admin. Guards both demotion and removal, +-- because either can be the action that orphans the group. +CREATE OR REPLACE FUNCTION public.assert_not_last_admin(p_group_id text, p_user_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_admin_count integer; +BEGIN + SELECT count(*) INTO v_admin_count + FROM public.study_group_members + WHERE group_id = p_group_id AND is_admin = true; + + IF v_admin_count <= 1 AND EXISTS ( + SELECT 1 FROM public.study_group_members + WHERE group_id = p_group_id AND user_id = p_user_id AND is_admin = true + ) THEN + RAISE EXCEPTION 'This is the last admin. Promote someone else first.' + USING ERRCODE = 'P0001'; + END IF; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.assert_not_last_admin(text, uuid) FROM public, anon, authenticated; + +-- Display name for audit metadata. Reads profiles as definer so the entry is +-- readable even to members who could not otherwise see that profile row. +CREATE OR REPLACE FUNCTION public.group_member_label(p_user_id uuid) +RETURNS text +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT COALESCE( + NULLIF(btrim(p.display_name), ''), + split_part(p.email, '@', 1), + 'A member' + ) + FROM public.profiles p + WHERE p.id = p_user_id; +$$; + +REVOKE EXECUTE ON FUNCTION public.group_member_label(uuid) FROM public, anon; +GRANT EXECUTE ON FUNCTION public.group_member_label(uuid) TO authenticated; + +-- =========================================================================== +-- 4. Moderation RPCs +-- =========================================================================== +CREATE OR REPLACE FUNCTION public.set_group_member_role( + p_group_id text, + p_user_id uuid, + p_is_admin boolean +) +RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_actor uuid; + v_current boolean; +BEGIN + v_actor := public.assert_group_admin(p_group_id); + + SELECT is_admin INTO v_current + FROM public.study_group_members + WHERE group_id = p_group_id AND user_id = p_user_id; + + IF v_current IS NULL THEN + RAISE EXCEPTION 'That person is not a member of this group' USING ERRCODE = 'P0002'; + END IF; + IF v_current = p_is_admin THEN + RETURN; -- idempotent: double-tap or a stale UI, not an error + END IF; + IF NOT p_is_admin THEN + PERFORM public.assert_not_last_admin(p_group_id, p_user_id); + END IF; + + UPDATE public.study_group_members + SET is_admin = p_is_admin + WHERE group_id = p_group_id AND user_id = p_user_id; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES ( + p_group_id, + v_actor, + NULL, + CASE WHEN p_is_admin THEN 'member_promoted' ELSE 'member_demoted' END, + jsonb_build_object('member_name', public.group_member_label(p_user_id)) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION public.remove_group_member( + p_group_id text, + p_user_id uuid +) +RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_actor uuid; + v_label text; +BEGIN + v_actor := public.assert_group_admin(p_group_id); + + IF p_user_id = v_actor THEN + -- Leaving is a different operation with different rules (and is allowed by + -- the "members: admin or self delete" policy). Routing it through the + -- moderation RPC would log an admin as having removed themselves. + RAISE EXCEPTION 'Use Leave group to remove yourself' USING ERRCODE = 'P0001'; + END IF; + + PERFORM public.assert_not_last_admin(p_group_id, p_user_id); + v_label := public.group_member_label(p_user_id); + + DELETE FROM public.study_group_members + WHERE group_id = p_group_id AND user_id = p_user_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'That person is not a member of this group' USING ERRCODE = 'P0002'; + END IF; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES ( + p_group_id, v_actor, NULL, 'member_removed', + jsonb_build_object('member_name', v_label) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION public.ban_group_member( + p_group_id text, + p_user_id uuid, + p_reason text DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_actor uuid; + v_label text; +BEGIN + v_actor := public.assert_group_admin(p_group_id); + + IF p_user_id = v_actor THEN + RAISE EXCEPTION 'You cannot ban yourself' USING ERRCODE = 'P0001'; + END IF; + + PERFORM public.assert_not_last_admin(p_group_id, p_user_id); + v_label := public.group_member_label(p_user_id); + + -- Ban first, then remove: if the delete failed after the ban we would have a + -- banned member, which the next join attempt corrects. The reverse order + -- could leave someone removed but free to walk straight back in. + INSERT INTO public.group_bans (group_id, user_id, banned_by, reason) + VALUES (p_group_id, p_user_id, v_actor, NULLIF(btrim(p_reason), '')) + ON CONFLICT (group_id, user_id) DO UPDATE + SET banned_by = EXCLUDED.banned_by, + reason = EXCLUDED.reason, + created_at = now(); + + DELETE FROM public.study_group_members + WHERE group_id = p_group_id AND user_id = p_user_id; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES ( + p_group_id, v_actor, NULL, 'member_banned', + jsonb_build_object('member_name', v_label) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION public.unban_group_member( + p_group_id text, + p_user_id uuid +) +RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_actor uuid; + v_label text; +BEGIN + v_actor := public.assert_group_admin(p_group_id); + v_label := public.group_member_label(p_user_id); + + DELETE FROM public.group_bans + WHERE group_id = p_group_id AND user_id = p_user_id; + + IF NOT FOUND THEN + RETURN; -- already unbanned; nothing happened, so log nothing + END IF; + + -- Unbanning restores eligibility, not membership. Silently re-adding someone + -- to a group they were thrown out of is not a decision an admin asked for. + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES ( + p_group_id, v_actor, NULL, 'member_unbanned', + jsonb_build_object('member_name', v_label) + ); +END; +$$; + +-- =========================================================================== +-- 5. Bans are enforced on every join path +-- =========================================================================== +-- A ban that only the UI respects is not a ban. Each join RPC is redefined to +-- check group_bans; the rest of each body is unchanged from its current +-- definition. +CREATE OR REPLACE FUNCTION public.join_group_by_invite_code(p_invite_code text) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; + v_user_id uuid; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT id INTO v_group_id + FROM public.study_groups + WHERE invite_code = p_invite_code; + + IF v_group_id IS NULL THEN + RAISE EXCEPTION 'Invalid invite code'; + END IF; + + IF public.is_group_banned(v_group_id, v_user_id) THEN + RAISE EXCEPTION 'You can no longer join this group' USING ERRCODE = '42501'; + END IF; + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_group_id, v_user_id, NULL, 'member_joined', '{}'::jsonb); + + RETURN v_group_id; +END; +$$; + +CREATE OR REPLACE FUNCTION public.join_public_group_by_name(p_group_name text) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; + v_user_id uuid; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT id INTO v_group_id + FROM public.study_groups + WHERE name = p_group_name; + + IF v_group_id IS NULL THEN + RAISE EXCEPTION 'Group not found'; + END IF; + + IF public.is_group_banned(v_group_id, v_user_id) THEN + RAISE EXCEPTION 'You can no longer join this group' USING ERRCODE = '42501'; + END IF; + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_group_id, v_user_id, NULL, 'member_joined', '{}'::jsonb); + + RETURN v_group_id; +END; +$$; + +-- join_group_by_invite_link carries invite bookkeeping (revocation, expiry, +-- max_uses, use_count) that must not be duplicated, so this is its existing +-- body with two changes and nothing else. The `FOR UPDATE` row lock is +-- load-bearing — it is what makes the max_uses check safe against two people +-- redeeming the last use of a link simultaneously — and is preserved exactly. +-- +-- Change 1: the ban check, on both the group_invites path and the legacy +-- study_groups.invite_code fallback. +-- Change 2: the audit entry no longer carries `invite_code` in its metadata. +-- audit_logs is readable by every member of the group, so writing a +-- live invite code into it let any member harvest working codes for +-- their own group from the activity feed. The member's display name +-- is more useful in the feed anyway. +CREATE OR REPLACE FUNCTION public.join_group_by_invite_link( + p_invite_code text +) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_group_id text; + v_user_id uuid; + v_invite record; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- 1. Fetch and lock invite row + SELECT * INTO v_invite + FROM public.group_invites + WHERE code = p_invite_code FOR UPDATE; + + IF v_invite IS NULL THEN + -- Fallback to the legacy static invite_code column on study_groups + SELECT id INTO v_group_id + FROM public.study_groups + WHERE invite_code = p_invite_code; + + IF v_group_id IS NULL THEN + RAISE EXCEPTION 'Invalid invite code or link'; + END IF; + + IF public.is_group_banned(v_group_id, v_user_id) THEN + RAISE EXCEPTION 'You can no longer join this group' USING ERRCODE = '42501'; + END IF; + + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_group_id, v_user_id, NULL, 'member_joined', '{}'::jsonb); + + RETURN v_group_id; + END IF; + + -- 2. Validate invite constraints + IF v_invite.is_revoked THEN + RAISE EXCEPTION 'This invite link has been revoked'; + END IF; + + IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN + RAISE EXCEPTION 'This invite link has expired'; + END IF; + + IF v_invite.max_uses IS NOT NULL AND v_invite.use_count >= v_invite.max_uses THEN + RAISE EXCEPTION 'This invite link has reached its usage limit'; + END IF; + + IF public.is_group_banned(v_invite.group_id, v_user_id) THEN + RAISE EXCEPTION 'You can no longer join this group' USING ERRCODE = '42501'; + END IF; + + -- 3. Add member + INSERT INTO public.study_group_members (group_id, user_id, is_admin) + VALUES (v_invite.group_id, v_user_id, false) + ON CONFLICT (group_id, user_id) DO NOTHING; + + -- 4. Update invite metrics + UPDATE public.group_invites + SET use_count = use_count + 1 + WHERE code = p_invite_code; + + -- 5. Log activity + INSERT INTO public.audit_logs (group_id, actor_id, file_id, event_type, metadata) + VALUES (v_invite.group_id, v_user_id, NULL, 'member_joined', + jsonb_build_object('member_name', public.group_member_label(v_user_id))); + + RETURN v_invite.group_id; +END; +$$; + +-- =========================================================================== +-- 6. Grants +-- =========================================================================== +REVOKE EXECUTE ON FUNCTION public.set_group_member_role(text, uuid, boolean) FROM public, anon; +REVOKE EXECUTE ON FUNCTION public.remove_group_member(text, uuid) FROM public, anon; +REVOKE EXECUTE ON FUNCTION public.ban_group_member(text, uuid, text) FROM public, anon; +REVOKE EXECUTE ON FUNCTION public.unban_group_member(text, uuid) FROM public, anon; + +GRANT EXECUTE ON FUNCTION public.set_group_member_role(text, uuid, boolean) TO authenticated; +GRANT EXECUTE ON FUNCTION public.remove_group_member(text, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.ban_group_member(text, uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.unban_group_member(text, uuid) TO authenticated; diff --git a/app/supabase/migrations/20260730112523_notifications.sql b/app/supabase/migrations/20260730112523_notifications.sql new file mode 100644 index 0000000..15770e1 --- /dev/null +++ b/app/supabase/migrations/20260730112523_notifications.sql @@ -0,0 +1,360 @@ +-- Notifications: per-category preferences, an in-app inbox, and device tokens +-- for push delivery. +-- +-- Until this migration the app had no notification system at all. The only +-- thing resembling one was a ScaffoldMessenger SnackBar shown when a share was +-- opened *while the app was already in the foreground* — i.e. visible only to +-- someone who was already looking. AndroidManifest.xml documented the absence +-- and told future contributors not to add POST_NOTIFICATIONS without a real +-- code path; this is that code path. +-- +-- Shape: writers never talk to a push provider. They insert a row into +-- `notifications`, which is simultaneously the in-app inbox and the delivery +-- queue. Everything that decides *whether* a person hears about an event is +-- enforced here in the database, so a modified client cannot notify people it +-- should not be able to reach, and cannot suppress a security alert for +-- someone else. + +-- =========================================================================== +-- 1. Per-category preferences +-- =========================================================================== +CREATE TABLE IF NOT EXISTS public.notification_preferences ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + -- Someone invited you, or asked to join a group you administer. + invites boolean NOT NULL DEFAULT true, + -- You were added/removed/banned, or your role changed. + membership boolean NOT NULL DEFAULT true, + -- New documents in your groups. + documents boolean NOT NULL DEFAULT true, + -- Access revoked, suspicious access, integrity findings. Defaults on and is + -- the one category worth arguing about before ever defaulting off. + security boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.notification_preferences ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "notification_preferences: self select" + ON public.notification_preferences FOR SELECT TO authenticated + USING (user_id = (SELECT auth.uid())); + +CREATE POLICY "notification_preferences: self insert" + ON public.notification_preferences FOR INSERT TO authenticated + WITH CHECK (user_id = (SELECT auth.uid())); + +CREATE POLICY "notification_preferences: self update" + ON public.notification_preferences FOR UPDATE TO authenticated + USING (user_id = (SELECT auth.uid())) + WITH CHECK (user_id = (SELECT auth.uid())); + +-- =========================================================================== +-- 2. Device tokens +-- =========================================================================== +-- The token is the primary key, not (user_id, token). A push token belongs to +-- an app install, not to a person: when someone signs out and a different +-- account signs in on the same handset, FCM hands back the same token. With a +-- composite key that produces two live rows and the previous account keeps +-- receiving that device's notifications. Keyed on the token, re-registration +-- is an UPSERT that reassigns ownership, which is the correct outcome. +CREATE TABLE IF NOT EXISTS public.device_tokens ( + token text PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + platform text NOT NULL CHECK (platform IN ('android', 'ios', 'web')), + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_device_tokens_user_id + ON public.device_tokens(user_id); + +ALTER TABLE public.device_tokens ENABLE ROW LEVEL SECURITY; + +-- No SELECT policy: a client never needs to read the token list back, and not +-- granting it means a compromised session cannot enumerate the account's other +-- devices. Writes go through the RPCs below. +CREATE POLICY "device_tokens: self delete" + ON public.device_tokens FOR DELETE TO authenticated + USING (user_id = (SELECT auth.uid())); + +-- =========================================================================== +-- 3. The inbox / delivery queue +-- =========================================================================== +-- title and body are written by the enqueue function from fixed templates and +-- deliberately name no document and no group. They are what a push provider +-- receives and what a lock screen renders, and a lock screen is readable by +-- whoever is holding the phone. Context the recipient needs is carried as ids +-- and resolved in-app, behind RLS, after they unlock. +CREATE TABLE IF NOT EXISTS public.notifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + category text NOT NULL CHECK (category IN ('invites', 'membership', 'documents', 'security')), + title text NOT NULL, + body text NOT NULL, + -- In-app route, e.g. 'group:' or 'audit'. Never a URL with a secret in it. + deep_link text, + group_id text REFERENCES public.study_groups(id) ON DELETE CASCADE, + read_at timestamptz, + -- Stamped once a push has been handed to the provider, so a delivery sweep + -- is idempotent and a provider outage does not silently drop notifications. + pushed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread + ON public.notifications(user_id, created_at DESC) + WHERE read_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_notifications_pending_push + ON public.notifications(created_at) + WHERE pushed_at IS NULL; + +ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "notifications: self select" + ON public.notifications FOR SELECT TO authenticated + USING (user_id = (SELECT auth.uid())); + +CREATE POLICY "notifications: self delete" + ON public.notifications FOR DELETE TO authenticated + USING (user_id = (SELECT auth.uid())); + +-- No INSERT or UPDATE policy on purpose. Inserting is the enqueue function's +-- job (SECURITY DEFINER) — if clients could insert, any account could push +-- arbitrary text to any other account's lock screen. Marking read goes through +-- mark_notifications_read() so `pushed_at` stays out of reach. + +-- =========================================================================== +-- 4. Registration / read RPCs +-- =========================================================================== +CREATE OR REPLACE FUNCTION public.register_device_token( + p_token text, + p_platform text +) +RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_user_id uuid := auth.uid(); +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + IF p_token IS NULL OR length(p_token) < 16 OR length(p_token) > 512 THEN + RAISE EXCEPTION 'Invalid device token'; + END IF; + IF p_platform NOT IN ('android', 'ios', 'web') THEN + RAISE EXCEPTION 'Invalid platform'; + END IF; + + INSERT INTO public.device_tokens (token, user_id, platform, last_seen_at) + VALUES (p_token, v_user_id, p_platform, now()) + ON CONFLICT (token) DO UPDATE + SET user_id = EXCLUDED.user_id, + platform = EXCLUDED.platform, + last_seen_at = now(); + + -- First sight of this account: materialise default preferences so the + -- delivery sweep can join against a real row instead of guessing. + INSERT INTO public.notification_preferences (user_id) + VALUES (v_user_id) + ON CONFLICT (user_id) DO NOTHING; +END; +$$; + +-- Sign-out path. Takes the token rather than clearing every token for the +-- account: signing out of one handset must not silence the user's other +-- devices. +CREATE OR REPLACE FUNCTION public.unregister_device_token(p_token text) +RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + DELETE FROM public.device_tokens + WHERE token = p_token AND user_id = auth.uid(); +END; +$$; + +CREATE OR REPLACE FUNCTION public.mark_notifications_read(p_ids uuid[] DEFAULT NULL) +RETURNS integer +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_user_id uuid := auth.uid(); + v_count integer; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + UPDATE public.notifications + SET read_at = now() + WHERE user_id = v_user_id + AND read_at IS NULL + AND (p_ids IS NULL OR id = ANY(p_ids)); + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.register_device_token(text, text) FROM public, anon; +REVOKE EXECUTE ON FUNCTION public.unregister_device_token(text) FROM public, anon; +REVOKE EXECUTE ON FUNCTION public.mark_notifications_read(uuid[]) FROM public, anon; +GRANT EXECUTE ON FUNCTION public.register_device_token(text, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.unregister_device_token(text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.mark_notifications_read(uuid[]) TO authenticated; + +-- =========================================================================== +-- 5. Enqueue +-- =========================================================================== +-- The single writer. Checks the recipient's preference for the category and +-- drops the notification if it is off, so an unwanted category costs nothing +-- downstream and never reaches the inbox either. +-- +-- Not callable by clients: the triggers below are SECURITY DEFINER and run as +-- the table owner. +CREATE OR REPLACE FUNCTION public.enqueue_notification( + p_user_id uuid, + p_category text, + p_title text, + p_body text, + p_deep_link text DEFAULT NULL, + p_group_id text DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_enabled boolean; +BEGIN + IF p_user_id IS NULL THEN RETURN; END IF; + + SELECT CASE p_category + WHEN 'invites' THEN invites + WHEN 'membership' THEN membership + WHEN 'documents' THEN documents + WHEN 'security' THEN security + END + INTO v_enabled + FROM public.notification_preferences + WHERE user_id = p_user_id; + + -- No preferences row yet means the account has never opened the app since + -- this shipped. Default-on matches the column defaults. + IF v_enabled IS NOT NULL AND v_enabled = false THEN + RETURN; + END IF; + + INSERT INTO public.notifications + (user_id, category, title, body, deep_link, group_id) + VALUES + (p_user_id, p_category, p_title, p_body, p_deep_link, p_group_id); +END; +$$; + +REVOKE EXECUTE ON FUNCTION + public.enqueue_notification(uuid, text, text, text, text, text) + FROM public, anon, authenticated; + +-- =========================================================================== +-- 6. Event triggers +-- =========================================================================== +-- Membership changes → tell the person it happened to, and the admins. +CREATE OR REPLACE FUNCTION public.trg_notify_membership() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_admin record; +BEGIN + IF TG_OP = 'INSERT' THEN + -- Admins of the group learn someone arrived. The joiner already knows — + -- they just tapped the button — so they get nothing. + FOR v_admin IN + SELECT user_id FROM public.study_group_members + WHERE group_id = NEW.group_id AND is_admin = true AND user_id <> NEW.user_id + LOOP + PERFORM public.enqueue_notification( + v_admin.user_id, 'membership', + 'New member', 'Someone joined a group you administer.', + 'group:' || NEW.group_id, NEW.group_id + ); + END LOOP; + RETURN NEW; + + ELSIF TG_OP = 'UPDATE' AND NEW.is_admin IS DISTINCT FROM OLD.is_admin THEN + PERFORM public.enqueue_notification( + NEW.user_id, 'membership', + CASE WHEN NEW.is_admin THEN 'You are now an admin' ELSE 'Your role changed' END, + CASE WHEN NEW.is_admin + THEN 'You can now manage members and settings in one of your groups.' + ELSE 'You no longer have admin rights in one of your groups.' END, + 'group:' || NEW.group_id, NEW.group_id + ); + RETURN NEW; + + ELSIF TG_OP = 'DELETE' THEN + -- Only when someone else did it. Leaving voluntarily is not news. + IF OLD.user_id <> COALESCE(auth.uid(), OLD.user_id) THEN + PERFORM public.enqueue_notification( + OLD.user_id, 'membership', + 'Group access ended', + 'You were removed from a group. Its documents are no longer available to you.', + 'groups', NULL + ); + END IF; + RETURN OLD; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$; + +DROP TRIGGER IF EXISTS notify_membership ON public.study_group_members; +CREATE TRIGGER notify_membership + AFTER INSERT OR UPDATE OR DELETE ON public.study_group_members + FOR EACH ROW EXECUTE FUNCTION public.trg_notify_membership(); + +-- New document → tell the other members. The file name is deliberately absent +-- from the text; it is exactly the kind of thing that should not surface on a +-- lock screen. +CREATE OR REPLACE FUNCTION public.trg_notify_new_document() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_member record; +BEGIN + FOR v_member IN + SELECT user_id FROM public.study_group_members + WHERE group_id = NEW.group_id + AND user_id <> NEW.uploaded_by + LOOP + PERFORM public.enqueue_notification( + v_member.user_id, 'documents', + 'New document', 'A document was shared in one of your groups.', + 'group:' || NEW.group_id, NEW.group_id + ); + END LOOP; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS notify_new_document ON public.secure_files; +CREATE TRIGGER notify_new_document + AFTER INSERT ON public.secure_files + FOR EACH ROW EXECUTE FUNCTION public.trg_notify_new_document(); + +-- =========================================================================== +-- 7. Housekeeping +-- =========================================================================== +-- Read notifications older than 30 days are not history — the audit log is +-- history. Keeping them only grows the inbox query. +CREATE OR REPLACE FUNCTION public.prune_old_notifications() +RETURNS integer +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_count integer; +BEGIN + DELETE FROM public.notifications + WHERE read_at IS NOT NULL AND read_at < now() - interval '30 days'; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.prune_old_notifications() FROM public, anon, authenticated; diff --git a/app/supabase/migrations/20260730115330_enable_realtime_notifications.sql b/app/supabase/migrations/20260730115330_enable_realtime_notifications.sql new file mode 100644 index 0000000..6a8eea1 --- /dev/null +++ b/app/supabase/migrations/20260730115330_enable_realtime_notifications.sql @@ -0,0 +1,32 @@ +-- Fix for 20260730112523_notifications: the table was created but never added +-- to the realtime publication. +-- +-- NotificationRepository.watchInbox() subscribes with `.stream(primaryKey: +-- ['id'])`, and that is the only live path — fetchInbox() is used for the +-- initial load, nothing polls afterwards. Without the table in +-- `supabase_realtime` the subscription fails outright: +-- +-- NO SUS: Notification stream error: RealtimeSubscribeException( +-- status: channelError, ... table: notifications ...) +-- +-- observed on a real device (OnePlus CPH2487, Android 16) on 2026-07-30. The +-- inbox would show whatever existed at launch and never move again — a +-- notification system that silently stops notifying. +-- +-- Same guarded DO-block shape the rest of this repo uses (see +-- 20260704000000_fhe_subsystem.sql), so a re-run and a local stack without the +-- publication both stay no-ops rather than errors. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN + BEGIN + ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications; + EXCEPTION WHEN duplicate_object THEN + NULL; -- already added + END; + END IF; +END $$; + +-- RLS still applies to realtime: "notifications: self select" is what stops the +-- subscription delivering another account's rows. The `.eq('user_id', …)` in +-- the Dart client is defence in depth, not the boundary. diff --git a/app/supabase/migrations/20260730162359_enable_realtime_user_risk_state.sql b/app/supabase/migrations/20260730162359_enable_realtime_user_risk_state.sql new file mode 100644 index 0000000..2262d89 --- /dev/null +++ b/app/supabase/migrations/20260730162359_enable_realtime_user_risk_state.sql @@ -0,0 +1,27 @@ +-- Migration: 20260730162359_enable_realtime_user_risk_state.sql +-- watchMyRiskState subscribes to public.user_risk_state, but the table was +-- never added to the supabase_realtime publication, so every subscribe failed +-- with RealtimeSubscribeException(status: channelError). Mirrors the existing +-- enable_realtime_share_view_events / enable_realtime_notifications migrations. +-- +-- REPLICA IDENTITY FULL so the `user_id=eq.` filter still matches on +-- DELETE; with the default identity a delete only carries the primary key and +-- would be dropped by the filter before reaching the client. RLS on the table +-- already restricts rows to `user_id = auth.uid() OR is_super_admin(auth.uid())`, +-- so streaming does not widen visibility. +-- +-- Applied to the live project on 2026-07-30 as version 20260730162359. + +alter table public.user_risk_state replica identity full; + +do $$ +begin + if not exists ( + select 1 from pg_publication_tables + where pubname = 'supabase_realtime' + and schemaname = 'public' + and tablename = 'user_risk_state' + ) then + alter publication supabase_realtime add table public.user_risk_state; + end if; +end $$; diff --git a/app/supabase/migrations/20260801062256_revoke_internal_function_execute.sql b/app/supabase/migrations/20260801062256_revoke_internal_function_execute.sql new file mode 100644 index 0000000..ae9f26d --- /dev/null +++ b/app/supabase/migrations/20260801062256_revoke_internal_function_execute.sql @@ -0,0 +1,26 @@ +-- Follow-up to 20260730112424_group_moderation and 20260730112523_notifications. +-- +-- Applied 2026-08-01. The filename carries the ledger version it actually +-- landed under (20260801062256), not the 20260730120000 it was drafted as — +-- they must match or `supabase db push` re-runs this as if it were new. +-- +-- PostgREST exposes every function in `public` as /rest/v1/rpc/, and the +-- two trigger functions landed with the default PUBLIC execute grant. Calling a +-- trigger function directly always fails ("trigger functions can only be called +-- as triggers"), so nothing here was exploitable — but they have no business +-- being in the API surface, and the database linter flags them +-- (0028/0029 *_security_definer_function_executable). +REVOKE EXECUTE ON FUNCTION public.trg_notify_membership() FROM public, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.trg_notify_new_document() FROM public, anon, authenticated; + +-- These two are only ever called from inside SECURITY DEFINER bodies, which run +-- as the owner and do not need the caller to hold EXECUTE. Granting them to +-- `authenticated` let any signed-in account resolve an arbitrary user id to a +-- display name, or probe whether a given user is banned from a given group. +-- Verified: no client code and no edge function calls either one. +REVOKE EXECUTE ON FUNCTION public.group_member_label(uuid) FROM authenticated; +REVOKE EXECUTE ON FUNCTION public.is_group_banned(text, uuid) FROM authenticated; + +-- shares_group_with() keeps its grant on purpose: it is evaluated inside the +-- profiles SELECT policy, and RLS policy expressions run as the querying role, +-- not as the policy owner. Revoking it would break every profile read. diff --git a/app/supabase/migrations/20260826085740_secure_two_digit_redemption_pairing.sql b/app/supabase/migrations/20260826085740_secure_two_digit_redemption_pairing.sql new file mode 100644 index 0000000..1b3276d --- /dev/null +++ b/app/supabase/migrations/20260826085740_secure_two_digit_redemption_pairing.sql @@ -0,0 +1,75 @@ +-- Two-digit redemption pairing +-- +-- The old eight-character code was the only credential for the alternate +-- redemption flow. A two-digit value cannot safely carry that responsibility. +-- New rows therefore bind the human-friendly 00–99 confirmation code to a +-- random 256-bit link token. The link token is the authorization boundary; +-- the two digits merely confirm that the recipient and sender are referring +-- to the same share. Existing short codes keep working until their normal +-- short expiry so links already sent are not broken. + +ALTER TABLE public.burn_redemption_codes + ADD COLUMN IF NOT EXISTS redeem_token_hash TEXT; + +-- `code_hash` used to be globally unique. That was appropriate when the +-- eight-character code was the credential, but it would cap the two-digit +-- flow at 100 lifetime rows. New pairings are unique by code + secret token; +-- legacy rows retain their one-code uniqueness while they age out. +ALTER TABLE public.burn_redemption_codes + DROP CONSTRAINT IF EXISTS burn_redemption_codes_code_hash_key; + +CREATE UNIQUE INDEX IF NOT EXISTS burn_redemption_codes_pairing_key_idx + ON public.burn_redemption_codes (code_hash, redeem_token_hash) + WHERE redeem_token_hash IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS burn_redemption_codes_legacy_code_idx + ON public.burn_redemption_codes (code_hash) + WHERE redeem_token_hash IS NULL; + +-- Keep the legacy function safe if it is called by an older deployed client: +-- it can only consume the pre-pairing rows that have no link token. +CREATE OR REPLACE FUNCTION public.claim_redemption_code(p_code_hash text) +RETURNS public.burn_redemption_codes +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_row public.burn_redemption_codes; +BEGIN + UPDATE public.burn_redemption_codes + SET consumed_at = now() + WHERE code_hash = p_code_hash + AND redeem_token_hash IS NULL + AND consumed_at IS NULL + AND expires_at > now() + RETURNING * INTO v_row; + + RETURN v_row; +END; +$$; + +-- The pairing-aware claim is atomic: both the opaque link token and the +-- two-digit confirmation must match the same still-live row. +CREATE OR REPLACE FUNCTION public.claim_redemption_code( + p_code_hash text, + p_redeem_token_hash text +) +RETURNS public.burn_redemption_codes +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_row public.burn_redemption_codes; +BEGIN + UPDATE public.burn_redemption_codes + SET consumed_at = now() + WHERE code_hash = p_code_hash + AND redeem_token_hash = p_redeem_token_hash + AND consumed_at IS NULL + AND expires_at > now() + RETURNING * INTO v_row; + + RETURN v_row; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.claim_redemption_code(text) + FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.claim_redemption_code(text, text) + FROM PUBLIC, anon, authenticated; diff --git a/app/supabase/migrations/20260919090000_nosus_canary.sql b/app/supabase/migrations/20260919090000_nosus_canary.sql new file mode 100644 index 0000000..5bc18c8 --- /dev/null +++ b/app/supabase/migrations/20260919090000_nosus_canary.sql @@ -0,0 +1,164 @@ +-- NO SUS Canary — experiment project ffidvfguojalzpclipzi ONLY. +-- Never apply to production (rxfnazmusofikwaggntb). +-- +-- The server stores encrypted copies it cannot read, which device (hashed) +-- and name opened which copy, and Monad transaction hashes. It never stores +-- note text, keys, salts or codewords. +-- +-- Access model: RLS on, NO policies. Only the service role (inside the +-- `canary` edge function) can read or write. Clients never query these tables. +-- +-- Reverse (manual, if ever needed): +-- drop function if exists public.canary_assign_copy(uuid, text, text); +-- drop function if exists public.canary_lease_relayer(integer, integer); +-- drop function if exists public.canary_release_relayer(integer); +-- drop table if exists public.canary_copies; +-- drop table if exists public.canary_notes; +-- drop table if exists public.canary_relayer_leases; + +create table if not exists public.canary_notes ( + id uuid primary key, + owner_hash text not null check (owner_hash ~ '^[0-9a-f]{64}$'), + copy_count integer not null check (copy_count between 2 and 100), + copies_hash text not null check (copies_hash ~ '^0x[0-9a-f]{64}$'), + chain_note_id text not null unique check (chain_note_id ~ '^0x[0-9a-f]{64}$'), + seal_tx_hash text check (seal_tx_hash is null or seal_tx_hash ~ '^0x[0-9a-f]{64}$'), + expires_at timestamptz not null, + created_at timestamptz not null default now() +); + +create table if not exists public.canary_copies ( + note_id uuid not null references public.canary_notes(id) on delete cascade, + copy_index integer not null check (copy_index >= 0 and copy_index < 100), + ciphertext text not null check (char_length(ciphertext) <= 12000), + iv text not null check (iv ~ '^[0-9a-f]{32}$'), + reader_device_hash text check (reader_device_hash is null or reader_device_hash ~ '^[0-9a-f]{64}$'), + reader_name text check (reader_name is null or char_length(reader_name) between 1 and 40), + reader_tag text check (reader_tag is null or reader_tag ~ '^0x[0-9a-f]{64}$'), + opened_at timestamptz, + open_tx_hash text check (open_tx_hash is null or open_tx_hash ~ '^0x[0-9a-f]{64}$'), + primary key (note_id, copy_index) +); + +-- One copy per device per note (a retry gets the same copy back). +create unique index if not exists canary_copies_one_per_device + on public.canary_copies (note_id, reader_device_hash) + where reader_device_hash is not null; + +-- One row per relayer key; a key is "leased" while it has a transaction in +-- flight, so no two requests ever use the same key (and nonce) at once. +create table if not exists public.canary_relayer_leases ( + relayer_index integer primary key check (relayer_index between 0 and 31), + busy_until timestamptz not null default 'epoch' +); + +alter table public.canary_notes enable row level security; +alter table public.canary_copies enable row level security; +alter table public.canary_relayer_leases enable row level security; + +revoke all on public.canary_notes from anon, authenticated; +revoke all on public.canary_copies from anon, authenticated; +revoke all on public.canary_relayer_leases from anon, authenticated; +grant all on public.canary_notes to service_role; +grant all on public.canary_copies to service_role; +grant all on public.canary_relayer_leases to service_role; + +-- Give a device its copy: the same copy again if it already has one, +-- otherwise the lowest unassigned copy. Concurrency-safe (SKIP LOCKED + the +-- unique index above). Returns no row when every copy is taken. +create or replace function public.canary_assign_copy( + p_note_id uuid, + p_device_hash text, + p_reader_name text +) +returns table ( + copy_index integer, + ciphertext text, + iv text, + reader_tag text, + opened_at timestamptz, + open_tx_hash text +) +language plpgsql +security definer +set search_path = public +as $$ +#variable_conflict use_column +begin + return query + select c.copy_index, c.ciphertext, c.iv, c.reader_tag, c.opened_at, c.open_tx_hash + from public.canary_copies c + where c.note_id = p_note_id + and c.reader_device_hash = p_device_hash; + if found then + return; + end if; + + return query + update public.canary_copies c + set reader_device_hash = p_device_hash, + reader_name = p_reader_name, + opened_at = now() + where (c.note_id, c.copy_index) = ( + select c2.note_id, c2.copy_index + from public.canary_copies c2 + where c2.note_id = p_note_id + and c2.reader_device_hash is null + order by c2.copy_index + limit 1 + for update skip locked + ) + returning c.copy_index, c.ciphertext, c.iv, c.reader_tag, c.opened_at, c.open_tx_hash; +end; +$$; + +-- Lease a free relayer key index in [0, p_count). Null when all are busy. +create or replace function public.canary_lease_relayer(p_count integer, p_seconds integer) +returns integer +language plpgsql +security definer +set search_path = public +as $$ +declare + v_index integer; +begin + insert into public.canary_relayer_leases (relayer_index) + select g from generate_series(0, p_count - 1) as g + on conflict (relayer_index) do nothing; + + select l.relayer_index into v_index + from public.canary_relayer_leases l + where l.relayer_index < p_count + and l.busy_until < now() + order by l.busy_until + limit 1 + for update skip locked; + + if v_index is null then + return null; + end if; + + update public.canary_relayer_leases + set busy_until = now() + make_interval(secs => p_seconds) + where relayer_index = v_index; + return v_index; +end; +$$; + +create or replace function public.canary_release_relayer(p_index integer) +returns void +language sql +security definer +set search_path = public +as $$ + update public.canary_relayer_leases + set busy_until = 'epoch' + where relayer_index = p_index; +$$; + +revoke all on function public.canary_assign_copy(uuid, text, text) from public, anon, authenticated; +revoke all on function public.canary_lease_relayer(integer, integer) from public, anon, authenticated; +revoke all on function public.canary_release_relayer(integer) from public, anon, authenticated; +grant execute on function public.canary_assign_copy(uuid, text, text) to service_role; +grant execute on function public.canary_lease_relayer(integer, integer) to service_role; +grant execute on function public.canary_release_relayer(integer) to service_role; diff --git a/app/supabase/migrations/20261010090000_nosus_canary_solana.sql b/app/supabase/migrations/20261010090000_nosus_canary_solana.sql new file mode 100644 index 0000000..ff0fd82 --- /dev/null +++ b/app/supabase/migrations/20261010090000_nosus_canary_solana.sql @@ -0,0 +1,80 @@ +-- NO SUS Canary: Solana devnet proof. Experiment project ffidvfguojalzpclipzi ONLY. +-- Never apply to production (rxfnazmusofikwaggntb). +-- +-- Adds the Solana memo signature next to each Monad transaction hash. Like the +-- Monad hashes these are public identifiers of sha256-based ids; the server +-- still never stores note text, keys, salts or codewords. +-- +-- Apply before deploying the `canary` edge function that selects these columns. +-- +-- Reverse (manual, if ever needed): +-- drop function if exists public.canary_assign_copy(uuid, text, text); +-- alter table public.canary_copies drop column if exists open_solana_sig; +-- alter table public.canary_notes drop column if exists seal_solana_sig; +-- -- then re-create canary_assign_copy and its grants from 20260919090000_nosus_canary.sql + +alter table public.canary_notes + add column if not exists seal_solana_sig text + check (seal_solana_sig is null or seal_solana_sig ~ '^[1-9A-HJ-NP-Za-km-z]{64,100}$'); + +alter table public.canary_copies + add column if not exists open_solana_sig text + check (open_solana_sig is null or open_solana_sig ~ '^[1-9A-HJ-NP-Za-km-z]{64,100}$'); + +-- Same function as before plus open_solana_sig in the result. Postgres cannot +-- change a function's result columns with "or replace", so drop it first; the +-- grants are dropped with it and re-applied below. +drop function if exists public.canary_assign_copy(uuid, text, text); + +-- Give a device its copy: the same copy again if it already has one, +-- otherwise the lowest unassigned copy. Concurrency-safe (SKIP LOCKED + the +-- one-per-device unique index). Returns no row when every copy is taken. +create or replace function public.canary_assign_copy( + p_note_id uuid, + p_device_hash text, + p_reader_name text +) +returns table ( + copy_index integer, + ciphertext text, + iv text, + reader_tag text, + opened_at timestamptz, + open_tx_hash text, + open_solana_sig text +) +language plpgsql +security definer +set search_path = public +as $$ +#variable_conflict use_column +begin + return query + select c.copy_index, c.ciphertext, c.iv, c.reader_tag, c.opened_at, c.open_tx_hash, c.open_solana_sig + from public.canary_copies c + where c.note_id = p_note_id + and c.reader_device_hash = p_device_hash; + if found then + return; + end if; + + return query + update public.canary_copies c + set reader_device_hash = p_device_hash, + reader_name = p_reader_name, + opened_at = now() + where (c.note_id, c.copy_index) = ( + select c2.note_id, c2.copy_index + from public.canary_copies c2 + where c2.note_id = p_note_id + and c2.reader_device_hash is null + order by c2.copy_index + limit 1 + for update skip locked + ) + returning c.copy_index, c.ciphertext, c.iv, c.reader_tag, c.opened_at, c.open_tx_hash, c.open_solana_sig; +end; +$$; + +revoke all on function public.canary_assign_copy(uuid, text, text) from public, anon, authenticated; +grant execute on function public.canary_assign_copy(uuid, text, text) to service_role; diff --git a/app/test/features/auth/auth_providers_test.dart b/app/test/features/auth/auth_providers_test.dart new file mode 100644 index 0000000..25930e6 --- /dev/null +++ b/app/test/features/auth/auth_providers_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/features/auth/domain/entities/authenticated_user.dart'; +import 'package:no_sus/features/auth/domain/repositories/auth_repository.dart'; +import 'package:no_sus/features/auth/presentation/providers/auth_providers.dart'; + +void main() { + test('auth state provider reads from the injected repository', () async { + const user = AuthenticatedUser(id: 'user-1', email: 'student@example.com'); + final container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(const _FakeAuthRepository(user)), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen(authStateProvider, (_, _) {}); + addTearDown(subscription.close); + + final result = await container.read(authStateProvider.future); + + expect(result?.id, user.id); + expect(result?.email, user.email); + }); +} + +class _FakeAuthRepository implements AuthRepository { + final AuthenticatedUser user; + + const _FakeAuthRepository(this.user); + + @override + AuthenticatedUser get currentUser => user; + + @override + Stream watchAuthState() => Stream.value(user); + + @override + Future signIn({ + required String email, + required String password, + }) async { + return user; + } + + @override + Future signUp({ + required String email, + required String password, + }) async { + return user; + } + + @override + Future signInWithGoogle() async {} + + @override + Future signInWithGitHub() async {} + + @override + Future signInWithPhone(String phone) async {} + + @override + Future verifyPhoneOtp(String phone, String otp) async { + return user; + } + + @override + Future requestPasswordReset(String email) async {} + + @override + Future updatePassword(String newPassword) async {} + + @override + Future signOut() async {} +} diff --git a/app/test/features/auth/pending_intent_test.dart b/app/test/features/auth/pending_intent_test.dart new file mode 100644 index 0000000..f601f53 --- /dev/null +++ b/app/test/features/auth/pending_intent_test.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/core/providers/theme_provider.dart'; +import 'package:no_sus/features/auth/presentation/providers/pending_intent_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// The "preserve intent" contract: tap Join Community → hit the auth wall → +/// sign up → land back on the join flow, not on Home. +/// +/// Persistence is the load-bearing part. Sign-up can round trip through an +/// email confirmation link or an OAuth browser hand-off, either of which can +/// take the process down, so an in-memory intent would be dropped exactly when +/// it matters most. +void main() { + Future container() async { + final prefs = await SharedPreferences.getInstance(); + final c = ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ); + addTearDown(c.dispose); + return c; + } + + test('an intent survives a process restart', () async { + SharedPreferences.setMockInitialValues({}); + final first = await container(); + + await first + .read(pendingIntentProvider.notifier) + .set( + const PendingIntent(PendingIntentKind.joinGroup, payload: 'ABCD2345'), + ); + + final relaunched = await container(); + final restored = relaunched.read(pendingIntentProvider); + + expect(restored?.kind, PendingIntentKind.joinGroup); + expect(restored?.payload, 'ABCD2345'); + }); + + test('take() returns the intent once and clears it', () async { + SharedPreferences.setMockInitialValues({}); + final c = await container(); + final notifier = c.read(pendingIntentProvider.notifier); + + await notifier.set(const PendingIntent(PendingIntentKind.browseGroups)); + + expect(notifier.take()?.kind, PendingIntentKind.browseGroups); + expect( + notifier.take(), + isNull, + reason: 'a resumed intent must never fire twice on a rebuild', + ); + + // And the clear is durable, not just in memory. + final relaunched = await container(); + expect(relaunched.read(pendingIntentProvider), isNull); + }); + + test('an invite parked by the previous build is still honoured', () async { + // The old flow wrote this key directly. An app updated mid-flow — invite + // saved by the old build, consumed by the new one — must not lose it. + SharedPreferences.setMockInitialValues({'pending_invite_code': 'LEGACY99'}); + final c = await container(); + + final restored = c.read(pendingIntentProvider); + expect(restored?.kind, PendingIntentKind.joinGroup); + expect(restored?.payload, 'LEGACY99'); + + c.read(pendingIntentProvider.notifier).clear(); + + final relaunched = await container(); + expect( + relaunched.read(pendingIntentProvider), + isNull, + reason: 'clearing must remove the legacy key too, or it replays forever', + ); + }); + + test( + 'a corrupt stored intent is ignored rather than crashing boot', + () async { + SharedPreferences.setMockInitialValues({ + 'nosus_pending_intent': 'not json at all', + }); + final c = await container(); + expect(c.read(pendingIntentProvider), isNull); + }, + ); + + test('an intent kind this build does not know is ignored', () async { + // Forward compatibility: a newer build could park a kind this one has never + // heard of. Dropping it is correct; throwing during provider build is not. + SharedPreferences.setMockInitialValues({ + 'nosus_pending_intent': jsonEncode({ + 'kind': 'somethingFromTheFuture', + 'payload': 'x', + }), + }); + final c = await container(); + expect(c.read(pendingIntentProvider), isNull); + }); +} diff --git a/app/test/features/auth/providers/auth_providers_test.dart b/app/test/features/auth/providers/auth_providers_test.dart new file mode 100644 index 0000000..20f5e20 --- /dev/null +++ b/app/test/features/auth/providers/auth_providers_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:no_sus/features/auth/domain/repositories/auth_repository.dart'; +import 'package:no_sus/features/auth/domain/usecases/sign_in_use_case.dart'; +import 'package:no_sus/features/auth/domain/usecases/sign_up_use_case.dart'; +import 'package:no_sus/features/auth/domain/entities/authenticated_user.dart'; +import 'package:no_sus/features/auth/domain/usecases/sign_out_use_case.dart'; +import 'package:no_sus/features/auth/presentation/providers/auth_providers.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late MockAuthRepository mockAuthRepository; + late ProviderContainer container; + + setUp(() { + mockAuthRepository = MockAuthRepository(); + container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(mockAuthRepository), + ], + ); + }); + + tearDown(() { + container.dispose(); + }); + + test('signInUseCaseProvider provides SignInUseCase with mocked repository', () async { + final useCase = container.read(signInUseCaseProvider); + expect(useCase, isA()); + + when(() => mockAuthRepository.signIn(email: 'test@test.com', password: 'password')) + .thenAnswer((_) async => const AuthenticatedUser(id: 'test_id', email: 'test@test.com')); + + await useCase(email: 'test@test.com', password: 'password'); + verify(() => mockAuthRepository.signIn(email: 'test@test.com', password: 'password')).called(1); + }); + + test('signUpUseCaseProvider provides SignUpUseCase with mocked repository', () async { + final useCase = container.read(signUpUseCaseProvider); + expect(useCase, isA()); + + when(() => mockAuthRepository.signUp(email: 'test@test.com', password: 'password')) + .thenAnswer((_) async => const AuthenticatedUser(id: 'test_id', email: 'test@test.com')); + + await useCase(email: 'test@test.com', password: 'password'); + verify(() => mockAuthRepository.signUp(email: 'test@test.com', password: 'password')).called(1); + }); + + test('signOutUseCaseProvider provides SignOutUseCase with mocked repository', () async { + final useCase = container.read(signOutUseCaseProvider); + expect(useCase, isA()); + + when(() => mockAuthRepository.signOut()).thenAnswer((_) async {}); + + await useCase(); + verify(() => mockAuthRepository.signOut()).called(1); + }); +} diff --git a/app/test/features/groups/providers/groups_provider_test.dart b/app/test/features/groups/providers/groups_provider_test.dart new file mode 100644 index 0000000..bc9aab3 --- /dev/null +++ b/app/test/features/groups/providers/groups_provider_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:no_sus/features/groups/providers/groups_provider.dart'; + +void main() { + test('searchQueryProvider initializes with empty string and updates correctly', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Initial state + expect(container.read(searchQueryProvider), ''); + + // Update state + container.read(searchQueryProvider.notifier).update('crypto'); + expect(container.read(searchQueryProvider), 'crypto'); + }); +} diff --git a/app/test/features/help/help_catalog_test.dart b/app/test/features/help/help_catalog_test.dart new file mode 100644 index 0000000..2a17af1 --- /dev/null +++ b/app/test/features/help/help_catalog_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/features/help/domain/help_topic.dart'; + +/// Help is the surface a confused user reaches for, and every contextual +/// "What's this?" button deep-links into it by id. A renamed or removed topic +/// turns those buttons into dead ends, which is exactly the failure the Help +/// centre exists to prevent — so the ids are pinned here. +void main() { + group('HelpCatalog', () { + test('every id constant resolves to a topic', () { + const ids = [ + HelpCatalog.whatIsNoSus, + HelpCatalog.groups, + HelpCatalog.roles, + HelpCatalog.secureDocuments, + HelpCatalog.watermarking, + HelpCatalog.burnNotes, + HelpCatalog.burnFiles, + HelpCatalog.shareLinks, + HelpCatalog.auditLog, + HelpCatalog.notifications, + HelpCatalog.account, + HelpCatalog.troubleshooting, + ]; + + for (final id in ids) { + expect( + HelpCatalog.byId(id), + isNotNull, + reason: 'a WhatsThisButton points at "$id"', + ); + } + }); + + test('topic ids are unique', () { + final ids = HelpCatalog.topics.map((t) => t.id).toList(); + expect(ids.toSet().length, ids.length); + }); + + test('every topic has a summary and at least one section', () { + for (final topic in HelpCatalog.topics) { + expect(topic.summary.trim(), isNotEmpty, reason: topic.id); + expect(topic.sections, isNotEmpty, reason: topic.id); + for (final section in topic.sections) { + expect(section.heading.trim(), isNotEmpty, reason: topic.id); + expect(section.body.trim(), isNotEmpty, reason: topic.id); + } + } + }); + + test('an unknown id resolves to null rather than throwing', () { + expect(HelpCatalog.byId('no-such-topic'), isNull); + }); + + group('search', () { + test('an empty query returns everything', () { + expect(HelpCatalog.search(' ').length, HelpCatalog.topics.length); + }); + + test('matches on body text, not just titles', () { + // Watermark is a real product concept; retired experiments are absent. + final results = HelpCatalog.search('watermark'); + expect(results, isNotEmpty); + expect(results.map((t) => t.id), contains(HelpCatalog.watermarking)); + }); + + test('matches on keywords that never appear in the visible copy', () { + // Someone searching the symptom, not the feature name. + final results = HelpCatalog.search('expired'); + expect(results.map((t) => t.id), contains(HelpCatalog.troubleshooting)); + }); + + test('is case insensitive', () { + expect(HelpCatalog.search('BURN'), isNotEmpty); + }); + + test('a query matching nothing returns empty rather than everything', () { + expect(HelpCatalog.search('zzzznotathing'), isEmpty); + }); + }); + + test('screenshot-blocking copy stays scoped to Android', () { + // PROJECT_CONSTITUTION.md §2.3: copy must never outrun the actual + // capability. ScreenshotGuard returns early on web and iOS, so any claim + // that screenshots are blocked has to say where. + final topic = HelpCatalog.byId(HelpCatalog.watermarking)!; + final cautions = topic.sections.where((s) => s.isCaution).toList(); + expect( + cautions, + isNotEmpty, + reason: 'the limits of screenshot blocking must be called out', + ); + expect( + cautions.any((s) => s.body.contains('Android')), + isTrue, + reason: 'the platform scope of screenshot blocking must be explicit', + ); + }); + }); +} diff --git a/app/test/features/notifications/app_notification_test.dart b/app/test/features/notifications/app_notification_test.dart new file mode 100644 index 0000000..9d230c8 --- /dev/null +++ b/app/test/features/notifications/app_notification_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/features/notifications/domain/app_notification.dart'; + +/// The wire names here are column names on `notification_preferences` and +/// values of the CHECK constraint on `notifications.category` +/// (20260730112523_notifications.sql). A mismatch does not fail loudly — it +/// silently stops delivering a category — so it is pinned. +void main() { + group('NotificationCategory', () { + test('wire names match the database schema exactly', () { + expect(NotificationCategory.values.map((c) => c.wireName).toSet(), { + 'invites', + 'membership', + 'documents', + 'security', + }); + }); + + test('round-trips through fromWire', () { + for (final category in NotificationCategory.values) { + expect(NotificationCategory.fromWire(category.wireName), category); + } + }); + + test('an unknown category from a newer build resolves to null', () { + expect(NotificationCategory.fromWire('somethingNew'), isNull); + expect(NotificationCategory.fromWire(null), isNull); + }); + + test('security is the only category flagged critical', () { + expect(NotificationCategory.values.where((c) => c.isCritical).toList(), [ + NotificationCategory.security, + ]); + }); + + test('every category has user-facing copy', () { + for (final category in NotificationCategory.values) { + expect(category.label.trim(), isNotEmpty); + expect(category.description.trim(), isNotEmpty); + } + }); + }); + + group('AppNotification.fromRow', () { + test('parses a full row', () { + final n = AppNotification.fromRow({ + 'id': 'n1', + 'category': 'documents', + 'title': 'New document', + 'body': 'A document was shared in one of your groups.', + 'deep_link': 'group:g1', + 'group_id': 'g1', + 'created_at': '2026-07-27T10:00:00Z', + 'read_at': null, + }); + + expect(n.id, 'n1'); + expect(n.category, NotificationCategory.documents); + expect(n.deepLink, 'group:g1'); + expect(n.isUnread, isTrue); + }); + + test('a row with read_at set is not unread', () { + final n = AppNotification.fromRow({ + 'id': 'n2', + 'category': 'security', + 'title': 'Access revoked', + 'body': 'Your access to a group changed.', + 'created_at': '2026-07-27T10:00:00Z', + 'read_at': '2026-07-27T11:00:00Z', + }); + + expect(n.isUnread, isFalse); + }); + + test('an unrecognised category falls back rather than throwing', () { + // A newer server build could enqueue a category this client predates. + // Showing it under a known heading beats crashing the inbox. + final n = AppNotification.fromRow({ + 'id': 'n3', + 'category': 'somethingNew', + 'title': 'Something happened', + 'body': '', + 'created_at': '2026-07-27T10:00:00Z', + }); + + expect(n.category, NotificationCategory.membership); + }); + + test('a malformed timestamp does not throw', () { + final n = AppNotification.fromRow({ + 'id': 'n4', + 'category': 'invites', + 'title': 'Invite', + 'body': '', + 'created_at': 'not-a-date', + }); + + expect(n.createdAt.millisecondsSinceEpoch, 0); + }); + }); + + group('NotificationPreferences', () { + test('defaults to everything on, matching the column defaults', () { + for (final category in NotificationCategory.values) { + expect(NotificationPreferences.allOn.isEnabled(category), isTrue); + } + }); + + test('an absent column reads as enabled, not disabled', () { + // enqueue_notification treats "no preference row" as on. A client that + // defaulted the other way would show the user muted categories that are + // in fact delivering. + final prefs = NotificationPreferences.fromRow({'invites': false}); + expect(prefs.isEnabled(NotificationCategory.invites), isFalse); + expect(prefs.isEnabled(NotificationCategory.security), isTrue); + }); + + test('withCategory changes one flag and leaves the rest alone', () { + final updated = NotificationPreferences.allOn.withCategory( + NotificationCategory.documents, + false, + ); + + expect(updated.isEnabled(NotificationCategory.documents), isFalse); + expect(updated.isEnabled(NotificationCategory.invites), isTrue); + expect( + NotificationPreferences.allOn.isEnabled(NotificationCategory.documents), + isTrue, + reason: 'the original must not be mutated', + ); + }); + + test('toColumns emits exactly the schema column names', () { + expect(NotificationPreferences.allOn.toColumns().keys.toSet(), { + 'invites', + 'membership', + 'documents', + 'security', + }); + }); + }); +} diff --git a/app/test/features/onboarding/onboarding_providers_test.dart b/app/test/features/onboarding/onboarding_providers_test.dart new file mode 100644 index 0000000..7304b7f --- /dev/null +++ b/app/test/features/onboarding/onboarding_providers_test.dart @@ -0,0 +1,195 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/core/providers/theme_provider.dart'; +import 'package:no_sus/features/auth/domain/entities/authenticated_user.dart'; +import 'package:no_sus/features/auth/domain/repositories/auth_repository.dart'; +import 'package:no_sus/features/auth/presentation/providers/auth_providers.dart'; +import 'package:no_sus/features/onboarding/presentation/providers/onboarding_providers.dart'; +import 'package:no_sus/features/onboarding/presentation/providers/tour_providers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Regression tests for the two bugs that made first-run setup replay. +/// +/// 1. Completion used to live only in memory, so finishing setup while the +/// backend was unreachable persisted nothing and the whole flow ran again on +/// every launch. +/// 2. Making it durable then created the opposite bug: a device-wide boolean +/// would let a *second* account signing in on the same handset skip setup, +/// because AuthGate treats the local flag as an OR against the server's +/// per-account `profiles.onboarding_completed`. +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future containerFor(String? userId) async { + final prefs = await SharedPreferences.getInstance(); + return ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + authRepositoryProvider.overrideWithValue( + _FakeAuthRepository( + userId == null + ? null + : AuthenticatedUser(id: userId, email: '$userId@example.com'), + ), + ), + ], + ); + } + + /// authStateProvider is async, and OnboardingNotifier keys off it. Without + /// settling it first the notifier reads a null user and the assertions below + /// would pass for the wrong reason. + Future settleAuth(ProviderContainer container) async { + final sub = container.listen(authStateProvider, (_, _) {}); + addTearDown(sub.close); + await container.read(authStateProvider.future); + } + + group('OnboardingNotifier', () { + test('survives a restart once completed', () async { + final first = await containerFor('user-1'); + addTearDown(first.dispose); + await settleAuth(first); + + expect(first.read(onboardingCompletedProvider), isFalse); + await first.read(onboardingCompletedProvider.notifier).complete(); + expect(first.read(onboardingCompletedProvider), isTrue); + + // A fresh container over the same SharedPreferences is what a relaunch + // looks like — this is the case the in-memory notifier got wrong. + final relaunched = await containerFor('user-1'); + addTearDown(relaunched.dispose); + await settleAuth(relaunched); + + expect(relaunched.read(onboardingCompletedProvider), isTrue); + }); + + test( + 'does not carry over to a different account on the same device', + () async { + final first = await containerFor('user-1'); + addTearDown(first.dispose); + await settleAuth(first); + await first.read(onboardingCompletedProvider.notifier).complete(); + + final second = await containerFor('user-2'); + addTearDown(second.dispose); + await settleAuth(second); + + expect( + second.read(onboardingCompletedProvider), + isFalse, + reason: 'a second account must still get first-run setup', + ); + }, + ); + + test('reset clears completion so a new account starts fresh', () async { + final container = await containerFor('user-1'); + addTearDown(container.dispose); + await settleAuth(container); + + await container.read(onboardingCompletedProvider.notifier).complete(); + await container.read(onboardingCompletedProvider.notifier).reset(); + + expect(container.read(onboardingCompletedProvider), isFalse); + }); + }); + + group('welcomeSeenProvider', () { + test('is device-scoped and survives a restart', () async { + final first = await containerFor(null); + addTearDown(first.dispose); + + expect(first.read(welcomeSeenProvider), isFalse); + await first.read(welcomeSeenProvider.notifier).markSeen(); + + final relaunched = await containerFor(null); + addTearDown(relaunched.dispose); + expect(relaunched.read(welcomeSeenProvider), isTrue); + }); + }); + + group('TourProgressNotifier', () { + test('remembers dismissed tips and forgets them on reset', () async { + final container = await containerFor('user-1'); + addTearDown(container.dispose); + + final notifier = container.read(tourProgressProvider.notifier); + expect(notifier.hasSeen(TourSteps.workspaceTools), isFalse); + + await notifier.markSeen([TourSteps.workspaceTools]); + expect(notifier.hasSeen(TourSteps.workspaceTools), isTrue); + expect( + notifier.hasSeen(TourSteps.groupsCreate), + isFalse, + reason: 'tips are tracked individually, not as one global flag', + ); + + // A relaunch must not re-show a dismissed tip. + final relaunched = await containerFor('user-1'); + addTearDown(relaunched.dispose); + expect( + relaunched + .read(tourProgressProvider.notifier) + .hasSeen(TourSteps.workspaceTools), + isTrue, + ); + + await relaunched.read(tourProgressProvider.notifier).resetAll(); + expect( + relaunched + .read(tourProgressProvider.notifier) + .hasSeen(TourSteps.workspaceTools), + isFalse, + reason: 'Help → show tips again must actually replay them', + ); + }); + }); +} + +class _FakeAuthRepository implements AuthRepository { + final AuthenticatedUser? user; + + const _FakeAuthRepository(this.user); + + @override + AuthenticatedUser? get currentUser => user; + + @override + Stream watchAuthState() => Stream.value(user); + + @override + Future signIn({ + required String email, + required String password, + }) async => user!; + + @override + Future signUp({ + required String email, + required String password, + }) async => user!; + + @override + Future signInWithGoogle() async {} + + @override + Future signInWithGitHub() async {} + + @override + Future signInWithPhone(String phone) async {} + + @override + Future verifyPhoneOtp(String phone, String otp) async => + user!; + + @override + Future requestPasswordReset(String email) async {} + + @override + Future updatePassword(String newPassword) async {} + + @override + Future signOut() async {} +} diff --git a/app/test/providers/upload_provider_test.dart b/app/test/providers/upload_provider_test.dart new file mode 100644 index 0000000..62d0f39 --- /dev/null +++ b/app/test/providers/upload_provider_test.dart @@ -0,0 +1,120 @@ +import 'dart:typed_data'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:no_sus/features/auth/domain/entities/authenticated_user.dart'; +import 'package:no_sus/features/auth/presentation/providers/auth_providers.dart'; +import 'package:no_sus/features/files/domain/models/secure_file_metadata.dart'; +import 'package:no_sus/features/files/domain/repositories/secure_file_repository.dart'; +import 'package:no_sus/features/files/presentation/providers/secure_file_providers.dart'; +import 'package:no_sus/features/files/presentation/providers/upload_provider.dart'; +import 'package:no_sus/features/groups/models/group_file.dart'; +import 'package:no_sus/features/auth/domain/repositories/auth_repository.dart'; + +class MockSecureFileRepository extends Mock implements SecureFileRepository {} +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + setUpAll(() { + registerFallbackValue(SecureFileType.pdf); + registerFallbackValue(Uint8List(0)); + }); + + group('UploadNotifier', () { + late MockSecureFileRepository mockRepo; + late MockAuthRepository mockAuthRepo; + late ProviderContainer container; + + setUp(() { + mockRepo = MockSecureFileRepository(); + mockAuthRepo = MockAuthRepository(); + + when(() => mockAuthRepo.currentUser).thenReturn( + const AuthenticatedUser(id: 'u1', email: 'test@nosus.io'), + ); + + container = ProviderContainer( + overrides: [ + secureFileRepositoryProvider.overrideWithValue(mockRepo), + authRepositoryProvider.overrideWithValue(mockAuthRepo), + // Ignore profile provider for simple test + ], + ); + }); + + tearDown(() { + container.dispose(); + }); + + test('initial state is idle', () { + final state = container.read(uploadProvider); + expect(state.stage, UploadStage.idle); + expect(state.progress, 0.0); + }); + + test('uploadDocument successful flow', () async { + when(() => mockRepo.uploadFile( + groupId: any(named: 'groupId'), + name: any(named: 'name'), + type: any(named: 'type'), + rawBytes: any(named: 'rawBytes'), + uploaderName: any(named: 'uploaderName'), + uploaderInitials: any(named: 'uploaderInitials'), + onProgress: any(named: 'onProgress'), + )).thenAnswer((invocation) async { + final onProgress = invocation.namedArguments[#onProgress] as void Function(double)?; + if (onProgress != null) { + onProgress(0.5); + onProgress(1.0); + } + }); + + final notifier = container.read(uploadProvider.notifier); + + final future = notifier.uploadDocument( + 'test.pdf', + FileType.pdf, + 'g1', + Uint8List.fromList([1, 2, 3]), + ); + + expect(container.read(uploadProvider).stage, UploadStage.processing); + + await future; + + verify(() => mockRepo.uploadFile( + groupId: 'g1', + name: 'test.pdf', + type: SecureFileType.pdf, + rawBytes: any(named: 'rawBytes'), + uploaderName: any(named: 'uploaderName'), + uploaderInitials: any(named: 'uploaderInitials'), + onProgress: any(named: 'onProgress'), + )).called(1); + }); + + test('uploadDocument error flow', () async { + when(() => mockRepo.uploadFile( + groupId: any(named: 'groupId'), + name: any(named: 'name'), + type: any(named: 'type'), + rawBytes: any(named: 'rawBytes'), + uploaderName: any(named: 'uploaderName'), + uploaderInitials: any(named: 'uploaderInitials'), + onProgress: any(named: 'onProgress'), + )).thenThrow(Exception('Upload failed')); + + final notifier = container.read(uploadProvider.notifier); + await notifier.uploadDocument( + 'test.pdf', + FileType.pdf, + 'g1', + Uint8List.fromList([1, 2, 3]), + ); + + final state = container.read(uploadProvider); + expect(state.stage, UploadStage.error); + expect(state.errorMessage, contains('Upload failed')); + }); + }); +} diff --git a/app/test/supabase_service_test.dart b/app/test/supabase_service_test.dart new file mode 100644 index 0000000..9794c87 --- /dev/null +++ b/app/test/supabase_service_test.dart @@ -0,0 +1,97 @@ +// ignore_for_file: avoid_print +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:no_sus/services/supabase_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group( + 'Supabase Storage Integration Tests', + () { + setUpAll(() async { + await SupabaseService.instance.initialize(); + }); + + test('Upload, Download, and Delete flow via Supabase Storage', () async { + expect( + SupabaseService.instance.isConfigured, + isTrue, + reason: 'Supabase credentials should be configured', + ); + + final client = Supabase.instance.client; + final timestamp = DateTime.now().millisecondsSinceEpoch; + final email = 'integration_test_$timestamp@example.com'; + final password = 'Password123!'; + + print('Signing up integration test user: $email...'); + final authRes = await client.auth.signUp(email: email, password: password); + final user = authRes.user; + expect(user, isNotNull); + final userId = user!.id; + + // The signup trigger handle_new_user auto-joins the user to the Global Community + // and creates the group if it doesn't exist. Let's define the group ID. + const globalGroupId = '8055b43a-6d07-4588-a39f-159e6154a019'; + + final testFileName = 'file_test_integration_$timestamp'; + final testData = Uint8List.fromList([ + 104, + 101, + 108, + 108, + 111, + 32, + 119, + 111, + 114, + 108, + 100, + ]); + + // Insert metadata record first to satisfy RLS SELECT/INSERT constraints if needed. + // Wait, for INSERT into storage.objects, we check bucket_id = 'secure-files' and auth.uid() = owner_id. + // For SELECT, we check group membership via public.secure_files. + print('Inserting secure file metadata to public.secure_files...'); + await client.from('secure_files').insert({ + 'id': testFileName, + 'group_id': globalGroupId, + 'name': 'Test Integration File', + 'type': 'pdf', + 'size_bytes': testData.length, + 'is_watermarked': true, + 'is_pinned': false, + 'security_status': 'secured', + 'owner_id': userId, + 'uploaded_by': userId, + }); + + print('Uploading test bytes to Supabase Storage...'); + await client.storage + .from('secure-files') + .uploadBinary(testFileName, testData); + + print('Downloading test bytes...'); + final downloadedData = await client.storage + .from('secure-files') + .download(testFileName); + expect(downloadedData, equals(testData)); + + print('Deleting test file from storage...'); + await client.storage + .from('secure-files') + .remove([testFileName]); + + print('Deleting test file metadata...'); + await client.from('secure_files').delete().eq('id', testFileName); + + print('Cleaning up: deleting test auth user...'); + // We cannot delete ourselves via Client API. We can run it in database after or just delete the user's session. + await client.auth.signOut(); + }); + }, + skip: 'Requires live Supabase project credentials.', + ); +} diff --git a/app/test/unit/app_constants_test.dart b/app/test/unit/app_constants_test.dart new file mode 100644 index 0000000..e230b19 --- /dev/null +++ b/app/test/unit/app_constants_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/core/constants/app_constants.dart'; + +void main() { + group('AppConstants', () { + test('contains expected core identity constants', () { + expect(AppConstants.appName, 'NO SUS'); + expect(AppConstants.appTagline, 'See who opened it'); + }); + + test('avatarColorToAsset and role map correctly', () { + expect(AppConstants.avatarColorToAsset['FF0072FF'], 'assets/images/avatar_builder.png'); + expect(AppConstants.avatarColorToAsset['FFADF474'], 'assets/images/avatar_archivist.png'); + expect(AppConstants.avatarAsset('UNKNOWN'), 'assets/images/avatar_builder.png'); + expect(AppConstants.avatarRole('UNKNOWN'), 'SYSTEMS ARCHITECT'); + expect(AppConstants.avatarRole('FF0072FF'), 'SYSTEMS ARCHITECT'); + expect(AppConstants.avatarRole('FFADF474'), 'INFORMATION TRUSTEE'); + expect(AppConstants.avatarIcon('FF0072FF'), isNotNull); + }); + }); +} diff --git a/app/test/unit/burn_crypto_web_compat_test.dart b/app/test/unit/burn_crypto_web_compat_test.dart new file mode 100644 index 0000000..fe6d69b --- /dev/null +++ b/app/test/unit/burn_crypto_web_compat_test.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:encrypt/encrypt.dart' as enc; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/services/burn_file_crypto.dart'; + +/// Known-answer tests pinning the burn note/file ciphertext formats. +/// +/// The landing page (homepage/src/lib/burnCrypto.ts) reimplements these +/// exact paths in WebCrypto so notes/files created on nosus.foo decrypt in +/// the app and vice versa. These vectors were cross-verified against +/// WebCrypto (see tool/crypto_compat_probe.dart): +/// * Burn Note = AES-256-CTR (counter = IV, full-block carry) over +/// PKCS7-PADDED plaintext — the `encrypt` package pads even in its +/// default SIC/CTR mode. Ciphertext stored base64. +/// * Burn File = AES-256-CBC/PKCS7 over the packed payload +/// `[4-byte BE header len][JSON {name,type,size}][raw bytes]`. +/// +/// If either test fails, the web implementation no longer matches the app — +/// DO NOT "fix" by updating the expected values without updating +/// homepage/src/lib/burnCrypto.ts in lockstep. +void main() { + final keyBytes = Uint8List.fromList(List.generate(32, (i) => i + 1)); + final ivBytes = Uint8List.fromList(List.generate(16, (i) => 0xA0 + i)); + final key = enc.Key(keyBytes); + final iv = enc.IV(ivBytes); + + test('burn note ciphertext matches the WebCrypto-verified vector', () { + const noteText = 'Meet at the library, 6pm. Burn after reading. äöü✓'; + final encrypter = enc.Encrypter(enc.AES(key)); + expect( + encrypter.encrypt(noteText, iv: iv).base64, + '71lEqc0Io3FpcmZgKYel21XtoAbwGtLrCxvNr7EFoQu+hzjeP+Pg5XjnAWmz6EMr' + '0aCQC+1tPVTKU+TYxjJQ7Q==', + ); + // And the viewer's decrypt path accepts it back. + expect( + encrypter.decrypt64(encrypter.encrypt(noteText, iv: iv).base64, iv: iv), + noteText, + ); + }); + + test('burn file ciphertext matches the WebCrypto-verified vector', () { + final fileBytes = + Uint8List.fromList(List.generate(1000, (i) => (i * 7 + 3) % 256)); + final packed = packBurnFilePayload( + fileName: 'exam notes.pdf', + mimeType: 'application/pdf', + fileBytes: fileBytes, + ); + final ciphertext = encryptBurnFilePayload(packed, key, iv); + + // Same ciphertext WebCrypto AES-CBC produced for the same inputs. + expect( + leading48(ciphertext), + leading48(base64.decode( + // The verified vector's first 48 bytes — enough to catch any + // mode/padding/packing drift (a format change alters block 0), + // without bloating the test with the full 1KB blob. + 'nkE9Mmwi34RhWM7mGECy27W5S6AJaqrkr8Xh57HTSiVE1cWIJMkCME6RxwAvDLKp', + )), + ); + + final unpacked = + unpackBurnFilePayload(decryptBurnFilePayload(ciphertext, key, iv)); + expect(unpacked.fileName, 'exam notes.pdf'); + expect(unpacked.mimeType, 'application/pdf'); + expect(unpacked.bytes, fileBytes); + }); +} + +/// Compares only the leading bytes (the base64 snippet above decodes to 48 +/// bytes) — enough to detect any algorithm/mode/padding/packing drift. +List leading48(List bytes) => bytes.sublist(0, 48); diff --git a/app/test/unit/burn_file_crypto_test.dart b/app/test/unit/burn_file_crypto_test.dart new file mode 100644 index 0000000..1c14a5b --- /dev/null +++ b/app/test/unit/burn_file_crypto_test.dart @@ -0,0 +1,104 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/services/burn_file_crypto.dart'; + +void main() { + group('pack/unpackBurnFilePayload', () { + test('round-trips name, mime type, and bytes', () { + final original = Uint8List.fromList(List.generate(256, (i) => i % 256)); + final packed = packBurnFilePayload( + fileName: 'report.pdf', + mimeType: 'application/pdf', + fileBytes: original, + ); + final unpacked = unpackBurnFilePayload(packed); + + expect(unpacked.fileName, 'report.pdf'); + expect(unpacked.mimeType, 'application/pdf'); + expect(unpacked.bytes, original); + }); + + test('round-trips a filename with unicode and special characters', () { + final original = Uint8List.fromList([1, 2, 3, 4, 5]); + final packed = packBurnFilePayload( + fileName: '成绩单 — final (v2) 😀.pdf', + mimeType: 'application/pdf', + fileBytes: original, + ); + final unpacked = unpackBurnFilePayload(packed); + + expect(unpacked.fileName, '成绩单 — final (v2) 😀.pdf'); + expect(unpacked.bytes, original); + }); + + test('round-trips a zero-byte file', () { + final original = Uint8List(0); + final packed = packBurnFilePayload( + fileName: 'empty.txt', + mimeType: 'text/plain', + fileBytes: original, + ); + final unpacked = unpackBurnFilePayload(packed); + + expect(unpacked.fileName, 'empty.txt'); + expect(unpacked.bytes, isEmpty); + }); + }); + + group('encrypt/decryptBurnFilePayload', () { + test('round-trips through AES-256-CBC with a fresh key/IV', () { + final keyMaterial = generateBurnFileKeyMaterial(); + final packed = packBurnFilePayload( + fileName: 'secret.bin', + mimeType: 'application/octet-stream', + fileBytes: Uint8List.fromList(List.generate(1000, (i) => (i * 7) % 256)), + ); + + final ciphertext = encryptBurnFilePayload(packed, keyMaterial.key, keyMaterial.iv); + // Ciphertext must not equal plaintext, and must not trivially reveal size + // relationships that break confidentiality assumptions. + expect(ciphertext, isNot(equals(packed))); + + final decrypted = decryptBurnFilePayload(ciphertext, keyMaterial.key, keyMaterial.iv); + expect(decrypted, packed); + + final unpacked = unpackBurnFilePayload(decrypted); + expect(unpacked.fileName, 'secret.bin'); + }); + + test('decrypting with the wrong key never recovers the original bytes', () { + // Under CBC+PKCS7, a wrong key almost always fails PKCS7 padding + // validation on the last block and throws, rather than silently + // returning garbage — that's the desirable property (tampering/wrong + // key fails loudly). Either way, the original bytes must never come + // back out. + final keyMaterial = generateBurnFileKeyMaterial(); + final wrongKeyMaterial = generateBurnFileKeyMaterial(); + final packed = packBurnFilePayload( + fileName: 'a.txt', + mimeType: 'text/plain', + fileBytes: Uint8List.fromList([1, 2, 3]), + ); + final ciphertext = encryptBurnFilePayload(packed, keyMaterial.key, keyMaterial.iv); + + Uint8List? decrypted; + try { + decrypted = decryptBurnFilePayload(ciphertext, wrongKeyMaterial.key, keyMaterial.iv); + } catch (_) { + // Padding-validation failure is an acceptable, expected outcome. + } + if (decrypted != null) { + expect(decrypted, isNot(equals(packed))); + } + }); + }); + + group('hex helpers', () { + test('bytesToHex/hexToBytes round-trip', () { + final bytes = Uint8List.fromList([0, 1, 15, 16, 255]); + final hex = bytesToHex(bytes); + expect(hex, '00010f10ff'); + expect(hexToBytes(hex), bytes); + }); + }); +} diff --git a/app/test/unit/canary_fingerprint_test.dart b/app/test/unit/canary_fingerprint_test.dart new file mode 100644 index 0000000..7faef7b --- /dev/null +++ b/app/test/unit/canary_fingerprint_test.dart @@ -0,0 +1,107 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/features/canary/domain/canary_fingerprint.dart'; +import 'package:no_sus/features/canary/presentation/canary_composer_screen.dart' + show kCanaryDemoNote; + +final _zeroWidth = RegExp( + '[${String.fromCharCode(0x2060)}${String.fromCharCode(0x200B)}${String.fromCharCode(0x200C)}]', +); + +String _stripMarkers(String s) => s.replaceAll(_zeroWidth, ''); + +void main() { + final plan = buildCanaryPlan(kCanaryDemoNote); + final codewords = generateCanaryCodewords( + 50, + plan.bitCount, + random: Random(42), + ); + final copies = [ + for (var i = 0; i < codewords.length; i++) + renderCanaryCopy(plan, codewords[i], copyIndex: i), + ]; + + test('demo note has enough swappable words for 100 readers', () { + expect(plan.bitCount, greaterThanOrEqualTo(canaryRequiredSlots(100))); + expect(canaryMaxCopiesFor(plan), 100); + }); + + test('all-zero codeword reproduces the original note exactly', () { + expect( + renderCanaryCopy(plan, List.filled(plan.bitCount, 0)), + kCanaryDemoNote, + ); + }); + + test('every copy is different and carries its own marker', () { + expect(copies.map(_stripMarkers).toSet().length, copies.length); + for (var i = 0; i < copies.length; i++) { + expect(decodeCanaryMarker(copies[i]), i); + } + }); + + test('exact copy is identified by the invisible marker', () { + final match = matchCanaryLeak(copies[7], plan, codewords); + expect(match.kind, CanaryMatchKind.exactMarker); + expect(match.copyIndex, 7); + }); + + test('marker-stripped copy is identified by its words', () { + final match = matchCanaryLeak(_stripMarkers(copies[12]), plan, codewords); + expect(match.kind, CanaryMatchKind.confident); + expect(match.copyIndex, 12); + }); + + test('OCR-like text (caps, no punctuation, line breaks) still matches', () { + final ocr = _stripMarkers( + copies[30], + ).replaceAll(RegExp(r'[,.!?]'), '').replaceAll(' ', '\n').toUpperCase(); + final match = matchCanaryLeak(ocr, plan, codewords); + expect(match.kind, CanaryMatchKind.confident); + expect(match.copyIndex, 30); + }); + + test("sender's original is reported as the original, not a reader", () { + final match = matchCanaryLeak(kCanaryDemoNote, plan, codewords); + expect(match.kind, CanaryMatchKind.senderOriginal); + expect(match.copyIndex, isNull); + }); + + test('unrelated text never names anyone', () { + final match = matchCanaryLeak( + 'The quick brown fox jumps over the lazy dog.', + plan, + codewords, + ); + expect(match.copyIndex, isNull); + }); + + test('partial excerpts never name the wrong copy', () { + final rng = Random(7); + for (var t = 0; t < 200; t++) { + final i = rng.nextInt(copies.length); + final clean = _stripMarkers(copies[i]); + final from = (clean.length * rng.nextDouble() * 0.5).floor(); + final to = (from + clean.length * 0.35).floor().clamp(0, clean.length); + final match = matchCanaryLeak(clean.substring(from, to), plan, codewords); + if (match.copyIndex != null) expect(match.copyIndex, i); + } + }); + + test('too-short notes are rejected for large reader counts', () { + final short = buildCanaryPlan("Hi, please don't share this. Okay?"); + expect(short.bitCount, lessThan(canaryRequiredSlots(10))); + expect(() => generateCanaryCodewords(10, short.bitCount), throwsStateError); + }); + + test('plan survives a JSON round trip', () { + final restored = CanaryPlan.fromJson( + Map.from(plan.toJson()), + ); + expect(restored.tokens, plan.tokens); + expect(restored.bitCount, plan.bitCount); + expect(renderCanaryCopy(restored, codewords[3], copyIndex: 3), copies[3]); + }); +} diff --git a/app/test/unit/canary_link_test.dart b/app/test/unit/canary_link_test.dart new file mode 100644 index 0000000..a16e62f --- /dev/null +++ b/app/test/unit/canary_link_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/features/canary/domain/canary_link.dart'; +import 'package:no_sus/main.dart' show extractBurnNoteToken, extractCanaryToken; + +const _noteId = '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'; +final _key = 'ab' * 32; + +void main() { + test('reader link round-trips', () { + final link = buildCanaryReaderLink( + origin: 'https://https-shubhamsahu.github.io', + basePath: '/nosus-canary', + noteId: _noteId, + keyHex: _key, + ); + expect( + link, + 'https://https-shubhamsahu.github.io/nosus-canary/#/canary/$_noteId?k=$_key', + ); + final token = parseCanaryReaderLink(link); + expect(token, isNotNull); + expect(token!.noteId, _noteId); + expect(token.keyHex, _key); + }); + + test('rejects wrong shapes', () { + expect(parseCanaryReaderLink('https://x.y/#/canary/$_noteId'), isNull); + expect( + parseCanaryReaderLink('https://x.y/#/canary/$_noteId?k=abc'), + isNull, + ); + expect( + parseCanaryReaderLink('https://x.y/#/canary/not-a-uuid?k=$_key'), + isNull, + ); + expect( + parseCanaryReaderLink('https://x.y/canary/$_noteId?k=$_key'), + isNull, + ); + expect( + parseCanaryReaderLink( + 'https://x.y/#/burn/$_noteId?k=$_key&v=${'cd' * 16}', + ), + isNull, + ); + }); + + test('Canary and Burn extractors never claim each other\'s links', () { + final canary = Uri.parse('https://x.y/#/canary/$_noteId?k=$_key'); + final burn = Uri.parse( + 'https://x.y/#/burn/$_noteId?k=$_key&v=${'cd' * 16}', + ); + expect(extractCanaryToken(canary)?.noteId, _noteId); + expect(extractBurnNoteToken(canary), isNull); + expect(extractCanaryToken(burn), isNull); + }); + + test('chain note id is a stable 32-byte hex value', () { + final id = canaryChainNoteId(_noteId); + expect(id, matches(RegExp(r'^0x[0-9a-f]{64}$'))); + expect(canaryChainNoteId(_noteId), id); + // Must equal sha256("nosus-canary:" + noteId); the edge function computes + // the same value. Known answer (also used to test the edge function): + expect( + id, + '0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e', + ); + }); + + test('explorer links point at the right testnet per chain', () { + final monadHash = '0x${'ab' * 32}'; + // Base58 Solana signatures are 86–88 chars; this one is 88. + final solanaSig = '5' * 88; + + expect( + canaryExplorerTxUrl(monadHash), + 'https://testnet.monadscan.com/tx/$monadHash', + ); + expect( + canarySolanaExplorerTxUrl(solanaSig), + 'https://explorer.solana.com/tx/$solanaSig?cluster=devnet', + ); + expect(CanaryChain.monad.txUrl(monadHash), canaryExplorerTxUrl(monadHash)); + expect( + CanaryChain.solana.txUrl(solanaSig), + canarySolanaExplorerTxUrl(solanaSig), + ); + }); + + test('chain labels read correctly in chips and sentences', () { + expect(CanaryChain.monad.label, 'Monad'); + expect(CanaryChain.solana.label, 'Solana'); + expect(CanaryChain.monad.network, 'Monad testnet'); + expect(CanaryChain.solana.network, 'Solana devnet'); + }); +} diff --git a/app/test/unit/canary_models_test.dart b/app/test/unit/canary_models_test.dart new file mode 100644 index 0000000..fc74d38 --- /dev/null +++ b/app/test/unit/canary_models_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/features/canary/domain/canary_fingerprint.dart'; +import 'package:no_sus/features/canary/domain/canary_models.dart'; + +const _noteId = '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'; +final _monadHash = '0x${'ab' * 32}'; +final _solanaSig = '5' * 88; + +CanaryOwnerRecord _record({String? sealTxHash, String? sealSolanaSig}) => + CanaryOwnerRecord( + noteId: _noteId, + keyHex: 'cd' * 32, + ownerSecret: 'ef' * 32, + saltHex: '01' * 32, + copyCount: 2, + createdAt: DateTime.utc(2026, 10, 10, 12), + expiresAt: DateTime.utc(2026, 10, 11, 12), + readerLink: 'https://x.y/#/canary/$_noteId?k=${'cd' * 32}', + preview: 'The quick brown fox jumps over the lazy dog.', + plan: buildCanaryPlan( + 'The quick brown fox jumps over the lazy dog. ' + 'It was a very big and really nice day for a walk.', + ), + codewords: const [ + [0, 1, 0], + [1, 0, 1], + ], + sealTxHash: sealTxHash, + sealSolanaSig: sealSolanaSig, + ); + +void main() { + test('owner record round-trips with both proofs', () { + final original = _record( + sealTxHash: _monadHash, + sealSolanaSig: _solanaSig, + ); + final json = original.toJson(); + expect(json['v'], 1); + expect(json['sealTxHash'], _monadHash); + expect(json['sealSolanaSig'], _solanaSig); + + final restored = CanaryOwnerRecord.fromJson(json); + expect(restored.noteId, original.noteId); + expect(restored.sealTxHash, _monadHash); + expect(restored.sealSolanaSig, _solanaSig); + expect(restored.codewords, original.codewords); + expect(restored.isSealed, isTrue); + }); + + test('owner record round-trips without a Solana proof', () { + final original = _record(sealTxHash: _monadHash); + final json = original.toJson(); + expect(json.containsKey('sealSolanaSig'), isTrue); + expect(json['sealSolanaSig'], isNull); + + final restored = CanaryOwnerRecord.fromJson(json); + expect(restored.sealTxHash, _monadHash); + expect(restored.sealSolanaSig, isNull); + // A Monad seal alone still counts, so records from the Monad build + // keep working. + expect(restored.isSealed, isTrue); + }); + + test('records saved before Solana proofs load with a null signature', () { + final legacy = _record(sealTxHash: _monadHash).toJson() + ..remove('sealSolanaSig'); + expect(legacy.containsKey('sealSolanaSig'), isFalse); + + final restored = CanaryOwnerRecord.fromJson(legacy); + expect(restored.sealTxHash, _monadHash); + expect(restored.sealSolanaSig, isNull); + expect(restored.isSealed, isTrue); + }); + + test('copyWith adds the Solana proof without dropping the Monad one', () { + final draft = _record(); + expect(draft.isSealed, isFalse); + + final sealed = draft.copyWith( + sealTxHash: _monadHash, + sealSolanaSig: _solanaSig, + ); + expect(sealed.sealTxHash, _monadHash); + expect(sealed.sealSolanaSig, _solanaSig); + + final untouched = sealed.copyWith(expiresAt: DateTime.utc(2027)); + expect(untouched.sealTxHash, _monadHash); + expect(untouched.sealSolanaSig, _solanaSig); + }); + + test('note status settles on Monad alone', () { + final future = DateTime.now().add(const Duration(days: 1)); + final status = CanaryNoteStatus( + copyCount: 1, + sealTxHash: _monadHash, + sealSolanaSig: null, + expiresAt: future, + copies: [ + CanaryCopyStatus( + copyIndex: 0, + readerName: 'Sam', + openedAt: DateTime.now(), + openTxHash: _monadHash, + openSolanaSig: null, + ), + ], + ); + expect(status.isSettled, isTrue); + expect(status.copy(0)?.openSolanaSig, isNull); + }); + + test('a Solana-only seal counts as sealed (Monad off)', () { + final record = _record(sealSolanaSig: _solanaSig); + expect(record.sealTxHash, isNull); + expect(record.isSealed, isTrue); + + final restored = CanaryOwnerRecord.fromJson(record.toJson()); + expect(restored.sealTxHash, isNull); + expect(restored.sealSolanaSig, _solanaSig); + expect(restored.isSealed, isTrue); + }); + + CanaryCopyStatus copy({String? tx, String? sig}) => CanaryCopyStatus( + copyIndex: 0, + readerName: 'Sam', + openedAt: DateTime.now(), + openTxHash: tx, + openSolanaSig: sig, + ); + + CanaryNoteStatus status(CanaryCopyStatus c) => CanaryNoteStatus( + copyCount: 1, + sealTxHash: null, + sealSolanaSig: _solanaSig, + expiresAt: DateTime.now().add(const Duration(days: 1)), + copies: [c], + ); + + test('a copy is recorded by either chain', () { + expect(copy().isRecorded, isFalse); + expect(copy(tx: _monadHash).isRecorded, isTrue); + expect(copy(sig: _solanaSig).isRecorded, isTrue); + }); + + test('note status settles on Solana alone (Monad off)', () { + expect(status(copy(sig: _solanaSig)).isSettled, isTrue); + // The open memo has not landed yet: keep polling. + expect(status(copy()).isSettled, isFalse); + }); +} diff --git a/app/test/unit/coach_mark_placement_test.dart b/app/test/unit/coach_mark_placement_test.dart new file mode 100644 index 0000000..812fac8 --- /dev/null +++ b/app/test/unit/coach_mark_placement_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/components/coach_mark.dart'; + +void main() { + test('web placement keeps a chrome floor even when padding is zero', () { + const media = MediaQueryData(size: Size(390, 700)); + final webInset = CoachMarkPlacement.bottomInset(media, isWeb: true); + final nativeInset = CoachMarkPlacement.bottomInset(media, isWeb: false); + + expect(webInset, CoachMarkPlacement.webBottomChrome); + expect(nativeInset, 0); + + final safe = CoachMarkPlacement.safeViewport( + const Size(390, 700), + media, + isWeb: true, + ); + expect(safe.bottom, lessThan(700 - CoachMarkPlacement.webBottomChrome)); + expect(safe.height, greaterThan(400)); + }); + + test('treats a target below the fold as not visible', () { + const viewport = Rect.fromLTWH(24, 12, 342, 640); + const offscreen = Rect.fromLTWH(40, 900, 300, 80); + const onscreen = Rect.fromLTWH(40, 200, 300, 80); + + expect(CoachMarkPlacement.targetVisible(offscreen, viewport), isFalse); + expect(CoachMarkPlacement.targetVisible(onscreen, viewport), isTrue); + expect(CoachMarkPlacement.targetVisible(null, viewport), isFalse); + }); + + test('places the tip above a target that sits on the bottom edge', () { + const viewport = Rect.fromLTWH(24, 12, 342, 640); + const lowTarget = Rect.fromLTWH(40, 520, 300, 120); + + expect(CoachMarkPlacement.placeBelow(lowTarget, viewport), isFalse); + }); +} diff --git a/app/test/unit/deep_link_parsing_test.dart b/app/test/unit/deep_link_parsing_test.dart new file mode 100644 index 0000000..834df45 --- /dev/null +++ b/app/test/unit/deep_link_parsing_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/main.dart'; + +// These lock the two shared-into-the-wild URL contracts. Links people have +// already sent must keep parsing forever — a regression here is a silent +// production outage for every link in flight. +void main() { + const keyHex = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; // 64 + const ivHex = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; // 32 + const noteId = '01234567-89ab-cdef-0123-456789abcdef'; + + group('extractBurnNoteToken', () { + test('parses the production nosus.foo link format', () { + final uri = Uri.parse( + 'https://nosus.foo/#/burn/$noteId?k=$keyHex&v=$ivHex', + ); + final token = extractBurnNoteToken(uri); + expect(token, isNotNull); + expect(token!.id, noteId); + expect(token.keyHex, keyHex); + expect(token.ivHex, ivHex); + }); + + test('parses the legacy GitHub Pages subdirectory link format', () { + // Links shared before the nosus.foo migration must keep resolving — + // GitHub Pages 301-redirects the old .github.io URL to the custom + // domain, but the fragment/query parsing itself must still work + // regardless of which host or base path served the page. + final uri = Uri.parse( + 'https://https-shubhamsahu.github.io/NON_SUS/#/burn/$noteId?k=$keyHex&v=$ivHex', + ); + final token = extractBurnNoteToken(uri); + expect(token, isNotNull); + expect(token!.id, noteId); + expect(token.keyHex, keyHex); + expect(token.ivHex, ivHex); + }); + + test('parses with a cache-buster query before the fragment', () { + final uri = Uri.parse( + 'https://host/NON_SUS/?cb=1234#/burn/$noteId?k=$keyHex&v=$ivHex', + ); + expect(extractBurnNoteToken(uri), isNotNull); + }); + + test('parses the legacy double-hash format', () { + final uri = Uri.parse('https://host/#/burn/$noteId%23$keyHex.$ivHex'); + final token = extractBurnNoteToken(uri); + expect(token, isNotNull); + expect(token!.id, noteId); + }); + + test('rejects wrong-length keys', () { + final uri = Uri.parse('https://host/#/burn/$noteId?k=deadbeef&v=$ivHex'); + expect(extractBurnNoteToken(uri), isNull); + }); + + test('returns null on ordinary app URLs', () { + expect(extractBurnNoteToken(Uri.parse('https://host/NON_SUS/')), isNull); + expect(extractBurnNoteToken(Uri.parse('file:///')), isNull); + }); + }); + + group('extractShareToken', () { + test('parses token from hash fragment', () { + expect( + extractShareToken(Uri.parse('https://host/NON_SUS/?cb=1#/v/abc123')), + 'abc123', + ); + }); + + test('parses token from path', () { + expect(extractShareToken(Uri.parse('https://host/v/tok9')), 'tok9'); + }); + + test('returns null when absent', () { + expect(extractShareToken(Uri.parse('https://host/NON_SUS/')), isNull); + }); + }); + + group('extractInviteToken', () { + test('parses invite code from hash fragment (nosus.foo)', () { + expect( + extractInviteToken(Uri.parse('https://nosus.foo/#/join/invite123')), + 'invite123', + ); + }); + + test( + 'parses invite code from hash fragment (legacy GitHub Pages link)', + () { + expect( + extractInviteToken( + Uri.parse( + 'https://https-shubhamsahu.github.io/NON_SUS/#/join/invite123', + ), + ), + 'invite123', + ); + }, + ); + + test('parses invite code from path', () { + expect( + extractInviteToken(Uri.parse('https://nosus.app/join/inviteABC')), + 'inviteABC', + ); + }); + + test('parses invite code from path segments for deep links', () { + expect( + extractInviteToken(Uri.parse('io.supabase.nosus://join/inviteXYZ')), + 'inviteXYZ', + ); + }); + + test('parses invite code from host/segments for custom schemes', () { + expect( + extractInviteToken(Uri.parse('foo.nosus.app://join/inviteDEF')), + 'inviteDEF', + ); + }); + + test('returns null when absent', () { + expect(extractInviteToken(Uri.parse('https://nosus.app/v/tok9')), isNull); + }); + }); + + group('extractBurnFileToken', () { + test('parses id/key/iv from the hash fragment', () { + final uri = Uri.parse( + 'https://nosus.foo/#/burnfile/$noteId?k=$keyHex&v=$ivHex', + ); + final token = extractBurnFileToken(uri); + expect(token, isNotNull); + expect(token!.id, noteId); + expect(token.keyHex, keyHex); + expect(token.ivHex, ivHex); + }); + + test('does not collide with the burnfile/ vs burn/ prefix', () { + // "burnfile/" contains "burn" but never "burn/" (no slash immediately + // after "burn") — must not be picked up by extractBurnNoteToken, and + // extractBurnFileToken must not match a plain burn note link either. + final burnFileUri = Uri.parse( + 'https://nosus.foo/#/burnfile/$noteId?k=$keyHex&v=$ivHex', + ); + expect(extractBurnNoteToken(burnFileUri), isNull); + + final burnNoteUri = Uri.parse( + 'https://nosus.foo/#/burn/$noteId?k=$keyHex&v=$ivHex', + ); + expect(extractBurnFileToken(burnNoteUri), isNull); + }); + + test('rejects wrong-length key/iv', () { + final uri = Uri.parse( + 'https://nosus.foo/#/burnfile/$noteId?k=deadbeef&v=$ivHex', + ); + expect(extractBurnFileToken(uri), isNull); + }); + + test('returns null on ordinary app URLs', () { + expect(extractBurnFileToken(Uri.parse('https://nosus.foo/')), isNull); + expect(extractBurnFileToken(Uri.parse('file:///')), isNull); + }); + }); + + group('extractBurnFilesToken', () { + const noteId2 = 'fedcba98-7654-3210-fedc-ba9876543210'; + const keyHex2 = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; // 64 + const ivHex2 = 'dddddddddddddddddddddddddddddddd'; // 32 + + test('parses a two-file batch from the hash fragment', () { + final uri = Uri.parse( + 'https://app.nosus.foo/#/burnfiles/$noteId,$noteId2?k=$keyHex,$keyHex2&v=$ivHex,$ivHex2', + ); + final tokens = extractBurnFilesToken(uri); + expect(tokens, isNotNull); + expect(tokens!.length, 2); + expect(tokens[0].id, noteId); + expect(tokens[0].keyHex, keyHex); + expect(tokens[0].ivHex, ivHex); + expect(tokens[1].id, noteId2); + expect(tokens[1].keyHex, keyHex2); + expect(tokens[1].ivHex, ivHex2); + }); + + test('does not collide with the singular burnfile/ link, and vice versa', () { + // The two routes must stay structurally disjoint so a link generated + // under one format is never silently reinterpreted by the other. + final singleUri = Uri.parse( + 'https://app.nosus.foo/#/burnfile/$noteId?k=$keyHex&v=$ivHex', + ); + expect(extractBurnFilesToken(singleUri), isNull); + + final batchUri = Uri.parse( + 'https://app.nosus.foo/#/burnfiles/$noteId,$noteId2?k=$keyHex,$keyHex2&v=$ivHex,$ivHex2', + ); + expect(extractBurnFileToken(batchUri), isNull); + }); + + test('rejects mismatched id/key/iv list lengths', () { + final uri = Uri.parse( + 'https://app.nosus.foo/#/burnfiles/$noteId,$noteId2?k=$keyHex&v=$ivHex,$ivHex2', + ); + expect(extractBurnFilesToken(uri), isNull); + }); + + test('returns null on ordinary app URLs', () { + expect( + extractBurnFilesToken(Uri.parse('https://app.nosus.foo/')), + isNull, + ); + expect(extractBurnFilesToken(Uri.parse('file:///')), isNull); + }); + }); + + group('extractRedemptionToken', () { + const redemptionToken = + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + + test('parses a two-digit pairing link from the hash fragment', () { + expect( + extractRedemptionToken( + Uri.parse('https://app.nosus.foo/#/redeem/$redemptionToken'), + ), + redemptionToken, + ); + }); + + test('parses a path-style pairing link', () { + expect( + extractRedemptionToken( + Uri.parse('https://app.nosus.foo/redeem/$redemptionToken'), + ), + redemptionToken, + ); + }); + + test('rejects non-secret pairing values', () { + expect( + extractRedemptionToken(Uri.parse('https://app.nosus.foo/#/redeem/42')), + isNull, + ); + expect( + extractRedemptionToken( + Uri.parse('https://app.nosus.foo/#/burn/$noteId'), + ), + isNull, + ); + }); + }); +} diff --git a/app/test/unit/intelligence_test.dart b/app/test/unit/intelligence_test.dart new file mode 100644 index 0000000..eea5046 --- /dev/null +++ b/app/test/unit/intelligence_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/features/intelligence/data/disabled_document_intelligence.dart'; +import 'package:no_sus/features/intelligence/data/local_document_intelligence.dart'; + +void main() { + test('disabled provider is not available', () { + expect(const DisabledDocumentIntelligence().isAvailable, isFalse); + }); + + test('local provider classifies without a model', () async { + final intel = const LocalDocumentIntelligence(); + final insight = await intel.inspect( + title: 'NDA-acme.pdf', + mimeType: 'application/pdf', + plainText: 'This non-disclosure agreement binds both parties. Confidential terms follow.', + ); + expect(insight.classification, 'Agreement'); + expect(insight.summary, isNotEmpty); + }); +} diff --git a/app/test/unit/version_compare_test.dart b/app/test/unit/version_compare_test.dart new file mode 100644 index 0000000..7fef392 --- /dev/null +++ b/app/test/unit/version_compare_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/features/config/domain/app_update.dart'; + +void main() { + group('compareVersions', () { + test('equal versions compare as zero', () { + expect(compareVersions('1.2.0', '1.2.0'), 0); + expect(compareVersions('0.0.0', '0.0.0'), 0); + }); + + test('orders by major, minor, patch numerically', () { + expect(compareVersions('2.0.0', '1.9.9'), greaterThan(0)); + expect(compareVersions('1.3.0', '1.2.9'), greaterThan(0)); + expect(compareVersions('1.2.1', '1.2.0'), greaterThan(0)); + expect(compareVersions('1.2.0', '1.2.1'), lessThan(0)); + }); + + test('compares numerically, not lexically (1.10 > 1.9)', () { + expect(compareVersions('1.10.0', '1.9.0'), greaterThan(0)); + expect(compareVersions('1.2.10', '1.2.9'), greaterThan(0)); + }); + + test('tolerates a leading v/V prefix', () { + expect(compareVersions('v1.2.1', '1.2.0'), greaterThan(0)); + expect(compareVersions('V1.2.0', 'v1.2.0'), 0); + }); + + test('treats missing segments as zero (1.2 == 1.2.0)', () { + expect(compareVersions('1.2', '1.2.0'), 0); + expect(compareVersions('1.2.0.1', '1.2'), greaterThan(0)); + }); + + test('treats non-numeric segments as zero rather than crashing', () { + expect(compareVersions('1.2.beta', '1.2.0'), 0); + expect(compareVersions('garbage', '0.0.0'), 0); + }); + + test('drives the update decision the provider makes', () { + // The banner shows only when latest > installed. + const installed = '1.2.0'; + expect(compareVersions('9.9.9', installed) > 0, isTrue); + expect(compareVersions('1.2.0', installed) > 0, isFalse); + expect(compareVersions('1.1.9', installed) > 0, isFalse); + }); + }); +} diff --git a/app/test/widget/auth_screen_test.dart b/app/test/widget/auth_screen_test.dart new file mode 100644 index 0000000..3ab925f --- /dev/null +++ b/app/test/widget/auth_screen_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:no_sus/features/auth/presentation/controllers/auth_controller.dart'; +import 'package:no_sus/features/auth/presentation/screens/auth_screen.dart'; + +class MockAuthController extends Notifier> with Mock implements AuthController { + @override + AsyncValue build() => const AsyncValue.data(null); +} + +void main() { + group('AuthScreen Widget Tests', () { + testWidgets('renders sign in screen correctly', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => MockAuthController()), + ], + child: const MaterialApp( + home: AuthScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify essential UI elements are present + expect(find.text('NO SUS'), findsOneWidget); + expect(find.text('See who opened it'), findsOneWidget); + expect(find.byType(TextFormField), findsNWidgets(2)); // Email and Password + expect(find.text('ENTER WORKSPACE'), findsOneWidget); + expect(find.text('NEW TO WORKSPACE? SIGN UP'), findsOneWidget); + }); + + testWidgets('toggles to sign up mode', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => MockAuthController()), + ], + child: const MaterialApp( + home: AuthScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Tap 'NEW TO WORKSPACE? SIGN UP' + await tester.ensureVisible(find.text('NEW TO WORKSPACE? SIGN UP')); + await tester.tap(find.text('NEW TO WORKSPACE? SIGN UP')); + await tester.pumpAndSettle(); + + // Verify UI changes to sign up mode + expect(find.text('REGISTER'), findsOneWidget); + expect(find.text('ALREADY REGISTERED? SIGN IN'), findsOneWidget); + }); + + testWidgets('shows validation errors for empty fields on submit', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => MockAuthController()), + ], + child: const MaterialApp( + home: AuthScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Tap 'ENTER WORKSPACE' without entering anything + await tester.tap(find.text('ENTER WORKSPACE')); + await tester.pumpAndSettle(); // Pump for validation and animations + + expect(find.text('Email is required'), findsOneWidget); + expect(find.text('Password is required'), findsOneWidget); + }); + + testWidgets('main action button is announced as a labeled button to screen readers', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => MockAuthController()), + ], + child: const MaterialApp( + home: AuthScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Checks the Semantics widget's own configuration directly — a + // regression guard against silently losing the button:true/label + // wrapping this widget relies on for screen-reader announcement. + final finder = find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == 'Enter workspace', + ); + expect(finder, findsOneWidget); + final semanticsWidget = tester.widget(finder); + expect(semanticsWidget.properties.button, isTrue); + }); + }); +} diff --git a/app/test/widget/canary_app_test.dart b/app/test/widget/canary_app_test.dart new file mode 100644 index 0000000..f8f1591 --- /dev/null +++ b/app/test/widget/canary_app_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/config/app_mode.dart'; +import 'package:no_sus/core/providers/theme_provider.dart'; +import 'package:no_sus/features/canary/presentation/canary_app.dart'; +import 'package:no_sus/features/canary/presentation/ui/wallet_button.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Widget tests draw text in a placeholder font whose glyphs are full +/// squares, which makes the pixel-font labels measure about twice their real +/// width. Load the real bundled fonts so layout checks match the browser. +Future _loadFonts() async { + const families = { + 'Outfit': ['Outfit-Regular', 'Outfit-SemiBold', 'Outfit-Bold'], + 'Inter': ['Inter-Regular', 'Inter-Medium', 'Inter-SemiBold', 'Inter-Bold'], + 'VT323': ['VT323-Regular'], + }; + for (final entry in families.entries) { + final loader = FontLoader(entry.key); + for (final file in entry.value) { + loader.addFont(rootBundle.load('assets/google_fonts/$file.ttf')); + } + await loader.load(); + } +} + +void main() { + setUpAll(_loadFonts); + setUp(() => SharedPreferences.setMockInitialValues({})); + + // Phone, small laptop, common laptop, the nav breakpoint, full HD. + // Any RenderFlex overflow fails the test. + for (final size in const [ + Size(390, 844), + Size(1200, 800), + Size(1366, 768), + Size(1440, 900), + Size(1920, 1080), + ]) { + testWidgets('CanaryApp opens Canary home with no back button at ' + '${size.width.toInt()}x${size.height.toInt()}', (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const CanaryApp(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Every reader gets their own copy.'), findsOneWidget); + expect(find.byType(BackButton), findsNothing); + + // CANARY_MONAD decides whether any Monad UI exists at all. + final monadText = find.textContaining( + RegExp(r'[Mm]onad|MONAD|\bMON\b'), + ); + if (kCanaryMonad) { + expect(find.byType(WalletButton), findsOneWidget); + expect(monadText, findsWidgets); + } else { + expect(find.byType(WalletButton), findsNothing); + expect(monadText, findsNothing); + expect(find.textContaining('SOLANA'), findsWidgets); + } + + await tester.pumpWidget(const SizedBox()); + }); + } +} diff --git a/app/test/widget/coach_mark_test.dart b/app/test/widget/coach_mark_test.dart new file mode 100644 index 0000000..7507aeb --- /dev/null +++ b/app/test/widget/coach_mark_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/components/coach_mark.dart'; +import 'package:no_sus/core/providers/theme_provider.dart'; +import 'package:no_sus/features/onboarding/presentation/providers/tour_providers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('keeps DONE on screen when the spotlight target is off-screen', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 700); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final padKey = GlobalKey(); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: MaterialApp(home: _TipHost(padKey: padKey)), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('This pad is yours alone'), findsOneWidget); + expect(find.text('DONE'), findsOneWidget); + expect(tester.takeException(), isNull); + + final done = tester.getRect(find.text('DONE')); + expect(done.bottom, lessThanOrEqualTo(700)); + expect(done.top, greaterThanOrEqualTo(0)); + }); +} + +class _TipHost extends ConsumerStatefulWidget { + const _TipHost({required this.padKey}); + + final GlobalKey padKey; + + @override + ConsumerState<_TipHost> createState() => _TipHostState(); +} + +class _TipHostState extends ConsumerState<_TipHost> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + CoachMarks.showSequence(context, ref, [ + CoachMarkStep( + id: TourSteps.workspacePad, + targetKey: widget.padKey, + title: 'This pad is yours alone', + body: + 'Nothing typed here is shared with any group. It saves as you type, and ' + 'syncs to your account so it follows you between devices.', + ), + ]); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + Positioned( + top: 900, + left: 16, + right: 16, + child: Container( + key: widget.padKey, + height: 80, + color: Colors.grey, + ), + ), + ], + ), + ); + } +} diff --git a/app/test/widget/floating_nav_test.dart b/app/test/widget/floating_nav_test.dart new file mode 100644 index 0000000..ab7213a --- /dev/null +++ b/app/test/widget/floating_nav_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/components/floating_nav.dart'; + +void main() { + testWidgets('uses an icon-only footer on compact phone widths', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(360, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(360, 700)), + child: Scaffold( + body: Stack( + children: [FloatingNav(currentIndex: 2, onTap: (_) {})], + ), + ), + ), + ), + ); + + expect(find.text('Study Desk'), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Study Desk tab' && + widget.properties.selected == true, + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('keeps labels on wider layouts', (tester) async { + await tester.binding.setSurfaceSize(const Size(600, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(600, 700)), + child: Scaffold( + body: Stack( + children: [FloatingNav(currentIndex: 0, onTap: (_) {})], + ), + ), + ), + ), + ); + + expect(find.text('Study Desk'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/test/widget/groups_screen_test.dart b/app/test/widget/groups_screen_test.dart new file mode 100644 index 0000000..2f65419 --- /dev/null +++ b/app/test/widget/groups_screen_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:no_sus/features/groups/screens/groups_screen.dart'; +import 'package:no_sus/features/groups/domain/models/study_group.dart'; +import 'package:no_sus/features/groups/providers/groups_provider.dart'; + +class MockGroupsNotifier extends GroupsNotifier with Mock { + @override + Future> build() async { + return [ + StudyGroup( + id: 'g1', + name: 'Math 101', + description: 'Calculus study group', + inviteCode: 'MATH101', + members: const [], + fileCount: 0, + lastActivity: DateTime.now(), + ), + StudyGroup( + id: 'g2', + name: 'Physics', + description: 'Physics group', + inviteCode: 'PHYS200', + members: const [], + fileCount: 0, + lastActivity: DateTime.now(), + ), + ]; + } +} + +void main() { + group('GroupsScreen Widget Tests', () { + testWidgets('renders groups and search bar correctly', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + groupsProvider.overrideWith(() => MockGroupsNotifier()), + ], + child: const MaterialApp( + home: Scaffold( + body: GroupsScreen(), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify essential UI elements are present + expect(find.text('STUDY GROUPS'), findsOneWidget); // Assuming header says this + expect(find.byType(TextField), findsOneWidget); // Search bar + + // Verify groups are displayed + expect(find.text('Math 101'), findsOneWidget); + expect(find.text('Physics'), findsOneWidget); + }); + + testWidgets('group cards and create-group FAB are announced as labeled buttons', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + groupsProvider.overrideWith(() => MockGroupsNotifier()), + ], + child: const MaterialApp( + home: Scaffold( + body: GroupsScreen(), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Regression guard for the accessibility fixes in GroupCard and the + // create-group FAB — checks the Semantics widgets' own configuration. + final groupCardFinder = find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == 'Open group Math 101', + ); + expect(groupCardFinder, findsOneWidget); + expect(tester.widget(groupCardFinder).properties.button, isTrue); + + final createFabFinder = find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == 'Create group', + ); + expect(createFabFinder, findsOneWidget); + expect(tester.widget(createFabFinder).properties.button, isTrue); + }); + }); +} diff --git a/app/test/widget/proof_chips_test.dart b/app/test/widget/proof_chips_test.dart new file mode 100644 index 0000000..e833165 --- /dev/null +++ b/app/test/widget/proof_chips_test.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/config/app_mode.dart'; +import 'package:no_sus/features/canary/presentation/ui/chain_chip.dart'; +import 'package:no_sus/features/canary/presentation/ui/proof_chips.dart'; +import 'package:no_sus/theme.dart'; + +/// Widget tests draw text in a placeholder font whose glyphs are full +/// squares, which makes the pixel-font labels measure about twice their real +/// width. Load the real bundled fonts so layout checks match the browser. +Future _loadFonts() async { + const families = { + 'Outfit': ['Outfit-Regular', 'Outfit-SemiBold', 'Outfit-Bold'], + 'Inter': ['Inter-Regular', 'Inter-Medium', 'Inter-SemiBold', 'Inter-Bold'], + 'VT323': ['VT323-Regular'], + }; + for (final entry in families.entries) { + final loader = FontLoader(entry.key); + for (final file in entry.value) { + loader.addFont(rootBundle.load('assets/google_fonts/$file.ttf')); + } + await loader.load(); + } +} + +const _monadTx = + '0x9f2c4b1e7d3a5c6b8e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d'; +const _solanaSig = + '5VfYmGZ8s3kQxq2wE1rT9yU4iO6pA7sD8fG0hJ1kL2zX3cV4bN5mQm2p'; + +Widget _host(Widget child, {double? width}) => MaterialApp( + theme: CanaryTokens.theme(), + home: Scaffold( + body: Center( + child: width == null ? child : SizedBox(width: width, child: child), + ), + ), +); + +void main() { + setUpAll(_loadFonts); + + testWidgets('Monad only shows one linked chip and nothing for Solana', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + labelPrefix: 'Sealed on', + showMonad: true, + ), + ), + ); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.text('Sealed on Monad · 0x9f2c…1c2d'), findsOneWidget); + expect(find.textContaining('Solana'), findsNothing); + expect(find.byType(PendingChainChip), findsNothing); + expect( + find.bySemanticsLabel(RegExp(r'View proof on Monad testnet')), + findsOneWidget, + ); + }); + + testWidgets('Monad + Solana shows both chips with bounded hashes', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + solanaSig: _solanaSig, + labelPrefix: 'Recorded on', + showMonad: true, + ), + ), + ); + + expect(find.byType(ChainChip), findsNWidgets(2)); + expect(find.text('Recorded on Monad · 0x9f2c…1c2d'), findsOneWidget); + // Base58 signature: no 0x prefix, first 4 + last 4. + expect(find.text('Recorded on Solana · 5VfY…Qm2p'), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp(r'View proof on Solana devnet')), + findsOneWidget, + ); + }); + + testWidgets('pending Solana shows a progress ring and no link', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + solanaPending: true, + showMonad: true, + ), + ), + ); + // One frame only: the progress ring animates forever. + await tester.pump(); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.byType(PendingChainChip), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Solana…'), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp(r'Recording on Solana devnet')), + findsOneWidget, + ); + expect(find.bySemanticsLabel(RegExp(r'View proof on Solana')), findsNothing); + expect(find.byType(InkWell), findsOneWidget); // only the Monad chip taps + }); + + testWidgets('Solana off (null, not pending) renders nothing for Solana', ( + tester, + ) async { + await tester.pumpWidget( + _host(const ProofChips(monadTxHash: _monadTx, showMonad: true)), + ); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.byType(PendingChainChip), findsNothing); + expect(find.textContaining('Solana'), findsNothing); + }); + + testWidgets('two chips wrap onto a second row at 320 px without overflow', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + solanaSig: _solanaSig, + labelPrefix: 'Recorded on', + showMonad: true, + ), + width: 320, + ), + ); + + expect(tester.takeException(), isNull); + final chips = find.byType(ChainChip); + expect(chips, findsNWidgets(2)); + final first = tester.getRect(chips.at(0)); + final second = tester.getRect(chips.at(1)); + expect(second.top, greaterThanOrEqualTo(first.bottom)); + expect(first.height, greaterThanOrEqualTo(48)); + expect(second.height, greaterThanOrEqualTo(48)); + expect(first.right, lessThanOrEqualTo(tester.getRect(find.byType(ProofChips)).right)); + }); + + group('Monad hidden (CANARY_MONAD off)', () { + testWidgets('only the Solana chip shows, even when a Monad hash exists', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + solanaSig: _solanaSig, + monadPending: true, + labelPrefix: 'Sealed on', + showMonad: false, + ), + ), + ); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.text('Sealed on Solana · 5VfY…Qm2p'), findsOneWidget); + expect(find.byType(PendingChainChip), findsNothing); + expect(find.textContaining('Monad'), findsNothing); + expect(find.bySemanticsLabel(RegExp('Monad')), findsNothing); + }); + + testWidgets('a Solana-only proof renders on its own', (tester) async { + await tester.pumpWidget( + _host(const ProofChips(solanaSig: _solanaSig, showMonad: false)), + ); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.text('Solana · 5VfY…Qm2p'), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp(r'View proof on Solana devnet')), + findsOneWidget, + ); + }); + + testWidgets('pending Solana alone shows only the progress chip', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadPending: true, + solanaPending: true, + showMonad: false, + ), + ), + ); + await tester.pump(); + + expect(find.byType(ChainChip), findsNothing); + expect(find.byType(PendingChainChip), findsOneWidget); + expect(find.text('Solana…'), findsOneWidget); + expect(find.textContaining('Monad'), findsNothing); + }); + + testWidgets('nothing to show renders an empty box', (tester) async { + await tester.pumpWidget( + _host(const ProofChips(monadTxHash: _monadTx, showMonad: false)), + ); + + expect(find.byType(ChainChip), findsNothing); + expect(find.byType(PendingChainChip), findsNothing); + }); + }); + + testWidgets('showMonad defaults to the CANARY_MONAD build flag', ( + tester, + ) async { + await tester.pumpWidget(_host(const ProofChips(monadTxHash: _monadTx))); + + expect( + find.byType(ChainChip), + kCanaryMonad ? findsOneWidget : findsNothing, + ); + }); +} diff --git a/app/test/widget/splash_screen_test.dart b/app/test/widget/splash_screen_test.dart new file mode 100644 index 0000000..bcc5879 --- /dev/null +++ b/app/test/widget/splash_screen_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/screens/splash_screen.dart'; + +void main() { + testWidgets('shows only the canonical wordmark on compact screens', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 440)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(const MaterialApp(home: BrandSplash())); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText().contains('NO SUS'), + ), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('brand-square-stop')), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'NO SUS opening application', + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('shows the finished mark immediately with reduced motion', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: true), + child: BrandSplash(), + ), + ), + ); + + expect( + find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText().contains('NO SUS'), + ), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('brand-square-stop')), findsOneWidget); + }); +} diff --git a/app/test/widget/welcome_screen_test.dart b/app/test/widget/welcome_screen_test.dart new file mode 100644 index 0000000..48a0151 --- /dev/null +++ b/app/test/widget/welcome_screen_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/core/providers/theme_provider.dart'; +import 'package:no_sus/features/onboarding/presentation/screens/welcome_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// The signed-out surface. +/// +/// Before this, `AuthGate` rendered `AuthScreen` the instant `user == null`, so +/// the first thing anyone ever saw was an email field with no explanation of +/// what they were signing up for and no way to try any part of the product. +/// These tests pin the replacement: the tools that genuinely need no account +/// are reachable, and both auth routes are offered rather than assumed. +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future pumpWelcome(WidgetTester tester) async { + // The screen is one lazy ListView, so the default 800x600 test surface + // builds only the header and nothing below the fold. A tall viewport + // materialises the whole page so the assertions below test content rather + // than scroll position. + tester.view.physicalSize = const Size(1200, 3600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const MaterialApp(home: WelcomeScreen()), + ), + ); + // The cards use flutter_animate entrance effects, which leave pending + // timers that fail the test unless they are run to completion. + await tester.pumpAndSettle(); + } + + testWidgets('offers the no-account tools before asking anyone to sign up', ( + tester, + ) async { + await pumpWelcome(tester); + + expect(find.text('TRY IT NOW — NO ACCOUNT NEEDED'), findsOneWidget); + expect(find.text('NO SUS Canary'), findsOneWidget); + expect(find.text('Open Canary →'), findsWidgets); + expect(find.text('Monad receipts'), findsNothing); + expect(find.text('Send a Burn Note'), findsOneWidget); + expect(find.text('Send a Burn File'), findsOneWidget); + expect(find.text('Redeem a code'), findsOneWidget); + }); + + testWidgets('shows account features as a static mockup, without sign-in', ( + tester, + ) async { + await pumpWelcome(tester); + + expect(find.text('COMING LATER — MOCKUP'), findsOneWidget); + expect(find.text('Study groups'), findsOneWidget); + expect(find.text('Secure documents'), findsOneWidget); + expect(find.text('Activity log'), findsOneWidget); + expect(find.text('CREATE A FREE ACCOUNT'), findsNothing); + expect(find.text('I ALREADY HAVE AN ACCOUNT'), findsNothing); + }); + + testWidgets('help is reachable without an account', (tester) async { + await pumpWelcome(tester); + + // A confused visitor often cannot get past the thing confusing them, so + // Help must not sit behind the auth wall. + expect(find.widgetWithText(TextButton, 'What is NO SUS?'), findsOneWidget); + expect(find.byTooltip('Help'), findsOneWidget); + }); + + testWidgets('the guest tool cards carry screen-reader labels', ( + tester, + ) async { + // AGENTS.md §7: a bare GestureDetector announces nothing. These cards are + // the primary calls to action on the app's first screen. + await pumpWelcome(tester); + + // Matched on the Semantics widget's own properties rather than through + // find.bySemanticsLabel — the same approach the existing auth/groups + // regression tests use, because the rendered semantics tree does not + // resolve reliably in this codebase (AGENTS.md §7). + final labelled = tester + .widgetList(find.byType(Semantics)) + .where((s) => s.properties.label?.contains('Send a Burn Note') ?? false) + .toList(); + + expect(labelled, isNotEmpty, reason: 'the card must announce itself'); + expect(labelled.first.properties.button, isTrue); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart new file mode 100644 index 0000000..9056ce6 --- /dev/null +++ b/app/test/widget_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/main.dart'; + +void main() { + testWidgets('builds the NO SUS application shell', (tester) async { + await tester.pumpWidget(const ProviderScope(child: MyApp())); + + final app = tester.widget(find.byType(MaterialApp)); + expect(app.title, 'NO SUS'); + expect(app.debugShowCheckedModeBanner, isFalse); + }); +} diff --git a/app/tool/canary_fingerprint_harness.dart b/app/tool/canary_fingerprint_harness.dart new file mode 100644 index 0000000..0ddf472 --- /dev/null +++ b/app/tool/canary_fingerprint_harness.dart @@ -0,0 +1,197 @@ +// Accuracy sweep for the Canary fingerprint engine. Run from app/: +// dart run tool/canary_fingerprint_harness.dart +// It must end with "FAILURES: 0". Its printed report is its output. +// ignore_for_file: avoid_print + +import 'dart:io'; +import 'dart:math'; + +import 'package:no_sus/features/canary/domain/canary_fingerprint.dart'; + +const demoNote = + "Hey everyone, please don't share this outside the group. We're launching " + "the new app on Friday and it's almost ready. The first five people who " + "sign up get early access, so kindly keep it quiet till the launch. I'll " + "organize a small call afterwards for anyone who wants a demo. Let's make " + "sure nobody posts screenshots, okay?"; + +const longNote = + "Hi everyone, please don't forward this. We're finalizing the budget and it's nearly done. " + "The first ten people who reply can join the review, so kindly keep it quiet until Monday. " + "I'll organize a short call afterwards for anybody who can't read the whole email. " + "Someone from finance will share the favorite options, okay? Let's make sure nobody posts " + "screenshots, and maybe we can wrap up by five. There's a lot riding on this, so don't be late."; + +final zwChars = RegExp( + '[${String.fromCharCode(0x2060)}${String.fromCharCode(0x200B)}${String.fromCharCode(0x200C)}]', +); + +String stripZw(String s) => s.replaceAll(zwChars, ''); + +String ocrLike(String s) => stripZw( + s, +).replaceAll(RegExp(r'[,.!?]'), '').replaceAll(' ', '\n').toUpperCase(); + +String noisy(String s, Random rng, double rate) { + final chars = stripZw(s).split(''); + for (var i = 0; i < chars.length; i++) { + if (RegExp('[a-z]').hasMatch(chars[i]) && rng.nextDouble() < rate) { + chars[i] = String.fromCharCode(97 + rng.nextInt(26)); + } + } + return chars.join(); +} + +String excerpt(String s, double from, double to) { + final clean = stripZw(s); + return clean.substring( + (clean.length * from).floor(), + (clean.length * to.clamp(0, 1)).floor(), + ); +} + +var failures = 0; + +void expectKind( + String label, + CanaryMatch m, + Set kinds, { + int? copy, +}) { + final ok = kinds.contains(m.kind) && (copy == null || m.copyIndex == copy); + print( + '${ok ? "PASS" : "FAIL"} $label -> ${m.kind.name} copy=${m.copyIndex} cand=${m.candidates} ${m.agreeing}/${m.known}', + ); + if (!ok) failures++; +} + +bool named(CanaryMatch m) => + m.kind == CanaryMatchKind.confident || + m.kind == CanaryMatchKind.likely || + m.kind == CanaryMatchKind.exactMarker; + +void runFor(String title, String note, int copyCount) { + print(''); + print('==================== $title ($copyCount copies)'); + final plan = buildCanaryPlan(note); + print( + 'slots=${plan.bitCount} required=${canaryRequiredSlots(copyCount)} maxCopies=${canaryMaxCopiesFor(plan)}', + ); + for (final s in plan.slots) { + print(' slot "${s.original}" -> "${s.alternate}"'); + } + if (plan.bitCount < canaryRequiredSlots(copyCount)) { + print('REJECTED (as designed): note too short for $copyCount copies'); + return; + } + final codes = generateCanaryCodewords( + copyCount, + plan.bitCount, + random: Random(42), + ); + final copies = [ + for (var i = 0; i < codes.length; i++) + renderCanaryCopy(plan, codes[i], copyIndex: i), + ]; + print('sample copy 1: ${stripZw(copies[0])}'); + print( + 'original == zero render: ${renderCanaryCopy(plan, List.filled(plan.bitCount, 0)) == note}', + ); + + expectKind('exact marker copy 7', matchCanaryLeak(copies[7], plan, codes), { + CanaryMatchKind.exactMarker, + }, copy: 7); + expectKind( + 'zw stripped copy 7', + matchCanaryLeak(stripZw(copies[7]), plan, codes), + {CanaryMatchKind.confident}, + copy: 7, + ); + expectKind( + 'ocr formatting copy 12', + matchCanaryLeak(ocrLike(copies[12]), plan, codes), + {CanaryMatchKind.confident}, + copy: 12, + ); + expectKind('sender original', matchCanaryLeak(note, plan, codes), { + CanaryMatchKind.senderOriginal, + }); + expectKind( + 'unrelated text', + matchCanaryLeak('the quick brown fox jumps over the lazy dog', plan, codes), + {CanaryMatchKind.notEnough, CanaryMatchKind.noMatch}, + ); + + final rng = Random(7); + var okStrip = 0, okOcr = 0, okNoise = 0, wrongNoise = 0; + const trials = 300; + for (var t = 0; t < trials; t++) { + final i = rng.nextInt(codes.length); + final a = matchCanaryLeak(stripZw(copies[i]), plan, codes); + if (named(a) && a.copyIndex == i) okStrip++; + final b = matchCanaryLeak(ocrLike(copies[i]), plan, codes); + if (named(b) && b.copyIndex == i) okOcr++; + final c = matchCanaryLeak(noisy(copies[i], rng, 0.02), plan, codes); + if (named(c) && c.copyIndex == i) okNoise++; + if (named(c) && c.copyIndex != i) wrongNoise++; + } + print( + 'sweep: stripped $okStrip/$trials, ocr $okOcr/$trials, 2% noise right $okNoise/$trials, WRONG $wrongNoise/$trials', + ); + if (wrongNoise > 0) failures++; + + var exRight = 0, exWrong = 0, colluder = 0, innocent = 0; + for (var t = 0; t < 600; t++) { + final i = rng.nextInt(codes.length); + final from = rng.nextDouble() * 0.5; + final to = from + 0.3 + rng.nextDouble() * 0.2; + final m = matchCanaryLeak(excerpt(copies[i], from, to), plan, codes); + if (named(m)) { + if (m.copyIndex == i) { + exRight++; + } else { + exWrong++; + } + } + final j = (i + 1 + rng.nextInt(codes.length - 1)) % codes.length; + final mix = [ + for (var k = 0; k < plan.bitCount; k++) + rng.nextBool() ? codes[i][k] : codes[j][k], + ]; + final bm = matchCanaryLeak(renderCanaryCopy(plan, mix), plan, codes); + if (named(bm)) { + if (bm.copyIndex == i || bm.copyIndex == j) { + colluder++; + } else { + innocent++; + } + } + } + print('excerpts: named right $exRight/600, WRONG $exWrong/600'); + print( + 'two-copy blends: named a colluder $colluder/600, named an INNOCENT $innocent/600', + ); + if (exWrong > 0) failures++; + + final distinct = copies.map(stripZw).toSet().length; + if (distinct != copies.length) { + print('FAIL distinct copies $distinct/${copies.length}'); + failures++; + } + for (var i = 0; i < codes.length; i++) { + if (decodeCanaryMarker(copies[i]) != i) { + print('FAIL marker $i'); + failures++; + } + } +} + +void main() { + runFor('DEMO NOTE', demoNote, 20); + runFor('LONG NOTE', longNote, 20); + runFor('LONG NOTE', longNote, 50); + runFor('LONG NOTE', longNote, 100); + print(''); + print('FAILURES: $failures'); + if (failures > 0) exitCode = 1; +} diff --git a/app/web/.nojekyll b/app/web/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/app/web/account-deletion.html b/app/web/account-deletion.html new file mode 100644 index 0000000..a0eb700 --- /dev/null +++ b/app/web/account-deletion.html @@ -0,0 +1,38 @@ + + + + + + Account Deletion — NO SUS + + + + + +

← NO SUS home

+

NO SUS — DELETE YOUR ACCOUNT

+ +

Delete in the app (immediate)

+
    +
  1. Open NO SUS and sign in.
  2. +
  3. Tap your avatar → Profile & Settings.
  4. +
  5. Scroll to Danger Zone → Delete User Account.
  6. +
  7. Type your email to confirm.
  8. +
+

This permanently deletes your profile, group memberships, uploaded files and their storage objects, your avatar, and your authentication record. Groups where you were the only admin are removed or ownership is transferred. This cannot be undone.

+ +

Request deletion by email

+

If you can't access the app, email shubhamsahu@nosus.foo from the address on your account with the subject "NO SUS account deletion request". Deletion is completed within 30 days.

+ +

What is retained

+

Tamper-evident group audit entries reference actor IDs; after deletion these no longer resolve to any identity. Feedback you submitted is kept in anonymized form (the link to your account is severed). App usage events (see the privacy policy) are kept with your account ID removed, so they are no longer linked to your account.

+ + diff --git a/app/web/favicon.png b/app/web/favicon.png new file mode 100644 index 0000000..62ce204 Binary files /dev/null and b/app/web/favicon.png differ diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png new file mode 100644 index 0000000..12b77d0 Binary files /dev/null and b/app/web/icons/Icon-192.png differ diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png new file mode 100644 index 0000000..dacc90f Binary files /dev/null and b/app/web/icons/Icon-512.png differ diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..c94766e Binary files /dev/null and b/app/web/icons/Icon-maskable-192.png differ diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..152370a Binary files /dev/null and b/app/web/icons/Icon-maskable-512.png differ diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..25acfa6 --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + NO SUS + + + + + + + + + + +
+
NO SUS
+
+ + + + + + diff --git a/app/web/manifest.json b/app/web/manifest.json new file mode 100644 index 0000000..02d6803 --- /dev/null +++ b/app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "NO SUS", + "short_name": "NO SUS", + "start_url": ".", + "display": "standalone", + "background_color": "#0D0D0D", + "theme_color": "#0D0D0D", + "description": "NO SUS — secure document sharing with watermarking, blur-until-touch protection, and tamper-evident audit logs.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/app/web/passkeys-bundle.js b/app/web/passkeys-bundle.js new file mode 100644 index 0000000..fbec4a7 --- /dev/null +++ b/app/web/passkeys-bundle.js @@ -0,0 +1,377 @@ +var PasskeyAuthenticator = (function (exports) { + 'use strict'; + + /****************************************************************************** + Copyright (c) Microsoft Corporation. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + ***************************************************************************** */ + /* global Reflect, Promise, SuppressedError, Symbol */ + + + function __classPrivateFieldGet(receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); + } + + function __classPrivateFieldSet(receiver, state, value, kind, f) { + if (kind === "m") throw new TypeError("Private method is not writable"); + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); + return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; + } + + typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { + var e = new Error(message); + return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; + }; + + // src/webauthn-json/base64url.ts + function base64urlToBuffer(baseurl64String) { + const padding = "==".slice(0, (4 - baseurl64String.length % 4) % 4); + const base64String = baseurl64String.replace(/-/g, "+").replace(/_/g, "/") + padding; + const str = atob(base64String); + const buffer = new ArrayBuffer(str.length); + const byteView = new Uint8Array(buffer); + for (let i = 0; i < str.length; i++) { + byteView[i] = str.charCodeAt(i); + } + return buffer; + } + function bufferToBase64url(buffer) { + const byteView = new Uint8Array(buffer); + let str = ""; + for (const charCode of byteView) { + str += String.fromCharCode(charCode); + } + const base64String = btoa(str); + const base64urlString = base64String.replace(/\+/g, "-").replace( + /\//g, + "_" + ).replace(/=/g, ""); + return base64urlString; + } + + // src/webauthn-json/convert.ts + var copyValue = "copy"; + var convertValue = "convert"; + function convert(conversionFn, schema2, input) { + if (schema2 === copyValue) { + return input; + } + if (schema2 === convertValue) { + return conversionFn(input); + } + if (schema2 instanceof Array) { + return input.map((v) => convert(conversionFn, schema2[0], v)); + } + if (schema2 instanceof Object) { + const output = {}; + for (const [key, schemaField] of Object.entries(schema2)) { + if (schemaField.derive) { + const v = schemaField.derive(input); + if (v !== undefined) { + input[key] = v; + } + } + if (!(key in input)) { + if (schemaField.required) { + throw new Error(`Missing key: ${key}`); + } + continue; + } + if (input[key] == null) { + output[key] = null; + continue; + } + output[key] = convert( + conversionFn, + schemaField.schema, + input[key] + ); + } + return output; + } + } + function derived(schema2, derive) { + return { + required: true, + schema: schema2, + derive + }; + } + function required(schema2) { + return { + required: true, + schema: schema2 + }; + } + function optional(schema2) { + return { + required: false, + schema: schema2 + }; + } + + // src/webauthn-json/basic/schema.ts + var publicKeyCredentialDescriptorSchema = { + type: required(copyValue), + id: required(convertValue), + transports: optional(copyValue) + }; + var simplifiedExtensionsSchema = { + appid: optional(copyValue), + appidExclude: optional(copyValue), + credProps: optional(copyValue) + }; + var simplifiedClientExtensionResultsSchema = { + appid: optional(copyValue), + appidExclude: optional(copyValue), + credProps: optional(copyValue) + }; + var credentialCreationOptions = { + publicKey: required({ + rp: required(copyValue), + user: required({ + id: required(convertValue), + name: required(copyValue), + displayName: required(copyValue) + }), + challenge: required(convertValue), + pubKeyCredParams: required(copyValue), + timeout: optional(copyValue), + excludeCredentials: optional([publicKeyCredentialDescriptorSchema]), + authenticatorSelection: optional(copyValue), + attestation: optional(copyValue), + extensions: optional(simplifiedExtensionsSchema) + }), + signal: optional(copyValue) + }; + var publicKeyCredentialWithAttestation = { + type: required(copyValue), + id: required(copyValue), + rawId: required(convertValue), + authenticatorAttachment: optional(copyValue), + response: required({ + clientDataJSON: required(convertValue), + attestationObject: required(convertValue), + transports: derived( + copyValue, + (response) => { + var _a; + return ((_a = response.getTransports) == null ? undefined : _a.call(response)) || []; + } + ) + }), + clientExtensionResults: derived( + simplifiedClientExtensionResultsSchema, + (pkc) => pkc.getClientExtensionResults() + ) + }; + var credentialRequestOptions = { + mediation: optional(copyValue), + publicKey: required({ + challenge: required(convertValue), + timeout: optional(copyValue), + rpId: optional(copyValue), + allowCredentials: optional([publicKeyCredentialDescriptorSchema]), + userVerification: optional(copyValue), + extensions: optional(simplifiedExtensionsSchema) + }), + signal: optional(copyValue) + }; + var publicKeyCredentialWithAssertion = { + type: required(copyValue), + id: required(copyValue), + rawId: required(convertValue), + authenticatorAttachment: optional(copyValue), + response: required({ + clientDataJSON: required(convertValue), + authenticatorData: required(convertValue), + signature: required(convertValue), + userHandle: required(convertValue) + }), + clientExtensionResults: derived( + simplifiedClientExtensionResultsSchema, + (pkc) => pkc.getClientExtensionResults() + ) + }; + + // src/webauthn-json/basic/api.ts + function createRequestFromJSON(requestJSON) { + return convert(base64urlToBuffer, credentialCreationOptions, requestJSON); + } + function createResponseToJSON(credential) { + return convert( + bufferToBase64url, + publicKeyCredentialWithAttestation, + credential + ); + } + async function create(requestJSON) { + const credential = await navigator.credentials.create( + createRequestFromJSON(requestJSON) + ); + return createResponseToJSON(credential); + } + function getRequestFromJSON(requestJSON) { + return convert(base64urlToBuffer, credentialRequestOptions, requestJSON); + } + function getResponseToJSON(credential) { + return convert( + bufferToBase64url, + publicKeyCredentialWithAssertion, + credential + ); + } + async function get(requestJSON) { + const credential = await navigator.credentials.get( + getRequestFromJSON(requestJSON) + ); + return getResponseToJSON(credential); + } + + var _PasskeyAuthenticator_abortController; + const ABORTED_BY_USER = 'operation aborted by user.'; + class PasskeyAuthenticator { + constructor() { + _PasskeyAuthenticator_abortController.set(this, undefined); + } + async register(params) { + try { + const typedParams = JSON.parse(params); + const out = await create(typedParams); + return JSON.stringify(out); + } + catch (e) { + let error; + if (e instanceof DOMException) { + error = PlatformError.fromDOMException(e); + } + else if (e instanceof String) { + error = PlatformError.unknownError(e.toString()); + } + else { + error = PlatformError.unknownError('-'); + } + const serializedError = JSON.stringify(error); + console.log(serializedError); + return Promise.reject(serializedError); + } + } + async login(params) { + try { + __classPrivateFieldSet(this, _PasskeyAuthenticator_abortController, new AbortController(), "f"); + const typedParams = JSON.parse(params); + typedParams.signal = __classPrivateFieldGet(this, _PasskeyAuthenticator_abortController, "f").signal; + const out = await get(typedParams); + return JSON.stringify(out); + } + catch (e) { + let error; + if (e instanceof DOMException) { + error = PlatformError.fromDOMException(e); + } + else if (e instanceof String) { + error = PlatformError.unknownError(e.toString()); + } + else if (e === ABORTED_BY_USER) { + error = PlatformError.cancelled(); + } + else { + error = PlatformError.unknownError('-'); + } + const serializedError = JSON.stringify(error); + return Promise.reject(serializedError); + } + } + abortCurrentWebAuthnOperation() { + if (!__classPrivateFieldGet(this, _PasskeyAuthenticator_abortController, "f")) { + return; + } + __classPrivateFieldGet(this, _PasskeyAuthenticator_abortController, "f").abort(ABORTED_BY_USER); + } + } + _PasskeyAuthenticator_abortController = new WeakMap(); + class PlatformError { + constructor(code, message, details) { + this.code = code; + this.message = message; + this.details = details; + } + static fromDOMException(error) { + switch (error.name) { + case 'NotAllowedError': + return PlatformError.cancelled(); + default: + return new PlatformError(error.name, error.message, ''); + } + } + static unknownError(error) { + return new PlatformError('unknown', error, ''); + } + static cancelled() { + return new PlatformError('cancelled', 'operation was cancelled by the user.', ''); + } + } + + let passkeyAuthenticator = new PasskeyAuthenticator(); + async function isUserVerifyingPlatformAuthenticatorAvailable() { + if (!window.PublicKeyCredential) { + return undefined; + } + try { + return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + } + catch (e) { + return undefined; + } + } + async function isConditionalMediationAvailable() { + if (!window.PublicKeyCredential) { + return undefined; + } + try { + return await window.PublicKeyCredential.isConditionalMediationAvailable(); + } + catch (e) { + return undefined; + } + } + function hasPasskeySupport() { + return Boolean(window.PublicKeyCredential); + } + function init() { + passkeyAuthenticator = new PasskeyAuthenticator(); + } + function register(params) { + return passkeyAuthenticator.register(params); + } + function login(params) { + return passkeyAuthenticator.login(params); + } + function cancelCurrentAuthenticatorOperation() { + passkeyAuthenticator.abortCurrentWebAuthnOperation(); + } + + exports.cancelCurrentAuthenticatorOperation = cancelCurrentAuthenticatorOperation; + exports.hasPasskeySupport = hasPasskeySupport; + exports.init = init; + exports.isConditionalMediationAvailable = isConditionalMediationAvailable; + exports.isUserVerifyingPlatformAuthenticatorAvailable = isUserVerifyingPlatformAuthenticatorAvailable; + exports.login = login; + exports.register = register; + + return exports; + +})({}); diff --git a/app/web/privacy.html b/app/web/privacy.html new file mode 100644 index 0000000..951ba70 --- /dev/null +++ b/app/web/privacy.html @@ -0,0 +1,59 @@ + + + + + + Privacy Policy — NO SUS + + + + + +

← NO SUS home

+

NO SUS — PRIVACY POLICY

+

Last updated: 17 September 2026

+ +

1. What we collect

+
    +
  • Account data: your email address, display name, and chosen avatar (a preset character or a photo you upload).
  • +
  • Content you upload: documents and notes you add to groups, stored in private storage protected by row-level security. Optional profile photos are stored in a public avatars bucket.
  • +
  • Activity records: to protect shared documents, we record events such as file uploads, views, membership changes, and screenshot attempts in a tamper-evident audit log visible to members of the same group.
  • +
  • Share-link views: when someone opens a document you shared by link, we record the email they enter and view timestamps, visible to you as the sender.
  • +
  • Feedback: messages you submit through Help & Feedback, with your app version.
  • +
  • Website visits: our website, nosus.foo, uses Cloudflare Web Analytics to count visits. It sets no cookies and, according to Cloudflare, does not collect visitors' personal data; we see aggregate figures such as pages viewed, referring sites, browser and device types, countries, and page-load speed. It never loads on a link that carries a share key or sign-in data, and it is not part of the NO SUS app.
  • +
  • Device security signals: on sign-in and periodically during use, the app checks the device for indicators of tampering (root access, known instrumentation/hooking frameworks, active screen mirroring). Findings are written to a tamper-evident security ledger tied to your account — never sold or shared with third parties — and used only to flag your own account as higher-risk (e.g. requiring re-authentication) if serious tampering is detected.
  • +
  • App usage events: the app records a small, fixed set of events so we can see where people get stuck — for example: the app was opened; the welcome screen was viewed; a no-account tool was opened; someone reached a feature that needs an account; sign-up was started or completed, or sign-in completed; onboarding was started, skipped, or completed; creating or joining a group was started or completed; a first document was uploaded or viewed; or a notification permission prompt was shown, accepted, or declined. Each event carries the app version, the platform (such as web or Android), your account ID if you are signed in, and at most a short fixed label such as which tool or action it concerns. Events are recorded whether or not you are signed in — including when someone opens a shared link in the app — and carry no account ID when no one is signed in. They never include document or file names, group names, file contents, invite codes, or share-link keys. They are stored in our own Supabase database, readable only by NO SUS administrators, and are not sold or shared. There is currently no setting to turn them off.
  • +
+ +

2. Burn notes & burn files

+

Burn notes and burn files are encrypted on your device before upload, and the decryption key travels in the link you share. So that a recipient can unlock a single note or file with a short code instead of the full link, a copy of its key is also sent to our servers and kept until that code is used or expires (20 minutes by default, never longer than the note or file itself), then deleted. Multi-file shares from the app do not use codes, so their keys are not sent to us. The encrypted content is permanently deleted the moment it is read/downloaded (or when it expires unread). Burn files require no NO SUS account on either end; we do not know who sent or received them.

+ +

3. What we don't do

+
    +
  • We do not sell your data.
  • +
  • We do not use third-party advertising or tracking SDKs. The website visit counter described above sets no cookies.
  • +
  • We do not read your documents for any purpose other than serving them back to you and your group.
  • +
+ +

4. Where data lives

+

Data is stored on Supabase (managed Postgres, storage, and authentication). Transport is encrypted with TLS. Access is enforced with row-level security so users can only reach rows their membership allows.

+ +

5. Data deletion

+

You can delete your account at any time from Profile → Danger Zone → Delete User Account. This permanently removes your profile, group memberships, uploaded files, and auth record. App usage events are not deleted: your account ID is removed from them, detaching them from your account, and the rest of each event is kept. See account deletion for details.

+ +

6. Contact

+

Questions or requests: shubhamsahu@nosus.foo

+ +

7. Terms of Service

+

Please also review our Terms of Service, which govern your use of NO SUS.

+ + diff --git a/app/web/robots.txt b/app/web/robots.txt new file mode 100644 index 0000000..5154108 --- /dev/null +++ b/app/web/robots.txt @@ -0,0 +1,4 @@ +# app.nosus.foo — the app shell itself has no indexable content; the +# marketing site at nosus.foo is the canonical, indexed surface. +User-agent: * +Disallow: / diff --git a/app/web/terms.html b/app/web/terms.html new file mode 100644 index 0000000..b0c7db5 --- /dev/null +++ b/app/web/terms.html @@ -0,0 +1,69 @@ + + + + + + Terms of Service — NO SUS + + + + + +

← NO SUS home

+

NO SUS — TERMS OF SERVICE

+

Last updated: 8 July 2026

+ +

1. Service Description

+

NO SUS is a secure study group workspace designed for students and educators to collaborate on documents, share files, and communicate privately. The service includes features for group management, file sharing, encrypted burn notes, and activity logging.

+ +

2. User Accounts & Eligibility

+

You must be at least 13 years old (or the age of digital consent in your jurisdiction) to use NO SUS. By creating an account, you represent that you meet this age requirement and all information you provide is accurate and complete.

+ +

3. User Responsibilities

+

You agree to use NO SUS only for lawful purposes and in a way that does not infringe upon the rights of others or restrict their use and enjoyment of the service. Prohibited behavior includes:

+
    +
  • Harassing, threatening, or abusing other users
  • +
  • Uploading content that is defamatory, obscene, or illegal
  • +
  • Attempting to gain unauthorized access to the service or other user accounts
  • +
  • Using the service to distribute malware or engage in phishing
  • +
  • Sharing content you do not have the right to distribute
  • +
  • Reverse engineering, decompiling, or attempting to derive the source code
  • +
+ +

4. Content Ownership

+

You retain all ownership rights to content you upload (documents, files, notes). By uploading content, you grant NO SUS a worldwide, royalty-free license to store, access, and serve that content as necessary to provide the service. Burn notes are encrypted client-side and immediately deleted after being read or upon expiration — NO SUS does not retain access to decrypted content.

+ +

5. Account Termination

+

You can delete your account at any time from Profile → Danger Zone → Delete User Account. Upon deletion, all your personal data and uploaded files are permanently removed from our servers. Group memberships are revoked and audit logs are retained for group security purposes without personal identifiers.

+ +

6. Limitation of Liability

+

NO SUS is provided "as is" without warranties of any kind, either express or implied. We do not warrant that the service will be uninterrupted, error-free, or secure. To the fullest extent permitted by law, NO SUS and its creators shall not be liable for any indirect, incidental, special, consequential, or punitive damages arising from your use of the service, including data loss, business interruption, or unauthorized access — even if advised of the possibility of such damages.

+ +

7. Indemnification

+

You agree to indemnify and hold harmless NO SUS and its creators from any claims, damages, or costs (including legal fees) arising from your violation of these terms, your use of the service in violation of law, or your infringement of any intellectual property right.

+ +

8. Changes to Terms

+

We may update these terms at any time. We will notify users of material changes by updating the "Last updated" date and sending a notice through the app. Your continued use of NO SUS after changes become effective constitutes acceptance of the new terms.

+ +

9. Termination by NO SUS

+

We reserve the right to suspend or terminate your account if you violate these terms or pose a risk to the service or other users. Upon termination, your access to the service will be immediately revoked.

+ +

10. Governing Law

+

These terms are governed by and construed in accordance with the laws of the jurisdiction in which the service is provided, and you irrevocably submit to the exclusive jurisdiction of the courts in that location.

+ +

11. Entire Agreement

+

These terms, together with our Privacy Policy, constitute the entire agreement between you and NO SUS regarding the use of the service and supersede all prior agreements.

+ +

12. Contact

+

Questions or disputes regarding these terms: shubhamsahu@nosus.foo

+ + diff --git a/app/windows/.gitignore b/app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/app/windows/CMakeLists.txt b/app/windows/CMakeLists.txt new file mode 100644 index 0000000..2a3aead --- /dev/null +++ b/app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(no_sus LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "no_sus") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/app/windows/flutter/CMakeLists.txt b/app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/app/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..7995504 --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,32 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + RivePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RivePlugin")); + SentryFlutterPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SentryFlutterPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/app/windows/flutter/generated_plugin_registrant.h b/app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..da808f2 --- /dev/null +++ b/app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,31 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + app_links + file_selector_windows + firebase_core + rive_common + sentry_flutter + share_plus + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/windows/runner/CMakeLists.txt b/app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/app/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc new file mode 100644 index 0000000..79fb377 --- /dev/null +++ b/app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "no_sus" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "no_sus" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "no_sus.exe" "\0" + VALUE "ProductName", "no_sus" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/app/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp new file mode 100644 index 0000000..000c79d --- /dev/null +++ b/app/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"no_sus", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/app/windows/runner/resource.h b/app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/app/windows/runner/resources/app_icon.ico b/app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/app/windows/runner/resources/app_icon.ico differ diff --git a/app/windows/runner/runner.exe.manifest b/app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/app/windows/runner/utils.cpp b/app/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/app/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/app/windows/runner/utils.h b/app/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/app/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/app/windows/runner/win32_window.cpp b/app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/app/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/app/windows/runner/win32_window.h b/app/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/app/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/assets/readme/banner.svg b/assets/readme/banner.svg new file mode 100644 index 0000000..8ce1e6f --- /dev/null +++ b/assets/readme/banner.svg @@ -0,0 +1 @@ +NO SUS Canary: every reader gets their own copy. \ No newline at end of file diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000..fb34500 Binary files /dev/null and b/assets/social-preview.png differ diff --git a/contracts/AGENTS.md b/contracts/AGENTS.md deleted file mode 100644 index e2a3525..0000000 --- a/contracts/AGENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -# Contract scope - -Read `README.md` and the root `AGENTS.md` before editing. Preserve a one-open-only record, named -recipient enforcement, and expiry enforcement. Add a test for any contract behavior change. - -No payable flows, owner controls, key commitments, salts, plaintext, filenames, or public key-proof -functions belong here. A wallet acknowledgement is not proof that a human read content. diff --git a/contracts/README.md b/contracts/README.md deleted file mode 100644 index 82aae27..0000000 --- a/contracts/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Smart contracts - -`NoSusMonadDrops` is a deliberately small Monad-testnet receipt contract. It stores only encrypted -ciphertext digests, public wallet addresses, expiry, and acknowledgement state. - -## Public contract - -- `seal(id, ciphertextDigest, recipient, expiresAt)` creates a drop. -- `acknowledgeOpen(id)` records the one eligible opening wallet. -- `canDecrypt(id, account)` is the threshold-custody policy predicate. -- `receiptOf(id)` returns public receipt state. - -The contract does not accept payments, keys, salts, plaintext, filenames, or key proofs. - -## Commands - -```sh -npm install -npm run test -npm run compile -``` - -Set `MONAD_RPC_URL` and `DEPLOYER_PRIVATE_KEY` only in a local ignored `.env`. A testnet deploy also -requires an explicit named-target approval and `CONFIRM_MONAD_DEPLOY=1`. The script refuses to -broadcast without that flag. diff --git a/contracts/contracts/NoSusMonadDrops.sol b/contracts/contracts/NoSusMonadDrops.sol deleted file mode 100644 index dafd05d..0000000 --- a/contracts/contracts/NoSusMonadDrops.sol +++ /dev/null @@ -1,93 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -/// @title NoSusMonadDrops -/// @notice A Monad-testnet receipt for an encrypted drop. The contract never receives content, -/// encryption keys, salts, filenames, or private storage locations. -contract NoSusMonadDrops { - struct Drop { - address sender; - address recipient; - address opener; - bytes32 ciphertextDigest; - uint64 sealedAt; - uint64 openedAt; - uint64 expiresAt; - } - - mapping(bytes32 id => Drop) private drops; - - error DropAlreadyExists(); - error DropDoesNotExist(); - error DropAlreadyOpened(); - error DropExpired(); - error InvalidCiphertextDigest(); - error InvalidExpiry(); - error RecipientOnly(); - - event Sealed( - bytes32 indexed id, - address indexed sender, - address indexed recipient, - bytes32 ciphertextDigest, - uint64 expiresAt - ); - event OpenAcknowledged( - bytes32 indexed id, - address indexed opener, - uint64 openedAt - ); - - function seal( - bytes32 id, - bytes32 ciphertextDigest, - address recipient, - uint64 expiresAt - ) external { - if (drops[id].sender != address(0)) revert DropAlreadyExists(); - if (ciphertextDigest == bytes32(0)) revert InvalidCiphertextDigest(); - if (expiresAt != 0 && expiresAt <= block.timestamp) revert InvalidExpiry(); - - uint64 sealedAt = uint64(block.timestamp); - drops[id] = Drop({ - sender: msg.sender, - recipient: recipient, - opener: address(0), - ciphertextDigest: ciphertextDigest, - sealedAt: sealedAt, - openedAt: 0, - expiresAt: expiresAt - }); - - emit Sealed(id, msg.sender, recipient, ciphertextDigest, expiresAt); - } - - function acknowledgeOpen(bytes32 id) external { - Drop storage drop = drops[id]; - if (drop.sender == address(0)) revert DropDoesNotExist(); - if (drop.opener != address(0)) revert DropAlreadyOpened(); - if (drop.expiresAt != 0 && block.timestamp >= drop.expiresAt) revert DropExpired(); - if (drop.recipient != address(0) && drop.recipient != msg.sender) { - revert RecipientOnly(); - } - - uint64 openedAt = uint64(block.timestamp); - drop.opener = msg.sender; - drop.openedAt = openedAt; - - emit OpenAcknowledged(id, msg.sender, openedAt); - } - - function canDecrypt(bytes32 id, address account) external view returns (bool) { - Drop memory drop = drops[id]; - if (account == address(0) || drop.sender == address(0) || drop.opener != account) return false; - if (drop.expiresAt != 0 && block.timestamp >= drop.expiresAt) return false; - return true; - } - - function receiptOf(bytes32 id) external view returns (Drop memory) { - Drop memory drop = drops[id]; - if (drop.sender == address(0)) revert DropDoesNotExist(); - return drop; - } -} diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts deleted file mode 100644 index 39f49c3..0000000 --- a/contracts/hardhat.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { HardhatUserConfig } from "hardhat/config"; -import hardhatToolboxViem from "@nomicfoundation/hardhat-toolbox-viem"; - -const monadRpcUrl = process.env.MONAD_RPC_URL ?? "https://rpc.testnet.monad.xyz"; -const deployerPrivateKey = process.env.DEPLOYER_PRIVATE_KEY; - -const config: HardhatUserConfig = { - plugins: [hardhatToolboxViem], - solidity: { - version: "0.8.24", - }, - networks: { - monadTestnet: { - type: "http", - chainType: "generic", - url: monadRpcUrl, - chainId: 10143, - accounts: deployerPrivateKey ? [deployerPrivateKey] : [], - }, - }, -}; - -export default config; diff --git a/contracts/package-lock.json b/contracts/package-lock.json deleted file mode 100644 index 1e7bd58..0000000 --- a/contracts/package-lock.json +++ /dev/null @@ -1,3278 +0,0 @@ -{ - "name": "@nosus-monad/contracts", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@nosus-monad/contracts", - "version": "0.1.0", - "devDependencies": { - "@nomicfoundation/hardhat-toolbox-viem": "5.0.7", - "@types/node": "24.10.1", - "hardhat": "3.16.0", - "typescript": "7.0.2" - } - }, - "node_modules/@actions/core": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", - "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@actions/exec": "^3.0.0", - "@actions/http-client": "^4.0.0" - } - }, - "node_modules/@actions/exec": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", - "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@actions/io": "^3.0.2" - } - }, - "node_modules/@actions/http-client": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", - "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "tunnel": "^0.0.6", - "undici": "^6.23.0" - } - }, - "node_modules/@actions/io": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", - "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@adraffy/ens-normalize": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", - "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@cto.af/wtf8": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.5.tgz", - "integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@ethersproject/abi": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.8.0.tgz", - "integrity": "sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@ethersproject/abstract-provider": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", - "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0" - } - }, - "node_modules/@ethersproject/abstract-signer": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", - "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0" - } - }, - "node_modules/@ethersproject/address": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", - "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/rlp": "^5.8.0" - } - }, - "node_modules/@ethersproject/base64": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", - "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0" - } - }, - "node_modules/@ethersproject/bignumber": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", - "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@ethersproject/bytes": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", - "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/constants": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", - "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/bignumber": "^5.8.0" - } - }, - "node_modules/@ethersproject/hash": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", - "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/base64": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@ethersproject/keccak256": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", - "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@ethersproject/logger": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", - "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/@ethersproject/networks": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", - "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/properties": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", - "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/rlp": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", - "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/signing-key": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", - "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "bn.js": "^5.2.1", - "elliptic": "6.6.1", - "hash.js": "1.1.7" - } - }, - "node_modules/@ethersproject/strings": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", - "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" - } - }, - "node_modules/@ethersproject/web": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", - "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/base64": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@jest/diff-sequences": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", - "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", - "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@jest/react-is-19": { - "name": "react-is", - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", - "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@jest/schemas": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", - "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@noble/ciphers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.1.tgz", - "integrity": "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", - "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.4.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", - "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nomicfoundation/edr": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr/-/edr-0.19.0.tgz", - "integrity": "sha512-wzuyAylm4XWIahFjy1pFDQfHny4Xsx73T98d8KJAkGG/Rpgp0M36rqS4lzjbGQ1tXDrWEBG0WRr0v1jrQhQ0xg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 22" - }, - "optionalDependencies": { - "@nomicfoundation/edr-darwin-arm64": "0.19.0", - "@nomicfoundation/edr-darwin-x64": "0.19.0", - "@nomicfoundation/edr-linux-arm64-gnu": "0.19.0", - "@nomicfoundation/edr-linux-arm64-musl": "0.19.0", - "@nomicfoundation/edr-linux-x64-gnu": "0.19.0", - "@nomicfoundation/edr-linux-x64-musl": "0.19.0", - "@nomicfoundation/edr-win32-x64-msvc": "0.19.0" - } - }, - "node_modules/@nomicfoundation/edr-darwin-arm64": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-arm64/-/edr-darwin-arm64-0.19.0.tgz", - "integrity": "sha512-Yo2MkZoM85zkasUPJDj8l8QezPdrMzqQUJhp78TUQDq8FQXhdJTZ39k3Jax5OQ3hqjMhMkrGbctn9w9h6mEusg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 22" - } - }, - "node_modules/@nomicfoundation/edr-darwin-x64": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-x64/-/edr-darwin-x64-0.19.0.tgz", - "integrity": "sha512-RC7RVX1iATg9SlJTnKD57A8jRBIBiUKmAdi8maBN4zhmK5Z7aAeiwdQX6VjPOum3c2Mdpp2Q7JfooJxxF6bCvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 22" - } - }, - "node_modules/@nomicfoundation/edr-linux-arm64-gnu": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-gnu/-/edr-linux-arm64-gnu-0.19.0.tgz", - "integrity": "sha512-vhEKPUomGwM0Lsjpwd5q/Jc6yE2hpspJBCRYFkCghzCmwgZHtoYn/2nd1nOLsTt7jqMlvAKJSaLCrhz1HCTCTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 22" - } - }, - "node_modules/@nomicfoundation/edr-linux-arm64-musl": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-musl/-/edr-linux-arm64-musl-0.19.0.tgz", - "integrity": "sha512-mlkpVUolkSpMIriZyiJeTbgx/m4Ngm9lQtah2BG92H+dgtwMIMze6Q6OQfZG6oi6wMrhDgu8WEyAy3XgIwlcRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 22" - } - }, - "node_modules/@nomicfoundation/edr-linux-x64-gnu": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-gnu/-/edr-linux-x64-gnu-0.19.0.tgz", - "integrity": "sha512-d0Z+WVbX5mm6s9ozc5TXrCRjQykdN81tGI+eaM4hQcD/S5VSY9rwKuCXyLJ/GkgeI5S8HEHsyVdXEsDJznPyhA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 22" - } - }, - "node_modules/@nomicfoundation/edr-linux-x64-musl": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-musl/-/edr-linux-x64-musl-0.19.0.tgz", - "integrity": "sha512-UIrlNqYj9lbUXpBy++WwHeC0xZqyFw9TXy06/8+0PLffI7Si4kiTKfXqdITGQhP6Vf/JuJ8y+jos8oy6bL8jFQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 22" - } - }, - "node_modules/@nomicfoundation/edr-win32-x64-msvc": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-win32-x64-msvc/-/edr-win32-x64-msvc-0.19.0.tgz", - "integrity": "sha512-pIyEJBiS8l09Yndz+aE/JmkD39FCqtyiRO+wlx6tX426o4290acRfJ0r7GwTBmqqZD5G4pLOOB0uLIMRyDrY+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 22" - } - }, - "node_modules/@nomicfoundation/hardhat-errors": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-errors/-/hardhat-errors-3.0.21.tgz", - "integrity": "sha512-jXgqgxmI1D7vYcWRQnb6HsRDssxE87a4xJ48FQAdkZUPclB0aVISx6IoVG1Kttx2jJ7tNuo9GasJxpOv7stqyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nomicfoundation/hardhat-utils": "^4.1.5" - } - }, - "node_modules/@nomicfoundation/hardhat-ignition": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-ignition/-/hardhat-ignition-3.1.8.tgz", - "integrity": "sha512-zVsR1SA1gqjd3SF3C+BljttSEMOaIbSIEvHgFhXF/A1IgeecJYD/MqGcvGAW4s51r5ZGQnUSl9t/5VQKvuExlw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@nomicfoundation/hardhat-errors": "^3.0.16", - "@nomicfoundation/hardhat-utils": "^4.1.4", - "@nomicfoundation/ignition-core": "^3.1.8", - "@nomicfoundation/ignition-ui": "^3.1.2", - "json5": "^2.2.3", - "prompts": "^2.4.2" - }, - "peerDependencies": { - "@nomicfoundation/hardhat-verify": "^3.0.0", - "hardhat": "^3.8.0" - } - }, - "node_modules/@nomicfoundation/hardhat-ignition-viem": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-ignition-viem/-/hardhat-ignition-viem-3.1.6.tgz", - "integrity": "sha512-ggjHftjDNLQPnHBMEtDcTafYJfTzqTMlorKlAWj0HunzwiDhC5p7ox2MuEGmZHqt2oVjrnPF5E+Dp3G0RlYRgg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@nomicfoundation/hardhat-errors": "^3.0.15" - }, - "peerDependencies": { - "@nomicfoundation/hardhat-ignition": "^3.1.2", - "@nomicfoundation/hardhat-verify": "^3.0.0", - "@nomicfoundation/hardhat-viem": "^3.0.4", - "@nomicfoundation/ignition-core": "^3.0.7", - "hardhat": "^3.8.0", - "viem": "^2.47.6" - } - }, - "node_modules/@nomicfoundation/hardhat-keystore": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-keystore/-/hardhat-keystore-3.0.13.tgz", - "integrity": "sha512-e4RdtlGBSHhgwv66tEyayrF/G5a2DVT70Jv3AGocUCpDtnxSfNDQez4Pmr7fmjNUhn0TSK6mOwtJ+Yjr1IEwrg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/ciphers": "1.2.1", - "@noble/hashes": "1.7.1", - "@nomicfoundation/hardhat-errors": "^3.0.15", - "@nomicfoundation/hardhat-utils": "^4.1.3", - "@nomicfoundation/hardhat-zod-utils": "^3.0.5", - "zod": "^3.23.8" - }, - "peerDependencies": { - "hardhat": "^3.13.0" - } - }, - "node_modules/@nomicfoundation/hardhat-network-helpers": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-network-helpers/-/hardhat-network-helpers-3.0.11.tgz", - "integrity": "sha512-3/xuORejAOGbfqBmIen+OCHu9ExK9O8pjE11ILU9NcN37zy3GDbyNCKV9keK1gSjWWVb0okAlAR/yvewfEE4TA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@nomicfoundation/hardhat-errors": "^3.0.16", - "@nomicfoundation/hardhat-utils": "^4.1.4" - }, - "peerDependencies": { - "hardhat": "^3.8.0" - } - }, - "node_modules/@nomicfoundation/hardhat-node-test-reporter": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-node-test-reporter/-/hardhat-node-test-reporter-3.1.1.tgz", - "integrity": "sha512-dGWMfDYM2gg7leFA2w3RMKW3TzIXJm0+8VLOLxCbROQ9bJTzI76nwrPNmVNAeH49Q2cU3WCdgw7YXAp7lcoYRA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@actions/core": "^3.0.1", - "jest-diff": "^30.4.1" - } - }, - "node_modules/@nomicfoundation/hardhat-node-test-runner": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-node-test-runner/-/hardhat-node-test-runner-3.1.0.tgz", - "integrity": "sha512-afuTNxslf1H6tkXApf1r4Is12dyxsew1AscutGvQG+maPTDuk/BuTQ2a9idXBULZA3/6TGfbEEQvki5FxaONWQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@nomicfoundation/hardhat-errors": "^3.0.15", - "@nomicfoundation/hardhat-node-test-reporter": "^3.1.0", - "@nomicfoundation/hardhat-utils": "^4.1.2", - "@nomicfoundation/hardhat-zod-utils": "^3.0.5", - "tsx": "^4.23.1", - "zod": "^3.23.8" - }, - "peerDependencies": { - "hardhat": "^3.12.0" - } - }, - "node_modules/@nomicfoundation/hardhat-toolbox-viem": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-toolbox-viem/-/hardhat-toolbox-viem-5.0.7.tgz", - "integrity": "sha512-aHF77tTYmBIbuxY99KKBPx4cLWdcQvvar1PpzWJyrDgC+P6rA7Edi1GpvvPcwO/HdfZXuACQ74zSS2PnmJu3mA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@nomicfoundation/hardhat-ignition": "^3.0.7", - "@nomicfoundation/hardhat-ignition-viem": "^3.0.7", - "@nomicfoundation/hardhat-keystore": "^3.0.0", - "@nomicfoundation/hardhat-network-helpers": "^3.0.0", - "@nomicfoundation/hardhat-node-test-runner": "^3.0.0", - "@nomicfoundation/hardhat-verify": "^3.0.0", - "@nomicfoundation/hardhat-viem": "^3.0.4", - "@nomicfoundation/hardhat-viem-assertions": "^3.0.5", - "@nomicfoundation/ignition-core": "^3.0.7", - "hardhat": "^3.8.0", - "viem": "^2.47.6" - } - }, - "node_modules/@nomicfoundation/hardhat-utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-utils/-/hardhat-utils-4.1.9.tgz", - "integrity": "sha512-vUyHQ2H1VfknTUxO3Vat7z0y9WkDUF69pLrrLZN2K1/+TKJN+TVzEMeXW/2GXJzIhm2uc2qZXOYwQUh+SU9PbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@streamparser/json-node": "^0.0.22", - "env-paths": "^4.0.0", - "ethereum-cryptography": "^2.2.1", - "fast-equals": "^6.0.0", - "json-stream-stringify": "^3.1.7", - "rfdc": "^1.3.1", - "undici": "^6.28.0" - } - }, - "node_modules/@nomicfoundation/hardhat-vendored": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-vendored/-/hardhat-vendored-3.0.4.tgz", - "integrity": "sha512-RO8Otj1FvRvxJmXzkxh1vTwK/+cqSVPYLqY6RrWkmzHEEcxnAwAFsBYdW7xyTEyW/pVbSSNd2gs3aoGdGZaoNA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@nomicfoundation/hardhat-verify": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-verify/-/hardhat-verify-3.1.0.tgz", - "integrity": "sha512-qGaaUr7deSSGnpZP7nZg7jQyaft0QPUzfbGhdO46BygAR9lUHHLppuuWnUpdqwV3d1IMSo8pFSEpVlt/ZtyQvQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/abi": "^5.8.0", - "@nomicfoundation/hardhat-errors": "^3.0.16", - "@nomicfoundation/hardhat-utils": "^4.1.4", - "@nomicfoundation/hardhat-zod-utils": "^3.0.5", - "cbor2": "^2.0.0", - "zod": "^3.23.8" - }, - "peerDependencies": { - "hardhat": "^3.12.0" - } - }, - "node_modules/@nomicfoundation/hardhat-viem": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-viem/-/hardhat-viem-3.0.9.tgz", - "integrity": "sha512-GtQ7l55C70Jj80yrZGdW7Kah0vPmEje29G/xVfcxchVxRRFdj1XyFZFn+7e53b11qhHMWDB787pVN+I8YZae3w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@nomicfoundation/hardhat-errors": "^3.0.15", - "@nomicfoundation/hardhat-utils": "^4.1.2" - }, - "peerDependencies": { - "hardhat": "^3.8.0", - "viem": "^2.47.6" - } - }, - "node_modules/@nomicfoundation/hardhat-viem-assertions": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-viem-assertions/-/hardhat-viem-assertions-3.1.2.tgz", - "integrity": "sha512-vMdyBnvgAD/2uZVzDwWc4ynGekxEQx/1LJ3gIXmsJtAPWENolfBXs3fHCxnA48he7lldyEsymZaNya8MZBLYwA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@nomicfoundation/hardhat-errors": "^3.0.16", - "@nomicfoundation/hardhat-utils": "^4.1.4" - }, - "peerDependencies": { - "@nomicfoundation/hardhat-viem": "^3.0.4", - "hardhat": "^3.8.0", - "viem": "^2.47.6" - } - }, - "node_modules/@nomicfoundation/hardhat-zod-utils": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-zod-utils/-/hardhat-zod-utils-3.0.6.tgz", - "integrity": "sha512-DraBttTle+tRi24wkEfTYUrsX/Cdp74ySXcc4MUzMQPBQNUTFx5YyTH7qFhXQdz3V171iqLAQpOAhNcEqwmq9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nomicfoundation/hardhat-errors": "^3.0.13", - "@nomicfoundation/hardhat-utils": "^4.1.2" - }, - "peerDependencies": { - "zod": "^3.23.8" - } - }, - "node_modules/@nomicfoundation/ignition-core": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/@nomicfoundation/ignition-core/-/ignition-core-3.1.9.tgz", - "integrity": "sha512-HpAUqg3wh1FnpQMfLc/K10UJFTcyABLcPiZmNk5Rc5Znx1FzMOLPUvyMO/Vl3Ta21u8INQJ2VhmJeCiXpRzgRw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@ethersproject/address": "5.8.0", - "@nomicfoundation/hardhat-errors": "^3.0.16", - "@nomicfoundation/hardhat-utils": "^4.1.4", - "@nomicfoundation/solidity-analyzer": "^0.1.1", - "cbor2": "^2.0.0", - "ethers": "^6.14.0", - "immer": "11.1.15", - "lodash-es": "4.18.1", - "ndjson": "2.0.0" - } - }, - "node_modules/@nomicfoundation/ignition-ui": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/ignition-ui/-/ignition-ui-3.1.2.tgz", - "integrity": "sha512-OoS5eQi9WBeiYI6EXurhqrpr6syRVhnaUzdx5fyK/1syKGq9BsjWWHXTNru0qk5ZFQ9f/KMTZotcDZD4eAdCpg==", - "dev": true, - "peer": true - }, - "node_modules/@nomicfoundation/solidity-analyzer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer/-/solidity-analyzer-0.1.2.tgz", - "integrity": "sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - }, - "optionalDependencies": { - "@nomicfoundation/solidity-analyzer-darwin-arm64": "0.1.2", - "@nomicfoundation/solidity-analyzer-darwin-x64": "0.1.2", - "@nomicfoundation/solidity-analyzer-linux-arm64-gnu": "0.1.2", - "@nomicfoundation/solidity-analyzer-linux-arm64-musl": "0.1.2", - "@nomicfoundation/solidity-analyzer-linux-x64-gnu": "0.1.2", - "@nomicfoundation/solidity-analyzer-linux-x64-musl": "0.1.2", - "@nomicfoundation/solidity-analyzer-win32-x64-msvc": "0.1.2" - } - }, - "node_modules/@nomicfoundation/solidity-analyzer-darwin-arm64": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-darwin-arm64/-/solidity-analyzer-darwin-arm64-0.1.2.tgz", - "integrity": "sha512-JaqcWPDZENCvm++lFFGjrDd8mxtf+CtLd2MiXvMNTBD33dContTZ9TWETwNFwg7JTJT5Q9HEecH7FA+HTSsIUw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/@nomicfoundation/solidity-analyzer-darwin-x64": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-darwin-x64/-/solidity-analyzer-darwin-x64-0.1.2.tgz", - "integrity": "sha512-fZNmVztrSXC03e9RONBT+CiksSeYcxI1wlzqyr0L7hsQlK1fzV+f04g2JtQ1c/Fe74ZwdV6aQBdd6Uwl1052sw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/@nomicfoundation/solidity-analyzer-linux-arm64-gnu": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-arm64-gnu/-/solidity-analyzer-linux-arm64-gnu-0.1.2.tgz", - "integrity": "sha512-3d54oc+9ZVBuB6nbp8wHylk4xh0N0Gc+bk+/uJae+rUgbOBwQSfuGIbAZt1wBXs5REkSmynEGcqx6DutoK0tPA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/@nomicfoundation/solidity-analyzer-linux-arm64-musl": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-arm64-musl/-/solidity-analyzer-linux-arm64-musl-0.1.2.tgz", - "integrity": "sha512-iDJfR2qf55vgsg7BtJa7iPiFAsYf2d0Tv/0B+vhtnI16+wfQeTbP7teookbGvAo0eJo7aLLm0xfS/GTkvHIucA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/@nomicfoundation/solidity-analyzer-linux-x64-gnu": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-x64-gnu/-/solidity-analyzer-linux-x64-gnu-0.1.2.tgz", - "integrity": "sha512-9dlHMAt5/2cpWyuJ9fQNOUXFB/vgSFORg1jpjX1Mh9hJ/MfZXlDdHQ+DpFCs32Zk5pxRBb07yGvSHk9/fezL+g==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/@nomicfoundation/solidity-analyzer-linux-x64-musl": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-x64-musl/-/solidity-analyzer-linux-x64-musl-0.1.2.tgz", - "integrity": "sha512-GzzVeeJob3lfrSlDKQw2bRJ8rBf6mEYaWY+gW0JnTDHINA0s2gPR4km5RLIj1xeZZOYz4zRw+AEeYgLRqB2NXg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/@nomicfoundation/solidity-analyzer-win32-x64-msvc": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-win32-x64-msvc/-/solidity-analyzer-win32-x64-msvc-0.1.2.tgz", - "integrity": "sha512-Fdjli4DCcFHb4Zgsz0uEJXZ2K7VEO+w5KVv7HmT7WO10iODdU9csC2az4jrhEsRtiR9Gfd74FlG0NYlw1BMdyA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/@scure/base": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", - "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz", - "integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.4.0", - "@noble/hashes": "~1.4.0", - "@scure/base": "~1.1.6" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.3.0.tgz", - "integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.4.0", - "@scure/base": "~1.1.6" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@sentry/core": { - "version": "9.47.1", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz", - "integrity": "sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@streamparser/json": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.22.tgz", - "integrity": "sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@streamparser/json-node": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@streamparser/json-node/-/json-node-0.0.22.tgz", - "integrity": "sha512-sJT2ptNRwqB1lIsQrQlCoWk5rF4tif9wDh+7yluAGijJamAhrHGYpFB/Zg3hJeceoZypi74ftXk8DHzwYpbZSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@streamparser/json": "^0.0.22" - } - }, - "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/abitype": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", - "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3.22.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/adm-zip": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", - "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0" - } - }, - "node_modules/aes-js": { - "version": "4.0.0-beta.5", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", - "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/bn.js": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", - "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/cbor2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz", - "integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@cto.af/wtf8": "0.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/elliptic": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/env-paths": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", - "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-safe-filename": "^0.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/ethereum-cryptography": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz", - "integrity": "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/curves": "1.4.2", - "@noble/hashes": "1.4.0", - "@scure/bip32": "1.4.0", - "@scure/bip39": "1.3.0" - } - }, - "node_modules/ethereum-cryptography/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", - "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@adraffy/ens-normalize": "1.11.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.21.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ethers/node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/ethers/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/fast-equals": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.3.tgz", - "integrity": "sha512-IECu4C0fFZFoUC2cA2rDaNUuVIfWIZMwh3tY+ng924vOuD9OaUFFrcIrVwCKbV7TJDkGmrHfhwZ7R0AnHfVhyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/hardhat": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-3.16.0.tgz", - "integrity": "sha512-LWFFR3rYl8AIhwpYVjK2L5y0Z0CqdQFQ8AZWe8gqf1t7KGH+E/EguoIX7rcB4KfyKoWK6ooj+7Jgba4vlQ1feQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nomicfoundation/edr": "0.19.0", - "@nomicfoundation/hardhat-errors": "^3.0.17", - "@nomicfoundation/hardhat-utils": "^4.1.6", - "@nomicfoundation/hardhat-vendored": "^3.0.4", - "@nomicfoundation/hardhat-zod-utils": "^3.0.5", - "@nomicfoundation/solidity-analyzer": "^0.1.1", - "@sentry/core": "^9.4.0", - "adm-zip": "^0.6.0", - "chokidar": "^5.0.0", - "enquirer": "^2.3.0", - "ethereum-cryptography": "^2.2.1", - "micro-eth-signer": "^0.14.0", - "p-map": "^7.0.6", - "resolve.exports": "^2.0.3", - "semver": "^7.8.5", - "tsx": "^4.23.1", - "ws": "^8.21.1", - "zod": "^3.23.8" - }, - "bin": { - "hardhat": "dist/src/cli.js" - } - }, - "node_modules/hardhat/node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, - "license": "MIT", - "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 - } - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/immer": { - "version": "11.1.15", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", - "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/is-safe-filename": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", - "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isows": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", - "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "peer": true, - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/jest-diff": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.1.tgz", - "integrity": "sha512-e3cNNMpv8Kh20MjjphTXs+3Vz7DQyLM1nft7KJhnh46atFhjVJRa+0Hq0beywuwsACtMQUBihQkFl8zxb7gt1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/diff-sequences": "30.5.0", - "@jest/get-type": "30.5.0", - "chalk": "^4.1.2", - "pretty-format": "30.5.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/js-sha3": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", - "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/json-stream-stringify": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/json-stream-stringify/-/json-stream-stringify-3.1.7.tgz", - "integrity": "sha512-F4MWetLtY42YMaAKw5cV4e47zMD5aOT+tjjQWjX18ACtdkQ5Y/vrcfbcQ107Rh+MXjOCIx4KhW0wPmOvG8iQ5w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=7.10.1" - } - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/micro-eth-signer": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/micro-eth-signer/-/micro-eth-signer-0.14.0.tgz", - "integrity": "sha512-5PLLzHiVYPWClEvZIXXFu5yutzpadb73rnQCpUqIHu3No3coFuWQNfE5tkBQJ7djuLYl6aRLaS0MgWJYGoqiBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.8.1", - "@noble/hashes": "~1.7.1", - "micro-packed": "~0.7.2" - } - }, - "node_modules/micro-eth-signer/node_modules/@noble/curves": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz", - "integrity": "sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.7.2" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/micro-eth-signer/node_modules/@noble/hashes": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", - "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/micro-packed": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.7.3.tgz", - "integrity": "sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/micro-packed/node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ndjson": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ndjson/-/ndjson-2.0.0.tgz", - "integrity": "sha512-nGl7LRGrzugTtaFcJMhLbpzJM6XdivmbkdlaGcrk/LXg2KL/YBC6z1g70xh0/al+oFuVFP8N8kiWRucmeEH/qQ==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "json-stringify-safe": "^5.0.1", - "minimist": "^1.2.5", - "readable-stream": "^3.6.0", - "split2": "^3.0.0", - "through2": "^4.0.0" - }, - "bin": { - "ndjson": "cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ox": { - "version": "0.14.44", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.44.tgz", - "integrity": "sha512-O54qXXHEk4ySamMSyBpI0AeBegS5frxU6+LA6Jb9Sf6kMOxcTNaxYmwZfpOu22DIQsdKfgQxHq8EsAGaoBQScA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@adraffy/ens-normalize": "^1.11.0", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "1.9.1", - "@noble/hashes": "^1.8.0", - "@scure/bip32": "^1.7.0", - "@scure/bip39": "^1.6.0", - "abitype": "^1.2.3", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/ox/node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ox/node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ox/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ox/node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ox/node_modules/@scure/bip32": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", - "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/curves": "~1.9.0", - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ox/node_modules/@scure/bip39": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/p-map": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz", - "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pretty-format": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", - "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "readable-stream": "^3.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/through2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", - "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "readable-stream": "3" - } - }, - "node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "dev": true, - "license": "0BSD", - "peer": true - }, - "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.6.11 <=0.7.0 || >=0.7.3" - } - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/undici": { - "version": "6.28.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/viem": { - "version": "2.56.5", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.56.5.tgz", - "integrity": "sha512-GuEf/oee0PHgy4D6JIA8u0rY1g+sM5nF+p7HyMHO3vjlHC5G/ljfrgnUOnrES9m5Ny/P3RwWCHjDuN2T6HUWzQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/curves": "1.9.1", - "@noble/hashes": "1.8.0", - "@scure/bip32": "1.7.0", - "@scure/bip39": "1.6.0", - "abitype": "1.2.3", - "isows": "1.0.7", - "ox": "0.14.44", - "ws": "8.21.0" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/viem/node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/viem/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/viem/node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/viem/node_modules/@scure/bip32": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", - "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/curves": "~1.9.0", - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/viem/node_modules/@scure/bip39": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "license": "MIT", - "peer": true, - "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 - } - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/contracts/package.json b/contracts/package.json deleted file mode 100644 index a9ebe75..0000000 --- a/contracts/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@nosus-monad/contracts", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "compile": "hardhat compile", - "test": "hardhat test", - "deploy:monad": "hardhat run scripts/deploy.ts --network monadTestnet" - }, - "devDependencies": { - "@nomicfoundation/hardhat-toolbox-viem": "5.0.7", - "@types/node": "24.10.1", - "hardhat": "3.16.0", - "typescript": "7.0.2" - } -} diff --git a/contracts/scripts/deploy.ts b/contracts/scripts/deploy.ts deleted file mode 100644 index 784e024..0000000 --- a/contracts/scripts/deploy.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { network } from "hardhat"; - -if (process.env.CONFIRM_MONAD_DEPLOY !== "1") { - throw new Error( - "Refusing to deploy to Monad Testnet. After an explicit named-target approval, set CONFIRM_MONAD_DEPLOY=1.", - ); -} - -const connection = await network.create("monadTestnet"); -const { viem } = connection; -const drops = await viem.deployContract("NoSusMonadDrops"); - -console.log(`NoSusMonadDrops deployed at: ${drops.address}`); -console.log("Record this address in web/.env.local as NEXT_PUBLIC_DROPS_CONTRACT_ADDRESS."); diff --git a/contracts/test/NoSusMonadDrops.test.ts b/contracts/test/NoSusMonadDrops.test.ts deleted file mode 100644 index 22482ee..0000000 --- a/contracts/test/NoSusMonadDrops.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { network } from "hardhat"; - -const id = "0x4f4e4f5355535f4d4f4e41445f44524f505f3030303030303030303030303030"; -const digest = "0x1d9f0d2a2e10e3580481170dc38f3dce11399ee6d26c6621dbb3dc401b3c7adf"; -const noRecipient = "0x0000000000000000000000000000000000000000"; - -async function expectCustomError(action: Promise, name: string) { - await assert.rejects(action, (error: unknown) => - error instanceof Error && error.message.includes(name), - ); -} - -describe("NoSusMonadDrops", () => { - async function deployDropContract() { - const { viem } = await network.getOrCreate(); - const [sender, recipient, stranger] = await viem.getWalletClients(); - const drops = await viem.deployContract("NoSusMonadDrops"); - return { viem, sender, recipient, stranger, drops }; - } - - it("seals a ciphertext digest and exposes the initial receipt", async () => { - const { drops, sender, viem } = await deployDropContract(); - const publicClient = await viem.getPublicClient(); - const now = BigInt((await publicClient.getBlock()).timestamp); - const expiry = now + 3600n; - - await drops.write.seal([id, digest, noRecipient, expiry]); - - const receipt = await drops.read.receiptOf([id]); - assert.equal(receipt.sender.toLowerCase(), sender.account.address.toLowerCase()); - assert.equal(receipt.ciphertextDigest, digest); - assert.equal(receipt.opener, noRecipient); - assert.equal(await drops.read.canDecrypt([id, noRecipient]), false); - assert.equal(await drops.read.canDecrypt([id, sender.account.address]), false); - const sealedEvents = await drops.getEvents.Sealed({ id }, { fromBlock: 0n }); - assert.equal(sealedEvents.length, 1); - assert.equal(sealedEvents[0].args.sender?.toLowerCase(), sender.account.address.toLowerCase()); - }); - - it("rejects duplicate IDs and zero ciphertext digests", async () => { - const { drops } = await deployDropContract(); - - await drops.write.seal([id, digest, noRecipient, 0n]); - await expectCustomError(drops.write.seal([id, digest, noRecipient, 0n]), "DropAlreadyExists"); - await expectCustomError( - drops.write.seal([`${id.slice(0, -1)}1`, `0x${"0".repeat(64)}`, noRecipient, 0n]), - "InvalidCiphertextDigest", - ); - }); - - it("allows only the named recipient to acknowledge once", async () => { - const { drops, recipient, stranger } = await deployDropContract(); - await drops.write.seal([id, digest, recipient.account.address, 0n]); - assert.equal(await drops.read.canDecrypt([id, recipient.account.address]), false); - - await expectCustomError( - drops.write.acknowledgeOpen([id], { account: stranger.account }), - "RecipientOnly", - ); - - await drops.write.acknowledgeOpen([id], { account: recipient.account }); - assert.equal(await drops.read.canDecrypt([id, recipient.account.address]), true); - assert.equal(await drops.read.canDecrypt([id, stranger.account.address]), false); - const openedEvents = await drops.getEvents.OpenAcknowledged( - { opener: recipient.account.address }, - { fromBlock: 0n }, - ); - assert.equal(openedEvents.length, 1); - assert.equal(openedEvents[0].args.id, id); - await expectCustomError( - drops.write.acknowledgeOpen([id], { account: recipient.account }), - "DropAlreadyOpened", - ); - }); - - it("refuses an expired drop", async () => { - const { drops, sender, viem } = await deployDropContract(); - const publicClient = await viem.getPublicClient(); - const now = BigInt((await publicClient.getBlock()).timestamp); - - await drops.write.seal([id, digest, noRecipient, now + 60n]); - await publicClient.request({ - method: "evm_increaseTime", - params: [61], - }); - await publicClient.request({ method: "evm_mine", params: [] }); - - await expectCustomError(drops.write.acknowledgeOpen([id]), "DropExpired"); - assert.equal(await drops.read.canDecrypt([id, sender.account.address]), false); - }); - - it("stops decryption after expiry even if already acknowledged", async () => { - const { drops, recipient, viem } = await deployDropContract(); - const publicClient = await viem.getPublicClient(); - const now = BigInt((await publicClient.getBlock()).timestamp); - - await drops.write.seal([id, digest, recipient.account.address, now + 60n]); - await drops.write.acknowledgeOpen([id], { account: recipient.account }); - assert.equal(await drops.read.canDecrypt([id, recipient.account.address]), true); - - await publicClient.request({ - method: "evm_increaseTime", - params: [61], - }); - await publicClient.request({ method: "evm_mine", params: [] }); - - assert.equal(await drops.read.canDecrypt([id, recipient.account.address]), false); - }); - - it("treats the exact expiry timestamp as expired", async () => { - const { drops, recipient, viem } = await deployDropContract(); - const publicClient = await viem.getPublicClient(); - const now = BigInt((await publicClient.getBlock()).timestamp); - const expiry = now + 60n; - - await drops.write.seal([id, digest, recipient.account.address, expiry]); - await publicClient.request({ - method: "evm_increaseTime", - params: [60], - }); - await publicClient.request({ method: "evm_mine", params: [] }); - - await expectCustomError(drops.write.acknowledgeOpen([id], { account: recipient.account }), "DropExpired"); - assert.equal(await drops.read.canDecrypt([id, recipient.account.address]), false); - }); -}); diff --git a/contracts/tsconfig.json b/contracts/tsconfig.json deleted file mode 100644 index 27a8578..0000000 --- a/contracts/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "module": "esnext", - "moduleResolution": "bundler", - "strict": true, - "target": "es2022", - "types": ["node"] - } -} diff --git a/docs/AGENT_COLLABORATION.md b/docs/AGENT_COLLABORATION.md deleted file mode 100644 index ecaa45a..0000000 --- a/docs/AGENT_COLLABORATION.md +++ /dev/null @@ -1,35 +0,0 @@ -# Independent-agent collaboration - -Folder layout reduces context; it does not prevent merge conflicts. Treat each -task as a narrow charter, then isolate it in a worktree. - -## Task charter - -Every agent prompt starts with: objective, allowed paths, forbidden paths, -contracts touched, and acceptance checks. One task owns one path area. If a -change crosses contract/web/backend boundaries, write a short decision in -`docs/` first and create a separate integration task. - -## Worktree pattern - -From the parent directory, give every agent a separate branch and folder: - -```powershell -git -C nosus-monad worktree add ..\nosus-monad- -b codex/ -``` - -Do not let two unconnected agents edit the same folder or shared lockfile. -They each make small commits; an integration agent reviews the diffs, resolves -only intentional conflicts, and runs the focused checks. - -## Current ownership boundaries - -| Area | Owner task type | Forbidden to unrelated tasks | -|---|---|---| -| `contracts/` | contract invariant | web/backend changes | -| `web/` | UI or wallet flow | Solidity/migration changes | -| `supabase/` | isolated backend | production NO SUS or client secrets | -| `docs/` + `.cursor/` | operating model | product behavior without owner approval | - -Main is the integration branch. Merge only after the explicit finalization -gate in `AGENTS.md`; deployment needs a named target. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b18c2c9..068a462 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,30 +1,78 @@ -# Architecture and trust model +# Architecture -## Flow +NO SUS Canary has three parts: the Flutter app (sender and reader), one Supabase Edge Function +called `canary` (the relayer), and Solana devnet (the receipts). The server stores only encrypted +copies; the chain stores only hashes. -1. The creator encrypts a harmless personal test note in the browser with Lit threshold custody. -2. The browser stores only encrypted payload and metadata in the experiment's Supabase project. -3. The creator seals the ciphertext digest and access policy in `NoSusMonadDrops` on Monad testnet. -4. The recipient wallet calls `acknowledgeOpen`. -5. Lit allows only the acknowledged wallet to decrypt after `canDecrypt(dropId, wallet)` succeeds - on Monad Testnet. The Lit access-control condition uses chain key `monadTestnet` and binds the - second argument to the authenticated wallet (`:userAddress`). -6. The acknowledged wallet may retry decryption until expiry. An acknowledgement is not evidence that - decryption succeeded, and the product does not claim to count or prevent repeated local reads. -7. The backend may remove an encrypted object after its retention window, but that deletion is - operational—not publicly or cryptographically provable. +```mermaid +sequenceDiagram + autonumber + participant S as Sender's device + participant F as Relayer (edge function "canary") + participant DB as Postgres + participant SOL as Solana devnet (SPL Memo) + participant R as Reader's browser -## What the receipt proves + S->>S: write note, make N marked copies, encrypt each (AES-256, key in the link fragment) + S->>F: create: note id, owner hash, N ciphertexts, copies hash + F->>DB: insert note + N encrypted copies + F->>SOL: seal memo {"t":"seal", note, copies, hash, exp} + SOL-->>F: signature (confirmed) + F-->>S: seal signature, then S shows the link and "Sealed on Solana" + R->>F: open: note id, device id, typed name + F->>DB: assign the next unused copy to this device + F->>SOL: open memo {"t":"open", note, copy, tag} + SOL-->>F: signature + F-->>R: that copy's ciphertext and signature, decrypted with the key from the link fragment + S->>F: status: note id, owner secret + F-->>S: who opened which copy, with signatures + S->>S: check a leak: match pasted text against every copy's fingerprint, on device +``` -It proves a wallet acknowledged a drop with a committed ciphertext digest at a chain timestamp. It does -not prove a human read it, establish real-world identity, stop screenshots, count decryptions, prove -storage deletion, or create legal evidence by itself. +## Components -## Data boundaries +| Part | Code | Notes | +|---|---|---| +| Sender and reader UI | `app/lib/features/canary/presentation/` | Flutter. The deployed site is the standalone build `--dart-define=NOSUS_CANARY_ONLY=true`. | +| Copy marking and leak matching | `app/lib/features/canary/domain/canary_fingerprint.dart` | Word-twin swaps (`do not`/`don't`, `5`/`five`, …) plus an invisible marker. Up to 100 copies. Matching never leaves the device and names nobody when unsure. | +| Encryption | `app/lib/features/canary/data/canary_crypto.dart` | AES-256 (`package:encrypt`, default SIC/CTR mode), random IV per copy. | +| Local store | `app/lib/features/canary/data/canary_store.dart` | The sender's notes, keys, salts and fingerprints, in browser storage. | +| Screenshot OCR | `app/lib/features/canary/ocr/` | Android only (ML Kit). | +| Relayer | `app/supabase/functions/canary/index.ts` | Actions `create`, `open`, `status`. Deployed with `--no-verify-jwt`; callers use the publishable key. | +| Which receipts to send | `app/supabase/functions/canary/gating.ts` | Pure rules, tested offline in `gating_test.ts`. | +| Solana receipts | `packages/receipts-ts` via `app/supabase/functions/canary/solana.ts` | `@solana/kit` 8.4, `@solana-program/memo` 0.15, v0 transactions, confirmed by polling `getSignatureStatuses`. | +| Schema | `app/supabase/migrations/20260919090000_nosus_canary.sql`, `20261010090000_nosus_canary_solana.sql` | `canary_notes`, `canary_copies`, `canary_assign_copy()`. RLS on, no `anon` grants. | + +## Rules the relayer follows + +- **A link is never handed out unsealed.** `create` returns only after the seal memo is confirmed. + If the memo fails, `create` fails with `retry: true`, and a retry with the same note id reuses + the stored copies. +- **An open is never lost.** If the open memo fails, the copy is still delivered and its + signature is stored as `null`; the next open of that copy from the same device sends it again. +- **One device, one copy.** The device id is hashed and bound to one copy, so reopening shows the + same copy and sends no new memo. +- **Limits.** 2–100 copies, links live 1 hour, 1 day or 7 days, names 1–40 characters, + ciphertexts up to 12,000 characters. + +## Receipt format -| Location | May contain | Must never contain | +One SPL Memo instruction per transaction, compact JSON, hashes only. Full spec: +[RECEIPTS_SPEC.md](RECEIPTS_SPEC.md). + +```text +seal: {"p":"nosus-canary","v":1,"t":"seal","note":"0x…","copies":10,"hash":"0x…","exp":1792501806} +open: {"p":"nosus-canary","v":1,"t":"open","note":"0x…","copy":0,"tag":"0x…"} +``` + +## Costs and timings (devnet, measured) + +| | Value | Source | |---|---|---| -| Monad contract | Drop ID, encrypted-ciphertext digest, wallet addresses, expiry, timestamps | Plaintext, key, salt, filename, content | -| Browser | Plaintext while composing/decrypting, wallet session | Service-role key, deployer private key | -| Supabase | Encrypted payload, Lit metadata, public receipt metadata | Plaintext, Lit private material | -| Lit | Threshold encryption policy and encrypted material | A hidden central-release override | +| Fee per receipt | 5,000 lamports (0.000005 SOL) | Solana base fee for one signature; no priority fee is set | +| Compute per receipt | about 84,500 (open) to 91,600 (seal) CU | sampled receipts on Explorer | +| Memo size | 197 (open) to 218 (seal) bytes | Explorer | +| Confirmation | ~1.1 s | `tool/solana-spike`, 10 Oct 2026 | +| Click "Create" → sealed link | ~2.9 s | live recording, 20 Oct 2026 | + +Security model and threat model: [../SECURITY.md](../SECURITY.md). diff --git a/docs/CURSOR_HANDOFF.md b/docs/CURSOR_HANDOFF.md deleted file mode 100644 index c40aee3..0000000 --- a/docs/CURSOR_HANDOFF.md +++ /dev/null @@ -1,34 +0,0 @@ -# Cursor handover - -This repository is designed to continue cleanly in Cursor without re-reading the production NO SUS -codebase. - -## Open the right folder - -Open `C:\Users\shubh\_Active_Projects\NO_SUS\nosus-monad` in Cursor, not `no_sus`. -Cursor automatically loads root `AGENTS.md` and the versioned `.cursor/rules/*.mdc` rules. The rules are -kept small and scoped so unrelated contract or web context is not loaded. - -## First Cursor prompt - -```text -Read AGENTS.md, docs/STATUS.md, docs/ARCHITECTURE.md, docs/LIT_MONAD_SPIKE.md, and the local README for the area you will touch. -This is the isolated NO SUS — Monad Experiment repository. Continue only the next blocking milestone: -prove Lit threshold decryption against NoSusMonadDrops on Monad testnet using two wallets. Do not touch -the production NO SUS repository, deploy, apply Supabase migrations, or claim the feature is live until -the documented threshold-gate acceptance tests pass. Keep changes small, update STATUS.md, and end your -response with the exact finalization question in AGENTS.md. -``` - -## How to keep Cursor on track - -1. Start each task with the prompt above plus one concrete file/feature request. -2. Let Cursor read only the named local README and files; do not ask it to scan parent directories. -3. Before a new capability, ask it to update `docs/STATUS.md` with the acceptance criteria first. -4. Review `git diff` before allowing a deployment, migration, or contract funding action. -5. When a task is done, commit it and ask Cursor to record the actual check output in `docs/STATUS.md`. - -## Escalation rules - -Stop and ask for direction if threshold custody cannot bind a Monad wallet to `canDecrypt`, if a design -would put a secret into public calldata, or if a change needs production NO SUS credentials or services. diff --git a/docs/DEMO_RUNBOOK.md b/docs/DEMO_RUNBOOK.md deleted file mode 100644 index 62f2420..0000000 --- a/docs/DEMO_RUNBOOK.md +++ /dev/null @@ -1,34 +0,0 @@ -# Demo runbook - -This is a testnet experiment. Use only harmless, personally owned test notes. -Never use credentials, third-party documents, financial records, employment -material, or sensitive personal data. - -## Before the demo - -1. Confirm `docs/STATUS.md` says Gate T1 is verified. If it is not, demonstrate - the receipt interface only; do not claim a threshold unlock exists. -2. Use a fresh isolated Supabase project and testnet-only environment values. -3. Verify the current wallet, Monad network, contract address, explorer URL, - and the explicit gas limit generated for the write. -4. Open the browser at a local preview. Have the recipient QR/link and a - prepared test wallet ready, but do not show seed phrases or private keys. - -## Live flow - -1. State the promise exactly: an eligible recipient wallet can request - decryption only after its acknowledgement is recorded on Monad Testnet. -2. Seal a harmless note. Show the digest and transaction link, never plaintext - in a recording or logs. -3. Scan the recipient link, connect the named test wallet, and acknowledge. -4. Show the new event in the Receipt Wall and the verifier page. -5. If Gate T1 is verified, show the acknowledged wallet decrypting and the - second wallet failing. If it is not, stop at the receipt and say so. - -## What to say when challenged - -- The receipt proves a wallet action against a committed encrypted drop. -- It does not prove a person read or understood content, prevent screenshots, - identify a person, establish legal admissibility, or independently prove - server-side deletion. -- Costs and timing are live testnet measurements, not fixed promises. diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md deleted file mode 100644 index 1b41825..0000000 --- a/docs/DESIGN_SYSTEM.md +++ /dev/null @@ -1,44 +0,0 @@ -# Design system - -The experiment uses the visual language reviewed from the user-provided NO SUS -Burn ad kit on 2026-09-16. That kit is a visual reference only. Its product -components and deployment assumptions belong to production NO SUS and must not -be copied into this separate experiment. - -## Visual rules - -- Use local Geist and Geist Mono assets from `web/src/app/fonts/`. -- Keep the canvas black (`#080808`), with white type, `#888888` secondary copy, - and thin white dividers. Use the small coral error state only for failure. -- Prefer flat rectangular surfaces, 2px corners, a white filled primary action, - and uppercase mono labels. Do not introduce gradients, glows, neon colors, - oversized shadows, or decorative crypto effects. -- Motion is functional and restrained: status may pulse by opacity; never use - motion to imply that a chain or threshold action succeeded. -- The experimental/testnet and trust-limit messages are product content, not - optional decoration. They must remain visible on every flow. - -## Token layers - -`web/src/app/globals.css` is the single source of truth: - -1. Raw palette tokens (`--black`, `--gray-dark`, `--white`) record values. -2. Semantic tokens (`--surface`, `--text-muted`, `--line`) describe purpose. -3. Component tokens (`--button-primary-bg`) describe reusable UI roles. - -Use an existing semantic token before adding a new value. Create a new raw token -only when a real, repeated need exists; then add its semantic alias. - -## Component expectations - -- Keep forms, receipt rows, and verifier fields as bordered information blocks. -- A status chip communicates system state; success styling is never evidence of - a real unlock unless the verified Lit–Monad gate permits it. -- The lock is a recognisable interface mark, not a security claim. Its unlocked - state is available only to a confirmed, unexpired recipient path. -- Preserve visible keyboard focus and `prefers-reduced-motion` support. - -## Before visual changes - -Read `web/README.md`, inspect only the screen you touch, and use localhost for -visual validation. Update this document if a durable visual rule changes. diff --git a/docs/LIT_MONAD_SPIKE.md b/docs/LIT_MONAD_SPIKE.md deleted file mode 100644 index df925de..0000000 --- a/docs/LIT_MONAD_SPIKE.md +++ /dev/null @@ -1,96 +0,0 @@ -# Gate T1 — Lit × Monad threshold-decrypt spike - -This is the first technical gate. Do not enable `web/src/lib/threshold.ts`, add -a fake fallback, or describe threshold unlock as shipped until every acceptance -test below has a recorded result in `STATUS.md`. - -## Preconditions - -- User explicitly approves a named Monad Testnet deployment target. -- `NoSusMonadDrops` is deployed and its bytecode/constructor-free address is - verified in the Monad explorer. -- Two controlled test wallets and only a harmless personal test note are ready. -- A current, supported Lit SDK is selected from Lit’s official documentation. - Do not silently revive the retired V3/`@lit-protocol/lit-node-client` Datil - packages. The selected packages for this spike are `@lit-protocol/lit-client` - **8.3.1**, `@lit-protocol/networks` **8.4.1**, and `@lit-protocol/auth` **8.2.3**, - targeting the Naga Dev network (`naga-dev`). Datil was sunset on 25 February 2026. - -## Selected condition - -The condition to send Lit is built in `web/src/lib/lit-conditions.ts` (browser) -and `tool/threshold-spike/conditions.mjs` (runner). It is an -`evmContractConditions` entry on chain key `monadTestnet` (Lit ACC schema, -chain ID 10143) that calls `canDecrypt(id, :userAddress)` and requires the -boolean result `true`. - -That chain key is published by Lit. It is **not** yet recorded evidence that -Lit nodes evaluate Monad Testnet state. Run `npm run dry-run` in -`tool/threshold-spike` to print the condition. `npm run live` runs the sender, -recipient, and stranger sequence against a named testnet deployment. It needs -three controlled test-wallet keys, not a Supabase project. Set -`THRESHOLD_SPIKE_CHECK_EXPIRY=1` to include the bounded wait-and-deny expiry test. - -## Condition to prove - -The Lit access-control condition must evaluate the deployed contract’s -`canDecrypt(bytes32 dropId, address wallet) -> bool` against Monad Testnet. - -The fixed first argument is the exact drop ID. The second argument must be the -address authenticated by the browser wallet. The condition’s result must equal -`true`. Do not use an off-chain API, a shared signed response, a server-owned -wallet, a key commitment, or a key/salt in transaction calldata. - -Lit’s custom-chain name/configuration is intentionally not assumed here. Record -the supported chain identifier, SDK package version, configuration, and source -link only after the live Lit client successfully reads Monad Testnet state. - -## Required evidence - -For one recorded ciphertext and drop ID, capture the exact command/browser -result (redacting no plaintext is needed because use a harmless note): - -1. Before `acknowledgeOpen`, recipient wallet A fails to decrypt. -2. Wallet A acknowledges and its transaction confirms. Wallet A can decrypt. -3. Wallet B attempts the same ciphertext and fails to decrypt. -4. A second `acknowledgeOpen` attempt reverts and does not change the opener. -5. A short-expiry drop lets no wallet decrypt after expiry. -6. Tampering with stored ciphertext fails integrity/decryption; no plaintext is - logged as part of the failure. - -## Implementation handoff - -When all acceptance tests are clean: - -1. Add the current supported Lit SDK with exact versions and a lockfile. -2. Implement browser-only encrypt/decrypt. Keep capacity credentials and any - server credentials out of `NEXT_PUBLIC_*`; do not turn the server into a - decryption oracle. -3. Persist only ciphertext, Lit metadata, digest, and receipt fields through a - narrow authenticated server route after it validates the sealed transaction. -4. Replace the guarded UI state with the real flow and add focused browser - tests for the six cases above. -5. Update `STATUS.md`, `ARCHITECTURE.md`, and `web/README.md` with the exact - evidence and remaining limitations. - -## Runner environment - -The runner reads values only from the process environment. Never paste a test -wallet private key into a command history, repository file, or `NEXT_PUBLIC_*` -variable. - -```text -THRESHOLD_SPIKE_RUN=1 -DROPS_CONTRACT_ADDRESS=0x... -SENDER_PRIVATE_KEY=0x... -RECIPIENT_PRIVATE_KEY=0x... -STRANGER_PRIVATE_KEY=0x... -SPIKE_DROP_ID=0x... # fresh random 32-byte value -SPIKE_EXPIRY_SECONDS=300 -THRESHOLD_SPIKE_CHECK_EXPIRY=1 # optional, makes the runner wait until expiry -``` - -The runner creates a harmless in-memory probe, prints no plaintext, and leaves -the product gate unchanged regardless of its result. Record the transaction -hashes, SDK versions, and all observed outcomes in `STATUS.md` before enabling -the browser flow. diff --git a/docs/README.md b/docs/README.md index ff68af0..b0c246c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,15 +1,11 @@ -# Documentation index +# Docs -Read this page after the root `AGENTS.md`; then open only the document relevant -to the task. - -| Document | Use it for | +| Document | Read it for | |---|---| -| [STATUS.md](STATUS.md) | Actual completed work, checks, and current blockers | -| [ARCHITECTURE.md](ARCHITECTURE.md) | Data flow and the limits of the receipt | -| [DESIGN_SYSTEM.md](DESIGN_SYSTEM.md) | Visual tokens, component rules, and the Burn-kit boundary | -| [LIT_MONAD_SPIKE.md](LIT_MONAD_SPIKE.md) | First technical gate: real address-bound threshold decrypt | -| [../tool/threshold-spike/README.md](../tool/threshold-spike/README.md) | Dry-run / live runner for that gate | -| [DEMO_RUNBOOK.md](DEMO_RUNBOOK.md) | Safe rehearsal and honest presentation language | -| [CURSOR_HANDOFF.md](CURSOR_HANDOFF.md) | Continuing with Cursor without importing production context | -| [AGENT_COLLABORATION.md](AGENT_COLLABORATION.md) | Isolated worktrees and path ownership for independent agents | +| [ARCHITECTURE.md](ARCHITECTURE.md) | How a note, a copy and a receipt move between device, relayer and Solana | +| [RECEIPTS_SPEC.md](RECEIPTS_SPEC.md) | The memo format (v1) and how to verify a receipt by hand | +| [../SECURITY.md](../SECURITY.md) | What is stored where, how each hash is made, the threat model | +| [SELF_HOSTING.md](SELF_HOSTING.md) | Running your own backend and devnet relayer | +| [STATUS.md](STATUS.md) | What is live, what is in progress, what needs owner approval | +| [canary/VERIFICATION.md](canary/VERIFICATION.md) | Every check that was run, with results | +| [../SUBMISSION.md](../SUBMISSION.md) | Judge FAQ and the 2-minute demo script | diff --git a/docs/RECEIPTS_SPEC.md b/docs/RECEIPTS_SPEC.md new file mode 100644 index 0000000..d417976 --- /dev/null +++ b/docs/RECEIPTS_SPEC.md @@ -0,0 +1,115 @@ +# NO SUS Canary receipts: memo format v1 + +A receipt is one Solana transaction with one SPL Memo instruction. The memo text says what +happened: a note was sealed, or one copy of it was opened. There is one receipt per seal and one +per open. + +The relayer (the Canary edge function) pays for and signs every receipt. Readers need no wallet. + +Code: [packages/receipts-ts](../packages/receipts-ts) (TypeScript, also used by the relayer) and +[packages/receipts-dart](../packages/receipts-dart). Both test against +[packages/test-vectors/receipts.json](../packages/test-vectors/receipts.json). + +## Where receipts live + +- Cluster: Solana devnet. +- Memo program: `MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr` (SPL Memo v3). Readers also accept + `Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo` (SPL Memo v1) and + `Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH` (p-memo). +- Relayer: `CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx`. It pays the fee and signs the memo. + +## Memo text + +Compact JSON: UTF-8, no spaces, keys in exactly this order. + +```text +seal: {"p":"nosus-canary","v":1,"t":"seal","note":"0x<64 hex>","copies":N,"hash":"0x<64 hex>","exp":E} +open: {"p":"nosus-canary","v":1,"t":"open","note":"0x<64 hex>","copy":N,"tag":"0x<64 hex>"} +``` + +| Key | In | Value | Meaning | +| -------- | ---- | -------------------------- | ------------------------------------------------ | +| `p` | both | `"nosus-canary"` | Protocol name. | +| `v` | both | `1` | Format version. | +| `t` | both | `"seal"` or `"open"` | What happened. | +| `note` | both | bytes32 | Which note: `chainNoteId`. | +| `copies` | seal | integer | How many marked copies (2 to 100 today). | +| `hash` | seal | bytes32 | Commitment to every copy: `copiesHash`. | +| `exp` | seal | integer | Expiry in unix seconds. `0` means never. | +| `copy` | open | integer | Which copy was opened, counting from 0. | +| `tag` | open | bytes32 | Which device opened it, as a salted hash. | + +- bytes32 is `0x` followed by exactly 64 lowercase hex characters. +- Integers are non-negative, at most 2^53 − 1, with no sign, decimal point or exponent. +- A reader must reject any memo that is not byte for byte what an encoder would write. That + includes another key order, extra or missing keys, whitespace, uppercase hex and `3.0`. The + simple way: parse, check each field, encode again, and compare the strings. + +Real example (seal `2BHqnuCx…512bQ`, 217 bytes): + +```json +{"p":"nosus-canary","v":1,"t":"seal","note":"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40","copies":3,"hash":"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8","exp":1791634855} +``` + +## How each value is computed + +Every hash is SHA-256 over UTF-8 text, written as lowercase hex. `+` joins strings. Numbers are +written in decimal. + +| Value | Formula | Who can recompute it | +| --------------- | ------------------------------------------------------------------------ | ------------------------------------ | +| `note` | `"0x" + sha256("nosus-canary:" + noteId)` | Anyone with the note's UUID. | +| copy digest | `sha256(salt + ":" + index + ":" + copyText)` | The note owner. | +| `hash` | `"0x" + sha256(digest_0 + digest_1 + … + digest_n-1)`, no separator | The note owner. | +| device hash | `sha256("device:" + deviceId)` | The relayer. Never on chain. | +| `tag` | `"0x" + sha256(tagSalt + ":" + noteId + ":" + copy + ":" + deviceHash)` | The relayer only. | + +- `noteId` is the lowercase UUID in the reader link (`…/#/canary/?k=…`). +- `salt` is 32 random bytes as hex. It and every copy text stay on the owner's device. +- `tagSalt` is a secret the relayer holds. Without it a tag says nothing about the device. + +## What is never on chain + +- Note text and copy text, in any form other than the hashes above. +- Keys: the note's AES key (it stays in the link fragment), relayer keys, salts. +- Names: reader names and owner names. +- Device ids. Only the salted `tag` is written. + +## Size + +A seal memo is at most 219 bytes today (100 copies, a 10-digit `exp`). An open memo is at most 198 +bytes. Readers reject anything over 566 bytes, the most a single-signer memo transaction can +carry. + +## Verify a receipt by hand + +1. Open the transaction on Solana Explorer: + `https://explorer.solana.com/tx/?cluster=devnet`. +2. Check that it succeeded, that the program is a Memo program from the table above, and that + the signer is the relayer. +3. Read the memo text. Check it has the exact shape above. +4. Recompute `note` from the note's UUID and compare: + + ```sh + printf '%s' 'nosus-canary:' | sha256sum # macOS: shasum -a 256 + ``` + + Put `0x` in front of the result. It must equal `note`. +5. For a seal, check `copies` and `exp`. For an open, `copy` tells you which marked copy was + opened. + +Or let a CLI do steps 1 to 4: + +```sh +deno run --allow-net packages/receipts-ts/cli.ts --note +dart run bin/verify.dart --note # from packages/receipts-dart +``` + +## Versioning + +- `v` goes up on any field change: a field added, removed, renamed or moved, a new meaning, or a + new hash formula. +- A reader for one version rejects every other version. +- A new version gets new vectors in `packages/test-vectors`. Old vectors stay, so old receipts + still verify. +- Package versions (0.1.0 today) are separate from `v`. diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md new file mode 100644 index 0000000..36a6c9d --- /dev/null +++ b/docs/SELF_HOSTING.md @@ -0,0 +1,84 @@ +# Run your own backend and relayer + +You don't need this to try Canary: `flutter run` uses the hosted devnet backend. Follow these steps +only to run Canary on your own Supabase project with your own Solana relayer key. Devnet only. + +You need: the [Supabase CLI](https://supabase.com/docs/guides/cli), Deno 2.x, the +[Solana CLI](https://docs.solana.com/cli/install-solana-cli-tools) (for `solana-keygen`), and a +Supabase project you own. + +## 1. Link the project + +```bash +cd app +supabase link --project-ref +``` + +## 2. Create the tables + +Apply only the two Canary migrations. Don't run `supabase db push`: the rest of +`app/supabase/migrations/` belongs to the full NO SUS app. + +```bash +supabase db query --linked --file supabase/migrations/20260919090000_nosus_canary.sql +supabase db query --linked --file supabase/migrations/20261010090000_nosus_canary_solana.sql +``` + +## 3. Make and fund a devnet relayer key + +```bash +solana-keygen new --no-bip39-passphrase --outfile ~/.config/nosus/relayer-devnet.json +solana-keygen pubkey ~/.config/nosus/relayer-devnet.json +``` + +Fund that address with devnet SOL at https://faucet.solana.com. Each receipt costs 0.000005 SOL, +so 1 SOL covers about 200,000 receipts. Keep the key file outside the repo. + +## 4. Set the function secrets + +```bash +supabase secrets set --project-ref \ + CANARY_TAG_SALT="$(openssl rand -hex 32)" \ + SOLANA_RELAYER_KEYS="$(cat ~/.config/nosus/relayer-devnet.json | tr -d ' \n')" \ + SOLANA_RPC_URL=https://api.devnet.solana.com +``` + +`SOLANA_RELAYER_KEYS` takes up to 32 comma-separated keys, each a base58 secret key or a +`solana-keygen` JSON array. Every secret is listed in [../.env.example](../.env.example). + +## 5. Bundle and deploy the function + +The function imports `packages/receipts-ts`, which is outside `app/`, so bundle it into one file +first and deploy the bundle from a scratch folder: + +```bash +cd .. # repo root +mkdir -p /tmp/canary-deploy/supabase/functions/canary +deno bundle --platform=deno app/supabase/functions/canary/index.ts \ + -o /tmp/canary-deploy/supabase/functions/canary/index.ts +supabase functions deploy canary --project-ref \ + --no-verify-jwt --use-api --workdir /tmp/canary-deploy +``` + +`--no-verify-jwt` is required: callers send only the publishable key. + +## 6. Point the app at your project + +Set `url` and `anonKey` (your publishable key) in `app/lib/config/supabase_credentials.dart`, then: + +```bash +cd app +flutter run -d chrome --dart-define=NOSUS_CANARY_ONLY=true +# or a static build for any host, served under /nosus-canary/: +flutter build web --release --base-href /nosus-canary/ --dart-define=NOSUS_CANARY_ONLY=true +``` + +## 7. Check it end to end + +Create a link, open it in a private window, and click the **Solana** chip. Explorer must show one +Memo instruction whose text starts with `{"p":"nosus-canary","v":1,"t":"open"` and holds hashes +only. Or decode it from the terminal: + +```bash +deno run --allow-net packages/receipts-ts/cli.ts +``` diff --git a/docs/STATUS.md b/docs/STATUS.md index 3f2b57f..521d89d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,58 +1,46 @@ -# Project status - -## Current state - -Repository scaffolded on 16 September 2026. Gate T1 is **not** verified. Nothing is -deployed, no Supabase project is linked, and the product latch in -`web/src/lib/threshold.ts` remains `spike-required`. - -## Completed locally - -- `NoSusMonadDrops` records a digest, optional recipient, expiry, sender, and exactly one opener. - It has no owner, payment, key, salt, or plaintext path. `canDecrypt` is false before - acknowledgement, false for any non-opener, and false after expiry even if already opened. -- Contract tests: `6 passing (6 nodejs)` from `contracts/npm test`, including zero-address - denial and the exact-expiry boundary. -- The Next.js experience includes a restrained, flat black-and-white sender view, recipient acknowledgement route, - verifier, and backend-powered-but-empty Receipt Wall. It refuses to create or decrypt a drop - until Gate T1 is verified. The verifier can read `receiptOf` / `canDecrypt` from Monad when a - contract address is configured; that is a public view, not a Lit unlock. -- Lit SDK selection: `@lit-protocol/lit-client` **8.3.1**, `@lit-protocol/networks` **8.4.1**, - and `@lit-protocol/auth` **8.2.3** on **naga-dev**. Retired Datil/`lit-node-client` packages - are not direct dependencies. Nested packages still print retirement warnings at install time. -- The access-control condition uses Lit chain key `monadTestnet` (in - `@lit-protocol/accs-schemas`, chain ID 10143) and `canDecrypt(id, :userAddress)`. -- Gate T1 runner: `tool/threshold-spike` now prepares the sender, recipient, and stranger proof: - recipient denial before acknowledgement, sender seal, recipient acknowledgement and decrypt, - stranger denial, and an optional expiry denial. `npm run check` → 2 passing and `npm run dry-run` - passed. It has not connected to Lit or Monad, and no live wallet/decrypt result has been recorded. -- Web check: `web/npm run check` passed. -- Local visual review passed at `http://127.0.0.1:3017`. The UI uses local Geist assets and the - documented three-layer token system; production Burn components were not imported. -- Navigation check: `node tool/check-repository.mjs` passed. -- Initial isolated Supabase migration was created locally only; it has not been applied anywhere. -- Testnet deploys require `CONFIRM_MONAD_DEPLOY=1` after an explicit named-target approval. - -## Next blocking milestone - -Prove the threshold gate on Monad testnet using two wallets: - -1. An unopened wallet cannot decrypt. -2. The acknowledged wallet can decrypt. -3. A second wallet cannot decrypt. -4. An expired or already-opened drop cannot decrypt. - -Do not build demo claims, real drop creation, public deployment, or substitute a server gate ahead -of this result. See `LIT_MONAD_SPIKE.md` for the exact evidence to collect. The user must still -name a Monad Testnet deployment target and provide two controlled test wallets before a live run. - -## Deliberate decisions - -- Display brand: **NO SUS — Monad Experiment**. -- Separate repository and backend; no future production integration is implied. -- Open wallet connect is the primary demo flow; keep a prepared test wallet as an operator fallback. -- Demo material is limited to harmless personal test notes. -- Lit Protocol is the selected threshold-custody provider. Its Monad evaluation is a technical gate, - not an assumption. The `monadTestnet` ACC chain key is the identifier to try; a live decrypt - result is the only accepted proof. -- Datil / `@lit-protocol/lit-node-client` is retired (sunset 25 February 2026) and is not used. +# Status (20 Oct 2026) + +## Live + +- **Web app:** https://shubham-sahu.me/nosus-canary/ (gh-pages, standalone build + `NOSUS_CANARY_ONLY=true`, base href `/nosus-canary/`). `/nosus-canary/app/` redirects there and + keeps the hash, so older reader links still open. +- **Backend:** Supabase project `ffidvfguojalzpclipzi` (experiment project only; never the NO SUS + production project). It was paused for inactivity and restored on 20 Oct 2026. +- **Relayer:** edge function `canary`, version 7, deployed 20 Oct 2026 from `chore/judge-ready` + (pre-bundled with `deno bundle`, steps in [SELF_HOSTING.md](SELF_HOSTING.md)). It runs + `packages/receipts-ts`. Version 6 (10 Oct) was downloaded as a backup before the deploy. + Secrets set: `CANARY_TAG_SALT`, `SOLANA_RELAYER_KEYS`, `SOLANA_RPC_URL`. `CANARY_CONTRACT_ADDRESS` + is unset, which keeps the legacy EVM prototype path off. +- **Receipts:** Solana devnet, relayer `CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx`. 31 receipts + (19 seals, 12 opens, 0 failed) as of 20 Oct 2026. + +## Changed on 20 Oct 2026 + +- `packages/receipts-ts` and `packages/receipts-dart` (v0.1) and shared test vectors. The relayer's + `solana.ts` imports `packages/receipts-ts`; memo bytes are unchanged (checked against the real + devnet memos in `solana_test.ts`) and the deployed relayer was smoke-tested on devnet. +- Pitch and demo videos are attached to the `v0.1.0` GitHub release. +- Dead prototype code removed: the Solidity contracts, the Next.js threshold-decrypt spike and its + docs. History keeps them (last on `main` at `f1bb47e`). +- CI now runs Flutter analyze and tests, the fingerprint harness, Deno checks and tests for the + relayer and the TS library, and Dart checks and tests for the Dart library. + +## Owner approval needed for + +Deploying the function, applying SQL, setting secrets, funding keys or spending devnet SOL, +changing gh-pages, merging, pushing, and anything made public. + +## Rules worth repeating + +- Never run `supabase db push` against `ffidvfguojalzpclipzi`: its remote migration history holds + only the bootstrap entry, so `db push` would try to re-apply old migrations. Apply single files + with `supabase db query --linked --file …`. +- Deploy `canary` with `--no-verify-jwt`; callers send only the publishable key. +- Keep `CANARY_MONAD` (Flutter) off and `CANARY_CONTRACT_ADDRESS` (function) unset. +- Use harmless test notes only. + +## Next + +See the roadmap in the [README](../README.md#roadmap): mainnet, publishing the receipts library, +the Android app on the Solana dApp Store, PDFs and images, batched receipts, AES-GCM. diff --git a/docs/canary/VERIFICATION.md b/docs/canary/VERIFICATION.md new file mode 100644 index 0000000..4e73aa9 --- /dev/null +++ b/docs/canary/VERIFICATION.md @@ -0,0 +1,45 @@ +# Verification log + +Every check below was run on the owner's machine. "Live" rows touched the real devnet backend with +owner approval. + +## 20 Oct 2026 (branch `chore/judge-ready`) + +| Check | Command | Result | +|---|---|---| +| Static analysis | `flutter analyze` (from `app/`) | No issues found | +| Flutter tests | `flutter test` (from `app/`) | 145 passed, 1 skipped (live-network diagnostic `db_test.dart` removed; `supabase_service_test.dart` stays skipped) | +| Fingerprint harness | `dart run tool/canary_fingerprint_harness.dart` (from `app/`) | `FAILURES: 0` | +| Relayer type-check | `deno check index.ts` (from `app/supabase/functions/canary/`) | Clean | +| Relayer tests | `deno test --allow-env` (same folder) | `gating_test.ts` 7 passed, `solana_test.ts` 5 passed | +| Relayer bundle | `deno bundle --platform=deno app/supabase/functions/canary/index.ts -o /index.ts` | OK, 2.3 MB | +| TS library | `deno check`, `deno lint`, `deno fmt --check`, `deno test` (from `packages/receipts-ts/`) | All clean, 19 passed | +| Dart library | `dart analyze`, `dart format --set-exit-if-changed .`, `dart test` (from `packages/receipts-dart/`) | No issues, 16 passed | +| Secret scan | All 46 commits on 17 refs plus unreachable objects | No secrets; only the Supabase publishable key (public by design) | +| Live end to end | Scripted Chrome on https://shubham-sahu.me/nosus-canary/ at 1280×720: create (10 copies, 1 hour) → Priya and Ben open → dashboard → leak check → Explorer | Sealed in 2.9 s; leak check named "Priya · Copy 1 / 10"; receipts below | +| Relayer redeploy (version 7, uses `packages/receipts-ts`) | `supabase functions deploy canary --no-verify-jwt --use-api` from a bundle; then `create` → `open` → `status` against the live function with a throwaway 2-copy note | All 200; [seal](https://explorer.solana.com/tx/6raNrS3Lccq68vqbmyBJrQqKKMJkiZETc1j9ZLPdXcPr1x2M7qRxgdLem7VD4tsPt1g2WzigJoajioREutkPZ5f?cluster=devnet) and [open](https://explorer.solana.com/tx/3s7bXFakuoDbcuLz6BA13oAXqNmbj71QHuyR31g3m4Q2c3bybFp2Hro4ktTo7Nwq6eHau8z8G7SVK7dEW7AypU8p?cluster=devnet) decode with `cli.ts --note` (match) | + +Receipts from the live run: +[seal](https://explorer.solana.com/tx/QqdiqT84UfFyHNhpfLrK32HfqbUB5vF8qtC4DiAQ7wZZGn2MkUVJGGpVPpgmk4MCxVLtkhmZm6rbuBxqyuL11wb?cluster=devnet), +[Priya's open](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet), +[Ben's open](https://explorer.solana.com/tx/3tqhYUQ2BppvWh6dy8q5u7U23NpZ7FqfAnd34B58GTriGVZXtykcPj1i4sF7bNh4t8ShnhQokySNz7Uwh7GMTcT1?cluster=devnet). +Two earlier attempts the same day sealed a note each (one also recorded one open) before the +recording script failed; those three memos are harmless test data. + +## 10 Oct 2026 (Solana receipts) + +| Check | Result | +|---|---| +| Spike against devnet (`tool/solana-spike/send_memo.ts`) | Seal [`2BHqnuCx…`](https://explorer.solana.com/tx/2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ?cluster=devnet) and open [`NwoJauec…`](https://explorer.solana.com/tx/NwoJauec9otkicaGf8yD6UechYgUnXRdYPqnuc5Kpn6uMYym6XVJcMmWdbVnwnRVncKKVgEyuZB2mR3T5REvdSr?cluster=devnet), ~1.1 s each, 5,000 lamports, memo round trip OK | +| Migration `20261010090000_nosus_canary_solana.sql` on `ffidvfguojalzpclipzi` | Applied with `supabase db query --linked --file`; columns and the 7-column `canary_assign_copy` verified; `anon` has no execute | +| Live end to end | Create (10 copies) → 2 opens → dashboard chips → leak check named copy 1 → Explorer memo with hashes only | +| `flutter analyze` / `flutter test` | No issues / 153 passed, 1 skipped | +| `deno check index.ts` / `gating_test.ts` | Clean / 7 passed | + +## 19–22 Sep 2026 (Canary feature) + +| Check | Result | +|---|---| +| Fingerprint harness | `FAILURES: 0` (see the table in [SECURITY.md](../../SECURITY.md#threat-model)) | +| Web release build | `flutter build web --release --base-href /nosus-canary/` OK | +| Android release APK | Built (34.4 MB, package `foo.nosus.canary`, minSdk 24) | diff --git a/docs/media/canary-demo.gif b/docs/media/canary-demo.gif new file mode 100644 index 0000000..c7c8619 Binary files /dev/null and b/docs/media/canary-demo.gif differ diff --git a/docs/media/canary-demo.mp4 b/docs/media/canary-demo.mp4 new file mode 100644 index 0000000..5e8a93c Binary files /dev/null and b/docs/media/canary-demo.mp4 differ diff --git a/docs/media/compose.png b/docs/media/compose.png new file mode 100644 index 0000000..833b011 Binary files /dev/null and b/docs/media/compose.png differ diff --git a/docs/media/dashboard.png b/docs/media/dashboard.png new file mode 100644 index 0000000..09caf31 Binary files /dev/null and b/docs/media/dashboard.png differ diff --git a/docs/media/explorer-receipt.png b/docs/media/explorer-receipt.png new file mode 100644 index 0000000..98ec6a7 Binary files /dev/null and b/docs/media/explorer-receipt.png differ diff --git a/docs/media/landing.png b/docs/media/landing.png new file mode 100644 index 0000000..6d2ea1c Binary files /dev/null and b/docs/media/landing.png differ diff --git a/docs/media/leak-check.png b/docs/media/leak-check.png new file mode 100644 index 0000000..47c7448 Binary files /dev/null and b/docs/media/leak-check.png differ diff --git a/docs/media/reader-copy.png b/docs/media/reader-copy.png new file mode 100644 index 0000000..c674148 Binary files /dev/null and b/docs/media/reader-copy.png differ diff --git a/packages/receipts-dart/.gitignore b/packages/receipts-dart/.gitignore new file mode 100644 index 0000000..cb3e7cf --- /dev/null +++ b/packages/receipts-dart/.gitignore @@ -0,0 +1 @@ +.dart_tool/ diff --git a/packages/receipts-dart/README.md b/packages/receipts-dart/README.md new file mode 100644 index 0000000..cabb385 --- /dev/null +++ b/packages/receipts-dart/README.md @@ -0,0 +1,91 @@ +# nosus_receipts (Dart) + +Encode, parse and fetch NO SUS Canary receipts. Pure Dart; works in Flutter apps and on the +command line. + +NO SUS Canary gives each reader a uniquely marked copy of a note. Every seal and every open is +written as one SPL Memo transaction on Solana. That memo is the receipt. This package reads and +writes it, so anyone can check a receipt without trusting the NO SUS server. The format is in +[docs/RECEIPTS_SPEC.md](../../docs/RECEIPTS_SPEC.md). + +**Status:** v0.1 · devnet · not yet published to JSR/npm/pub.dev — use a path or git dependency. + +## Install + +Path, from a clone of this repo: + +```yaml +dependencies: + nosus_receipts: + path: ../packages/receipts-dart +``` + +Git: + +```yaml +dependencies: + nosus_receipts: + git: + url: https://github.com/https-shubhamsahu/nosus-canary.git + path: packages/receipts-dart + ref: +``` + +Dependencies: `crypto` and `http` only. Needs Dart 3.13 or newer. + +## Usage + +```dart +import 'package:nosus_receipts/nosus_receipts.dart'; + +final client = SolanaReceiptClient(SolanaCluster.devnet.rpcUrl); +final r = await client.fetch('2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ'); +client.close(); + +// Is this the receipt for my note? noteId is the UUID in the reader link. +final mine = r.receipt.note == chainNoteId('1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'); +final text = switch (r.receipt) { + SealReceipt(:final copies) => 'sealed, $copies copies', + OpenReceipt(:final copy) => 'copy $copy opened', +}; +print('$text ${explorerTxUrl(r.signature)}'); +``` + +- `SealReceipt` / `OpenReceipt`: `encode()` gives the memo text byte for byte as on chain. The + constructors throw `ReceiptFormatException` on a bad field. +- `ReceiptMemo.parse(memo)`: strict parse, or throws `ReceiptFormatException`. +- `chainNoteId`, `copyDigest`, `copiesHash`, `deviceHash`, `readerTag`: the hashes in the memo. +- `SolanaReceiptClient.fetch(signature)`: one `getTransaction` call. Throws + `ReceiptFetchException` when the transaction is missing, failed, or has no NO SUS memo. +- `explorerTxUrl(signature, cluster: ...)`: Solana Explorer link. + +Sending memos is not in this package. The relayer is TypeScript (`packages/receipts-ts`). + +## CLI + +From `packages/receipts-dart`: + +```sh +dart run bin/verify.dart [--cluster devnet|mainnet-beta] [--rpc URL] [--note ] +``` + +It prints the decoded receipt and its Explorer link. With `--note`, it also checks that the +memo's `note` equals `chainNoteId(uuid)`. Exit codes: 0 ok, 1 receipt error or note mismatch, 2 +bad usage. Same output as the TypeScript CLI. + +## Tests + +From `packages/receipts-dart`: + +```sh +dart pub get +dart analyze +dart format --set-exit-if-changed . +dart test # offline; uses ../test-vectors and saved devnet responses +``` + +The TypeScript package tests against the same vectors in `packages/test-vectors`. + +## License + +Apache-2.0. diff --git a/packages/receipts-dart/analysis_options.yaml b/packages/receipts-dart/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/packages/receipts-dart/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/packages/receipts-dart/bin/verify.dart b/packages/receipts-dart/bin/verify.dart new file mode 100644 index 0000000..56f6f1c --- /dev/null +++ b/packages/receipts-dart/bin/verify.dart @@ -0,0 +1,112 @@ +// Verify a NO SUS Canary receipt from the command line: +// dart run bin/verify.dart [--cluster devnet|mainnet-beta] [--rpc URL] [--note ] +// Prints the decoded receipt and its Explorer link. With --note, also checks +// that the memo's note equals sha256("nosus-canary:" + uuid); exit 1 if not. +// Exit codes: 0 ok, 1 receipt error or note mismatch, 2 bad usage. + +import 'dart:io'; + +import 'package:nosus_receipts/nosus_receipts.dart'; + +const _usage = + 'usage: dart run bin/verify.dart ' + '[--cluster devnet|mainnet-beta] [--rpc URL] [--note ]'; +final _uuid = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', +); + +Future main(List args) async => exitCode = await _run(args); + +String _time(int seconds) => DateTime.fromMillisecondsSinceEpoch( + seconds * 1000, + isUtc: true, +).toIso8601String().replaceFirst('.000Z', 'Z'); + +Future _run(List args) async { + final opts = {}; + final positional = []; + for (var i = 0; i < args.length; i++) { + final eq = args[i].indexOf('='); + final flag = eq < 0 ? args[i] : args[i].substring(0, eq); + if (flag == '-h' || flag == '--help') { + stdout.writeln(_usage); + return 0; + } + if (!flag.startsWith('-')) { + positional.add(args[i]); + continue; + } + if (!const ['--cluster', '--rpc', '--note'].contains(flag)) { + return _bad('unknown option $flag'); + } + final value = eq >= 0 + ? args[i].substring(eq + 1) + : (++i < args.length ? args[i] : null); + if (value == null) return _bad('$flag needs a value'); + opts[flag.substring(2)] = value; + } + if (positional.length != 1) { + return _bad('give exactly one transaction signature'); + } + final cluster = SolanaCluster.byId(opts['cluster'] ?? 'devnet'); + if (cluster == null) return _bad('--cluster must be devnet or mainnet-beta'); + final note = opts['note']?.toLowerCase(); + if (note != null && !_uuid.hasMatch(note)) { + return _bad('--note must be a note UUID'); + } + + final client = SolanaReceiptClient(opts['rpc'] ?? cluster.rpcUrl); + final FetchedReceipt r; + try { + r = await client.fetch(positional.single); + } on Exception catch (e) { + stderr.writeln( + 'error: ${switch (e) { + ReceiptFetchException(:final message) => message, + FormatException(:final message) => message, + _ => '$e', + }}', + ); + return 1; + } finally { + client.close(); + } + + final rc = r.receipt; + final rows = <(String, Object)>[ + ('signature', r.signature), + ('slot', r.slot), + ('time', r.blockTime == null ? 'unknown' : _time(r.blockTime!)), + ('fee payer', r.feePayer), + ('program', r.memoProgram), + ('note', rc.note), + ...switch (rc) { + SealReceipt(:final copies, :final hash, :final exp) => [ + ('copies', copies), + ('hash', hash), + ('expires', exp == 0 ? 'never (0)' : '${_time(exp)} ($exp)'), + ], + OpenReceipt(:final copy, :final tag) => [('copy', copy), ('tag', tag)], + }, + ('explorer', explorerTxUrl(r.signature, cluster: cluster)), + ]; + stdout.writeln('NO SUS Canary ${rc.type} receipt (format v$receiptVersion)'); + for (final (k, v) in rows) { + stdout.writeln(' ${k.padRight(10)} $v'); + } + + if (note != null) { + final expected = chainNoteId(note); + if (expected != rc.note) { + stderr.writeln('note check: MISMATCH. chainNoteId($note) is $expected'); + return 1; + } + stdout.writeln('note check: ok, sha256("nosus-canary:$note") matches'); + } + return 0; +} + +int _bad(String problem) { + stderr.writeln('$problem\n$_usage'); + return 2; +} diff --git a/packages/receipts-dart/lib/nosus_receipts.dart b/packages/receipts-dart/lib/nosus_receipts.dart new file mode 100644 index 0000000..ebbf80b --- /dev/null +++ b/packages/receipts-dart/lib/nosus_receipts.dart @@ -0,0 +1,8 @@ +/// NO SUS Canary receipts: encode, parse and fetch the SPL Memo that records +/// each seal and each open on Solana. Spec: docs/RECEIPTS_SPEC.md. +library; + +export 'src/client.dart'; +export 'src/explorer.dart'; +export 'src/ids.dart'; +export 'src/memo.dart'; diff --git a/packages/receipts-dart/lib/src/client.dart b/packages/receipts-dart/lib/src/client.dart new file mode 100644 index 0000000..fbb923b --- /dev/null +++ b/packages/receipts-dart/lib/src/client.dart @@ -0,0 +1,177 @@ +// Reads one receipt back from the chain with a plain JSON-RPC getTransaction +// call. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'memo.dart'; + +/// A receipt as read from the chain. +class FetchedReceipt { + const FetchedReceipt({ + required this.signature, + required this.slot, + required this.blockTime, + required this.feePayer, + required this.memoProgram, + required this.receipt, + }); + + final String signature; + final int slot; + + /// Unix seconds; null when the node does not know it. + final int? blockTime; + + /// First account key: the relayer that paid for and signed the memo. + final String feePayer; + final String memoProgram; + final ReceiptMemo receipt; +} + +/// The transaction could not be read, or carries no NO SUS Canary receipt. +class ReceiptFetchException implements Exception { + const ReceiptFetchException(this.message); + + final String message; + + @override + String toString() => 'ReceiptFetchException: $message'; +} + +final _signature = RegExp(r'^[1-9A-HJ-NP-Za-km-z]{64,88}$'); +const _base58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +class SolanaReceiptClient { + /// [client] is closed by [close] only when this class created it. + SolanaReceiptClient(this.rpcUrl, {http.Client? client}) + : _client = client ?? http.Client(), + _ownsClient = client == null; + + final String rpcUrl; + final http.Client _client; + final bool _ownsClient; + + /// Fetches a transaction and decodes its NO SUS Canary memo. Throws + /// [ReceiptFetchException] when the transaction is missing or failed, has + /// no memo or a foreign memo, and [ReceiptFormatException] when the memo is + /// not exactly format v1. + Future fetch(String signature) async { + if (!_signature.hasMatch(signature)) { + throw const ReceiptFetchException( + 'signature must be a base58 transaction signature', + ); + } + final res = await _client.post( + Uri.parse(rpcUrl), + headers: {'content-type': 'application/json'}, + body: jsonEncode({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'getTransaction', + 'params': [ + signature, + { + 'encoding': 'jsonParsed', + 'maxSupportedTransactionVersion': 0, + 'commitment': 'confirmed', + }, + ], + }), + ); + if (res.statusCode < 200 || res.statusCode > 299) { + throw ReceiptFetchException( + 'getTransaction failed: HTTP ${res.statusCode}', + ); + } + final body = jsonDecode(utf8.decode(res.bodyBytes)) as Map; + final error = body['error']; + if (error != null) { + final message = error is Map ? error['message'] : null; + throw ReceiptFetchException( + 'getTransaction failed: ${message ?? 'RPC error'}', + ); + } + + final tx = body['result'] as Map?; + if (tx == null) { + throw ReceiptFetchException( + 'transaction $signature not found on this cluster', + ); + } + final meta = tx['meta'] as Map?; + if (meta?['err'] != null) { + throw ReceiptFetchException('transaction $signature failed on chain'); + } + + final message = + (tx['transaction'] as Map)['message'] + as Map; + final memos = [ + for (final ix in (message['instructions'] as List).cast()) + if (memoProgramIds.contains(ix['programId'])) + (program: ix['programId'] as String, text: _memoText(ix)), + ]; + if (memos.isEmpty) { + throw ReceiptFetchException( + 'transaction $signature has no memo instruction', + ); + } + final ours = memos + .where((m) => m.text.startsWith('{"p":"$receiptProtocol",')) + .toList(); + if (ours.isEmpty) { + throw ReceiptFetchException( + 'transaction $signature memo is not a NO SUS Canary receipt', + ); + } + if (ours.length > 1) { + throw ReceiptFetchException( + 'transaction $signature has more than one NO SUS Canary memo', + ); + } + + final keys = message['accountKeys'] as List; + return FetchedReceipt( + signature: signature, + slot: tx['slot'] as int, + blockTime: tx['blockTime'] as int?, + feePayer: keys.isEmpty ? '' : (keys.first as Map)['pubkey'] as String, + memoProgram: ours.single.program, + receipt: ReceiptMemo.parse(ours.single.text), + ); + } + + void close() { + if (_ownsClient) _client.close(); + } +} + +// RPC nodes decode SPL Memo v1/v3 into `parsed`; other memo programs (p-memo) +// come back as base58 `data`. +String _memoText(Map ix) { + final parsed = ix['parsed']; + if (parsed is String) return parsed; + final data = ix['data']; + if (data is String) { + return utf8.decode(_base58Decode(data), allowMalformed: true); + } + return ''; +} + +List _base58Decode(String text) { + var n = BigInt.zero; + for (final c in text.split('')) { + final digit = _base58.indexOf(c); + if (digit < 0) throw const ReceiptFetchException('memo data is not base58'); + n = n * BigInt.from(58) + BigInt.from(digit); + } + final bytes = []; + for (; n > BigInt.zero; n = n >> 8) { + bytes.add((n & BigInt.from(0xff)).toInt()); + } + // Each leading "1" is a leading zero byte. + final zeros = text.length - text.replaceFirst(RegExp('^1+'), '').length; + return [...List.filled(zeros, 0), ...bytes.reversed]; +} diff --git a/packages/receipts-dart/lib/src/explorer.dart b/packages/receipts-dart/lib/src/explorer.dart new file mode 100644 index 0000000..6d76ca4 --- /dev/null +++ b/packages/receipts-dart/lib/src/explorer.dart @@ -0,0 +1,27 @@ +enum SolanaCluster { + devnet('devnet', 'https://api.devnet.solana.com'), + mainnetBeta('mainnet-beta', 'https://api.mainnet-beta.solana.com'); + + const SolanaCluster(this.id, this.rpcUrl); + + /// The name Solana tools use: 'devnet' or 'mainnet-beta'. + final String id; + + /// Default public RPC endpoint. + final String rpcUrl; + + /// Null for an unknown id. + static SolanaCluster? byId(String id) => + values.where((c) => c.id == id).firstOrNull; +} + +/// Solana Explorer page for a transaction. Mainnet takes no cluster param. +String explorerTxUrl( + String signature, { + SolanaCluster cluster = SolanaCluster.devnet, +}) { + final base = 'https://explorer.solana.com/tx/$signature'; + return cluster == SolanaCluster.mainnetBeta + ? base + : '$base?cluster=${cluster.id}'; +} diff --git a/packages/receipts-dart/lib/src/ids.dart b/packages/receipts-dart/lib/src/ids.dart new file mode 100644 index 0000000..233f11f --- /dev/null +++ b/packages/receipts-dart/lib/src/ids.dart @@ -0,0 +1,36 @@ +// The sha256-based ids and hashes a receipt carries. Inputs are hashed as +// UTF-8 text; outputs are lowercase hex. Same formulas as the Canary app and +// the edge function, so anyone can recompute them. + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +String _sha256Hex(String text) => sha256.convert(utf8.encode(text)).toString(); + +/// The memo `note`: "0x" + sha256("nosus-canary:" + noteId). noteId is a +/// lowercase UUID. +String chainNoteId(String noteId) => '0x${_sha256Hex('nosus-canary:$noteId')}'; + +/// sha256("device:" + deviceId), no 0x. Only ever stored or hashed again, +/// never on chain. +String deviceHash(String deviceId) => _sha256Hex('device:$deviceId'); + +/// The open memo `tag`: +/// `"0x" + sha256(":::")`. +/// tagSalt is the relayer's secret, so only the relayer can recompute it. +String readerTag({ + required String tagSalt, + required String noteId, + required int copyIndex, + required String deviceHash, +}) => '0x${_sha256Hex('$tagSalt:$noteId:$copyIndex:$deviceHash')}'; + +/// Commitment to one copy: `sha256("::")`, no 0x. +String copyDigest(String saltHex, int index, String copyText) => + _sha256Hex('$saltHex:$index:$copyText'); + +/// The seal memo `hash`: "0x" + sha256 of the copy digests joined in copy +/// order. +String copiesHash(List copyDigests) => + '0x${_sha256Hex(copyDigests.join())}'; diff --git a/packages/receipts-dart/lib/src/memo.dart b/packages/receipts-dart/lib/src/memo.dart new file mode 100644 index 0000000..26e7c49 --- /dev/null +++ b/packages/receipts-dart/lib/src/memo.dart @@ -0,0 +1,192 @@ +// NO SUS Canary receipt memo, format v1 (spec: docs/RECEIPTS_SPEC.md). +// +// One SPL Memo per seal and per open. The memo is compact JSON with a fixed +// key order, so the same receipt is always the same bytes: +// {"p":"nosus-canary","v":1,"t":"seal","note":"0x…","copies":N,"hash":"0x…","exp":E} +// {"p":"nosus-canary","v":1,"t":"open","note":"0x…","copy":N,"tag":"0x…"} + +import 'dart:convert'; + +const receiptProtocol = 'nosus-canary'; +const receiptVersion = 1; + +/// Longest memo a single-signer transaction can carry. +const maxMemoBytes = 566; + +/// SPL Memo v3 (Solana Explorer shows the memo text). +const defaultMemoProgram = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'; + +/// Memo programs a receipt may be written with: SPL Memo v1, v3 and p-memo. +const memoProgramIds = [ + 'Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo', + defaultMemoProgram, + 'Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH', +]; + +/// A memo or memo field that is not exactly format v1. +class ReceiptFormatException extends FormatException { + const ReceiptFormatException(super.message); + + @override + String toString() => 'ReceiptFormatException: $message'; +} + +final _bytes32 = RegExp(r'^0x[0-9a-f]{64}$'); +const _maxSafeInteger = 9007199254740991; +const _sealKeys = 'p,v,t,note,copies,hash,exp'; +const _openKeys = 'p,v,t,note,copy,tag'; + +String _checkBytes32(String name, Object? value) { + if (value is String && _bytes32.hasMatch(value)) return value; + throw ReceiptFormatException('memo $name must be 0x + 64 hex chars'); +} + +// Safe integers only (2^53 - 1, as in JavaScript), so every encoded memo +// parses back to the same bytes in both packages. 3.0 passes here and is +// rejected later as not canonical, exactly like the TypeScript parser. +int _checkUint(String name, Object? value) { + if (value is num && + value >= 0 && + value <= _maxSafeInteger && + value == value.truncate()) { + return value.toInt(); + } + throw ReceiptFormatException('memo $name must be a non-negative integer'); +} + +/// One receipt: a [SealReceipt] or an [OpenReceipt]. +sealed class ReceiptMemo { + const ReceiptMemo(); + + /// Strict parse: accepts only the exact bytes [encode] would write. Throws + /// [ReceiptFormatException] otherwise. + factory ReceiptMemo.parse(String memo) { + if (utf8.encode(memo).length > maxMemoBytes) { + throw const ReceiptFormatException( + 'memo is longer than $maxMemoBytes bytes', + ); + } + final Object? m; + try { + m = jsonDecode(memo); + } on FormatException { + throw const ReceiptFormatException('memo is not JSON'); + } + if (m is! Map) { + throw const ReceiptFormatException('memo is not a JSON object'); + } + if (m['p'] != receiptProtocol) { + throw const ReceiptFormatException('memo p must be "$receiptProtocol"'); + } + if (m['v'] != receiptVersion) { + throw const ReceiptFormatException('memo v must be $receiptVersion'); + } + final t = m['t']; + if (t != 'seal' && t != 'open') { + throw const ReceiptFormatException('memo t must be "seal" or "open"'); + } + + final keys = t == 'seal' ? _sealKeys : _openKeys; + if (m.keys.join(',') != keys) { + throw ReceiptFormatException('memo keys must be $keys in this order'); + } + + final ReceiptMemo receipt = t == 'seal' + ? SealReceipt( + note: _checkBytes32('note', m['note']), + copies: _checkUint('copies', m['copies']), + hash: _checkBytes32('hash', m['hash']), + exp: _checkUint('exp', m['exp']), + ) + : OpenReceipt( + note: _checkBytes32('note', m['note']), + copy: _checkUint('copy', m['copy']), + tag: _checkBytes32('tag', m['tag']), + ); + // Catches whitespace, 3.0 / 3e0, duplicate keys and anything else that + // jsonDecode forgives. + if (receipt.encode() != memo) { + throw const ReceiptFormatException('memo is not in canonical form'); + } + return receipt; + } + + /// 'seal' or 'open', the memo's `t`. + String get type; + + /// The memo `note`: [chainNoteId] of the note. + String get note; + + /// The exact memo text written on chain. + String encode(); +} + +/// A sealed note: [copies] marked copies committed to by [hash]. +final class SealReceipt extends ReceiptMemo { + /// Throws [ReceiptFormatException] on a bad field. + SealReceipt({ + required this.note, + required this.copies, + required this.hash, + required this.exp, + }) { + _checkBytes32('note', note); + _checkUint('copies', copies); + _checkBytes32('hash', hash); + _checkUint('exp', exp); + } + + @override + final String note; + final int copies; + + /// [copiesHash] of the copy digests. + final String hash; + + /// Unix seconds; 0 when the note never expires. + final int exp; + + @override + String get type => 'seal'; + + @override + String encode() => jsonEncode({ + 'p': receiptProtocol, + 'v': receiptVersion, + 't': 'seal', + 'note': note, + 'copies': copies, + 'hash': hash, + 'exp': exp, + }); +} + +/// One opened copy: copy number [copy], reader [tag]. +final class OpenReceipt extends ReceiptMemo { + /// Throws [ReceiptFormatException] on a bad field. + OpenReceipt({required this.note, required this.copy, required this.tag}) { + _checkBytes32('note', note); + _checkUint('copy', copy); + _checkBytes32('tag', tag); + } + + @override + final String note; + final int copy; + + /// [readerTag] for the device that opened the copy. + final String tag; + + @override + String get type => 'open'; + + @override + String encode() => jsonEncode({ + 'p': receiptProtocol, + 'v': receiptVersion, + 't': 'open', + 'note': note, + 'copy': copy, + 'tag': tag, + }); +} diff --git a/packages/receipts-dart/pubspec.lock b/packages/receipts-dart/pubspec.lock new file mode 100644 index 0000000..3b86631 --- /dev/null +++ b/packages/receipts-dart/pubspec.lock @@ -0,0 +1,413 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "48e48f73a431eb269eec66d243a63a07ed90ef9417a74047a3b98d52338626ad" + url: "https://pub.dev" + source: hosted + version: "109.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6d377901d198536b648172974d9d4f277910502cad92193466869cac0ff34990" + url: "https://pub.dev" + source: hosted + version: "14.5.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: "89b8801ba49f8b593fbf2d3e59efd0f566c46cd4ba6837ae2610c72b52a0f899" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: ff89e37c19355194e279d26dafe8e4bece46c9ea63a30be4dc68214f5bc290d3 + url: "https://pub.dev" + source: hosted + version: "1.16.0" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "404dbf9bce29beafbe9dcb19219447b6f7d49900cd42b8d3b7af102b24508e25" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48 + url: "https://pub.dev" + source: hosted + version: "1.4.3" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: "480504af9e5323899c5be26a4f6c2fc4437d50954d39842f0290f4ae733d57b4" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "44b2fccf4407bcdb5e105c064c5d6b5e2a640c87761f8b03cdce8cf532d98cfa" + url: "https://pub.dev" + source: hosted + version: "1.33.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "47481c58bb92d0b1d218f0510ff5647e768f8e1256d07077a151fb520923b51d" + url: "https://pub.dev" + source: hosted + version: "0.7.15" + test_core: + dependency: transitive + description: + name: test_core + sha256: "9dec4c178bd61493e1cd2060ffe701fcb61c43da2493e99590fb1b0cc82eb4af" + url: "https://pub.dev" + source: hosted + version: "0.6.21" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.0 <4.0.0" diff --git a/packages/receipts-dart/pubspec.yaml b/packages/receipts-dart/pubspec.yaml new file mode 100644 index 0000000..f46a831 --- /dev/null +++ b/packages/receipts-dart/pubspec.yaml @@ -0,0 +1,17 @@ +name: nosus_receipts +description: >- + Encode, parse and fetch NO SUS Canary receipts: the SPL Memo that records + each seal and each open on Solana. +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.13.0 + +dependencies: + crypto: ^3.0.6 + http: ^1.2.0 + +dev_dependencies: + lints: ^6.1.0 + test: ^1.31.1 diff --git a/packages/receipts-dart/test/client_test.dart b/packages/receipts-dart/test/client_test.dart new file mode 100644 index 0000000..6759f18 --- /dev/null +++ b/packages/receipts-dart/test/client_test.dart @@ -0,0 +1,174 @@ +// SolanaReceiptClient against saved devnet getTransaction responses. Offline: +// the http client is a MockClient and never reaches the network. +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nosus_receipts/nosus_receipts.dart'; +import 'package:test/test.dart'; + +import 'vectors.dart'; + +const _rpc = 'http://rpc.test'; + +/// Answers every request with [body] and records the request bodies. +(SolanaReceiptClient, List) _client(Object? body, {int status = 200}) { + final requests = []; + final mock = MockClient((req) async { + expect(req.url.toString(), _rpc); + requests.add(jsonDecode(req.body)); + return http.Response(jsonEncode(body), status); + }); + return (SolanaReceiptClient(_rpc, client: mock), requests); +} + +Map get _seal => + readVector((section('devnet').first)['fixture'] as String); + +/// A copy of the seal fixture with its single instruction replaced. +Map _withInstruction(Object ix) { + final copy = _seal; + ((copy['result'] as Map)['transaction'] as Map)['message']['instructions'] = [ + ix, + ]; + return copy; +} + +Matcher _fails(String message) => + throwsA(isA().having((e) => '$e', 'message', contains(message))); + +void main() { + final sig = section('devnet').first['signature'] as String; + + test('fetch decodes every real devnet receipt', () async { + for (final v in section('devnet')) { + final (client, _) = _client(readVector(v['fixture'] as String)); + final r = await client.fetch(v['signature'] as String); + expect(r.signature, v['signature']); + expect(r.slot, v['slot']); + expect(r.blockTime, v['blockTime']); + expect(r.feePayer, v['feePayer']); + expect(r.memoProgram, v['memoProgram']); + expect(r.receipt.encode(), v['memo']); + } + }); + + test('fetch sends one jsonParsed getTransaction at confirmed', () async { + final (client, requests) = _client(_seal); + await client.fetch(sig); + expect(requests, [ + { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'getTransaction', + 'params': [ + sig, + { + 'encoding': 'jsonParsed', + 'maxSupportedTransactionVersion': 0, + 'commitment': 'confirmed', + }, + ], + }, + ]); + }); + + test( + 'fetch reads base58 data from memo programs the node does not parse', + () async { + final raw = vectors['rawMemoInstruction'] as Map; + final (client, _) = _client( + _withInstruction({ + 'accounts': [section('devnet').first['feePayer']], + 'data': raw['data'], + 'programId': raw['programId'], + 'stackHeight': 1, + }), + ); + final r = await client.fetch(sig); + expect(r.memoProgram, raw['programId']); + expect(r.receipt.encode(), raw['memo']); + }, + ); + + test('fetch errors', () async { + final memoIx = Map.from( + (((_seal['result'] as Map)['transaction'] as Map)['message'] + as Map)['instructions'][0] + as Map, + ); + final failed = _seal; + (failed['result'] as Map)['meta']['err'] = { + 'InstructionError': [0, 'Custom'], + }; + final twice = _seal; + ((twice['result'] as Map)['transaction'] as Map)['message']['instructions'] + .add(memoIx); + final v2 = section('invalidMemos').firstWhere((v) => v['name'] == 'v=2'); + + final cases = <(Object?, int, Matcher)>[ + ( + {'jsonrpc': '2.0', 'result': null, 'id': 1}, + 200, + _fails( + 'transaction $sig not found on this cluster', + ), + ), + ( + { + 'jsonrpc': '2.0', + 'error': {'code': -32602, 'message': 'Invalid param'}, + 'id': 1, + }, + 200, + _fails('getTransaction failed: Invalid param'), + ), + ({}, 503, _fails('HTTP 503')), + ( + failed, + 200, + _fails('transaction $sig failed on chain'), + ), + ( + _withInstruction({ + 'parsed': {'type': 'transfer', 'info': {}}, + 'program': 'system', + 'programId': '11111111111111111111111111111111', + }), + 200, + _fails( + 'transaction $sig has no memo instruction', + ), + ), + ( + _withInstruction({...memoIx, 'parsed': 'gm'}), + 200, + _fails( + 'transaction $sig memo is not a NO SUS Canary receipt', + ), + ), + ( + twice, + 200, + _fails('more than one NO SUS Canary memo'), + ), + ( + _withInstruction({...memoIx, 'parsed': v2['memo']}), + 200, + _fails(v2['error'] as String), + ), + ]; + for (final (body, status, matcher) in cases) { + final (client, _) = _client(body, status: status); + await expectLater(client.fetch(sig), matcher); + } + + final (client, _) = _client({}); + await expectLater( + client.fetch('not a signature!'), + _fails( + 'signature must be a base58 transaction signature', + ), + ); + }); +} diff --git a/packages/receipts-dart/test/ids_test.dart b/packages/receipts-dart/test/ids_test.dart new file mode 100644 index 0000000..8f9416d --- /dev/null +++ b/packages/receipts-dart/test/ids_test.dart @@ -0,0 +1,66 @@ +import 'package:nosus_receipts/nosus_receipts.dart'; +import 'package:test/test.dart'; + +import 'vectors.dart'; + +void main() { + test('chainNoteId', () { + for (final v in section('chainNoteId')) { + expect(chainNoteId(v['noteId'] as String), v['expected']); + } + }); + + test('deviceHash', () { + for (final v in section('deviceHash')) { + expect(deviceHash(v['deviceId'] as String), v['expected']); + } + }); + + test('copyDigest hashes the copy text as UTF-8', () { + for (final v in section('copyDigest')) { + expect( + copyDigest( + v['saltHex'] as String, + v['index'] as int, + v['copyText'] as String, + ), + v['expected'], + ); + } + }); + + test('copiesHash', () { + for (final v in section('copiesHash')) { + expect(copiesHash((v['digests'] as List).cast()), v['expected']); + } + }); + + test('readerTag', () { + for (final v in section('readerTag')) { + expect( + readerTag( + tagSalt: v['tagSalt'] as String, + noteId: v['noteId'] as String, + copyIndex: v['copyIndex'] as int, + deviceHash: v['deviceHash'] as String, + ), + v['expected'], + ); + } + }); + + test('explorerTxUrl: devnet has a cluster param, mainnet has none', () { + for (final v in section('explorerTxUrl')) { + final cluster = SolanaCluster.byId(v['cluster'] as String)!; + expect( + explorerTxUrl(v['signature'] as String, cluster: cluster), + v['expected'], + ); + } + expect( + explorerTxUrl('abc'), + 'https://explorer.solana.com/tx/abc?cluster=devnet', + ); + expect(SolanaCluster.byId('testnet'), isNull); + }); +} diff --git a/packages/receipts-dart/test/memo_test.dart b/packages/receipts-dart/test/memo_test.dart new file mode 100644 index 0000000..d754532 --- /dev/null +++ b/packages/receipts-dart/test/memo_test.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +import 'package:nosus_receipts/nosus_receipts.dart'; +import 'package:test/test.dart'; + +import 'vectors.dart'; + +SealReceipt _seal(Map i) => SealReceipt( + note: i['note'] as String, + copies: i['copies'] as int, + hash: i['hash'] as String, + exp: i['exp'] as int, +); + +OpenReceipt _open(Map i) => OpenReceipt( + note: i['note'] as String, + copy: i['copy'] as int, + tag: i['tag'] as String, +); + +void main() { + test('seal memos encode to the exact vector bytes and parse back', () { + for (final v in section('sealMemo')) { + final input = v['input'] as Map; + expect(_seal(input).encode(), v['memo'], reason: v['name'] as String); + final parsed = ReceiptMemo.parse(v['memo'] as String) as SealReceipt; + expect( + [parsed.type, parsed.note, parsed.copies, parsed.hash, parsed.exp], + ['seal', input['note'], input['copies'], input['hash'], input['exp']], + ); + } + }); + + test('open memos encode to the exact vector bytes and parse back', () { + for (final v in section('openMemo')) { + final input = v['input'] as Map; + expect(_open(input).encode(), v['memo'], reason: v['name'] as String); + final parsed = ReceiptMemo.parse(v['memo'] as String) as OpenReceipt; + expect( + [parsed.type, parsed.note, parsed.copy, parsed.tag], + ['open', input['note'], input['copy'], input['tag']], + ); + } + }); + + test('the largest memo today is 219 bytes', () { + final sizes = [ + ...section('sealMemo'), + ...section('openMemo'), + ].map((v) => utf8.encode(v['memo'] as String).length); + expect(sizes.reduce((a, b) => a > b ? a : b), 219); + expect(maxMemoBytes, vectors['maxMemoBytes']); + }); + + test('constructors reject bad fields with the shared messages', () { + for (final v in section('encodeErrors')) { + final input = v['input'] as Map; + expect( + () => v['kind'] == 'seal' ? _seal(input) : _open(input), + throwsA( + isA().having( + (e) => e.message, + 'message', + v['error'], + ), + ), + reason: v['name'] as String, + ); + } + }); + + test('parse rejects every invalid vector with the shared message', () { + for (final v in section('invalidMemos')) { + expect( + () => ReceiptMemo.parse(v['memo'] as String), + throwsA( + isA().having( + (e) => e.message, + 'message', + v['error'], + ), + ), + reason: v['name'] as String, + ); + } + }); + + test('memo program ids', () { + expect(memoProgramIds, contains(defaultMemoProgram)); + expect( + memoProgramIds, + contains((vectors['rawMemoInstruction'] as Map)['programId']), + ); + }); +} diff --git a/packages/receipts-dart/test/vectors.dart b/packages/receipts-dart/test/vectors.dart new file mode 100644 index 0000000..11a5b2b --- /dev/null +++ b/packages/receipts-dart/test/vectors.dart @@ -0,0 +1,13 @@ +import 'dart:convert'; +import 'dart:io'; + +/// The shared vectors in packages/test-vectors. `dart test` runs from the +/// package root. +final Map vectors = readVector('receipts.json'); + +Map readVector(String path) => + jsonDecode(File('../test-vectors/$path').readAsStringSync()) + as Map; + +List> section(String name) => + (vectors[name] as List).cast>(); diff --git a/packages/receipts-ts/README.md b/packages/receipts-ts/README.md new file mode 100644 index 0000000..66bec65 --- /dev/null +++ b/packages/receipts-ts/README.md @@ -0,0 +1,82 @@ +# @nosus/receipts (TypeScript) + +Encode, parse, send and fetch NO SUS Canary receipts. + +NO SUS Canary gives each reader a uniquely marked copy of a note. Every seal and every open is +written as one SPL Memo transaction on Solana. That memo is the receipt. This package reads and +writes it, so anyone can check a receipt without trusting the NO SUS server. The format is in +[docs/RECEIPTS_SPEC.md](../../docs/RECEIPTS_SPEC.md). + +**Status:** v0.1 · devnet · not yet published to JSR/npm/pub.dev — use a path or git dependency. + +## Install + +Path, from a clone of this repo: + +```ts +import { fetchReceipt } from "./packages/receipts-ts/mod.ts"; +``` + +Git, pinned to a commit: + +```ts +import { fetchReceipt } from "https://raw.githubusercontent.com/https-shubhamsahu/nosus-canary//packages/receipts-ts/mod.ts"; +``` + +Dependencies are pinned inline (`npm:@solana/kit@8.4.0`, `npm:@solana-program/memo@0.15.0`), so no +import map is needed. Only `sendMemo` uses them. `@solana/kit` pulls in `ws`, which reads +`WS_NO_BUFFER_UTIL` and `WS_NO_UTF_8_VALIDATE` when it loads, so a program that imports `mod.ts` +needs `--allow-env` for those two names. + +## Usage + +```ts +import { chainNoteId, encodeOpenMemo, explorerTxUrl, fetchReceipt } from "./mod.ts"; + +const sig = + "2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ"; +const { receipt, feePayer } = await fetchReceipt(sig); // devnet unless you pass rpcUrl +console.log(receipt.t, receipt.note, feePayer, explorerTxUrl(sig)); + +// Is this the receipt for my note? noteId is the UUID in the reader link. +const mine = receipt.note === await chainNoteId("1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"); + +// Build a memo. Throws ReceiptFormatError on a bad field. +const memo = encodeOpenMemo({ note: receipt.note, copy: 0, tag: "0x" + "ab".repeat(32) }); +``` + +| Export | What it does | +| -------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `encodeSealMemo`, `encodeOpenMemo` | Memo text, byte for byte as on chain. | +| `parseReceiptMemo` | Strict parse to `SealReceipt` or `OpenReceipt`, or throws. | +| `chainNoteId`, `copyDigest`, `copiesHash`, `deviceHash`, `readerTag` | The hashes in the memo (async, WebCrypto). | +| `fetchReceipt` | One `getTransaction` call; returns slot, time, fee payer, receipt. | +| `explorerTxUrl` | Solana Explorer link for a signature. | +| `parseRelayerKeys`, `sendMemo` | Relayer side: parse keys and send a memo. Needs funded devnet keys. | + +## CLI + +```sh +deno run --allow-net packages/receipts-ts/cli.ts [--cluster devnet|mainnet-beta] [--rpc URL] [--note ] +``` + +It prints the decoded receipt and its Explorer link. With `--note`, it also checks that the memo's +`note` equals `chainNoteId(uuid)`. Exit codes: 0 ok, 1 receipt error or note mismatch, 2 bad usage. +The CLI does not load `@solana/kit`, so `--allow-net` is the only permission. + +## Tests + +From `packages/receipts-ts`: + +```sh +deno test # offline; uses ../test-vectors and saved devnet responses +deno check +deno lint +deno fmt --check +``` + +The Dart package tests against the same vectors in `packages/test-vectors`. + +## License + +Apache-2.0. diff --git a/packages/receipts-ts/cli.ts b/packages/receipts-ts/cli.ts new file mode 100644 index 0000000..6826504 --- /dev/null +++ b/packages/receipts-ts/cli.ts @@ -0,0 +1,89 @@ +// Verify a NO SUS Canary receipt from the command line: +// deno run --allow-net cli.ts [--cluster devnet|mainnet-beta] [--rpc URL] [--note ] +// Prints the decoded receipt and its Explorer link. With --note, also checks +// that the memo's note equals sha256("nosus-canary:" + uuid); exit 1 if not. +// Exit codes: 0 ok, 1 receipt error or note mismatch, 2 bad usage. + +import { chainNoteId } from "./src/ids.ts"; +import { type Cluster, DEFAULT_RPC_URLS, explorerTxUrl } from "./src/explorer.ts"; +import { type FetchedReceipt, fetchReceipt } from "./src/fetch.ts"; + +const USAGE = + "usage: cli.ts [--cluster devnet|mainnet-beta] [--rpc URL] [--note ]"; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +function time(seconds: number): string { + return new Date(seconds * 1000).toISOString().replace(".000Z", "Z"); +} + +async function main(args: string[]): Promise { + const opts: Record = {}; + const positional: string[] = []; + for (let i = 0; i < args.length; i++) { + const [flag, inline] = args[i].split(/=(.*)/s); + if (flag === "-h" || flag === "--help") { + console.log(USAGE); + return 0; + } + if (!flag.startsWith("-")) { + positional.push(args[i]); + continue; + } + if (!["--cluster", "--rpc", "--note"].includes(flag)) return usage(`unknown option ${flag}`); + const value = inline ?? args[++i]; + if (value === undefined) return usage(`${flag} needs a value`); + opts[flag.slice(2)] = value; + } + if (positional.length !== 1) return usage("give exactly one transaction signature"); + const cluster = (opts.cluster ?? "devnet") as Cluster; + if (!Object.hasOwn(DEFAULT_RPC_URLS, cluster)) { + return usage("--cluster must be devnet or mainnet-beta"); + } + const note = opts.note?.toLowerCase(); + if (note !== undefined && !UUID_RE.test(note)) return usage("--note must be a note UUID"); + + const signature = positional[0]; + let r: FetchedReceipt; + try { + r = await fetchReceipt(signature, { rpcUrl: opts.rpc ?? DEFAULT_RPC_URLS[cluster] }); + } catch (e) { + console.error(`error: ${e instanceof Error ? e.message : String(e)}`); + return 1; + } + + const rc = r.receipt; + const rows: [string, string | number][] = [ + ["signature", r.signature], + ["slot", r.slot], + ["time", r.blockTime === null ? "unknown" : time(r.blockTime)], + ["fee payer", r.feePayer], + ["program", r.memoProgram], + ["note", rc.note], + ]; + if (rc.t === "seal") { + rows.push(["copies", rc.copies], ["hash", rc.hash]); + rows.push(["expires", rc.exp === 0 ? "never (0)" : `${time(rc.exp)} (${rc.exp})`]); + } else { + rows.push(["copy", rc.copy], ["tag", rc.tag]); + } + rows.push(["explorer", explorerTxUrl(r.signature, cluster)]); + console.log(`NO SUS Canary ${rc.t} receipt (format v${rc.v})`); + for (const [k, v] of rows) console.log(` ${k.padEnd(10)} ${v}`); + + if (note !== undefined) { + const expected = await chainNoteId(note); + if (expected !== rc.note) { + console.error(`note check: MISMATCH. chainNoteId(${note}) is ${expected}`); + return 1; + } + console.log(`note check: ok, sha256("nosus-canary:${note}") matches`); + } + return 0; +} + +function usage(problem: string): number { + console.error(`${problem}\n${USAGE}`); + return 2; +} + +if (import.meta.main) Deno.exit(await main(Deno.args)); diff --git a/packages/receipts-ts/deno.json b/packages/receipts-ts/deno.json new file mode 100644 index 0000000..a512426 --- /dev/null +++ b/packages/receipts-ts/deno.json @@ -0,0 +1,30 @@ +{ + "name": "@nosus/receipts", + "version": "0.1.0", + "license": "Apache-2.0", + "exports": "./mod.ts", + "tasks": { + "test": "deno test", + "check": "deno check mod.ts cli.ts test/", + "lint": "deno lint", + "fmt": "deno fmt --check" + }, + "fmt": { + "lineWidth": 100 + }, + "lint": { + "rules": { + "exclude": [ + "no-import-prefix" + ] + } + }, + "publish": { + "include": [ + "mod.ts", + "cli.ts", + "src/", + "README.md" + ] + } +} diff --git a/packages/receipts-ts/deno.lock b/packages/receipts-ts/deno.lock new file mode 100644 index 0000000..8371be9 --- /dev/null +++ b/packages/receipts-ts/deno.lock @@ -0,0 +1,472 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1.0.19": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.14", + "npm:@solana-program/memo@0.15.0": "0.15.0_@solana+kit@8.4.0", + "npm:@solana/kit@8.4.0": "8.4.0" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + } + }, + "npm": { + "@solana-program/memo@0.15.0_@solana+kit@8.4.0": { + "integrity": "sha512-OofePobs2d+mZpCYq/dJ1Mv4//TVBHiYZN1is9CfsrkuMJboVK3ghv9T1bzdbM4OcywGLEyvZ4HjCByfg3RWwg==", + "dependencies": [ + "@solana/kit" + ] + }, + "@solana/accounts@8.4.0": { + "integrity": "sha512-YklERWeG5UD2fzxR0zwWgzCVyuHRqg6eTIyGBAe8hOWIim8TYS1aPBVFAlVkSZiAXaFRxmu1E3IRfeCe/QSQmg==", + "dependencies": [ + "@solana/addresses", + "@solana/codecs-core", + "@solana/codecs-strings", + "@solana/errors", + "@solana/rpc-spec", + "@solana/rpc-types" + ] + }, + "@solana/addresses@8.4.0": { + "integrity": "sha512-5nF1AAaCtmaSEHcFIIs6TPMIWplaYBxeJ2ZR0z5oPIY+fjpWb9lxCuJyYQxYKHIvp4OdSiueW0q11yXrnyqM+A==", + "dependencies": [ + "@solana/assertions", + "@solana/codecs-core", + "@solana/codecs-strings", + "@solana/errors", + "@solana/nominal-types" + ] + }, + "@solana/assertions@8.4.0": { + "integrity": "sha512-Pg/PKw/fA+KIHp6X6aGx4fIkr6S7k7wUw7v8Rkr3TkY4tzrbjZDnXEgv1/8dxGh+IX3UNb+5stD8rtAAscxW1g==", + "dependencies": [ + "@solana/errors" + ] + }, + "@solana/codecs-core@8.4.0": { + "integrity": "sha512-HOsIB3Ic/qnlXv4V0Q5gpxFxBy5pBiINpEMtCzGtHRnj7mI3Amsh8HKR7x9nbP1SBDuDlOrP/Mds9yIqyKn8TQ==", + "dependencies": [ + "@solana/errors" + ] + }, + "@solana/codecs-data-structures@8.4.0": { + "integrity": "sha512-qx9EnKGZzpiNonUoYNMZC4gfw86jfu4uShh0bcWb1o9dPU1kgZ/xCsCgTRf/5WEPZU2fbPNGtvVMr4dKWCTjzw==", + "dependencies": [ + "@solana/codecs-core", + "@solana/codecs-numbers", + "@solana/errors" + ] + }, + "@solana/codecs-numbers@8.4.0": { + "integrity": "sha512-A9SlaBylkTFjtBiVrFUX3We5SZK4L/Q6SKpPyazZQ1MTCO0FyTuKyZwDART2Pzg+C/5xLWjSns+FSTR8FhD/Rg==", + "dependencies": [ + "@solana/codecs-core", + "@solana/errors" + ] + }, + "@solana/codecs-strings@8.4.0": { + "integrity": "sha512-jQhRrJT3ZoeDoSA5DtGxPmI5ODJi3STTCrtIXIniLr1J1cXUSlPfYe8BKOurNwC+SlEJyRmKfpVze+qeLacmmw==", + "dependencies": [ + "@solana/codecs-core", + "@solana/codecs-numbers", + "@solana/errors" + ] + }, + "@solana/codecs@8.4.0": { + "integrity": "sha512-mY7CpMQ4nbecxQIyvslEw7nEreiY0QVvg+aZOPbrBjIiZxu2qj9fyhCrDXJdQlcJ+aYh1sInbR8KARpmHgJLQg==", + "dependencies": [ + "@solana/codecs-core", + "@solana/codecs-data-structures", + "@solana/codecs-numbers", + "@solana/codecs-strings", + "@solana/fixed-points", + "@solana/options" + ] + }, + "@solana/errors@8.4.0": { + "integrity": "sha512-t59ageeK8xa6LrCzj93QEhYtVr5t3GdnTH3qJMGqrCFMYM6IvNFLljlz4N75X+xYktj3sRABTrNoCfekeQlckA==", + "dependencies": [ + "chalk", + "commander" + ], + "bin": true + }, + "@solana/fast-stable-stringify@8.4.0": { + "integrity": "sha512-XKVKeocf0T9hjmUHKrOG+exxfZmv+IPQrdkvuTdegyB6V5X4aOlR6IfrJQiTMsJx6IY4EAW3aisxTgZmH5OlYg==" + }, + "@solana/fixed-points@8.4.0": { + "integrity": "sha512-YzWZDPn3B6o3j+leuOhxH7Soip12NJVx5lJaRpEeBB4AcJczTiKGCQMSsxQglGSRHc0jwfrH4+Uxla1BlCkgBg==", + "dependencies": [ + "@solana/codecs-core", + "@solana/errors" + ] + }, + "@solana/functional@8.4.0": { + "integrity": "sha512-q5pEoqAtEGFsJXK3mnlemLinzRJHvOMjGmuLoWU6c33NQHjfpTEz80hjnRA25BABMLC0l/vhymeLh+YhUv/szw==" + }, + "@solana/instruction-plans@8.4.0": { + "integrity": "sha512-+qY9d2Jzt2uxzjXs/ZZi448MbpMH0fiB3pWy8krUbCxW8eTl8tMuqIZvltMe05FldFR432xi6xFeMVl0Bw4CWA==", + "dependencies": [ + "@solana/errors", + "@solana/instructions", + "@solana/keys", + "@solana/promises", + "@solana/transaction-messages", + "@solana/transactions" + ] + }, + "@solana/instructions@8.4.0": { + "integrity": "sha512-muT/BBKDTl0UsHsnKySVaNBWIcHbs8nn8nOd1GI9J0OvX4gV/pNtaJfrEW+ETX9SjcIB4AcBQZsIr3LjavvrKQ==", + "dependencies": [ + "@solana/codecs-core", + "@solana/errors" + ] + }, + "@solana/keys@8.4.0": { + "integrity": "sha512-arjjFlJLBnGq0PWWswqO1n8kHxW02EbP9KTCoZo/TYDcSDcPdC4gL4fjAxbP6t5fVXTIu1tM3QGmab6FZxnmJg==", + "dependencies": [ + "@solana/assertions", + "@solana/codecs-core", + "@solana/codecs-strings", + "@solana/errors", + "@solana/nominal-types", + "@solana/promises" + ] + }, + "@solana/kit@8.4.0": { + "integrity": "sha512-jbexijXuCqQ7MV5lFzPDEsVScreFENkhUOUVofniTkBsfi67cYGgEeGzJa7oskHGyXcWaEogW0VPVh4ep3cWnw==", + "dependencies": [ + "@solana/accounts", + "@solana/addresses", + "@solana/codecs", + "@solana/errors", + "@solana/functional", + "@solana/instruction-plans", + "@solana/instructions", + "@solana/keys", + "@solana/offchain-messages", + "@solana/plugin-core", + "@solana/plugin-interfaces", + "@solana/program-client-core", + "@solana/programs", + "@solana/promises", + "@solana/rpc", + "@solana/rpc-api", + "@solana/rpc-parsed-types", + "@solana/rpc-spec-types", + "@solana/rpc-subscriptions", + "@solana/rpc-types", + "@solana/signers", + "@solana/subscribable", + "@solana/sysvars", + "@solana/transaction-confirmation", + "@solana/transaction-introspection", + "@solana/transaction-messages", + "@solana/transactions" + ] + }, + "@solana/nominal-types@8.4.0": { + "integrity": "sha512-01LNXtH3THaR0SFXbJ9iXhaZAVBfLJoHWYC/x2GdNIontc45qMX8wFtglZe4RaT528/kre6J+f+4NGGGd4+YPA==" + }, + "@solana/offchain-messages@8.4.0": { + "integrity": "sha512-vE28aDf14XldqoYGwwRG1OvsUKVal1FTxM+O1p1bPHho0bQgUiHvqtsDvQtTZBPJpx7d4l5Xo7TKN4DVZ+VgTQ==", + "dependencies": [ + "@solana/addresses", + "@solana/codecs-core", + "@solana/codecs-data-structures", + "@solana/codecs-numbers", + "@solana/codecs-strings", + "@solana/errors", + "@solana/keys", + "@solana/nominal-types" + ] + }, + "@solana/options@8.4.0": { + "integrity": "sha512-3fnmCT/WNWOMpD5uhyi1/aI4fwtb13MQSEqiTqsboZhHClli1WAOyPhyC5I/YeJ7wF+RMzX8UE/11KU9IMazNA==", + "dependencies": [ + "@solana/codecs-core", + "@solana/codecs-data-structures", + "@solana/codecs-numbers", + "@solana/codecs-strings", + "@solana/errors" + ] + }, + "@solana/plugin-core@8.4.0": { + "integrity": "sha512-mRB5iQ+ZEISGyHnFvFDUJyDmK7YbPxmsJk38SKg3ncxqcSxMEhK4hZ7kJBo2UMsyDX1Sn2VSGJYPhZwdOa3s8w==" + }, + "@solana/plugin-interfaces@8.4.0": { + "integrity": "sha512-R1y8z9aBCHXnx7Aqax4ORBaC1wQaHS+KQjnbNEbVZijeJhfoKbSBA5vwlweEZPKT+l2iPL9jlxPrDlVDl4NyhQ==", + "dependencies": [ + "@solana/accounts", + "@solana/addresses", + "@solana/instruction-plans", + "@solana/keys", + "@solana/rpc-spec", + "@solana/rpc-subscriptions-spec", + "@solana/rpc-types", + "@solana/signers", + "@solana/transactions" + ] + }, + "@solana/program-client-core@8.4.0": { + "integrity": "sha512-GBgz9wHMp2BOKRTp/IAATooTLPIe+6Y0jCTYOSkXME9F7xyh1o7mGu0lEbotnB6C3fubaXzU6Swk9SYskl7Sig==", + "dependencies": [ + "@solana/accounts", + "@solana/addresses", + "@solana/codecs-core", + "@solana/errors", + "@solana/instruction-plans", + "@solana/instructions", + "@solana/plugin-interfaces", + "@solana/rpc-api", + "@solana/signers" + ] + }, + "@solana/programs@8.4.0": { + "integrity": "sha512-KZvtGP7spytak0igTSb1tulNc5fhCEyjYNlMBObveohVhrzT7/t7M6ioqcJpAvv1bWm+E7E4FI5B+fjMKMpAxQ==", + "dependencies": [ + "@solana/addresses", + "@solana/errors" + ] + }, + "@solana/promises@8.4.0": { + "integrity": "sha512-keb+TXJZHRT605HLakRanfjnSVUFYG6za+P7W8UDKA2ZUp+LTi1As5fQTkhRyFO/GOrbmP9a7lYC5eNGAekg6w==" + }, + "@solana/rpc-api@8.4.0": { + "integrity": "sha512-tD26U9U7pOQoLiTp7MoyWYPSmnvLAa9OrpQPGQ8NVUU7xXOeVKSKRe3rI4EL/zzcPNXeCAKtLJ449u8ejLT7LA==", + "dependencies": [ + "@solana/addresses", + "@solana/codecs-core", + "@solana/codecs-strings", + "@solana/errors", + "@solana/keys", + "@solana/rpc-parsed-types", + "@solana/rpc-spec", + "@solana/rpc-transformers", + "@solana/rpc-types", + "@solana/transaction-messages", + "@solana/transactions" + ] + }, + "@solana/rpc-parsed-types@8.4.0": { + "integrity": "sha512-/vBcGcLy/W1r8t0ST+gc3EqVPFx7dRFxkt5RoYE9tepEiroOwLbVg7rKtgtKifkFBODSVDcupEkTBBhRbWpB9A==" + }, + "@solana/rpc-spec-types@8.4.0": { + "integrity": "sha512-YnxoUzWy/bwdclB3ipu7+4/mnnb4Jqmgq1O20zhQ81cUGZseEWy++Y4GJA0OBZvrcDHhoRntSd26ABg9q0XaVg==", + "dependencies": [ + "@solana/errors" + ] + }, + "@solana/rpc-spec@8.4.0": { + "integrity": "sha512-g5R8jJoYDjwb4sEUw8PxReAw5OETSjWSgMF+f/Q+0BGRiX+hNO//3qxWRL0rpWAnHi9R03W5Li/sPOYC8IGL1Q==", + "dependencies": [ + "@solana/errors", + "@solana/rpc-spec-types", + "@solana/subscribable" + ] + }, + "@solana/rpc-subscriptions-api@8.4.0": { + "integrity": "sha512-qsSYyfmYSB8/VE6yks4Ls4fQyyi2VhS09PZaENfTSFk6iKAYyEnhOkwCu//MdeWOrrHDmGmxC2UdzpB61f+Fcg==", + "dependencies": [ + "@solana/addresses", + "@solana/keys", + "@solana/rpc-subscriptions-spec", + "@solana/rpc-transformers", + "@solana/rpc-types", + "@solana/transaction-messages", + "@solana/transactions" + ] + }, + "@solana/rpc-subscriptions-channel-websocket@8.4.0": { + "integrity": "sha512-O0UAs9G/lgBRf4Aw7kvG5quHWDWdfuTn8BJUy3KOvCFADOOGrrH6leXLHJAMxN2T8KmyO28cs2+GGW/CD5kWNQ==", + "dependencies": [ + "@solana/errors", + "@solana/functional", + "@solana/rpc-subscriptions-spec", + "@solana/subscribable", + "ws" + ] + }, + "@solana/rpc-subscriptions-spec@8.4.0": { + "integrity": "sha512-H7Jqleo+WR1hUcPexQCn5rIgGsXa7Ip0Pnl3Vzvv/DIpYEN5lpMrWXf3v9fVyG3jRxom5WW32xmYhiHa2dORzw==", + "dependencies": [ + "@solana/errors", + "@solana/promises", + "@solana/rpc-spec-types", + "@solana/subscribable" + ] + }, + "@solana/rpc-subscriptions@8.4.0": { + "integrity": "sha512-XkWl2flaGKkHzw6+StjVsYtJoB5xoGpnsdanXa1h1KnwK4HFFy/wsJ0P+5TY1Z0DR1IDn6voNl3KSQrQuEQm9Q==", + "dependencies": [ + "@solana/errors", + "@solana/fast-stable-stringify", + "@solana/functional", + "@solana/promises", + "@solana/rpc-spec-types", + "@solana/rpc-subscriptions-api", + "@solana/rpc-subscriptions-channel-websocket", + "@solana/rpc-subscriptions-spec", + "@solana/rpc-transformers", + "@solana/rpc-types", + "@solana/subscribable" + ] + }, + "@solana/rpc-transformers@8.4.0": { + "integrity": "sha512-FTlLgsjN/1RKeBidS5Dfb81VpeqRYESiKg7jTkbY6MXVL96bOMUihQDXAjmgUMMp95nTykuQjKas7pkG2Zsz5w==", + "dependencies": [ + "@solana/errors", + "@solana/functional", + "@solana/nominal-types", + "@solana/rpc-spec-types", + "@solana/rpc-types" + ] + }, + "@solana/rpc-transport-http@8.4.0": { + "integrity": "sha512-MSlFqehp1CpADeSozzb/YnBEvkBlIrTcmLIVmeKbcTMOELV5yZKnp+bMQl05Q0swsyTr423kQ1XvV9Oqk3/KoA==", + "dependencies": [ + "@solana/errors", + "@solana/rpc-spec", + "@solana/rpc-spec-types", + "undici-types" + ] + }, + "@solana/rpc-types@8.4.0": { + "integrity": "sha512-orCi24z9u7AdaozJfgtXwdwHErQy5vO3h6/wCBd/W0bWsfRXiCCWqJcZJqyoZvl1y0oESGEkZR0CzrpedyaAqg==", + "dependencies": [ + "@solana/addresses", + "@solana/codecs-core", + "@solana/codecs-numbers", + "@solana/codecs-strings", + "@solana/errors", + "@solana/fixed-points", + "@solana/nominal-types" + ] + }, + "@solana/rpc@8.4.0": { + "integrity": "sha512-xrJa4Bapskl1aud+qirKFbDy1A22vWHGwW9YKav20Q1YjJRItE8MfJ9N4c3MAtaSkC59QhBeHOsuf3F0gm+Hxw==", + "dependencies": [ + "@solana/errors", + "@solana/fast-stable-stringify", + "@solana/functional", + "@solana/rpc-api", + "@solana/rpc-spec", + "@solana/rpc-spec-types", + "@solana/rpc-transformers", + "@solana/rpc-transport-http", + "@solana/rpc-types" + ] + }, + "@solana/signers@8.4.0": { + "integrity": "sha512-hqBzc1M/+y+6rUy7OVAisjoS0/pwhIB3B0VOoJfQ9yBjFmx8TguxcKevn4dP0DIeBK4HqQgiqJyJN0f5hbHfxQ==", + "dependencies": [ + "@solana/addresses", + "@solana/codecs-core", + "@solana/errors", + "@solana/instructions", + "@solana/keys", + "@solana/nominal-types", + "@solana/offchain-messages", + "@solana/transaction-messages", + "@solana/transactions" + ] + }, + "@solana/subscribable@8.4.0": { + "integrity": "sha512-EgRMZWnxo1GYan014TscE5i9iDl/dgazwRAgyQ/QRUtB5FVcedRHr0Wh6dW6MJno4htKN4rZsMN+jAuaxLNfmw==", + "dependencies": [ + "@solana/errors", + "@solana/promises" + ] + }, + "@solana/sysvars@8.4.0": { + "integrity": "sha512-M2Wk70cji0n0muwHuZiPpDLeRy3Iq19FSOCNQPC/VL5Bg4Ltm7G9PXPvX4/TEODfFGgOLGja4rZmFlECl7tlXg==", + "dependencies": [ + "@solana/accounts", + "@solana/codecs-core", + "@solana/codecs-data-structures", + "@solana/codecs-numbers", + "@solana/errors", + "@solana/rpc-types" + ] + }, + "@solana/transaction-confirmation@8.4.0": { + "integrity": "sha512-tTKRbzqUzpQGh4ZmL8MTyvZBWHzvcuvDrGROFk8vw2b1FINsv6BAKbxXKWUTWp2RO0ABcznMt+sFHAyPwQ/2/w==", + "dependencies": [ + "@solana/addresses", + "@solana/codecs-strings", + "@solana/errors", + "@solana/keys", + "@solana/promises", + "@solana/rpc", + "@solana/rpc-subscriptions", + "@solana/rpc-types", + "@solana/transaction-messages", + "@solana/transactions" + ] + }, + "@solana/transaction-introspection@8.4.0": { + "integrity": "sha512-qVvPIRh7Q76k1UNcy2wZIKpA6emmfhMVNUFNheD+eCzOycGDcCT3Y/Mt7H5qU+dDq4NKLNR/20h4aRu1Qendbg==", + "dependencies": [ + "@solana/addresses", + "@solana/codecs-core", + "@solana/codecs-strings", + "@solana/errors", + "@solana/instructions", + "@solana/rpc-types", + "@solana/transaction-messages", + "@solana/transactions" + ] + }, + "@solana/transaction-messages@8.4.0": { + "integrity": "sha512-LdihUr4z6bAIv44z13uaShCXgb9C8DY0gZ+RcW9U+CcaDJwWhgZuy5IG2TBAAGa5lbEyRcYPQxD+/aE6b1F/WA==", + "dependencies": [ + "@solana/addresses", + "@solana/codecs-core", + "@solana/codecs-data-structures", + "@solana/codecs-numbers", + "@solana/errors", + "@solana/functional", + "@solana/instructions", + "@solana/nominal-types", + "@solana/rpc-types" + ] + }, + "@solana/transactions@8.4.0": { + "integrity": "sha512-+FFNSlr5vVTcGS9c7seH7mNnjZY21uZ3pHxiN9jeduODD7z+caKnGgjmzokZSDYapFUcupniIr736CrJNmbMFw==", + "dependencies": [ + "@solana/addresses", + "@solana/codecs-core", + "@solana/codecs-data-structures", + "@solana/codecs-numbers", + "@solana/codecs-strings", + "@solana/errors", + "@solana/functional", + "@solana/instructions", + "@solana/keys", + "@solana/nominal-types", + "@solana/rpc-types", + "@solana/transaction-messages" + ] + }, + "chalk@5.6.2": { + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" + }, + "commander@15.0.0": { + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==" + }, + "undici-types@8.11.2": { + "integrity": "sha512-iMVNmWZ0leK/goS6eXMizSzmm9CDWtyphwbaCms3DNLqRxDL+mMoNVcZMTyyVgXP0N+Z8neAMzDoUOUJL8veKg==" + }, + "ws@8.22.0": { + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==" + } + } +} diff --git a/packages/receipts-ts/mod.ts b/packages/receipts-ts/mod.ts new file mode 100644 index 0000000..095b866 --- /dev/null +++ b/packages/receipts-ts/mod.ts @@ -0,0 +1,35 @@ +/** + * NO SUS Canary receipts: encode, parse, send and fetch the SPL Memo that + * records each seal and each open on Solana. Spec: docs/RECEIPTS_SPEC.md. + * + * ```ts + * import { explorerTxUrl, fetchReceipt } from "@nosus/receipts"; + * + * const r = await fetchReceipt("2BHqnu…"); + * console.log(r.receipt, explorerTxUrl(r.signature)); + * ``` + * + * @module + */ + +export { + DEFAULT_MEMO_PROGRAM, + encodeOpenMemo, + encodeSealMemo, + MAX_MEMO_BYTES, + MEMO_PROGRAM_IDS, + type OpenInput, + type OpenReceipt, + parseReceiptMemo, + PROTOCOL, + type Receipt, + ReceiptFormatError, + type SealInput, + type SealReceipt, + VERSION, +} from "./src/memo.ts"; +export { chainNoteId, copiesHash, copyDigest, deviceHash, readerTag } from "./src/ids.ts"; +export { parseRelayerKeys } from "./src/keys.ts"; +export { type SendConfig, sendMemo } from "./src/send.ts"; +export { type FetchedReceipt, fetchReceipt } from "./src/fetch.ts"; +export { type Cluster, DEFAULT_RPC_URLS, explorerTxUrl } from "./src/explorer.ts"; diff --git a/packages/receipts-ts/src/base58.ts b/packages/receipts-ts/src/base58.ts new file mode 100644 index 0000000..4c2722c --- /dev/null +++ b/packages/receipts-ts/src/base58.ts @@ -0,0 +1,21 @@ +// Base58 (Bitcoin alphabet) to bytes. Kept local so parsing keys and reading +// receipts never load @solana/kit; only send.ts does. + +const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +export function base58Decode(text: string): Uint8Array { + let n = 0n; + for (const c of text) { + const digit = ALPHABET.indexOf(c); + if (digit < 0) throw new Error("not base58"); + n = n * 58n + BigInt(digit); + } + const bytes: number[] = []; + for (; n > 0n; n >>= 8n) bytes.unshift(Number(n & 0xffn)); + // Each leading "1" is a leading zero byte. + for (const c of text) { + if (c !== "1") break; + bytes.unshift(0); + } + return Uint8Array.from(bytes); +} diff --git a/packages/receipts-ts/src/explorer.ts b/packages/receipts-ts/src/explorer.ts new file mode 100644 index 0000000..a7f0d9e --- /dev/null +++ b/packages/receipts-ts/src/explorer.ts @@ -0,0 +1,13 @@ +export type Cluster = "devnet" | "mainnet-beta"; + +/** Default public RPC endpoint for each cluster. */ +export const DEFAULT_RPC_URLS: Readonly> = Object.freeze({ + "devnet": "https://api.devnet.solana.com", + "mainnet-beta": "https://api.mainnet-beta.solana.com", +}); + +/** Solana Explorer page for a transaction. Mainnet takes no cluster param. */ +export function explorerTxUrl(signature: string, cluster: Cluster = "devnet"): string { + const base = `https://explorer.solana.com/tx/${signature}`; + return cluster === "mainnet-beta" ? base : `${base}?cluster=${cluster}`; +} diff --git a/packages/receipts-ts/src/fetch.ts b/packages/receipts-ts/src/fetch.ts new file mode 100644 index 0000000..271596a --- /dev/null +++ b/packages/receipts-ts/src/fetch.ts @@ -0,0 +1,94 @@ +// Reads one receipt back from the chain with a plain JSON-RPC getTransaction +// call. No npm packages, so verifying needs nothing but fetch. + +import { MEMO_PROGRAM_IDS, parseReceiptMemo, PROTOCOL, type Receipt } from "./memo.ts"; +import { base58Decode } from "./base58.ts"; +import { DEFAULT_RPC_URLS } from "./explorer.ts"; + +export type FetchedReceipt = { + signature: string; + slot: number; + /** Unix seconds; null when the node does not know it. */ + blockTime: number | null; + /** First account key: the relayer that paid for and signed the memo. */ + feePayer: string; + memoProgram: string; + receipt: Receipt; +}; + +type Instruction = { programId: string; parsed?: unknown; data?: string }; +type Transaction = { + slot: number; + blockTime: number | null; + meta: { err: unknown } | null; + transaction: { message: { accountKeys: { pubkey: string }[]; instructions: Instruction[] } }; +}; + +const SIGNATURE_RE = /^[1-9A-HJ-NP-Za-km-z]{64,88}$/; + +/** + * Fetches a transaction and decodes its NO SUS Canary memo. Throws when the + * transaction is missing or failed, has no memo, has a foreign memo, or the + * memo is not exactly format v1 (ReceiptFormatError). + */ +export async function fetchReceipt( + signature: string, + opts: { rpcUrl?: string; fetch?: typeof fetch } = {}, +): Promise { + if (!SIGNATURE_RE.test(signature)) { + throw new Error("signature must be a base58 transaction signature"); + } + const res = await (opts.fetch ?? fetch)(opts.rpcUrl ?? DEFAULT_RPC_URLS.devnet, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "getTransaction", + params: [signature, { + encoding: "jsonParsed", + maxSupportedTransactionVersion: 0, + commitment: "confirmed", + }], + }), + }); + if (!res.ok) throw new Error(`getTransaction failed: HTTP ${res.status}`); + const body = await res.json() as { + result?: Transaction | null; + error?: { message?: string }; + }; + if (body.error) throw new Error(`getTransaction failed: ${body.error.message ?? "RPC error"}`); + + const tx = body.result; + if (!tx) throw new Error(`transaction ${signature} not found on this cluster`); + if (tx.meta?.err) throw new Error(`transaction ${signature} failed on chain`); + + const memos = tx.transaction.message.instructions + .filter((ix) => MEMO_PROGRAM_IDS.includes(ix.programId)) + .map((ix) => ({ program: ix.programId, text: memoText(ix) })); + if (memos.length === 0) throw new Error(`transaction ${signature} has no memo instruction`); + const ours = memos.filter((m) => m.text.startsWith(`{"p":"${PROTOCOL}",`)); + if (ours.length === 0) { + throw new Error(`transaction ${signature} memo is not a NO SUS Canary receipt`); + } + if (ours.length > 1) { + throw new Error(`transaction ${signature} has more than one NO SUS Canary memo`); + } + + return { + signature, + slot: tx.slot, + blockTime: tx.blockTime ?? null, + feePayer: tx.transaction.message.accountKeys[0]?.pubkey ?? "", + memoProgram: ours[0].program, + receipt: parseReceiptMemo(ours[0].text), + }; +} + +// RPC nodes decode SPL Memo v1/v3 into `parsed`; other memo programs (p-memo) +// come back as base58 `data`. +function memoText(ix: Instruction): string { + if (typeof ix.parsed === "string") return ix.parsed; + if (typeof ix.data === "string") return new TextDecoder().decode(base58Decode(ix.data)); + return ""; +} diff --git a/packages/receipts-ts/src/ids.ts b/packages/receipts-ts/src/ids.ts new file mode 100644 index 0000000..4398c85 --- /dev/null +++ b/packages/receipts-ts/src/ids.ts @@ -0,0 +1,38 @@ +// The sha256-based ids and hashes a receipt carries. Inputs are hashed as +// UTF-8 text; outputs are lowercase hex. Same formulas as the Canary app and +// the edge function, so anyone can recompute them. + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** The memo `note`: "0x" + sha256("nosus-canary:" + noteId). noteId is a lowercase UUID. */ +export async function chainNoteId(noteId: string): Promise { + return `0x${await sha256Hex(`nosus-canary:${noteId}`)}`; +} + +/** sha256("device:" + deviceId), no 0x. Only ever stored or hashed again, never on chain. */ +export function deviceHash(deviceId: string): Promise { + return sha256Hex(`device:${deviceId}`); +} + +/** + * The open memo `tag`: "0x" + sha256(":::"). + * tagSalt is the relayer's secret, so only the relayer can recompute it. + */ +export async function readerTag( + p: { tagSalt: string; noteId: string; copyIndex: number; deviceHash: string }, +): Promise { + return `0x${await sha256Hex(`${p.tagSalt}:${p.noteId}:${p.copyIndex}:${p.deviceHash}`)}`; +} + +/** Commitment to one copy: sha256("::"), no 0x. */ +export function copyDigest(saltHex: string, index: number, copyText: string): Promise { + return sha256Hex(`${saltHex}:${index}:${copyText}`); +} + +/** The seal memo `hash`: "0x" + sha256 of the copy digests joined in copy order. */ +export async function copiesHash(copyDigests: string[]): Promise { + return `0x${await sha256Hex(copyDigests.join(""))}`; +} diff --git a/packages/receipts-ts/src/keys.ts b/packages/receipts-ts/src/keys.ts new file mode 100644 index 0000000..0cb0341 --- /dev/null +++ b/packages/receipts-ts/src/keys.ts @@ -0,0 +1,44 @@ +// Relayer key parsing, in the SOLANA_RELAYER_KEYS format: comma-separated +// keypairs, each either a base58 64-byte secret key or a solana-keygen JSON +// array. Errors never include key material. + +import { base58Decode } from "./base58.ts"; + +const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; + +/** 64-byte secret keys (private key || public key), as solana-keygen writes them. */ +export function parseRelayerKeys(raw: string): Uint8Array[] { + // Items are comma-separated, but a JSON array item contains commas itself. + const items = raw.match(/\[[^\]]*\]|[^,\[\]\s]+/g) ?? []; + const keys = items.map(parseSecretKey); + if (keys.length === 0 || keys.length > 32) { + throw new Error("SOLANA_RELAYER_KEYS must hold 1 to 32 keys"); + } + return keys; +} + +function parseSecretKey(item: string): Uint8Array { + let bytes: Uint8Array; + if (item.startsWith("[")) { + let parsed: unknown; + try { + parsed = JSON.parse(item); + } catch { + throw new Error("SOLANA_RELAYER_KEYS: a JSON key is not valid JSON"); + } + if ( + !Array.isArray(parsed) || + !parsed.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) + ) { + throw new Error("SOLANA_RELAYER_KEYS: a JSON key must be an array of bytes"); + } + bytes = Uint8Array.from(parsed as number[]); + } else { + if (!BASE58_RE.test(item)) { + throw new Error("SOLANA_RELAYER_KEYS: a key must be base58 or a JSON array"); + } + bytes = base58Decode(item); + } + if (bytes.length !== 64) throw new Error("SOLANA_RELAYER_KEYS: a key must be 64 bytes"); + return bytes; +} diff --git a/packages/receipts-ts/src/memo.ts b/packages/receipts-ts/src/memo.ts new file mode 100644 index 0000000..b552e1b --- /dev/null +++ b/packages/receipts-ts/src/memo.ts @@ -0,0 +1,136 @@ +// NO SUS Canary receipt memo, format v1 (spec: docs/RECEIPTS_SPEC.md). +// +// One SPL Memo per seal and per open. The memo is compact JSON with a fixed +// key order, so the same receipt is always the same bytes: +// {"p":"nosus-canary","v":1,"t":"seal","note":"0x…","copies":N,"hash":"0x…","exp":E} +// {"p":"nosus-canary","v":1,"t":"open","note":"0x…","copy":N,"tag":"0x…"} + +export const PROTOCOL = "nosus-canary"; +export const VERSION = 1; + +/** Longest memo a single-signer transaction can carry. */ +export const MAX_MEMO_BYTES = 566; + +/** SPL Memo v3 (Solana Explorer shows the memo text). */ +export const DEFAULT_MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; + +/** Memo programs a receipt may be written with: SPL Memo v1, v3 and p-memo. */ +export const MEMO_PROGRAM_IDS: readonly string[] = Object.freeze([ + "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo", + DEFAULT_MEMO_PROGRAM, + "Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH", +]); + +export type SealInput = { note: string; copies: number; hash: string; exp: number }; +export type OpenInput = { note: string; copy: number; tag: string }; +export type SealReceipt = { v: 1; t: "seal" } & SealInput; +export type OpenReceipt = { v: 1; t: "open" } & OpenInput; +export type Receipt = SealReceipt | OpenReceipt; + +/** A memo or memo field that is not exactly format v1. */ +export class ReceiptFormatError extends Error { + constructor(message: string) { + super(message); + this.name = "ReceiptFormatError"; + } +} + +const BYTES32_RE = /^0x[0-9a-f]{64}$/; +const SEAL_KEYS = "p,v,t,note,copies,hash,exp"; +const OPEN_KEYS = "p,v,t,note,copy,tag"; + +function bytes32(name: string, value: unknown): string { + if (typeof value !== "string" || !BYTES32_RE.test(value)) { + throw new ReceiptFormatError(`memo ${name} must be 0x + 64 hex chars`); + } + return value; +} + +// Safe integers only, so every encoded memo parses back to the same bytes. +function uint(name: string, value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new ReceiptFormatError(`memo ${name} must be a non-negative integer`); + } + return value; +} + +// exp is unix seconds, 0 when the note never expires. +export function encodeSealMemo(p: SealInput): string { + return JSON.stringify({ + p: PROTOCOL, + v: VERSION, + t: "seal", + note: bytes32("note", p.note), + copies: uint("copies", p.copies), + hash: bytes32("hash", p.hash), + exp: uint("exp", p.exp), + }); +} + +export function encodeOpenMemo(p: OpenInput): string { + return JSON.stringify({ + p: PROTOCOL, + v: VERSION, + t: "open", + note: bytes32("note", p.note), + copy: uint("copy", p.copy), + tag: bytes32("tag", p.tag), + }); +} + +/** + * Strict parse: accepts only the exact bytes encodeSealMemo/encodeOpenMemo + * would write. Throws ReceiptFormatError otherwise. + */ +export function parseReceiptMemo(memo: string): Receipt { + if (new TextEncoder().encode(memo).length > MAX_MEMO_BYTES) { + throw new ReceiptFormatError(`memo is longer than ${MAX_MEMO_BYTES} bytes`); + } + let m: unknown; + try { + m = JSON.parse(memo); + } catch { + throw new ReceiptFormatError("memo is not JSON"); + } + if (typeof m !== "object" || m === null || Array.isArray(m)) { + throw new ReceiptFormatError("memo is not a JSON object"); + } + const o = m as Record; + if (o.p !== PROTOCOL) throw new ReceiptFormatError(`memo p must be "${PROTOCOL}"`); + if (o.v !== VERSION) throw new ReceiptFormatError(`memo v must be ${VERSION}`); + if (o.t !== "seal" && o.t !== "open") { + throw new ReceiptFormatError('memo t must be "seal" or "open"'); + } + + const keys = o.t === "seal" ? SEAL_KEYS : OPEN_KEYS; + if (Object.keys(o).join(",") !== keys) { + throw new ReceiptFormatError(`memo keys must be ${keys} in this order`); + } + + let receipt: Receipt; + let canonical: string; + if (o.t === "seal") { + receipt = { + v: 1, + t: "seal", + note: bytes32("note", o.note), + copies: uint("copies", o.copies), + hash: bytes32("hash", o.hash), + exp: uint("exp", o.exp), + }; + canonical = encodeSealMemo(receipt); + } else { + receipt = { + v: 1, + t: "open", + note: bytes32("note", o.note), + copy: uint("copy", o.copy), + tag: bytes32("tag", o.tag), + }; + canonical = encodeOpenMemo(receipt); + } + // Catches whitespace, 3.0 / 3e0, duplicate keys and anything else that + // JSON.parse forgives. + if (canonical !== memo) throw new ReceiptFormatError("memo is not in canonical form"); + return receipt; +} diff --git a/packages/receipts-ts/src/send.ts b/packages/receipts-ts/src/send.ts new file mode 100644 index 0000000..393fb3f --- /dev/null +++ b/packages/receipts-ts/src/send.ts @@ -0,0 +1,86 @@ +// Sends one memo as a Solana transaction from a relayer key. A Solana +// transaction is scoped by a recent blockhash, not an account nonce, so one +// relayer key can send concurrently and no lease is needed. + +import { + address, + appendTransactionMessageInstruction, + createKeyPairSignerFromBytes, + createSolanaRpc, + createTransactionMessage, + getBase64EncodedWireTransaction, + getSignatureFromTransaction, + pipe, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + signTransactionMessageWithSigners, +} from "npm:@solana/kit@8.4.0"; +import { Buffer } from "node:buffer"; +import { getAddMemoInstruction } from "npm:@solana-program/memo@0.15.0"; + +// The Supabase edge runtime resolves @solana/kit's Node build, whose base64 +// codec reads the global Buffer that Deno does not define. +(globalThis as { Buffer?: typeof Buffer }).Buffer ??= Buffer; + +export type SendConfig = { + rpcUrl: string; + /** 64-byte secret keys (private key || public key); see parseRelayerKeys. */ + keys: Uint8Array[]; + /** One of MEMO_PROGRAM_IDS. */ + memoProgram: string; +}; + +const CONFIRM_TIMEOUT_MS = 20_000; +const CONFIRM_POLL_MS = 400; + +/** + * Sends one memo instruction from a random relayer key and waits until the + * transaction is confirmed. Returns the base58 signature. Polls over HTTP; no + * WebSocket subscriptions. + */ +export async function sendMemo(cfg: SendConfig, p: { memo: string }): Promise { + const rpc = createSolanaRpc(cfg.rpcUrl); + const signer = await createKeyPairSignerFromBytes( + cfg.keys[Math.floor(Math.random() * cfg.keys.length)], + ); + const { value: latest } = await rpc.getLatestBlockhash({ commitment: "confirmed" }).send(); + + const message = pipe( + createTransactionMessage({ version: 0 }), + (m) => setTransactionMessageFeePayerSigner(signer, m), + (m) => setTransactionMessageLifetimeUsingBlockhash(latest, m), + (m) => + appendTransactionMessageInstruction( + getAddMemoInstruction( + { memo: p.memo, signers: [signer] }, + { programAddress: address(cfg.memoProgram) }, + ), + m, + ), + ); + const transaction = await signTransactionMessageWithSigners(message); + const signature = getSignatureFromTransaction(transaction); + + await rpc + .sendTransaction(getBase64EncodedWireTransaction(transaction), { + encoding: "base64", + preflightCommitment: "confirmed", + }) + .send(); + + const deadline = Date.now() + CONFIRM_TIMEOUT_MS; + for (;;) { + const { value: [status] } = await rpc.getSignatureStatuses([signature]).send(); + if (status?.err) { + const err = JSON.stringify(status.err, (_, v) => (typeof v === "bigint" ? v.toString() : v)); + throw new Error(`solana memo ${signature} failed: ${err}`); + } + if (status?.confirmationStatus === "confirmed" || status?.confirmationStatus === "finalized") { + return signature; + } + if (Date.now() >= deadline) { + throw new Error(`solana memo ${signature} not confirmed within ${CONFIRM_TIMEOUT_MS} ms`); + } + await new Promise((resolve) => setTimeout(resolve, CONFIRM_POLL_MS)); + } +} diff --git a/packages/receipts-ts/test/fetch_test.ts b/packages/receipts-ts/test/fetch_test.ts new file mode 100644 index 0000000..d083103 --- /dev/null +++ b/packages/receipts-ts/test/fetch_test.ts @@ -0,0 +1,138 @@ +// fetchReceipt against saved devnet getTransaction responses. Offline: fetch +// is injected and never reaches the network. +import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.19"; +import vectors from "../../test-vectors/receipts.json" with { type: "json" }; +import tx2BHqnu from "../../test-vectors/devnet/2BHqnu.json" with { type: "json" }; +import txNwoJau from "../../test-vectors/devnet/NwoJau.json" with { type: "json" }; +import tx62Frr2 from "../../test-vectors/devnet/62Frr2.json" with { type: "json" }; +import tx2b9SS9 from "../../test-vectors/devnet/2b9SS9.json" with { type: "json" }; +import tx4NXs89 from "../../test-vectors/devnet/4NXs89.json" with { type: "json" }; +import { fetchReceipt } from "../src/fetch.ts"; +import { parseReceiptMemo, ReceiptFormatError } from "../src/memo.ts"; + +const FIXTURES: Record = { + "devnet/2BHqnu.json": tx2BHqnu, + "devnet/NwoJau.json": txNwoJau, + "devnet/62Frr2.json": tx62Frr2, + "devnet/2b9SS9.json": tx2b9SS9, + "devnet/4NXs89.json": tx4NXs89, +}; +const SEAL = vectors.devnet[0]; +const RPC = "http://rpc.test"; + +// Returns `body` as the JSON-RPC response and records each request. +function rpc(body: unknown, status = 200) { + const requests: { url: string; body: unknown }[] = []; + const fetch = (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), body: JSON.parse(String(init?.body)) }); + return Promise.resolve(new Response(JSON.stringify(body), { status })); + }; + return { fetch: fetch as typeof globalThis.fetch, requests }; +} + +// A copy of the seal fixture with its single instruction replaced. +function withInstruction(ix: unknown) { + const copy = structuredClone(tx2BHqnu); + (copy.result.transaction.message.instructions as unknown[]) = [ix]; + return copy; +} + +Deno.test("fetchReceipt decodes every real devnet receipt", async () => { + for (const v of vectors.devnet) { + const { fetch } = rpc(FIXTURES[v.fixture]); + const r = await fetchReceipt(v.signature, { rpcUrl: RPC, fetch }); + assertEquals(r, { + signature: v.signature, + slot: v.slot, + blockTime: v.blockTime, + feePayer: v.feePayer, + memoProgram: v.memoProgram, + receipt: parseReceiptMemo(v.memo), + }); + } +}); + +Deno.test("fetchReceipt sends one jsonParsed getTransaction at confirmed", async () => { + const { fetch, requests } = rpc(tx2BHqnu); + await fetchReceipt(SEAL.signature, { rpcUrl: RPC, fetch }); + assertEquals(requests, [{ + url: RPC, + body: { + jsonrpc: "2.0", + id: 1, + method: "getTransaction", + params: [SEAL.signature, { + encoding: "jsonParsed", + maxSupportedTransactionVersion: 0, + commitment: "confirmed", + }], + }, + }]); +}); + +Deno.test("fetchReceipt reads base58 data from memo programs the node does not parse", async () => { + const raw = vectors.rawMemoInstruction; + const { fetch } = rpc(withInstruction({ + accounts: [SEAL.feePayer], + data: raw.data, + programId: raw.programId, + stackHeight: 1, + })); + const r = await fetchReceipt(SEAL.signature, { rpcUrl: RPC, fetch }); + assertEquals(r.memoProgram, raw.programId); + assertEquals(r.receipt, parseReceiptMemo(raw.memo)); +}); + +Deno.test("fetchReceipt errors", async () => { + const sig = SEAL.signature; + const memoIx = tx2BHqnu.result.transaction.message.instructions[0]; + const failed = structuredClone(tx2BHqnu); + (failed.result.meta.err as unknown) = { InstructionError: [0, "Custom"] }; + const cases: [unknown, string, number?][] = [ + [{ jsonrpc: "2.0", result: null, id: 1 }, `transaction ${sig} not found on this cluster`], + [{ jsonrpc: "2.0", error: { code: -32602, message: "Invalid param" }, id: 1 }, "Invalid param"], + [{}, "HTTP 503", 503], + [failed, `transaction ${sig} failed on chain`], + [ + withInstruction({ + parsed: { type: "transfer", info: {} }, + program: "system", + programId: "11111111111111111111111111111111", + }), + `transaction ${sig} has no memo instruction`, + ], + [ + withInstruction({ ...memoIx, parsed: "gm" }), + `transaction ${sig} memo is not a NO SUS Canary receipt`, + ], + ]; + for (const [body, message, status] of cases) { + const { fetch } = rpc(body, status); + await assertRejects(() => fetchReceipt(sig, { rpcUrl: RPC, fetch }), Error, message); + } + + const twice = structuredClone(tx2BHqnu); + twice.result.transaction.message.instructions.push(memoIx); + await assertRejects( + () => fetchReceipt(sig, { rpcUrl: RPC, fetch: rpc(twice).fetch }), + Error, + "more than one NO SUS Canary memo", + ); + + const v2 = vectors.invalidMemos.find((v) => v.name === "v=2")!; + await assertRejects( + () => + fetchReceipt(sig, { + rpcUrl: RPC, + fetch: rpc(withInstruction({ ...memoIx, parsed: v2.memo })).fetch, + }), + ReceiptFormatError, + v2.error, + ); + + await assertRejects( + () => fetchReceipt("not a signature!", { rpcUrl: RPC, fetch: rpc({}).fetch }), + Error, + "signature must be a base58 transaction signature", + ); +}); diff --git a/packages/receipts-ts/test/ids_test.ts b/packages/receipts-ts/test/ids_test.ts new file mode 100644 index 0000000..f2d15b7 --- /dev/null +++ b/packages/receipts-ts/test/ids_test.ts @@ -0,0 +1,33 @@ +import { assertEquals } from "jsr:@std/assert@1.0.19"; +import vectors from "../../test-vectors/receipts.json" with { type: "json" }; +import { chainNoteId, copiesHash, copyDigest, deviceHash, readerTag } from "../src/ids.ts"; +import { explorerTxUrl } from "../src/explorer.ts"; + +Deno.test("chainNoteId", async () => { + for (const v of vectors.chainNoteId) assertEquals(await chainNoteId(v.noteId), v.expected); +}); + +Deno.test("deviceHash", async () => { + for (const v of vectors.deviceHash) assertEquals(await deviceHash(v.deviceId), v.expected); +}); + +Deno.test("copyDigest hashes the copy text as UTF-8", async () => { + for (const v of vectors.copyDigest) { + assertEquals(await copyDigest(v.saltHex, v.index, v.copyText), v.expected); + } +}); + +Deno.test("copiesHash", async () => { + for (const v of vectors.copiesHash) assertEquals(await copiesHash(v.digests), v.expected); +}); + +Deno.test("readerTag", async () => { + for (const v of vectors.readerTag) assertEquals(await readerTag(v), v.expected); +}); + +Deno.test("explorerTxUrl: devnet has a cluster param, mainnet has none", () => { + for (const v of vectors.explorerTxUrl) { + assertEquals(explorerTxUrl(v.signature, v.cluster as "devnet" | "mainnet-beta"), v.expected); + } + assertEquals(explorerTxUrl("abc"), "https://explorer.solana.com/tx/abc?cluster=devnet"); +}); diff --git a/packages/receipts-ts/test/keys_test.ts b/packages/receipts-ts/test/keys_test.ts new file mode 100644 index 0000000..255c246 --- /dev/null +++ b/packages/receipts-ts/test/keys_test.ts @@ -0,0 +1,43 @@ +import { assertEquals, assertFalse, assertThrows } from "jsr:@std/assert@1.0.19"; +import { parseRelayerKeys } from "../src/keys.ts"; +import { base58Decode } from "../src/base58.ts"; + +// Test bytes 1..64 only; not a funded key. +const KEY = Uint8Array.from({ length: 64 }, (_, i) => i + 1); +const JSON_KEY = JSON.stringify([...KEY]); +const BASE58_KEY = + "2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T"; + +Deno.test("base58Decode keeps leading zero bytes", () => { + assertEquals(base58Decode(""), new Uint8Array()); + assertEquals(base58Decode("111"), new Uint8Array(3)); + assertEquals(base58Decode("11z"), Uint8Array.from([0, 0, 57])); + assertEquals(base58Decode("5Q"), Uint8Array.from([255])); + assertEquals(base58Decode("5R"), Uint8Array.from([1, 0])); + assertThrows(() => base58Decode("0"), Error, "not base58"); +}); + +Deno.test("parseRelayerKeys: base58 and solana-keygen JSON, comma-separated", () => { + assertEquals(parseRelayerKeys(BASE58_KEY), [KEY]); + assertEquals(parseRelayerKeys(JSON_KEY), [KEY]); + assertEquals(parseRelayerKeys(`${JSON_KEY}, ${BASE58_KEY},${JSON_KEY}`), [KEY, KEY, KEY]); +}); + +Deno.test("parseRelayerKeys: errors name the problem, never the key", () => { + const cases: [string, string][] = [ + ["", "SOLANA_RELAYER_KEYS must hold 1 to 32 keys"], + [" , ", "SOLANA_RELAYER_KEYS must hold 1 to 32 keys"], + [Array(33).fill(BASE58_KEY).join(","), "SOLANA_RELAYER_KEYS must hold 1 to 32 keys"], + ["[1,2,]", "SOLANA_RELAYER_KEYS: a JSON key is not valid JSON"], + ["[1,256]", "SOLANA_RELAYER_KEYS: a JSON key must be an array of bytes"], + ["[1.5]", "SOLANA_RELAYER_KEYS: a JSON key must be an array of bytes"], + [`0${BASE58_KEY}`, "SOLANA_RELAYER_KEYS: a key must be base58 or a JSON array"], + [BASE58_KEY.slice(0, 40), "SOLANA_RELAYER_KEYS: a key must be 64 bytes"], + [JSON.stringify([...KEY, 7]), "SOLANA_RELAYER_KEYS: a key must be 64 bytes"], + ]; + for (const [raw, message] of cases) { + const err = assertThrows(() => parseRelayerKeys(raw), Error); + assertEquals(err.message, message, raw); + assertFalse(err.message.includes(BASE58_KEY.slice(0, 8))); + } +}); diff --git a/packages/receipts-ts/test/memo_test.ts b/packages/receipts-ts/test/memo_test.ts new file mode 100644 index 0000000..01e98bb --- /dev/null +++ b/packages/receipts-ts/test/memo_test.ts @@ -0,0 +1,50 @@ +import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.19"; +import vectors from "../../test-vectors/receipts.json" with { type: "json" }; +import { + encodeOpenMemo, + encodeSealMemo, + MAX_MEMO_BYTES, + parseReceiptMemo, + ReceiptFormatError, +} from "../src/memo.ts"; + +Deno.test("seal memos encode to the exact vector bytes and parse back", () => { + for (const v of vectors.sealMemo) { + assertEquals(encodeSealMemo(v.input), v.memo, v.name); + assertEquals(parseReceiptMemo(v.memo), { v: 1, t: "seal", ...v.input }, v.name); + } +}); + +Deno.test("open memos encode to the exact vector bytes and parse back", () => { + for (const v of vectors.openMemo) { + assertEquals(encodeOpenMemo(v.input), v.memo, v.name); + assertEquals(parseReceiptMemo(v.memo), { v: 1, t: "open", ...v.input }, v.name); + } +}); + +Deno.test("the largest memo today is 219 bytes", () => { + const sizes = [...vectors.sealMemo, ...vectors.openMemo].map((v) => + new TextEncoder().encode(v.memo).length + ); + assertEquals(Math.max(...sizes), 219); +}); + +Deno.test("encoders reject bad fields with the shared messages", () => { + for (const v of vectors.encodeErrors) { + const encode = v.kind === "seal" + ? () => encodeSealMemo(v.input as Parameters[0]) + : () => encodeOpenMemo(v.input as Parameters[0]); + assertThrows(encode, ReceiptFormatError, v.error, v.name); + } +}); + +Deno.test("parseReceiptMemo rejects every invalid vector with the shared message", () => { + for (const v of vectors.invalidMemos) { + const err = assertThrows(() => parseReceiptMemo(v.memo), ReceiptFormatError); + assertEquals(err.message, v.error, v.name); + } +}); + +Deno.test("MAX_MEMO_BYTES matches the vectors", () => { + assertEquals(MAX_MEMO_BYTES, vectors.maxMemoBytes); +}); diff --git a/packages/test-vectors/devnet/2BHqnu.json b/packages/test-vectors/devnet/2BHqnu.json new file mode 100644 index 0000000..87f02d9 --- /dev/null +++ b/packages/test-vectors/devnet/2BHqnu.json @@ -0,0 +1,69 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1791631256, + "meta": { + "computeUnitsConsumed": 91585, + "costUnits": 92683, + "err": null, + "fee": 5000, + "innerInstructions": [], + "logMessages": [ + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr invoke [1]", + "Program log: Signed by CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "Program log: Memo (len 217): \"{\\\"p\\\":\\\"nosus-canary\\\",\\\"v\\\":1,\\\"t\\\":\\\"seal\\\",\\\"note\\\":\\\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\\\",\\\"copies\\\":3,\\\"hash\\\":\\\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\\\",\\\"exp\\\":1791634855}\"", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr consumed 91585 of 200000 compute units", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr success" + ], + "postBalances": [ + 4999995000, + 41509609334 + ], + "postTokenBalances": [], + "preBalances": [ + 5000000000, + 41509609334 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 509523773, + "transaction": { + "message": { + "accountKeys": [ + { + "pubkey": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "signer": true, + "source": "transaction", + "writable": true + }, + { + "pubkey": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "signer": false, + "source": "transaction", + "writable": false + } + ], + "addressTableLookups": [], + "instructions": [ + { + "parsed": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "program": "spl-memo", + "programId": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "stackHeight": 1 + } + ], + "recentBlockhash": "5RQyZ259mmkoeNFD4pfU7zzDwHc9zW1LijXT4GQxaQmB" + }, + "signatures": [ + "2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ" + ] + }, + "transactionIndex": 3, + "version": 0 + }, + "id": 1 +} diff --git a/packages/test-vectors/devnet/2b9SS9.json b/packages/test-vectors/devnet/2b9SS9.json new file mode 100644 index 0000000..637df3d --- /dev/null +++ b/packages/test-vectors/devnet/2b9SS9.json @@ -0,0 +1,69 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1791632860, + "meta": { + "computeUnitsConsumed": 84546, + "costUnits": 85639, + "err": null, + "fee": 5000, + "innerInstructions": [], + "logMessages": [ + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr invoke [1]", + "Program log: Signed by CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "Program log: Memo (len 197): \"{\\\"p\\\":\\\"nosus-canary\\\",\\\"v\\\":1,\\\"t\\\":\\\"open\\\",\\\"note\\\":\\\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\\\",\\\"copy\\\":0,\\\"tag\\\":\\\"0xe52417df0e5f0b104525ec1a583ec0bb64a71c2358a3e93d855b121172a4d10e\\\"}\"", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr consumed 84546 of 200000 compute units", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr success" + ], + "postBalances": [ + 4999900000, + 41509609334 + ], + "postTokenBalances": [], + "preBalances": [ + 4999905000, + 41509609334 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 509530464, + "transaction": { + "message": { + "accountKeys": [ + { + "pubkey": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "signer": true, + "source": "transaction", + "writable": true + }, + { + "pubkey": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "signer": false, + "source": "transaction", + "writable": false + } + ], + "addressTableLookups": [], + "instructions": [ + { + "parsed": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\",\"copy\":0,\"tag\":\"0xe52417df0e5f0b104525ec1a583ec0bb64a71c2358a3e93d855b121172a4d10e\"}", + "program": "spl-memo", + "programId": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "stackHeight": 1 + } + ], + "recentBlockhash": "EvrmQvr4RvAejEGnpTdvyc6QXNgJGKx9Uc5AF1Qz7Xw8" + }, + "signatures": [ + "2b9SS9RhwPvMrsDR5upY5a9w3gk7E2zgV4jmC69QTtZseBMmguXxNdBDqSFTZXW23cgLgAs8L9kFV39LyiU4bnTn" + ] + }, + "transactionIndex": 3, + "version": 0 + }, + "id": 1 +} diff --git a/packages/test-vectors/devnet/4NXs89.json b/packages/test-vectors/devnet/4NXs89.json new file mode 100644 index 0000000..05fabb3 --- /dev/null +++ b/packages/test-vectors/devnet/4NXs89.json @@ -0,0 +1,69 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1791632882, + "meta": { + "computeUnitsConsumed": 84546, + "costUnits": 85639, + "err": null, + "fee": 5000, + "innerInstructions": [], + "logMessages": [ + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr invoke [1]", + "Program log: Signed by CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "Program log: Memo (len 197): \"{\\\"p\\\":\\\"nosus-canary\\\",\\\"v\\\":1,\\\"t\\\":\\\"open\\\",\\\"note\\\":\\\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\\\",\\\"copy\\\":1,\\\"tag\\\":\\\"0x6ec7e19683ff70ce1d03ebf3df42242872f98407b47baada27b8cb50d934306e\\\"}\"", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr consumed 84546 of 200000 compute units", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr success" + ], + "postBalances": [ + 4999895000, + 41509609334 + ], + "postTokenBalances": [], + "preBalances": [ + 4999900000, + 41509609334 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 509530556, + "transaction": { + "message": { + "accountKeys": [ + { + "pubkey": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "signer": true, + "source": "transaction", + "writable": true + }, + { + "pubkey": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "signer": false, + "source": "transaction", + "writable": false + } + ], + "addressTableLookups": [], + "instructions": [ + { + "parsed": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\",\"copy\":1,\"tag\":\"0x6ec7e19683ff70ce1d03ebf3df42242872f98407b47baada27b8cb50d934306e\"}", + "program": "spl-memo", + "programId": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "stackHeight": 1 + } + ], + "recentBlockhash": "2D2Goyob1SvB8PEYNqjwGfs8FdJ4mHEMYs4mxzMSQy8f" + }, + "signatures": [ + "4NXs89eDP5hm2GyAkuA5rRDBQUNB5e6ssAChQJRCFGTYimyMDa3LmQRbcEVmaezQSXCx8kgt5GRkpUje59WqH2rc" + ] + }, + "transactionIndex": 5, + "version": 0 + }, + "id": 1 +} diff --git a/packages/test-vectors/devnet/62Frr2.json b/packages/test-vectors/devnet/62Frr2.json new file mode 100644 index 0000000..5634b2c --- /dev/null +++ b/packages/test-vectors/devnet/62Frr2.json @@ -0,0 +1,69 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1791632837, + "meta": { + "computeUnitsConsumed": 91949, + "costUnits": 93047, + "err": null, + "fee": 5000, + "innerInstructions": [], + "logMessages": [ + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr invoke [1]", + "Program log: Signed by CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "Program log: Memo (len 218): \"{\\\"p\\\":\\\"nosus-canary\\\",\\\"v\\\":1,\\\"t\\\":\\\"seal\\\",\\\"note\\\":\\\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\\\",\\\"copies\\\":10,\\\"hash\\\":\\\"0x40846d626dd4beb151497162ca5c1de105281a20d076223b76a44ab1d47962df\\\",\\\"exp\\\":1791719237}\"", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr consumed 91949 of 200000 compute units", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr success" + ], + "postBalances": [ + 4999905000, + 41509609334 + ], + "postTokenBalances": [], + "preBalances": [ + 4999910000, + 41509609334 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 509530368, + "transaction": { + "message": { + "accountKeys": [ + { + "pubkey": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "signer": true, + "source": "transaction", + "writable": true + }, + { + "pubkey": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "signer": false, + "source": "transaction", + "writable": false + } + ], + "addressTableLookups": [], + "instructions": [ + { + "parsed": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\",\"copies\":10,\"hash\":\"0x40846d626dd4beb151497162ca5c1de105281a20d076223b76a44ab1d47962df\",\"exp\":1791719237}", + "program": "spl-memo", + "programId": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "stackHeight": 1 + } + ], + "recentBlockhash": "DMmmhJAc7wUo9iozeGYotjnentLqav6HjB5copymG2hJ" + }, + "signatures": [ + "62Frr29RyPXhhUkAaxDLQHDKinMztPhrsYeokzvoiAAUjp9JBXbNUn3S6D4gX7QEqt2yfurqG54PAERb7fLKCskF" + ] + }, + "transactionIndex": 2, + "version": 0 + }, + "id": 1 +} diff --git a/packages/test-vectors/devnet/NwoJau.json b/packages/test-vectors/devnet/NwoJau.json new file mode 100644 index 0000000..29d93d7 --- /dev/null +++ b/packages/test-vectors/devnet/NwoJau.json @@ -0,0 +1,69 @@ +{ + "jsonrpc": "2.0", + "result": { + "blockTime": 1791631257, + "meta": { + "computeUnitsConsumed": 84546, + "costUnits": 85639, + "err": null, + "fee": 5000, + "innerInstructions": [], + "logMessages": [ + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr invoke [1]", + "Program log: Signed by CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "Program log: Memo (len 197): \"{\\\"p\\\":\\\"nosus-canary\\\",\\\"v\\\":1,\\\"t\\\":\\\"open\\\",\\\"note\\\":\\\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\\\",\\\"copy\\\":0,\\\"tag\\\":\\\"0x10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6\\\"}\"", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr consumed 84546 of 200000 compute units", + "Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr success" + ], + "postBalances": [ + 4999990000, + 41509609334 + ], + "postTokenBalances": [], + "preBalances": [ + 4999995000, + 41509609334 + ], + "preTokenBalances": [], + "rewards": [], + "status": { + "Ok": null + } + }, + "slot": 509523778, + "transaction": { + "message": { + "accountKeys": [ + { + "pubkey": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "signer": true, + "source": "transaction", + "writable": true + }, + { + "pubkey": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "signer": false, + "source": "transaction", + "writable": false + } + ], + "addressTableLookups": [], + "instructions": [ + { + "parsed": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copy\":0,\"tag\":\"0x10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6\"}", + "program": "spl-memo", + "programId": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "stackHeight": 1 + } + ], + "recentBlockhash": "81rte1Ja2nCdibyXqeEzJZFsAp7RP1t2wgBjjdygowLd" + }, + "signatures": [ + "NwoJauec9otkicaGf8yD6UechYgUnXRdYPqnuc5Kpn6uMYym6XVJcMmWdbVnwnRVncKKVgEyuZB2mR3T5REvdSr" + ] + }, + "transactionIndex": 10, + "version": 0 + }, + "id": 1 +} diff --git a/packages/test-vectors/receipts.json b/packages/test-vectors/receipts.json new file mode 100644 index 0000000..53c3613 --- /dev/null +++ b/packages/test-vectors/receipts.json @@ -0,0 +1,453 @@ +{ + "about": "NO SUS Canary receipt vectors, memo format v1. Both packages test against this file.", + "maxMemoBytes": 566, + "chainNoteId": [ + { + "noteId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", + "expected": "0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e" + }, + { + "noteId": "00000000-0000-4000-8000-000000000000", + "expected": "0x88538326b97d656f5d8f8cd20a2673e4621c615292f8ef03207faef39f93d7f7" + }, + { + "noteId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "expected": "0xbf3c93bbf98524532e883d50f2790461d21e2664f1d8b1448aeba7ab9c80d812" + } + ], + "deviceHash": [ + { + "deviceId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "expected": "9738e16064bc6e9a5ac8505293475b0717e349efc66d42a058608d402a0b3372" + }, + { + "deviceId": "device-on-a-test-phone", + "expected": "db5d5e52631e9e113a27dbbf52d93cc2c4273e3f136bc44aa1968bea99c81924" + } + ], + "copyDigest": [ + { + "saltHex": "5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", + "index": 0, + "copyText": "Lunch is at noon on Friday.", + "expected": "19c74fd9f2a8a2264ea8a8af015977085578380cd7272522b785e1fd15f42dab" + }, + { + "saltHex": "5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", + "index": 1, + "copyText": "Lunch is at noon on Friday.​", + "expected": "5b183e08c2cf4d6dc49bfb52732e0fdb7b95ec80e22a03cebf72037e2ac79827" + }, + { + "saltHex": "5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", + "index": 2, + "copyText": "Café ☕ at noon.", + "expected": "a1981e202f5edc1f9ee01fba89418946fbab343c1c60707e89a1e91e9e056867" + } + ], + "copiesHash": [ + { + "digests": [ + "19c74fd9f2a8a2264ea8a8af015977085578380cd7272522b785e1fd15f42dab", + "5b183e08c2cf4d6dc49bfb52732e0fdb7b95ec80e22a03cebf72037e2ac79827", + "a1981e202f5edc1f9ee01fba89418946fbab343c1c60707e89a1e91e9e056867" + ], + "expected": "0x7103d90f50d512f9f9ec27663bb1a9e15f5598d7f9e71395ed4197758a0754f4" + }, + { + "digests": [ + "19c74fd9f2a8a2264ea8a8af015977085578380cd7272522b785e1fd15f42dab", + "5b183e08c2cf4d6dc49bfb52732e0fdb7b95ec80e22a03cebf72037e2ac79827" + ], + "expected": "0x1c15eeda35f08e6c3f71856d299e32a91d33e5c6397078dbc14362a99d1e0735" + } + ], + "readerTag": [ + { + "tagSalt": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "noteId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", + "copyIndex": 0, + "deviceHash": "9738e16064bc6e9a5ac8505293475b0717e349efc66d42a058608d402a0b3372", + "expected": "0x3d7f04addc9492ec9b75534e7174b076a746f33235bef0ee31e944b0894d4fab" + }, + { + "tagSalt": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "noteId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", + "copyIndex": 2, + "deviceHash": "9738e16064bc6e9a5ac8505293475b0717e349efc66d42a058608d402a0b3372", + "expected": "0x5b95884239658bc8d460e497449fa024c892cabf1c90f2eaf4174917ce3b0c81" + } + ], + "sealMemo": [ + { + "name": "devnet 2BHqnu", + "input": { + "note": "0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40", + "copies": 3, + "hash": "0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8", + "exp": 1791634855 + }, + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}" + }, + { + "name": "devnet 62Frr2", + "input": { + "note": "0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d", + "copies": 10, + "hash": "0x40846d626dd4beb151497162ca5c1de105281a20d076223b76a44ab1d47962df", + "exp": 1791719237 + }, + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\",\"copies\":10,\"hash\":\"0x40846d626dd4beb151497162ca5c1de105281a20d076223b76a44ab1d47962df\",\"exp\":1791719237}" + }, + { + "name": "never expires", + "input": { + "note": "0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e", + "copies": 3, + "hash": "0x7103d90f50d512f9f9ec27663bb1a9e15f5598d7f9e71395ed4197758a0754f4", + "exp": 0 + }, + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e\",\"copies\":3,\"hash\":\"0x7103d90f50d512f9f9ec27663bb1a9e15f5598d7f9e71395ed4197758a0754f4\",\"exp\":0}" + }, + { + "name": "largest seal today (100 copies)", + "input": { + "note": "0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e", + "copies": 100, + "hash": "0x7103d90f50d512f9f9ec27663bb1a9e15f5598d7f9e71395ed4197758a0754f4", + "exp": 1791634855 + }, + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e\",\"copies\":100,\"hash\":\"0x7103d90f50d512f9f9ec27663bb1a9e15f5598d7f9e71395ed4197758a0754f4\",\"exp\":1791634855}" + } + ], + "openMemo": [ + { + "name": "devnet NwoJau", + "input": { + "note": "0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40", + "copy": 0, + "tag": "0x10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6" + }, + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copy\":0,\"tag\":\"0x10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6\"}" + }, + { + "name": "devnet 2b9SS9", + "input": { + "note": "0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d", + "copy": 0, + "tag": "0xe52417df0e5f0b104525ec1a583ec0bb64a71c2358a3e93d855b121172a4d10e" + }, + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\",\"copy\":0,\"tag\":\"0xe52417df0e5f0b104525ec1a583ec0bb64a71c2358a3e93d855b121172a4d10e\"}" + }, + { + "name": "devnet 4NXs89", + "input": { + "note": "0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d", + "copy": 1, + "tag": "0x6ec7e19683ff70ce1d03ebf3df42242872f98407b47baada27b8cb50d934306e" + }, + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\",\"copy\":1,\"tag\":\"0x6ec7e19683ff70ce1d03ebf3df42242872f98407b47baada27b8cb50d934306e\"}" + }, + { + "name": "copy 99", + "input": { + "note": "0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e", + "copy": 99, + "tag": "0x5b95884239658bc8d460e497449fa024c892cabf1c90f2eaf4174917ce3b0c81" + }, + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e\",\"copy\":99,\"tag\":\"0x5b95884239658bc8d460e497449fa024c892cabf1c90f2eaf4174917ce3b0c81\"}" + } + ], + "encodeErrors": [ + { + "name": "seal: uppercase note", + "kind": "seal", + "input": { + "note": "0xB8168EFCC5C17AFC454283C0E2237B39F5DC917D39AB0EB0F540F544B9878F2E", + "copies": 3, + "hash": "0x7103d90f50d512f9f9ec27663bb1a9e15f5598d7f9e71395ed4197758a0754f4", + "exp": 0 + }, + "error": "memo note must be 0x + 64 hex chars" + }, + { + "name": "seal: hash without 0x", + "kind": "seal", + "input": { + "note": "0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e", + "copies": 3, + "hash": "7103d90f50d512f9f9ec27663bb1a9e15f5598d7f9e71395ed4197758a0754f4", + "exp": 0 + }, + "error": "memo hash must be 0x + 64 hex chars" + }, + { + "name": "seal: negative copies", + "kind": "seal", + "input": { + "note": "0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e", + "copies": -1, + "hash": "0x7103d90f50d512f9f9ec27663bb1a9e15f5598d7f9e71395ed4197758a0754f4", + "exp": 0 + }, + "error": "memo copies must be a non-negative integer" + }, + { + "name": "seal: negative exp", + "kind": "seal", + "input": { + "note": "0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e", + "copies": 3, + "hash": "0x7103d90f50d512f9f9ec27663bb1a9e15f5598d7f9e71395ed4197758a0754f4", + "exp": -5 + }, + "error": "memo exp must be a non-negative integer" + }, + { + "name": "open: short tag", + "kind": "open", + "input": { + "note": "0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e", + "copy": 0, + "tag": "0xababababababababababababababababababababababababababababababab" + }, + "error": "memo tag must be 0x + 64 hex chars" + }, + { + "name": "open: negative copy", + "kind": "open", + "input": { + "note": "0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e", + "copy": -1, + "tag": "0x3d7f04addc9492ec9b75534e7174b076a746f33235bef0ee31e944b0894d4fab" + }, + "error": "memo copy must be a non-negative integer" + } + ], + "invalidMemos": [ + { + "name": "empty string", + "memo": "", + "error": "memo is not JSON" + }, + { + "name": "not JSON", + "memo": "hello", + "error": "memo is not JSON" + }, + { + "name": "truncated JSON", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855", + "error": "memo is not JSON" + }, + { + "name": "JSON array", + "memo": "[1,2]", + "error": "memo is not a JSON object" + }, + { + "name": "JSON null", + "memo": "null", + "error": "memo is not a JSON object" + }, + { + "name": "longer than 566 bytes", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"pad\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + "error": "memo is longer than 566 bytes" + }, + { + "name": "wrong p", + "memo": "{\"p\":\"nosus\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo p must be \"nosus-canary\"" + }, + { + "name": "missing p", + "memo": "{\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo p must be \"nosus-canary\"" + }, + { + "name": "v=2", + "memo": "{\"p\":\"nosus-canary\",\"v\":2,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo v must be 1" + }, + { + "name": "v as string", + "memo": "{\"p\":\"nosus-canary\",\"v\":\"1\",\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo v must be 1" + }, + { + "name": "unknown t", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"burn\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo t must be \"seal\" or \"open\"" + }, + { + "name": "extra key", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855,\"x\":1}", + "error": "memo keys must be p,v,t,note,copies,hash,exp in this order" + }, + { + "name": "missing key (seal exp)", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\"}", + "error": "memo keys must be p,v,t,note,copies,hash,exp in this order" + }, + { + "name": "missing key (open tag)", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copy\":0}", + "error": "memo keys must be p,v,t,note,copy,tag in this order" + }, + { + "name": "keys out of order", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"copies\":3,\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo keys must be p,v,t,note,copies,hash,exp in this order" + }, + { + "name": "open with seal keys", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo keys must be p,v,t,note,copy,tag in this order" + }, + { + "name": "uppercase hex note", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xE5BBE8D0602B1DFDA55EA516343E6A5064D021C37E6B7BA648BC5719AEA1FC40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo note must be 0x + 64 hex chars" + }, + { + "name": "uppercase 0X prefix", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copy\":0,\"tag\":\"0X10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6\"}", + "error": "memo tag must be 0x + 64 hex chars" + }, + { + "name": "short hex hash", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271\",\"exp\":1791634855}", + "error": "memo hash must be 0x + 64 hex chars" + }, + { + "name": "note as number", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":12,\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo note must be 0x + 64 hex chars" + }, + { + "name": "negative copies", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":-3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo copies must be a non-negative integer" + }, + { + "name": "float copies", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":1.5,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo copies must be a non-negative integer" + }, + { + "name": "negative copy", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copy\":-1,\"tag\":\"0x10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6\"}", + "error": "memo copy must be a non-negative integer" + }, + { + "name": "float exp", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855.5}", + "error": "memo exp must be a non-negative integer" + }, + { + "name": "copies as string", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":\"3\",\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo copies must be a non-negative integer" + }, + { + "name": "exp above 2^53", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":9007199254740993}", + "error": "memo exp must be a non-negative integer" + }, + { + "name": "integer written as 3.0", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3.0,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo is not in canonical form" + }, + { + "name": "integer written as 3e0", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3e0,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo is not in canonical form" + }, + { + "name": "v written as 1.0", + "memo": "{\"p\":\"nosus-canary\",\"v\":1.0,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo is not in canonical form" + }, + { + "name": "whitespace", + "memo": "{\"p\":\"nosus-canary\",\"v\": 1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}", + "error": "memo is not in canonical form" + }, + { + "name": "trailing newline", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}\n", + "error": "memo is not in canonical form" + }, + { + "name": "duplicate key", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855,\"p\":\"nosus-canary\"}", + "error": "memo is not in canonical form" + } + ], + "rawMemoInstruction": { + "programId": "Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copy\":0,\"tag\":\"0x10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6\"}", + "data": "Z7gZmhkMcFnC3TVXoi3681EbSqDfPuwjedJgqq7RUjdYgAz8VbnbKPJ1zFaos13xmGoS1Srt5vFCBRvx1qHiLLN4Ph2oW7Rvxs1nNANHUPiADwEYgbyQBjpG2ZBuSFgyDG57qPSDKvaUhjpGkDjJDFaUDJQmjVkiTz9Bn9q8WbMuUgD9oacePZsftzD4QsoEeiUXVTA5bE19kkTT4T3ye8gRwxP9m6EsxgrTrvmJ9j7mxvhNkFeNcs2JjEkYUv7JNJ9e3tXeJVVgg" + }, + "explorerTxUrl": [ + { + "signature": "2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ", + "cluster": "devnet", + "expected": "https://explorer.solana.com/tx/2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ?cluster=devnet" + }, + { + "signature": "2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ", + "cluster": "mainnet-beta", + "expected": "https://explorer.solana.com/tx/2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ" + } + ], + "devnet": [ + { + "signature": "2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ", + "fixture": "devnet/2BHqnu.json", + "slot": 509523773, + "blockTime": 1791631256, + "feePayer": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "memoProgram": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copies\":3,\"hash\":\"0x2a1ab6e3b5fae89ea53b689e88c9f6560d2b33711e36cc1d4524df6c680271e8\",\"exp\":1791634855}" + }, + { + "signature": "NwoJauec9otkicaGf8yD6UechYgUnXRdYPqnuc5Kpn6uMYym6XVJcMmWdbVnwnRVncKKVgEyuZB2mR3T5REvdSr", + "fixture": "devnet/NwoJau.json", + "slot": 509523778, + "blockTime": 1791631257, + "feePayer": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "memoProgram": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0xe5bbe8d0602b1dfda55ea516343e6a5064d021c37e6b7ba648bc5719aea1fc40\",\"copy\":0,\"tag\":\"0x10947d1df8aaba2c25d2088b054593cfdafd46f959c89007447309960b4845e6\"}" + }, + { + "signature": "62Frr29RyPXhhUkAaxDLQHDKinMztPhrsYeokzvoiAAUjp9JBXbNUn3S6D4gX7QEqt2yfurqG54PAERb7fLKCskF", + "fixture": "devnet/62Frr2.json", + "slot": 509530368, + "blockTime": 1791632837, + "feePayer": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "memoProgram": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"seal\",\"note\":\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\",\"copies\":10,\"hash\":\"0x40846d626dd4beb151497162ca5c1de105281a20d076223b76a44ab1d47962df\",\"exp\":1791719237}" + }, + { + "signature": "2b9SS9RhwPvMrsDR5upY5a9w3gk7E2zgV4jmC69QTtZseBMmguXxNdBDqSFTZXW23cgLgAs8L9kFV39LyiU4bnTn", + "fixture": "devnet/2b9SS9.json", + "slot": 509530464, + "blockTime": 1791632860, + "feePayer": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "memoProgram": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\",\"copy\":0,\"tag\":\"0xe52417df0e5f0b104525ec1a583ec0bb64a71c2358a3e93d855b121172a4d10e\"}" + }, + { + "signature": "4NXs89eDP5hm2GyAkuA5rRDBQUNB5e6ssAChQJRCFGTYimyMDa3LmQRbcEVmaezQSXCx8kgt5GRkpUje59WqH2rc", + "fixture": "devnet/4NXs89.json", + "slot": 509530556, + "blockTime": 1791632882, + "feePayer": "CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx", + "memoProgram": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "memo": "{\"p\":\"nosus-canary\",\"v\":1,\"t\":\"open\",\"note\":\"0x56f7956802f494824332309f033188a8850e78b4aed1eca6b1d923534315831d\",\"copy\":1,\"tag\":\"0x6ec7e19683ff70ce1d03ebf3df42242872f98407b47baada27b8cb50d934306e\"}" + } + ] +} diff --git a/supabase/.gitignore b/supabase/.gitignore deleted file mode 100644 index ad9264f..0000000 --- a/supabase/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Supabase -.branches -.temp - -# dotenvx -.env.keys -.env.local -.env.*.local diff --git a/supabase/AGENTS.md b/supabase/AGENTS.md deleted file mode 100644 index 2948056..0000000 --- a/supabase/AGENTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Supabase scope - -Read root `AGENTS.md` and `README.md` first. - -- This is a separate experiment backend; production NO SUS project IDs, URLs, - keys, migrations, and buckets are forbidden here. -- Create new migrations with the Supabase CLI. Applied migrations are - append-only. -- Enable RLS on every table and deny browser-table access by default. Use - narrow server routes and the server-only service role only when necessary. -- Never place plaintext, decryption keys, Lit session signatures, or secrets in - Postgres, Storage metadata, logs, or database functions. - -Would you like to finalize today's work? If yes, say whether to verify only, -build a target, or deploy a named target. diff --git a/supabase/README.md b/supabase/README.md deleted file mode 100644 index 6af18d4..0000000 --- a/supabase/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Supabase backend - -This directory is for a **new, isolated** Supabase project for the Monad -experiment. It must never be linked to production NO SUS. - -## Purpose - -The backend stores encrypted payloads, Lit metadata, public receipt metadata, -and event-indexing records. It does not accept, derive, log, or return -plaintext or decryption keys. - -## Entry points - -- `migrations/20260916082811_experiment_drops.sql` — initial RLS-protected - schema and private encrypted-drop storage bucket -- `../web/src/app/api/` — server-only, allow-listed read API for the live wall - and receipt verifier - -## Rules - -- Migrations are append-only. Make a new migration with `supabase migration new - `; never rewrite one that has been applied. -- RLS stays enabled. Browser clients receive only the publishable key; the - service-role key is server-only. -- Do not apply this migration, link a project, create a bucket remotely, or - store real notes until the root finalization gate is explicitly approved. diff --git a/supabase/migrations/20260916082811_experiment_drops.sql b/supabase/migrations/20260916082811_experiment_drops.sql deleted file mode 100644 index 175a2f7..0000000 --- a/supabase/migrations/20260916082811_experiment_drops.sql +++ /dev/null @@ -1,69 +0,0 @@ --- NO SUS — Monad Experiment. Separate project only; never apply to production NO SUS. --- The browser never writes these tables directly. Server-side event indexing and --- encrypted-payload persistence use the service role after on-chain validation. - -create table if not exists public.experiment_drops ( - id text primary key check (id ~ '^0x[0-9a-f]{64}$'), - sender_wallet text not null check (sender_wallet ~ '^0x[0-9a-f]{40}$'), - recipient_wallet text check (recipient_wallet is null or recipient_wallet ~ '^0x[0-9a-f]{40}$'), - opener_wallet text check (opener_wallet is null or opener_wallet ~ '^0x[0-9a-f]{40}$'), - ciphertext_digest text not null check (ciphertext_digest ~ '^0x[0-9a-f]{64}$'), - ciphertext_payload text not null, - lit_metadata jsonb not null check (jsonb_typeof(lit_metadata) = 'object'), - storage_path text unique, - expires_at timestamptz, - sealed_tx_hash text unique check (sealed_tx_hash is null or sealed_tx_hash ~ '^0x[0-9a-f]{64}$'), - opened_tx_hash text unique check (opened_tx_hash is null or opened_tx_hash ~ '^0x[0-9a-f]{64}$'), - created_at timestamptz not null default now(), - opened_at timestamptz, - constraint acknowledged_drop_has_opener check ( - (opened_at is null and opener_wallet is null and opened_tx_hash is null) - or (opened_at is not null and opener_wallet is not null and opened_tx_hash is not null) - ) -); - -create table if not exists public.experiment_drop_events ( - id bigint generated always as identity primary key, - drop_id text not null references public.experiment_drops(id) on delete cascade, - event_type text not null check (event_type in ('sealed', 'opened')), - actor_wallet text not null check (actor_wallet ~ '^0x[0-9a-f]{40}$'), - transaction_hash text not null check (transaction_hash ~ '^0x[0-9a-f]{64}$'), - block_number bigint not null check (block_number >= 0), - event_at timestamptz not null default now(), - unique (transaction_hash, event_type) -); - -create index if not exists experiment_drops_created_at_idx - on public.experiment_drops (created_at desc); -create index if not exists experiment_drops_opened_at_idx - on public.experiment_drops (opened_at desc nulls last); -create index if not exists experiment_drop_events_drop_id_idx - on public.experiment_drop_events (drop_id, event_at desc); - -alter table public.experiment_drops enable row level security; -alter table public.experiment_drop_events enable row level security; - --- No anon/authenticated policy is intentional. The only browser-safe reads are --- the explicit, allow-listed Next.js routes; service-role callers bypass RLS. -revoke all on table public.experiment_drops from anon, authenticated; -revoke all on table public.experiment_drop_events from anon, authenticated; - -insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) -values ( - 'encrypted-drops', - 'encrypted-drops', - false, - 1048576, - array['application/octet-stream', 'application/json'] -) -on conflict (id) do update set - public = excluded.public, - file_size_limit = excluded.file_size_limit, - allowed_mime_types = excluded.allowed_mime_types; - --- Private by default: no storage.objects policies are created for anon or --- authenticated. The server validates then uses the service role for writes. -comment on table public.experiment_drops is - 'Encrypted-drop metadata for the Monad testnet experiment. Never stores plaintext or decryption keys.'; -comment on table public.experiment_drop_events is - 'Indexed Sealed and OpenAcknowledged events for the public Receipt Wall.'; diff --git a/tool/build-backend-bootstrap.mjs b/tool/build-backend-bootstrap.mjs new file mode 100644 index 0000000..ee16177 --- /dev/null +++ b/tool/build-backend-bootstrap.mjs @@ -0,0 +1,19 @@ +// Generate the isolated fresh-project bootstrap without editing historical SQL. +// This generates SQL only; applying it always requires an explicit project ID. +import { readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const folder = resolve(root, 'app/supabase/migrations'); +let sql = readdirSync(folder).filter(name => name.endsWith('.sql')).sort() + .map(name => readFileSync(resolve(folder, name), 'utf8')).join('\n'); +sql = sql.replaceAll('rxfnazmusofikwaggntb.supabase.co', 'ffidvfguojalzpclipzi.supabase.co'); +// Production had an orphaned function, absent in a fresh DB. A later migration +// drops it. Skip only its search_path adjustment when it does not exist. +sql = sql.replace('ALTER FUNCTION public.revoke_device(uuid) SET search_path = public;', + "DO $bootstrap$ BEGIN IF to_regprocedure('public.revoke_device(uuid)') IS NOT NULL THEN ALTER FUNCTION public.revoke_device(uuid) SET search_path = public; END IF; END $bootstrap$;"); +if (sql.includes('rxfnazmusofikwaggntb.supabase.co')) throw new Error('Production URL in bootstrap'); +const output = resolve(root, 'app/supabase/.temp/experiment-bootstrap.sql'); +mkdirSync(dirname(output), { recursive: true }); +writeFileSync(output, sql); +console.log('Generated fresh-project SQL for ffidvfguojalzpclipzi only:', output); diff --git a/tool/check-repository.mjs b/tool/check-repository.mjs deleted file mode 100644 index 481817d..0000000 --- a/tool/check-repository.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { join, relative } from "node:path"; - -const root = process.cwd(); -const required = [ - "AGENTS.md", - "CLAUDE.md", - "README.md", - "docs/STATUS.md", - "docs/CURSOR_HANDOFF.md", - "docs/ARCHITECTURE.md", - "docs/LIT_MONAD_SPIKE.md", - "web/src/lib/lit-conditions.ts", - "tool/threshold-spike/conditions.mjs", - "contracts/README.md", - "contracts/AGENTS.md", - "web/README.md", - "web/AGENTS.md", - "supabase/README.md", - "supabase/AGENTS.md", - ".cursor/rules/00-project.mdc", - ".cursor/rules/10-contracts.mdc", - ".cursor/rules/20-web.mdc", -]; - -const failures = required.filter((file) => !existsSync(join(root, file))).map( - (file) => `Missing required navigation file: ${file}`, -); - -const rootRules = readFileSync(join(root, "AGENTS.md"), "utf8"); -if (!rootRules.includes("Would you like to finalize today's work?")) { - failures.push("AGENTS.md lacks the required finalization prompt."); -} - -function visit(directory) { - for (const item of readdirSync(directory)) { - if ([".git", "node_modules", ".next", "artifacts", "cache"].includes(item)) continue; - const fullPath = join(directory, item); - if (statSync(fullPath).isDirectory()) visit(fullPath); - else if (item.endsWith(".mdc")) { - const contents = readFileSync(fullPath, "utf8"); - if (!contents.startsWith("---\n")) { - failures.push(`Cursor rule lacks front matter: ${relative(root, fullPath)}`); - } - } - } -} -visit(join(root, ".cursor")); - -if (failures.length) { - console.error(failures.join("\n")); - process.exit(1); -} - -console.log("Repository navigation and Cursor handoff checks passed."); diff --git a/tool/prepare-experiment-secrets.mjs b/tool/prepare-experiment-secrets.mjs new file mode 100644 index 0000000..a1f61fa --- /dev/null +++ b/tool/prepare-experiment-secrets.mjs @@ -0,0 +1,18 @@ +// Fresh backend salts only. Never reads production credentials or prints secrets. +import { randomBytes } from 'node:crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const app = resolve(dirname(fileURLToPath(import.meta.url)), '../app'); +const envPath = resolve(app, '.env.local'); +if (existsSync(envPath)) throw new Error('Experiment secrets already exist; refusing to rotate.'); +if (readFileSync(resolve(app, 'supabase/.temp/project-ref'), 'utf8').trim() !== 'ffidvfguojalzpclipzi') { + throw new Error('Wrong backend target'); +} +const secrets = Object.fromEntries(['BURN_FILES_IP_SALT', 'REDEMPTION_CODE_SALT', 'BURN_FILES_CRON_SECRET'] + .map(name => [name, randomBytes(32).toString('hex')])); +writeFileSync(envPath, Object.entries(secrets).map(([name, value]) => `${name}=${value}`).join('\n') + '\n', {flag: 'wx', mode: 0o600}); +mkdirSync(resolve(app, 'supabase/.temp'), {recursive: true}); +writeFileSync(resolve(app, 'supabase/.temp/setup-cron.sql'), + `select vault.create_secret('${secrets.BURN_FILES_CRON_SECRET}', 'burn_files_cron_secret');\n`, {flag: 'wx', mode: 0o600}); +console.log('Generated ignored, experiment-only backend secrets.'); diff --git a/tool/solana-spike/.gitignore b/tool/solana-spike/.gitignore new file mode 100644 index 0000000..8535fbc --- /dev/null +++ b/tool/solana-spike/.gitignore @@ -0,0 +1,2 @@ +# Devnet relayer keypair written by send_memo.ts. Never commit. +.keys/ diff --git a/tool/solana-spike/README.md b/tool/solana-spike/README.md new file mode 100644 index 0000000..da6c07e --- /dev/null +++ b/tool/solana-spike/README.md @@ -0,0 +1,39 @@ +# Solana devnet memo spike + +Sends one NO SUS Canary "seal" memo and one "open" memo to Solana devnet through +the production module `app/supabase/functions/canary/solana.ts` (the exact code +path the `canary` edge function uses), then reads one transaction back and +checks that the decoded memo equals what was sent. Memos carry dummy +sha256-shaped ids only; no note text, names or keys. + +Deno is not installed in this repo; `npx` fetches the official npm build: + +```sh +npx -y deno run -A tool/solana-spike/send_memo.ts +``` + +## Keys + +On the first run the script generates a devnet keypair and writes it to +`tool/solana-spike/.keys/relayer.json` in `solana-keygen` format (a JSON array +of 64 bytes). The directory is git-ignored and the secret is never printed; +only the public key is. Later runs reuse the same file. + +The script feeds that file to `loadSolana()` through `SOLANA_RELAYER_KEYS`, so it +also proves the JSON-array form of the secret parses. `SOLANA_RPC_URL` is +honoured if set (default `https://api.devnet.solana.com`). + +## Funding + +Devnet SOL is free. The script asks the RPC faucet for 1 SOL when the balance is +below 0.01 SOL and retries a few times with backoff. The public faucet is +rate-limited; if it keeps refusing, fund the printed public key by hand at + and run the script again. One memo costs 5000 +lamports (0.000005 SOL), so 1 SOL covers about 200,000 memos. + +## Using the key as the edge function relayer + +Owner only, experiment project only, with explicit approval (see `AGENTS.md`): +set `SOLANA_RELAYER_KEYS` to the contents of `relayer.json` (or to a base58 +64-byte secret key; several keys may be comma-separated). Leaving it unset keeps +the Solana proof off and the function behaves exactly as before. diff --git a/tool/solana-spike/send_memo.ts b/tool/solana-spike/send_memo.ts new file mode 100644 index 0000000..13b1e57 --- /dev/null +++ b/tool/solana-spike/send_memo.ts @@ -0,0 +1,164 @@ +// NO SUS Canary: Solana devnet memo spike. +// +// Exercises the exact production code path (app/supabase/functions/canary/ +// solana.ts): loads or generates a devnet relayer key, funds it by airdrop, +// sends one seal memo and one open memo, then reads one back and checks the +// text. The secret key is written to .keys/relayer.json and never printed. +// +// npx -y deno run -A tool/solana-spike/send_memo.ts + +import { + createKeyPairSignerFromBytes, + createKeyPairSignerFromPrivateKeyBytes, + createSolanaRpc, + getBase58Encoder, + lamports, + type Signature, +} from "npm:@solana/kit@8.4.0"; +import { + loadSolana, + openMemo, + sealMemo, + sendSolanaMemo, +} from "../../app/supabase/functions/canary/solana.ts"; + +const KEYS_DIR = new URL(".keys/", import.meta.url); +const KEY_FILE = new URL("relayer.json", KEYS_DIR); +const MIN_BALANCE = 10_000_000n; // 0.01 SOL +const AIRDROP = lamports(1_000_000_000n); // 1 SOL + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function hex64(): string { + return Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function explorer(sig: string, rpcUrl: string): string { + const cluster = rpcUrl.includes("testnet") ? "testnet" : "devnet"; + return `https://explorer.solana.com/tx/${sig}?cluster=${cluster}`; +} + +// Returns the solana-keygen style JSON array (64 bytes) for the relayer, +// generating and saving a new one when the file does not exist yet. +async function loadOrCreateKeyJson(): Promise { + try { + return (await Deno.readTextFile(KEY_FILE)).trim(); + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; + } + const seed = crypto.getRandomValues(new Uint8Array(32)); + const signer = await createKeyPairSignerFromPrivateKeyBytes(seed); + const secret = new Uint8Array(64); + secret.set(seed, 0); + secret.set(getBase58Encoder().encode(signer.address), 32); + const json = JSON.stringify(Array.from(secret)); + await Deno.mkdir(KEYS_DIR, { recursive: true }); + await Deno.writeTextFile(KEY_FILE, json + "\n"); + console.log("Generated a new devnet keypair in tool/solana-spike/.keys/relayer.json"); + return json; +} + +type Rpc = ReturnType; + +async function waitConfirmed(rpc: Rpc, sig: Signature, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const { value: [status] } = await rpc.getSignatureStatuses([sig]).send(); + if (status?.err) return false; + if (status?.confirmationStatus === "confirmed" || status?.confirmationStatus === "finalized") { + return true; + } + await sleep(500); + } + return false; +} + +async function main() { + Deno.env.set("SOLANA_RELAYER_KEYS", await loadOrCreateKeyJson()); + const cfg = loadSolana(); + if (!cfg) throw new Error("loadSolana() returned null although SOLANA_RELAYER_KEYS is set"); + const signer = await createKeyPairSignerFromBytes(cfg.keys[0]); + console.log(`Relayer public key: ${signer.address}`); + console.log(`RPC: ${cfg.rpcUrl}`); + console.log(`Memo program: ${cfg.memoProgram}`); + + const rpc = createSolanaRpc(cfg.rpcUrl); + const balance = async () => + (await rpc.getBalance(signer.address, { commitment: "confirmed" }).send()).value; + console.log(`Balance: ${Number(await balance()) / 1e9} SOL`); + + if ((await balance()) < MIN_BALANCE) { + let funded = false; + for (let attempt = 1; attempt <= 4 && !funded; attempt++) { + try { + const sig = await rpc.requestAirdrop(signer.address, AIRDROP, { commitment: "confirmed" }) + .send(); + console.log(`Airdrop requested (${attempt}/4): ${sig}`); + funded = await waitConfirmed(rpc, sig, 30_000); + } catch (e) { + console.log(`Airdrop attempt ${attempt}/4 failed: ${e instanceof Error ? e.message : String(e)}`); + } + if (!funded && attempt < 4) await sleep(2_000 * 2 ** (attempt - 1)); + } + if (!funded) { + console.error( + `Devnet airdrop is rate-limited. Fund ${signer.address} at https://faucet.solana.com and run again.`, + ); + Deno.exit(1); + } + console.log(`Balance after airdrop: ${Number(await balance()) / 1e9} SOL`); + } + + const note = `0x${hex64()}`; + const seal = sealMemo({ + note, + copies: 3, + hash: `0x${hex64()}`, + exp: Math.floor(Date.now() / 1000) + 3600, + }); + console.log(`\nSeal memo (${new TextEncoder().encode(seal).length} bytes): ${seal}`); + let t0 = Date.now(); + const sealSig = await sendSolanaMemo(cfg, seal); + console.log(` confirmed in ${Date.now() - t0} ms: ${sealSig}`); + console.log(` ${explorer(sealSig, cfg.rpcUrl)}`); + + const open = openMemo({ note, copy: 0, tag: `0x${hex64()}` }); + console.log(`\nOpen memo (${new TextEncoder().encode(open).length} bytes): ${open}`); + t0 = Date.now(); + const openSig = await sendSolanaMemo(cfg, open); + console.log(` confirmed in ${Date.now() - t0} ms: ${openSig}`); + console.log(` ${explorer(openSig, cfg.rpcUrl)}`); + + // Round trip: read the seal transaction back and compare the memo text. + const tx = await rpc + .getTransaction(sealSig as Signature, { + encoding: "jsonParsed", + maxSupportedTransactionVersion: 0, + commitment: "confirmed", + }) + .send(); + if (!tx) throw new Error("getTransaction returned null"); + const ix = tx.transaction.message.instructions[0] as unknown as { + program?: string; + parsed?: unknown; + data?: unknown; + }; + // SPL Memo (MemoSq4…) is parsed by the RPC into a plain string; p-memo is not, + // so fall back to decoding the raw base58 instruction data. + const decoded = typeof ix.parsed === "string" + ? ix.parsed + : new TextDecoder().decode(getBase58Encoder().encode(String(ix.data ?? ""))); + console.log(`\nRead back (${ix.program ?? "raw"}): ${decoded}`); + console.log(`Compute units: ${tx.meta?.computeUnitsConsumed ?? "?"}, fee: ${tx.meta?.fee ?? "?"} lamports`); + if (decoded !== seal) { + console.error("MISMATCH: decoded memo differs from the sent memo"); + Deno.exit(1); + } + console.log("Round trip OK: decoded memo equals the sent memo."); + console.log(`Balance: ${Number(await balance()) / 1e9} SOL`); +} + +await main(); diff --git a/tool/threshold-spike/README.md b/tool/threshold-spike/README.md deleted file mode 100644 index c08a1a5..0000000 --- a/tool/threshold-spike/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Gate T1 runner - -This runner is the first proof for the product promise. It uses three controlled -test wallets: a sender seals a harmless probe for the recipient, the recipient -is denied before acknowledgement, decrypts after acknowledgement, and a stranger -is denied. It never prints the plaintext probe. - -Dry-run the Lit `canDecrypt` condition without enabling the product. - -```sh -npm install -npm run check -npm run dry-run -``` - -`npm run connect` handshakes Lit naga-dev and attempts a client-side encrypt. That still -does not prove decryption, does not deploy the contract, and does not flip the product -gate. `npm run live` additionally requires `THRESHOLD_SPIKE_RUN=1`, a deployed -`NoSusMonadDrops` address, and three controlled test wallets. Set -`THRESHOLD_SPIKE_CHECK_EXPIRY=1` to wait for and verify expiry in the same run. - -A successful live run still does not flip `web/src/lib/threshold.ts` until every case in -`docs/LIT_MONAD_SPIKE.md` is recorded in `docs/STATUS.md`. diff --git a/tool/threshold-spike/conditions.mjs b/tool/threshold-spike/conditions.mjs deleted file mode 100644 index 61c8117..0000000 --- a/tool/threshold-spike/conditions.mjs +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Canonical Lit access-control condition for Gate T1. - * Keep this aligned with web/src/lib/lit-conditions.ts. - */ - -export const LIT_ACC_CHAIN = "monadTestnet"; -export const CAN_DECRYPT_ABI = { - name: "canDecrypt", - type: "function", - stateMutability: "view", - inputs: [ - { name: "id", type: "bytes32" }, - { name: "account", type: "address" }, - ], - outputs: [{ name: "", type: "bool", internalType: "bool" }], -}; - -export function buildCanDecryptConditions(contractAddress, dropId) { - return [ - { - contractAddress, - chain: LIT_ACC_CHAIN, - functionName: "canDecrypt", - functionParams: [dropId, ":userAddress"], - functionAbi: CAN_DECRYPT_ABI, - returnValueTest: { - key: "", - comparator: "=", - value: "true", - }, - }, - ]; -} diff --git a/tool/threshold-spike/conditions.test.mjs b/tool/threshold-spike/conditions.test.mjs deleted file mode 100644 index 846f05f..0000000 --- a/tool/threshold-spike/conditions.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; - -import { buildCanDecryptConditions, LIT_ACC_CHAIN } from "./conditions.mjs"; - -const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); -const webSource = readFileSync(join(root, "web/src/lib/lit-conditions.ts"), "utf8"); - -test("the condition binds canDecrypt to the authenticated wallet on monadTestnet", () => { - const dropId = `0x${"11".repeat(32)}`; - const contract = "0x0000000000000000000000000000000000000001"; - const [condition] = buildCanDecryptConditions(contract, dropId); - - assert.equal(LIT_ACC_CHAIN, "monadTestnet"); - assert.equal(condition.chain, "monadTestnet"); - assert.equal(condition.functionName, "canDecrypt"); - assert.deepEqual(condition.functionParams, [dropId, ":userAddress"]); - assert.equal(condition.returnValueTest.value, "true"); - const serialized = JSON.stringify(condition); - assert.doesNotMatch(serialized, /plaintext|privateKey|salt/i); - assert.equal(condition.functionParams.includes(":userAddress"), true); -}); - -test("the web module uses the same chain, predicate, and user-address binding", () => { - assert.match(webSource, /export const LIT_ACC_CHAIN = "monadTestnet"/); - assert.match(webSource, /functionName: "canDecrypt"/); - assert.match(webSource, /:userAddress/); - assert.match(webSource, /value: "true"/); -}); diff --git a/tool/threshold-spike/package-lock.json b/tool/threshold-spike/package-lock.json deleted file mode 100644 index c796958..0000000 --- a/tool/threshold-spike/package-lock.json +++ /dev/null @@ -1,13776 +0,0 @@ -{ - "name": "@nosus-monad/threshold-spike", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@nosus-monad/threshold-spike", - "version": "0.1.0", - "dependencies": { - "@lit-protocol/auth": "8.2.3", - "@lit-protocol/lit-client": "8.3.1", - "@lit-protocol/networks": "8.4.1", - "viem": "2.38.3" - } - }, - "node_modules/@adraffy/ens-normalize": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", - "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", - "license": "MIT" - }, - "node_modules/@assemblyscript/loader": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/@assemblyscript/loader/-/loader-0.9.4.tgz", - "integrity": "sha512-HazVq9zwTVwGmqdwYzu7WyQ6FQVZ7SwET0KKQuKm55jD0IfUpZgN0OPIiZG3zV1iSrVYcN0bdwLRXI/VNCYsUA==", - "license": "Apache-2.0" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", - "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", - "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@ethersproject/abi": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.8.0.tgz", - "integrity": "sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@ethersproject/abstract-provider": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", - "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0" - } - }, - "node_modules/@ethersproject/abstract-provider/node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" - } - }, - "node_modules/@ethersproject/abstract-signer": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", - "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0" - } - }, - "node_modules/@ethersproject/address": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", - "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/rlp": "^5.8.0" - } - }, - "node_modules/@ethersproject/base64": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", - "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0" - } - }, - "node_modules/@ethersproject/basex": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.8.0.tgz", - "integrity": "sha512-PIgTszMlDRmNwW9nhS6iqtVfdTAKosA7llYXNmGPw4YAI1PUyMv28988wAb41/gHF/WqGdoLv0erHaRcHRKW2Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/properties": "^5.8.0" - } - }, - "node_modules/@ethersproject/bignumber": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", - "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@ethersproject/bytes": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", - "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/constants": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", - "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.8.0" - } - }, - "node_modules/@ethersproject/contracts": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.7.0.tgz", - "integrity": "sha512-5GJbzEU3X+d33CdfPhcyS+z8MzsTrBGk/sc+G+59+tPa9yFkl6HQ9D6L0QMgNTA9q8dT0XKxxkyp883XsQvbbg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "^5.7.0", - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0" - } - }, - "node_modules/@ethersproject/hash": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", - "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/base64": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@ethersproject/hdnode": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.8.0.tgz", - "integrity": "sha512-4bK1VF6E83/3/Im0ERnnUeWOY3P1BZml4ZD3wcH8Ys0/d1h1xaFt6Zc+Dh9zXf9TapGro0T4wvO71UTCp3/uoA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/basex": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/pbkdf2": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/sha2": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0", - "@ethersproject/strings": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/wordlists": "^5.8.0" - } - }, - "node_modules/@ethersproject/hdnode/node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" - } - }, - "node_modules/@ethersproject/json-wallets": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.8.0.tgz", - "integrity": "sha512-HxblNck8FVUtNxS3VTEYJAcwiKYsBIF77W15HufqlBF9gGfhmYOJtYZp8fSDZtn9y5EaXTE87zDwzxRoTFk11w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/hdnode": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/pbkdf2": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/random": "^5.8.0", - "@ethersproject/strings": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@ethersproject/json-wallets/node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" - } - }, - "node_modules/@ethersproject/json-wallets/node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT" - }, - "node_modules/@ethersproject/keccak256": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", - "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@ethersproject/logger": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", - "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@ethersproject/networks": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", - "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/pbkdf2": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.8.0.tgz", - "integrity": "sha512-wuHiv97BrzCmfEaPbUFpMjlVg/IDkZThp9Ri88BpjRleg4iePJaj2SW8AIyE8cXn5V1tuAaMj6lzvsGJkGWskg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/sha2": "^5.8.0" - } - }, - "node_modules/@ethersproject/properties": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", - "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/providers": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.0.tgz", - "integrity": "sha512-+TTrrINMzZ0aXtlwO/95uhAggKm4USLm1PbeCBR/3XZ7+Oey+3pMyddzZEyRhizHpy1HXV0FRWRMI1O3EGYibA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@ethersproject/random": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.8.0.tgz", - "integrity": "sha512-E4I5TDl7SVqyg4/kkA/qTfuLWAQGXmSOgYyO01So8hLfwgKvYK5snIlzxJMk72IFdG/7oh8yuSqY2KX7MMwg+A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/rlp": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", - "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/sha2": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.8.0.tgz", - "integrity": "sha512-dDOUrXr9wF/YFltgTBYS0tKslPEKr6AekjqDW2dbn1L1xmjGR+9GiKu4ajxovnrDbwxAKdHjW8jNcwfz8PAz4A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@ethersproject/signing-key": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", - "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "bn.js": "^5.2.1", - "elliptic": "6.6.1", - "hash.js": "1.1.7" - } - }, - "node_modules/@ethersproject/solidity": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.8.0.tgz", - "integrity": "sha512-4CxFeCgmIWamOHwYN9d+QWGxye9qQLilpgTU0XhYs1OahkclF+ewO+3V1U0mvpiuQxm5EHHmv8f7ClVII8EHsA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/sha2": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@ethersproject/strings": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", - "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/transactions": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.7.0.tgz", - "integrity": "sha512-kmcNicCp1lp8qanMTC3RIikGgoJ80ztTyvtsFvCYpSCfkjhD0jZ2LOrnbcuxuToLIUYYf+4XwD1rP+B/erDIhQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0" - } - }, - "node_modules/@ethersproject/units": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.8.0.tgz", - "integrity": "sha512-lxq0CAnc5kMGIiWW4Mr041VT8IhNM+Pn5T3haO74XZWFulk7wH1Gv64HqE96hT4a7iiNMdOCFEBgaxWuk8ETKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/wallet": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.8.0.tgz", - "integrity": "sha512-G+jnzmgg6UxurVKRKvw27h0kvG75YKXZKdlLYmAHeF32TGUzHkOFd7Zn6QHOTYRFWnfjtSSFjBowKo7vfrXzPA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/hdnode": "^5.8.0", - "@ethersproject/json-wallets": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/random": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/wordlists": "^5.8.0" - } - }, - "node_modules/@ethersproject/wallet/node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" - } - }, - "node_modules/@ethersproject/web": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", - "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/base64": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@ethersproject/wordlists": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.8.0.tgz", - "integrity": "sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", - "license": "MIT", - "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", - "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@lit-protocol/access-control-conditions": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/access-control-conditions/-/access-control-conditions-8.0.2.tgz", - "integrity": "sha512-5F5fA6QZL4+hYmF0knePoPzyfZLvEudKdf/4Sn3ioHn5jivgnr9QoYL//eHwry7G9kdgeFSJz8rbxdj/d6WjeQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@ethersproject/contracts": "5.8.0", - "@ethersproject/providers": "5.7.0", - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "8.0.2", - "@lit-protocol/contracts": "0.5.3", - "@lit-protocol/logger": "8.0.2", - "@lit-protocol/schemas": "8.0.2", - "@lit-protocol/types": "8.0.2", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "pino": "^9.6.0", - "siwe": "^2.3.2", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/access-control-conditions-schemas/-/access-control-conditions-schemas-8.0.2.tgz", - "integrity": "sha512-aMElsRHLfjOM/S5cEwta6G2+OblSnIve51lv9HGjpb5Lamru4qqVTA8yQwlh7Wh5Sk4/1vZyZc1Mwp3fLxK+Aw==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/constants": "8.0.2", - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/@ethersproject/contracts": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.8.0.tgz", - "integrity": "sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "^5.8.0", - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/@ethersproject/providers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.8.0.tgz", - "integrity": "sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/base64": "^5.8.0", - "@ethersproject/basex": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/random": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/sha2": "^5.8.0", - "@ethersproject/strings": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0", - "bech32": "1.1.4", - "ws": "8.18.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/@lit-protocol/constants": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-8.0.2.tgz", - "integrity": "sha512-2jw9PNmU2w/DVRn86+hQmoiW5uCl9bNCPZv/4eS13fiPhi4VERys5lC9zxn4uUyLeCUivDlPnuP1LO0ElyfXxA==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/@lit-protocol/contracts": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.5.3.tgz", - "integrity": "sha512-s5q/X0iGLdJYY8dGUJi8EmVkfVJRxMfsVXAb4t/8YEWvOC1oQHuO069nqTPmCqkJO8OcDUknaSSkuxuDIvp2oQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "peerDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/@noble/curves": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz", - "integrity": "sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.7.2" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/@noble/hashes": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", - "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/@scure/bip32": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz", - "integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.8.1", - "@noble/hashes": "~1.7.1", - "@scure/base": "~1.2.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/@scure/bip39": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.5.4.tgz", - "integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.7.1", - "@scure/base": "~1.2.4" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/ethers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", - "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.8.0", - "@ethersproject/abstract-provider": "5.8.0", - "@ethersproject/abstract-signer": "5.8.0", - "@ethersproject/address": "5.8.0", - "@ethersproject/base64": "5.8.0", - "@ethersproject/basex": "5.8.0", - "@ethersproject/bignumber": "5.8.0", - "@ethersproject/bytes": "5.8.0", - "@ethersproject/constants": "5.8.0", - "@ethersproject/contracts": "5.8.0", - "@ethersproject/hash": "5.8.0", - "@ethersproject/hdnode": "5.8.0", - "@ethersproject/json-wallets": "5.8.0", - "@ethersproject/keccak256": "5.8.0", - "@ethersproject/logger": "5.8.0", - "@ethersproject/networks": "5.8.0", - "@ethersproject/pbkdf2": "5.8.0", - "@ethersproject/properties": "5.8.0", - "@ethersproject/providers": "5.8.0", - "@ethersproject/random": "5.8.0", - "@ethersproject/rlp": "5.8.0", - "@ethersproject/sha2": "5.8.0", - "@ethersproject/signing-key": "5.8.0", - "@ethersproject/solidity": "5.8.0", - "@ethersproject/strings": "5.8.0", - "@ethersproject/transactions": "5.8.0", - "@ethersproject/units": "5.8.0", - "@ethersproject/wallet": "5.8.0", - "@ethersproject/web": "5.8.0", - "@ethersproject/wordlists": "5.8.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/viem": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.29.4.tgz", - "integrity": "sha512-Dhyae+w1LKKpYVXypGjBnZ3WU5EHl/Uip5RtVwVRYSVxD5VvHzqKzIfbFU1KP4vnnh3++ZNgLjBY/kVT/tPrrg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@noble/curves": "1.8.2", - "@noble/hashes": "1.7.2", - "@scure/bip32": "1.6.2", - "@scure/bip39": "1.5.4", - "abitype": "1.0.8", - "isows": "1.0.7", - "ox": "0.6.9", - "ws": "8.18.1" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/viem/node_modules/abitype": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", - "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3 >=3.22.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/viem/node_modules/ox": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", - "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.10.1", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@scure/bip32": "^1.5.0", - "@scure/bip39": "^1.4.0", - "abitype": "^1.0.6", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/viem/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@lit-protocol/access-control-conditions-schemas/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@ethersproject/contracts": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.8.0.tgz", - "integrity": "sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "^5.8.0", - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@lit-protocol/constants": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-8.0.2.tgz", - "integrity": "sha512-2jw9PNmU2w/DVRn86+hQmoiW5uCl9bNCPZv/4eS13fiPhi4VERys5lC9zxn4uUyLeCUivDlPnuP1LO0ElyfXxA==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@lit-protocol/contracts": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.5.3.tgz", - "integrity": "sha512-s5q/X0iGLdJYY8dGUJi8EmVkfVJRxMfsVXAb4t/8YEWvOC1oQHuO069nqTPmCqkJO8OcDUknaSSkuxuDIvp2oQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "peerDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@lit-protocol/schemas": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/schemas/-/schemas-8.0.2.tgz", - "integrity": "sha512-SdbYcm8nGg7HDyIgp/LK3d9wDZ9jpzZdPiRJeBHfoKfVq5qf44hugy/BO3IJXROVb+6TqrsEbsUS1rlqbqi/0A==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "8.0.2", - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "siwe": "^2.3.2", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@lit-protocol/types": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/types/-/types-8.0.2.tgz", - "integrity": "sha512-IDppgwcAPiIBmzVhfgK5h/5WLhJPFnlEhKqq/6X24M+sD8JL/rfac9P99+lHYMienki8rj4L9+zIUDIotFZCLw==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "8.0.2", - "@lit-protocol/contracts": "0.5.3", - "@lit-protocol/schemas": "8.0.2", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "siwe": "^2.3.2", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@noble/curves": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz", - "integrity": "sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.7.2" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@noble/hashes": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", - "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@scure/bip32": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz", - "integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.8.1", - "@noble/hashes": "~1.7.1", - "@scure/base": "~1.2.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/@scure/bip39": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.5.4.tgz", - "integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.7.1", - "@scure/base": "~1.2.4" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/ethers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", - "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.8.0", - "@ethersproject/abstract-provider": "5.8.0", - "@ethersproject/abstract-signer": "5.8.0", - "@ethersproject/address": "5.8.0", - "@ethersproject/base64": "5.8.0", - "@ethersproject/basex": "5.8.0", - "@ethersproject/bignumber": "5.8.0", - "@ethersproject/bytes": "5.8.0", - "@ethersproject/constants": "5.8.0", - "@ethersproject/contracts": "5.8.0", - "@ethersproject/hash": "5.8.0", - "@ethersproject/hdnode": "5.8.0", - "@ethersproject/json-wallets": "5.8.0", - "@ethersproject/keccak256": "5.8.0", - "@ethersproject/logger": "5.8.0", - "@ethersproject/networks": "5.8.0", - "@ethersproject/pbkdf2": "5.8.0", - "@ethersproject/properties": "5.8.0", - "@ethersproject/providers": "5.8.0", - "@ethersproject/random": "5.8.0", - "@ethersproject/rlp": "5.8.0", - "@ethersproject/sha2": "5.8.0", - "@ethersproject/signing-key": "5.8.0", - "@ethersproject/solidity": "5.8.0", - "@ethersproject/strings": "5.8.0", - "@ethersproject/transactions": "5.8.0", - "@ethersproject/units": "5.8.0", - "@ethersproject/wallet": "5.8.0", - "@ethersproject/web": "5.8.0", - "@ethersproject/wordlists": "5.8.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/ethers/node_modules/@ethersproject/providers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.8.0.tgz", - "integrity": "sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/base64": "^5.8.0", - "@ethersproject/basex": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/random": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/sha2": "^5.8.0", - "@ethersproject/strings": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0", - "bech32": "1.1.4", - "ws": "8.18.0" - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/ethers/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/viem": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.29.4.tgz", - "integrity": "sha512-Dhyae+w1LKKpYVXypGjBnZ3WU5EHl/Uip5RtVwVRYSVxD5VvHzqKzIfbFU1KP4vnnh3++ZNgLjBY/kVT/tPrrg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@noble/curves": "1.8.2", - "@noble/hashes": "1.7.2", - "@scure/bip32": "1.6.2", - "@scure/bip39": "1.5.4", - "abitype": "1.0.8", - "isows": "1.0.7", - "ox": "0.6.9", - "ws": "8.18.1" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/viem/node_modules/abitype": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", - "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3 >=3.22.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/viem/node_modules/ox": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", - "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.10.1", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@scure/bip32": "^1.5.0", - "@scure/bip39": "^1.4.0", - "abitype": "^1.0.6", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/access-control-conditions/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@lit-protocol/accs-schemas": { - "version": "0.0.24", - "resolved": "https://registry.npmjs.org/@lit-protocol/accs-schemas/-/accs-schemas-0.0.24.tgz", - "integrity": "sha512-sjUfMAc8kj/Tj/GAFnlrbvs9PzoSK5wR3L1hK4kFI6lifq31LsDGqNLWmIEDXRubE/YusK5QaY30h+WPgk8RFQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "ajv": "^8.12.0" - } - }, - "node_modules/@lit-protocol/auth": { - "version": "8.2.3", - "resolved": "https://registry.npmjs.org/@lit-protocol/auth/-/auth-8.2.3.tgz", - "integrity": "sha512-T7RaUhw0VbKyHivcUhTsaAGpPJSjWe0tSsu3ZluRNIPcerpNzX/jdVp4lVoozEKx491Ah2+lwAtWjYW6MiTXYQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@ethersproject/contracts": "5.7.0", - "@ethersproject/providers": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@jest/globals": "30.2.0", - "@lit-protocol/access-control-conditions": "8.0.2", - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/auth-helpers": "8.2.0", - "@lit-protocol/constants": "9.0.1", - "@lit-protocol/contracts": "0.9.1", - "@lit-protocol/crypto": "8.1.0", - "@lit-protocol/lit-client": "8.3.1", - "@lit-protocol/logger": "8.0.2", - "@lit-protocol/nacl": "7.1.1", - "@lit-protocol/networks": "8.4.1", - "@lit-protocol/schemas": "8.0.3", - "@lit-protocol/types": "8.0.3", - "@lit-protocol/uint8arrays": "7.1.1", - "@lit-protocol/wasm": "8.1.0", - "@noble/curves": "1.8.1", - "@noble/hashes": "1.8.0", - "@openagenda/verror": "3.1.4", - "@simplewebauthn/browser": "7.2.0", - "@simplewebauthn/typescript-types": "7.0.0", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "@wagmi/core": "2.22.1", - "ajv": "8.12.0", - "base64url": "3.0.1", - "bs58": "6.0.0", - "cbor-web": "9.0.2", - "depd": "2.0.0", - "elysia": "1.2.25", - "ethers": "5.7.2", - "jose": "4.14.4", - "pako": "2.1.0", - "pino": "9.6.0", - "pino-caller": "4.0.0", - "siwe": "2.3.2", - "siwe-recap": "0.0.2-alpha.0", - "stytch": "12.4.0", - "typechain": "8.3.2", - "typestub-ipfs-only-hash": "4.0.0", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - }, - "peerDependencies": { - "tslib": "2.8.1", - "viem": "2.38.3" - } - }, - "node_modules/@lit-protocol/auth-helpers": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@lit-protocol/auth-helpers/-/auth-helpers-8.2.0.tgz", - "integrity": "sha512-gIT123rGzP52iQ68WSAn/Ewq33bFy9VI10xhjLqa9kndYG7uuXoQTh95R5ORsQIvpZP+zVAVcEukMPAFq7G5pg==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@ethersproject/contracts": "5.7.0", - "@ethersproject/providers": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@lit-protocol/access-control-conditions": "8.0.2", - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "9.0.0", - "@lit-protocol/contracts": "0.9.0", - "@lit-protocol/logger": "8.0.2", - "@lit-protocol/schemas": "8.0.2", - "@lit-protocol/types": "8.0.2", - "@openagenda/verror": "3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "@wagmi/core": "2.22.1", - "ethers": "5.7.2", - "pino": "9.6.0", - "siwe": "2.3.2", - "siwe-recap": "0.0.2-alpha.0", - "tslib": "2.8.1", - "typechain": "8.3.2", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - }, - "peerDependencies": { - "viem": "2.38.3" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/abi": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", - "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/abstract-signer": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", - "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/address": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", - "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/rlp": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/base64": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", - "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/basex": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", - "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/bignumber": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", - "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/bytes": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", - "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/constants": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", - "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/hash": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", - "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/hdnode": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", - "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/json-wallets": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", - "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/keccak256": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", - "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/logger": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", - "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/networks": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", - "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/pbkdf2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", - "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/sha2": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/properties": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", - "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/random": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", - "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/rlp": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", - "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/sha2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", - "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/signing-key": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", - "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "bn.js": "^5.2.1", - "elliptic": "6.5.4", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/strings": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", - "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/wallet": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", - "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/json-wallets": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/web": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", - "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@ethersproject/wordlists": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", - "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/constants": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-9.0.0.tgz", - "integrity": "sha512-/BfZH2K351FQePsaUDPGtb6EDvi34Tkqg5wRZBrs9sh/WQB+1xXfbId3WHHSszV47adFS2n5Rk6dlEgUQSpWuA==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/contracts": "0.9.0", - "@openagenda/verror": "3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "5.7.2", - "tslib": "2.8.1", - "typechain": "8.3.2", - "viem": "2.38.3", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/contracts": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.9.0.tgz", - "integrity": "sha512-roswC9kJaMvKWqRrw9/ROKWpG3S+vPtlWPqVDS6JOjcPmaEEitMtfsAmCM4i3LykGKkbrW4MCvIatglByRu/Xw==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "peerDependencies": { - "typescript": "5.8.3", - "viem": "2.38.3" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/schemas": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/schemas/-/schemas-8.0.2.tgz", - "integrity": "sha512-SdbYcm8nGg7HDyIgp/LK3d9wDZ9jpzZdPiRJeBHfoKfVq5qf44hugy/BO3IJXROVb+6TqrsEbsUS1rlqbqi/0A==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "8.0.2", - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "siwe": "^2.3.2", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/schemas/node_modules/@lit-protocol/constants": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-8.0.2.tgz", - "integrity": "sha512-2jw9PNmU2w/DVRn86+hQmoiW5uCl9bNCPZv/4eS13fiPhi4VERys5lC9zxn4uUyLeCUivDlPnuP1LO0ElyfXxA==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/schemas/node_modules/@lit-protocol/contracts": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.5.3.tgz", - "integrity": "sha512-s5q/X0iGLdJYY8dGUJi8EmVkfVJRxMfsVXAb4t/8YEWvOC1oQHuO069nqTPmCqkJO8OcDUknaSSkuxuDIvp2oQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "peerDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/schemas/node_modules/viem": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.29.4.tgz", - "integrity": "sha512-Dhyae+w1LKKpYVXypGjBnZ3WU5EHl/Uip5RtVwVRYSVxD5VvHzqKzIfbFU1KP4vnnh3++ZNgLjBY/kVT/tPrrg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@noble/curves": "1.8.2", - "@noble/hashes": "1.7.2", - "@scure/bip32": "1.6.2", - "@scure/bip39": "1.5.4", - "abitype": "1.0.8", - "isows": "1.0.7", - "ox": "0.6.9", - "ws": "8.18.1" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/schemas/node_modules/viem/node_modules/abitype": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", - "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3 >=3.22.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/schemas/node_modules/viem/node_modules/ox": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", - "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.10.1", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@scure/bip32": "^1.5.0", - "@scure/bip39": "^1.4.0", - "abitype": "^1.0.6", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/schemas/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/types": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/types/-/types-8.0.2.tgz", - "integrity": "sha512-IDppgwcAPiIBmzVhfgK5h/5WLhJPFnlEhKqq/6X24M+sD8JL/rfac9P99+lHYMienki8rj4L9+zIUDIotFZCLw==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "8.0.2", - "@lit-protocol/contracts": "0.5.3", - "@lit-protocol/schemas": "8.0.2", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "siwe": "^2.3.2", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/types/node_modules/@lit-protocol/constants": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-8.0.2.tgz", - "integrity": "sha512-2jw9PNmU2w/DVRn86+hQmoiW5uCl9bNCPZv/4eS13fiPhi4VERys5lC9zxn4uUyLeCUivDlPnuP1LO0ElyfXxA==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/types/node_modules/@lit-protocol/contracts": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.5.3.tgz", - "integrity": "sha512-s5q/X0iGLdJYY8dGUJi8EmVkfVJRxMfsVXAb4t/8YEWvOC1oQHuO069nqTPmCqkJO8OcDUknaSSkuxuDIvp2oQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "peerDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/types/node_modules/viem": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.29.4.tgz", - "integrity": "sha512-Dhyae+w1LKKpYVXypGjBnZ3WU5EHl/Uip5RtVwVRYSVxD5VvHzqKzIfbFU1KP4vnnh3++ZNgLjBY/kVT/tPrrg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@noble/curves": "1.8.2", - "@noble/hashes": "1.7.2", - "@scure/bip32": "1.6.2", - "@scure/bip39": "1.5.4", - "abitype": "1.0.8", - "isows": "1.0.7", - "ox": "0.6.9", - "ws": "8.18.1" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/types/node_modules/viem/node_modules/abitype": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", - "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3 >=3.22.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/types/node_modules/viem/node_modules/ox": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", - "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.10.1", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@scure/bip32": "^1.5.0", - "@scure/bip39": "^1.4.0", - "abitype": "^1.0.6", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@lit-protocol/types/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@noble/curves": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz", - "integrity": "sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.7.2" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@noble/hashes": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", - "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@scure/bip32": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz", - "integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.8.1", - "@noble/hashes": "~1.7.1", - "@scure/base": "~1.2.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/@scure/bip39": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.5.4.tgz", - "integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.7.1", - "@scure/base": "~1.2.4" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT" - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/ethers/node_modules/@ethersproject/providers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", - "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/multiformats": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-11.0.2.tgz", - "integrity": "sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==", - "license": "Apache-2.0 OR MIT", - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@lit-protocol/auth-helpers/node_modules/siwe-recap": { - "version": "0.0.2-alpha.0", - "resolved": "https://registry.npmjs.org/siwe-recap/-/siwe-recap-0.0.2-alpha.0.tgz", - "integrity": "sha512-xqFUnvrACWW/Q4s5HQ02avg8IyH2RcgkUzfvN4scYaaHErotLVtTGDZkSS0sn/oNK4MXRt83lTqredsvXgt8iA==", - "license": "MIT", - "dependencies": { - "canonicalize": "^2.0.0", - "multiformats": "^11.0.2", - "siwe": "^2.1.4" - }, - "peerDependencies": { - "ethers": "^5.5.1" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/abi": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", - "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/abstract-signer": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", - "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/address": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", - "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/rlp": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/base64": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", - "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/basex": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", - "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/bignumber": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", - "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/bytes": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", - "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/constants": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", - "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/hash": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", - "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/hdnode": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", - "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/json-wallets": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", - "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/keccak256": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", - "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/logger": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", - "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/networks": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", - "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/pbkdf2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", - "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/sha2": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/properties": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", - "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/random": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", - "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/rlp": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", - "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/sha2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", - "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/signing-key": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", - "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "bn.js": "^5.2.1", - "elliptic": "6.5.4", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/strings": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", - "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/wallet": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", - "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/json-wallets": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/web": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", - "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/@ethersproject/wordlists": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", - "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT" - }, - "node_modules/@lit-protocol/auth/node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/@lit-protocol/auth/node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/@lit-protocol/auth/node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/ethers/node_modules/@ethersproject/providers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", - "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@lit-protocol/auth/node_modules/multiformats": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-11.0.2.tgz", - "integrity": "sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==", - "license": "Apache-2.0 OR MIT", - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@lit-protocol/auth/node_modules/siwe-recap": { - "version": "0.0.2-alpha.0", - "resolved": "https://registry.npmjs.org/siwe-recap/-/siwe-recap-0.0.2-alpha.0.tgz", - "integrity": "sha512-xqFUnvrACWW/Q4s5HQ02avg8IyH2RcgkUzfvN4scYaaHErotLVtTGDZkSS0sn/oNK4MXRt83lTqredsvXgt8iA==", - "license": "MIT", - "dependencies": { - "canonicalize": "^2.0.0", - "multiformats": "^11.0.2", - "siwe": "^2.1.4" - }, - "peerDependencies": { - "ethers": "^5.5.1" - } - }, - "node_modules/@lit-protocol/constants": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-9.0.1.tgz", - "integrity": "sha512-gaCMUPvQw9a/Eb6kpTJOMpEYXU5jyca88yB35CFuJ+hUkcfO/QkzKVAMwP+tpgLV8NIvYhpRyxIhKdfLWXbmYw==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/contracts": "0.9.1", - "@openagenda/verror": "3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "5.7.2", - "tslib": "2.8.1", - "typechain": "8.3.2", - "viem": "2.38.3", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/abi": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", - "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/abstract-signer": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", - "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/address": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", - "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/rlp": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/base64": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", - "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/basex": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", - "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/bignumber": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", - "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/bytes": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", - "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/constants": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", - "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/hash": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", - "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/hdnode": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", - "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/json-wallets": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", - "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/keccak256": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", - "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/logger": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", - "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/networks": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", - "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/pbkdf2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", - "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/sha2": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/properties": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", - "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/providers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", - "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/random": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", - "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/rlp": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", - "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/sha2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", - "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/signing-key": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", - "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "bn.js": "^5.2.1", - "elliptic": "6.5.4", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/strings": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", - "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/wallet": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", - "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/json-wallets": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/web": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", - "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/@ethersproject/wordlists": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", - "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/constants/node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT" - }, - "node_modules/@lit-protocol/constants/node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/@lit-protocol/constants/node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/@lit-protocol/constants/node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, - "node_modules/@lit-protocol/contracts": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.9.1.tgz", - "integrity": "sha512-nbc46P++OBouzIxTT6O30byZmOvfi7ORpT/vZCdW7a39+tMDd2dWWaKfcFRMP/4oqGPppGnvFHLVKNuaPtriwA==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "peerDependencies": { - "typescript": "5.8.3", - "viem": "2.38.3" - } - }, - "node_modules/@lit-protocol/crypto": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@lit-protocol/crypto/-/crypto-8.1.0.tgz", - "integrity": "sha512-Ae2+sM6I6jB6r8VGKlnyU1+lhZ/rsJqassqWY3PRjOwgIVTZpu9AGFByQh5IqkLQ+s6BJXmksL64367cV5Zs3A==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@jest/globals": "30.2.0", - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "9.0.0", - "@lit-protocol/contracts": "0.9.0", - "@lit-protocol/nacl": "7.1.1", - "@lit-protocol/schemas": "8.0.2", - "@lit-protocol/types": "8.0.2", - "@lit-protocol/uint8arrays": "7.1.1", - "@lit-protocol/wasm": "8.1.0", - "@noble/curves": "1.8.1", - "@noble/hashes": "1.8.0", - "@openagenda/verror": "3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ajv": "8.12.0", - "ethers": "5.7.2", - "pako": "2.1.0", - "siwe": "2.3.2", - "tslib": "2.8.1", - "typechain": "8.3.2", - "viem": "2.38.3", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/abi": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", - "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/abstract-signer": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", - "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/address": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", - "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/rlp": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/base64": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", - "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/basex": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", - "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/bignumber": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", - "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/bytes": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", - "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/constants": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", - "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/hash": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", - "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/hdnode": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", - "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/json-wallets": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", - "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/keccak256": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", - "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/logger": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", - "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/networks": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", - "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/pbkdf2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", - "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/sha2": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/properties": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", - "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/providers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", - "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/random": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", - "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/rlp": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", - "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/sha2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", - "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/signing-key": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", - "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "bn.js": "^5.2.1", - "elliptic": "6.5.4", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/strings": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", - "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/wallet": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", - "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/json-wallets": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/web": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", - "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@ethersproject/wordlists": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", - "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/constants": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-9.0.0.tgz", - "integrity": "sha512-/BfZH2K351FQePsaUDPGtb6EDvi34Tkqg5wRZBrs9sh/WQB+1xXfbId3WHHSszV47adFS2n5Rk6dlEgUQSpWuA==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/contracts": "0.9.0", - "@openagenda/verror": "3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "5.7.2", - "tslib": "2.8.1", - "typechain": "8.3.2", - "viem": "2.38.3", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/contracts": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.9.0.tgz", - "integrity": "sha512-roswC9kJaMvKWqRrw9/ROKWpG3S+vPtlWPqVDS6JOjcPmaEEitMtfsAmCM4i3LykGKkbrW4MCvIatglByRu/Xw==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "peerDependencies": { - "typescript": "5.8.3", - "viem": "2.38.3" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/schemas": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/schemas/-/schemas-8.0.2.tgz", - "integrity": "sha512-SdbYcm8nGg7HDyIgp/LK3d9wDZ9jpzZdPiRJeBHfoKfVq5qf44hugy/BO3IJXROVb+6TqrsEbsUS1rlqbqi/0A==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "8.0.2", - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "siwe": "^2.3.2", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/schemas/node_modules/@lit-protocol/constants": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-8.0.2.tgz", - "integrity": "sha512-2jw9PNmU2w/DVRn86+hQmoiW5uCl9bNCPZv/4eS13fiPhi4VERys5lC9zxn4uUyLeCUivDlPnuP1LO0ElyfXxA==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/schemas/node_modules/@lit-protocol/contracts": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.5.3.tgz", - "integrity": "sha512-s5q/X0iGLdJYY8dGUJi8EmVkfVJRxMfsVXAb4t/8YEWvOC1oQHuO069nqTPmCqkJO8OcDUknaSSkuxuDIvp2oQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "peerDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/schemas/node_modules/@noble/curves": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz", - "integrity": "sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.7.2" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/schemas/node_modules/@noble/hashes": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", - "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/schemas/node_modules/viem": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.29.4.tgz", - "integrity": "sha512-Dhyae+w1LKKpYVXypGjBnZ3WU5EHl/Uip5RtVwVRYSVxD5VvHzqKzIfbFU1KP4vnnh3++ZNgLjBY/kVT/tPrrg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@noble/curves": "1.8.2", - "@noble/hashes": "1.7.2", - "@scure/bip32": "1.6.2", - "@scure/bip39": "1.5.4", - "abitype": "1.0.8", - "isows": "1.0.7", - "ox": "0.6.9", - "ws": "8.18.1" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/schemas/node_modules/viem/node_modules/abitype": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", - "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3 >=3.22.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/schemas/node_modules/viem/node_modules/ox": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", - "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.10.1", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@scure/bip32": "^1.5.0", - "@scure/bip39": "^1.4.0", - "abitype": "^1.0.6", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/schemas/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/types": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/types/-/types-8.0.2.tgz", - "integrity": "sha512-IDppgwcAPiIBmzVhfgK5h/5WLhJPFnlEhKqq/6X24M+sD8JL/rfac9P99+lHYMienki8rj4L9+zIUDIotFZCLw==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "8.0.2", - "@lit-protocol/contracts": "0.5.3", - "@lit-protocol/schemas": "8.0.2", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "siwe": "^2.3.2", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/types/node_modules/@lit-protocol/constants": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-8.0.2.tgz", - "integrity": "sha512-2jw9PNmU2w/DVRn86+hQmoiW5uCl9bNCPZv/4eS13fiPhi4VERys5lC9zxn4uUyLeCUivDlPnuP1LO0ElyfXxA==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/contracts": "0.5.3", - "@openagenda/verror": "^3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "^5.7.1", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "viem": "2.29.4", - "zod": "3.24.3" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/types/node_modules/@lit-protocol/contracts": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.5.3.tgz", - "integrity": "sha512-s5q/X0iGLdJYY8dGUJi8EmVkfVJRxMfsVXAb4t/8YEWvOC1oQHuO069nqTPmCqkJO8OcDUknaSSkuxuDIvp2oQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "peerDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/types/node_modules/@noble/curves": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz", - "integrity": "sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.7.2" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/types/node_modules/@noble/hashes": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", - "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/types/node_modules/viem": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.29.4.tgz", - "integrity": "sha512-Dhyae+w1LKKpYVXypGjBnZ3WU5EHl/Uip5RtVwVRYSVxD5VvHzqKzIfbFU1KP4vnnh3++ZNgLjBY/kVT/tPrrg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@noble/curves": "1.8.2", - "@noble/hashes": "1.7.2", - "@scure/bip32": "1.6.2", - "@scure/bip39": "1.5.4", - "abitype": "1.0.8", - "isows": "1.0.7", - "ox": "0.6.9", - "ws": "8.18.1" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/types/node_modules/viem/node_modules/abitype": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", - "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3 >=3.22.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/types/node_modules/viem/node_modules/ox": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", - "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.10.1", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@scure/bip32": "^1.5.0", - "@scure/bip39": "^1.4.0", - "abitype": "^1.0.6", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@lit-protocol/types/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@scure/bip32": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz", - "integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.8.1", - "@noble/hashes": "~1.7.1", - "@scure/base": "~1.2.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@scure/bip32/node_modules/@noble/hashes": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", - "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@scure/bip39": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.5.4.tgz", - "integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.7.1", - "@scure/base": "~1.2.4" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/@scure/bip39/node_modules/@noble/hashes": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", - "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT" - }, - "node_modules/@lit-protocol/crypto/node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/@lit-protocol/crypto/node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/@lit-protocol/crypto/node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@lit-protocol/lit-client/-/lit-client-8.3.1.tgz", - "integrity": "sha512-3OyeB/j4iXhI47QCFtos8zyNCQWqpuEfv170PMs/chq+v1m+LT+ackm6MGv1XrAipGqAxcgCoDOLXkR9n0N6Ew==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@ethersproject/contracts": "5.7.0", - "@ethersproject/providers": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@jest/globals": "30.2.0", - "@lit-protocol/access-control-conditions": "8.0.2", - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/auth-helpers": "8.2.0", - "@lit-protocol/constants": "9.0.1", - "@lit-protocol/contracts": "0.9.1", - "@lit-protocol/crypto": "8.1.0", - "@lit-protocol/logger": "8.0.2", - "@lit-protocol/nacl": "7.1.1", - "@lit-protocol/networks": "8.4.1", - "@lit-protocol/schemas": "8.0.3", - "@lit-protocol/types": "8.0.3", - "@lit-protocol/uint8arrays": "7.1.1", - "@lit-protocol/wasm": "8.1.0", - "@noble/curves": "1.8.1", - "@noble/hashes": "1.8.0", - "@openagenda/verror": "3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "@wagmi/core": "2.22.1", - "ajv": "8.12.0", - "bs58": "6.0.0", - "elysia": "1.2.25", - "ethers": "5.7.2", - "pako": "2.1.0", - "pino": "9.6.0", - "pino-caller": "4.0.0", - "siwe": "2.3.2", - "siwe-recap": "0.0.2-alpha.0", - "tslib": "2.8.1", - "typechain": "8.3.2", - "typestub-ipfs-only-hash": "^4.0.0", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - }, - "peerDependencies": { - "viem": "2.38.3" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/abi": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", - "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/abstract-signer": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", - "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/address": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", - "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/rlp": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/base64": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", - "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/basex": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", - "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/bignumber": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", - "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/bytes": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", - "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/constants": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", - "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/hash": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", - "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/hdnode": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", - "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/json-wallets": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", - "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/keccak256": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", - "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/logger": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", - "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/networks": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", - "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/pbkdf2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", - "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/sha2": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/properties": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", - "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/random": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", - "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/rlp": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", - "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/sha2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", - "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/signing-key": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", - "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "bn.js": "^5.2.1", - "elliptic": "6.5.4", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/strings": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", - "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/wallet": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", - "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/json-wallets": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/web": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", - "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/@ethersproject/wordlists": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", - "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT" - }, - "node_modules/@lit-protocol/lit-client/node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/@lit-protocol/lit-client/node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/ethers/node_modules/@ethersproject/providers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", - "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/multiformats": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-11.0.2.tgz", - "integrity": "sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==", - "license": "Apache-2.0 OR MIT", - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@lit-protocol/lit-client/node_modules/siwe-recap": { - "version": "0.0.2-alpha.0", - "resolved": "https://registry.npmjs.org/siwe-recap/-/siwe-recap-0.0.2-alpha.0.tgz", - "integrity": "sha512-xqFUnvrACWW/Q4s5HQ02avg8IyH2RcgkUzfvN4scYaaHErotLVtTGDZkSS0sn/oNK4MXRt83lTqredsvXgt8iA==", - "license": "MIT", - "dependencies": { - "canonicalize": "^2.0.0", - "multiformats": "^11.0.2", - "siwe": "^2.1.4" - }, - "peerDependencies": { - "ethers": "^5.5.1" - } - }, - "node_modules/@lit-protocol/logger": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@lit-protocol/logger/-/logger-8.0.2.tgz", - "integrity": "sha512-261xUbpHs3UbCRnaardxn4IKe49fT5U1XeNkFWxyQqwF5XM6L/ldH+a3C3hM/Jr/Mm84vyqFsMqKFCQajjMEOQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "dependencies": { - "pino": "^9.6.0", - "tslib": "1.14.1" - } - }, - "node_modules/@lit-protocol/logger/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/@lit-protocol/nacl": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@lit-protocol/nacl/-/nacl-7.1.1.tgz", - "integrity": "sha512-uuaHcVFnf3G4zRGYN2CrhSVHWXNcCY+VGG2uGS+pSk4EtyRvf9aw8mctQinItua02mTTj6SfFs508VSJ1imWsw==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "tslib": "1.14.1" - } - }, - "node_modules/@lit-protocol/nacl/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/@lit-protocol/networks": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@lit-protocol/networks/-/networks-8.4.1.tgz", - "integrity": "sha512-36uuhCtAghXQAR/1oqlx/QK0nuYuMPMvpeQ4Yx9aoI3V7c3HAmtP1F+93Th7Y5twcI8nVIoB56XntKwXHNrv7g==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@ethersproject/contracts": "5.7.0", - "@ethersproject/providers": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@jest/globals": "30.2.0", - "@lit-protocol/access-control-conditions": "8.0.2", - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/auth-helpers": "8.2.0", - "@lit-protocol/constants": "9.0.1", - "@lit-protocol/contracts": "0.9.1", - "@lit-protocol/crypto": "8.1.0", - "@lit-protocol/logger": "8.0.2", - "@lit-protocol/nacl": "7.1.1", - "@lit-protocol/schemas": "8.0.3", - "@lit-protocol/types": "8.0.3", - "@lit-protocol/uint8arrays": "7.1.1", - "@lit-protocol/wasm": "8.1.0", - "@noble/curves": "1.8.1", - "@noble/hashes": "^1.8.0", - "@openagenda/verror": "3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "@wagmi/core": "2.22.1", - "ajv": "8.12.0", - "bs58": "6.0.0", - "elysia": "1.2.25", - "ethers": "5.7.2", - "node-localstorage": "3.0.5", - "pako": "2.1.0", - "pino": "9.6.0", - "pino-caller": "4.0.0", - "siwe": "2.3.2", - "siwe-recap": "0.0.2-alpha.0", - "tslib": "^2.8.1", - "typechain": "8.3.2", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - }, - "peerDependencies": { - "viem": "2.38.3" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/abi": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", - "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/abstract-signer": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", - "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/address": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", - "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/rlp": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/base64": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", - "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/basex": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", - "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/bignumber": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", - "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/bytes": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", - "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/constants": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", - "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/hash": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", - "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/hdnode": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", - "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/json-wallets": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", - "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/keccak256": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", - "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/logger": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", - "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/networks": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", - "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/pbkdf2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", - "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/sha2": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/properties": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", - "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/random": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", - "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/rlp": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", - "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/sha2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", - "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/signing-key": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", - "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "bn.js": "^5.2.1", - "elliptic": "6.5.4", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/strings": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", - "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/wallet": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", - "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/json-wallets": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/web": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", - "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/@ethersproject/wordlists": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", - "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT" - }, - "node_modules/@lit-protocol/networks/node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/@lit-protocol/networks/node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/@lit-protocol/networks/node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/ethers/node_modules/@ethersproject/providers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", - "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@lit-protocol/networks/node_modules/multiformats": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-11.0.2.tgz", - "integrity": "sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==", - "license": "Apache-2.0 OR MIT", - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@lit-protocol/networks/node_modules/siwe-recap": { - "version": "0.0.2-alpha.0", - "resolved": "https://registry.npmjs.org/siwe-recap/-/siwe-recap-0.0.2-alpha.0.tgz", - "integrity": "sha512-xqFUnvrACWW/Q4s5HQ02avg8IyH2RcgkUzfvN4scYaaHErotLVtTGDZkSS0sn/oNK4MXRt83lTqredsvXgt8iA==", - "license": "MIT", - "dependencies": { - "canonicalize": "^2.0.0", - "multiformats": "^11.0.2", - "siwe": "^2.1.4" - }, - "peerDependencies": { - "ethers": "^5.5.1" - } - }, - "node_modules/@lit-protocol/schemas": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@lit-protocol/schemas/-/schemas-8.0.3.tgz", - "integrity": "sha512-0ZB1JXaiWRNoTL+Dci5weiT3MEq4SFcq1g5tLIx7cWTjLY8KHypfVnd5hFs/KX7yUQl9DS0BLRlSUUGzS9l0eQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "9.0.1", - "@lit-protocol/contracts": "0.9.1", - "@openagenda/verror": "3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "5.7.2", - "siwe": "2.3.2", - "tslib": "2.8.1", - "typechain": "8.3.2", - "viem": "2.38.3", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/abi": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", - "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/abstract-signer": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", - "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/address": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", - "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/rlp": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/base64": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", - "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/basex": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", - "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/bignumber": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", - "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/bytes": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", - "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/constants": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", - "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/hash": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", - "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/hdnode": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", - "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/json-wallets": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", - "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/keccak256": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", - "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/logger": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", - "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/networks": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", - "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/pbkdf2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", - "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/sha2": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/properties": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", - "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/providers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", - "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/random": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", - "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/rlp": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", - "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/sha2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", - "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/signing-key": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", - "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "bn.js": "^5.2.1", - "elliptic": "6.5.4", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/strings": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", - "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/wallet": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", - "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/json-wallets": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/web": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", - "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/@ethersproject/wordlists": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", - "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT" - }, - "node_modules/@lit-protocol/schemas/node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/@lit-protocol/schemas/node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/@lit-protocol/schemas/node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, - "node_modules/@lit-protocol/types": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@lit-protocol/types/-/types-8.0.3.tgz", - "integrity": "sha512-6Y2RZrJ6n/s3FmhbtK2ccYrfGuapRajEklsVxj21sqHzOx0Q2/rIP4QiEw/k85YjTWLCCWAaiuR23ntdbtXDhg==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@lit-protocol/access-control-conditions-schemas": "8.0.2", - "@lit-protocol/constants": "9.0.1", - "@lit-protocol/contracts": "0.9.1", - "@lit-protocol/schemas": "8.0.3", - "@openagenda/verror": "3.1.4", - "@t3-oss/env-core": "0.13.8", - "@typechain/ethers-v6": "0.5.1", - "ethers": "5.7.2", - "siwe": "2.3.2", - "tslib": "2.8.1", - "typechain": "8.3.2", - "viem": "2.38.3", - "zod": "3.24.3", - "zod-validation-error": "3.4.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/abi": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", - "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/abstract-signer": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", - "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/address": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", - "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/rlp": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/base64": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", - "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/basex": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", - "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/bignumber": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", - "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/bytes": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", - "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/constants": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", - "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/hash": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", - "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/hdnode": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", - "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/json-wallets": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", - "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/keccak256": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", - "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/logger": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", - "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/networks": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", - "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/pbkdf2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", - "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/sha2": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/properties": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", - "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/providers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", - "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/random": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", - "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/rlp": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", - "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/sha2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", - "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/signing-key": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", - "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "bn.js": "^5.2.1", - "elliptic": "6.5.4", - "hash.js": "1.1.7" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/strings": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", - "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/wallet": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", - "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/json-wallets": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/web": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", - "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/@ethersproject/wordlists": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", - "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@lit-protocol/types/node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT" - }, - "node_modules/@lit-protocol/types/node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/@lit-protocol/types/node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/@lit-protocol/types/node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, - "node_modules/@lit-protocol/uint8arrays": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@lit-protocol/uint8arrays/-/uint8arrays-7.1.1.tgz", - "integrity": "sha512-9EycDdx3WQSH97DwIXUVhJXblVR1iJZCSa7watNzt+j2Au7CikO/DiBpIHfBe7ULVLkCI7li5hAnVB9vDfDEtQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "5.7.0", - "@lit-protocol/accs-schemas": "^0.0.24", - "@lit-protocol/constants": "7.1.1", - "@lit-protocol/contracts": "^0.0.74", - "@lit-protocol/types": "7.1.1", - "@openagenda/verror": "^3.1.4", - "depd": "^2.0.0", - "ethers": "^5.7.1", - "siwe": "^2.3.2", - "tslib": "1.14.1" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/@ethersproject/contracts": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.8.0.tgz", - "integrity": "sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "^5.8.0", - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/@ethersproject/contracts/node_modules/@ethersproject/abstract-provider": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", - "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/@ethersproject/providers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.8.0.tgz", - "integrity": "sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/base64": "^5.8.0", - "@ethersproject/basex": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/random": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/sha2": "^5.8.0", - "@ethersproject/strings": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0", - "bech32": "1.1.4", - "ws": "8.18.0" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/@ethersproject/providers/node_modules/@ethersproject/abstract-provider": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", - "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/@lit-protocol/constants": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@lit-protocol/constants/-/constants-7.1.1.tgz", - "integrity": "sha512-wJY5r8D0FdvtkQtcjx5JfPC3Qeb5SZ1m72HTcvJdqaJaBgaPP4eg2JSUg9uNJ+knOCyfdiBPl47XlWTGqe1X4Q==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "5.7.0", - "@lit-protocol/accs-schemas": "^0.0.24", - "@lit-protocol/contracts": "^0.0.74", - "@lit-protocol/types": "7.1.1", - "@openagenda/verror": "^3.1.4", - "depd": "^2.0.0", - "ethers": "^5.7.1", - "siwe": "^2.3.2", - "tslib": "1.14.1" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/@lit-protocol/contracts": { - "version": "0.0.74", - "resolved": "https://registry.npmjs.org/@lit-protocol/contracts/-/contracts-0.0.74.tgz", - "integrity": "sha512-8uV038gzBp7ew7a4884SVt9Zhu8CtiTb+A8dKNnByxVoT1kFt4O4DmsaniV8p9AGjNR13IWfpU1NFChmPHVIpQ==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "peerDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/@lit-protocol/types": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@lit-protocol/types/-/types-7.1.1.tgz", - "integrity": "sha512-oOtUOEA+5o6vb6xOx7x0vDG5IDsHANbWgB1nc23Ah7SctbITQM7F8VZNaCwRsSj8UxKjWD13eGsMHvMPxoFXdg==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "5.7.0", - "@lit-protocol/accs-schemas": "^0.0.24", - "depd": "^2.0.0", - "ethers": "^5.7.1", - "siwe": "^2.3.2", - "tslib": "1.14.1" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/ethers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", - "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.8.0", - "@ethersproject/abstract-provider": "5.8.0", - "@ethersproject/abstract-signer": "5.8.0", - "@ethersproject/address": "5.8.0", - "@ethersproject/base64": "5.8.0", - "@ethersproject/basex": "5.8.0", - "@ethersproject/bignumber": "5.8.0", - "@ethersproject/bytes": "5.8.0", - "@ethersproject/constants": "5.8.0", - "@ethersproject/contracts": "5.8.0", - "@ethersproject/hash": "5.8.0", - "@ethersproject/hdnode": "5.8.0", - "@ethersproject/json-wallets": "5.8.0", - "@ethersproject/keccak256": "5.8.0", - "@ethersproject/logger": "5.8.0", - "@ethersproject/networks": "5.8.0", - "@ethersproject/pbkdf2": "5.8.0", - "@ethersproject/properties": "5.8.0", - "@ethersproject/providers": "5.8.0", - "@ethersproject/random": "5.8.0", - "@ethersproject/rlp": "5.8.0", - "@ethersproject/sha2": "5.8.0", - "@ethersproject/signing-key": "5.8.0", - "@ethersproject/solidity": "5.8.0", - "@ethersproject/strings": "5.8.0", - "@ethersproject/transactions": "5.8.0", - "@ethersproject/units": "5.8.0", - "@ethersproject/wallet": "5.8.0", - "@ethersproject/web": "5.8.0", - "@ethersproject/wordlists": "5.8.0" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/ethers/node_modules/@ethersproject/abstract-provider": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", - "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0" - } - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/@lit-protocol/uint8arrays/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@lit-protocol/wasm": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@lit-protocol/wasm/-/wasm-8.1.0.tgz", - "integrity": "sha512-V1EIJHdwoTdxdFoq6N9msatXBNBWZ8cIoa1RvnmerO2QcwAZmq+dYntVK9+F6lFN9p9YL6AI1PW5349WTxg1Zg==", - "deprecated": "This package targets a retired Lit Protocol network and is no longer maintained. See https://developer.litprotocol.com for the current SDK.", - "dependencies": { - "@jest/globals": "30.2.0", - "ethers": "^5.7.1", - "pako": "^2.1.0", - "tslib": "1.14.1" - } - }, - "node_modules/@lit-protocol/wasm/node_modules/@ethersproject/contracts": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.8.0.tgz", - "integrity": "sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "^5.8.0", - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0" - } - }, - "node_modules/@lit-protocol/wasm/node_modules/@ethersproject/providers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.8.0.tgz", - "integrity": "sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/base64": "^5.8.0", - "@ethersproject/basex": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/random": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/sha2": "^5.8.0", - "@ethersproject/strings": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0", - "bech32": "1.1.4", - "ws": "8.18.0" - } - }, - "node_modules/@lit-protocol/wasm/node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" - } - }, - "node_modules/@lit-protocol/wasm/node_modules/ethers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", - "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@ethersproject/abi": "5.8.0", - "@ethersproject/abstract-provider": "5.8.0", - "@ethersproject/abstract-signer": "5.8.0", - "@ethersproject/address": "5.8.0", - "@ethersproject/base64": "5.8.0", - "@ethersproject/basex": "5.8.0", - "@ethersproject/bignumber": "5.8.0", - "@ethersproject/bytes": "5.8.0", - "@ethersproject/constants": "5.8.0", - "@ethersproject/contracts": "5.8.0", - "@ethersproject/hash": "5.8.0", - "@ethersproject/hdnode": "5.8.0", - "@ethersproject/json-wallets": "5.8.0", - "@ethersproject/keccak256": "5.8.0", - "@ethersproject/logger": "5.8.0", - "@ethersproject/networks": "5.8.0", - "@ethersproject/pbkdf2": "5.8.0", - "@ethersproject/properties": "5.8.0", - "@ethersproject/providers": "5.8.0", - "@ethersproject/random": "5.8.0", - "@ethersproject/rlp": "5.8.0", - "@ethersproject/sha2": "5.8.0", - "@ethersproject/signing-key": "5.8.0", - "@ethersproject/solidity": "5.8.0", - "@ethersproject/strings": "5.8.0", - "@ethersproject/transactions": "5.8.0", - "@ethersproject/units": "5.8.0", - "@ethersproject/wallet": "5.8.0", - "@ethersproject/web": "5.8.0", - "@ethersproject/wordlists": "5.8.0" - } - }, - "node_modules/@lit-protocol/wasm/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/@lit-protocol/wasm/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "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 - } - } - }, - "node_modules/@multiformats/base-x": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@multiformats/base-x/-/base-x-4.0.1.tgz", - "integrity": "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==", - "license": "MIT" - }, - "node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.1.tgz", - "integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.7.1" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves/node_modules/@noble/hashes": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", - "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@openagenda/verror": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@openagenda/verror/-/verror-3.1.4.tgz", - "integrity": "sha512-+V7QuD6v5sMWez7cu+5DXoXMim+iQssOcspoNgbWDW8sEyC54Mdo5VuIkcIjqhPmQYOzBWo5qlbzNGEpD6PzMA==", - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "depd": "^2.0.0", - "inherits": "^2.0.4", - "sprintf-js": "^1.1.2" - } - }, - "node_modules/@pkgr/core": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", - "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", - "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", - "license": "BSD-3-Clause" - }, - "node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", - "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.9.0", - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@simplewebauthn/browser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-7.2.0.tgz", - "integrity": "sha512-HHIvRPpqKy0UV/BsGAmx4rQRZuZTUFYLLH65FwpSOslqHruiHx3Ql/bq7A75bjWuJ296a+4BIAq3+SPaII01TQ==", - "license": "MIT", - "dependencies": { - "@simplewebauthn/typescript-types": "*" - } - }, - "node_modules/@simplewebauthn/typescript-types": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@simplewebauthn/typescript-types/-/typescript-types-7.0.0.tgz", - "integrity": "sha512-bV+xACCFTsrLR/23ozHO06ZllHZaxC8LlI5YCo79GvU2BrN+rePDU2yXwZIYndNWcMQwRdndRdAhpafOh9AC/g==", - "deprecated": "This package has been renamed to @simplewebauthn/types. Please install @simplewebauthn/types instead to ensure you receive future updates.", - "license": "MIT" - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@spruceid/siwe-parser": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@spruceid/siwe-parser/-/siwe-parser-2.1.2.tgz", - "integrity": "sha512-d/r3S1LwJyMaRAKQ0awmo9whfXeE88Qt00vRj91q5uv5ATtWIQEGJ67Yr5eSZw5zp1/fZCXZYuEckt8lSkereQ==", - "license": "Apache-2.0", - "dependencies": { - "@noble/hashes": "^1.1.2", - "apg-js": "^4.3.0", - "uri-js": "^4.4.1", - "valid-url": "^1.0.9" - } - }, - "node_modules/@stablelib/binary": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", - "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", - "license": "MIT", - "dependencies": { - "@stablelib/int": "^1.0.1" - } - }, - "node_modules/@stablelib/int": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", - "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", - "license": "MIT" - }, - "node_modules/@stablelib/random": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@stablelib/random/-/random-1.0.2.tgz", - "integrity": "sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==", - "license": "MIT", - "dependencies": { - "@stablelib/binary": "^1.0.1", - "@stablelib/wipe": "^1.0.1" - } - }, - "node_modules/@stablelib/wipe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", - "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", - "license": "MIT" - }, - "node_modules/@t3-oss/env-core": { - "version": "0.13.8", - "resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.13.8.tgz", - "integrity": "sha512-L1inmpzLQyYu4+Q1DyrXsGJYCXbtXjC4cICw1uAKv0ppYPQv656lhZPU91Qd1VS6SO/bou1/q5ufVzBGbNsUpw==", - "license": "MIT", - "peerDependencies": { - "arktype": "^2.1.0", - "typescript": ">=5.0.0", - "valibot": "^1.0.0-beta.7 || ^1.0.0", - "zod": "^3.24.0 || ^4.0.0-beta.0" - }, - "peerDependenciesMeta": { - "arktype": { - "optional": true - }, - "typescript": { - "optional": true - }, - "valibot": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@typechain/ethers-v6": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@typechain/ethers-v6/-/ethers-v6-0.5.1.tgz", - "integrity": "sha512-F+GklO8jBWlsaVV+9oHaPh5NJdd6rAKN4tklGfInX1Q7h0xPgVLP39Jl3eCulPB5qexI71ZFHwbljx4ZXNfouA==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.15", - "ts-essentials": "^7.0.1" - }, - "peerDependencies": { - "ethers": "6.x", - "typechain": "^8.3.2", - "typescript": ">=4.7.0" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, - "node_modules/@types/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.20.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", - "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "license": "MIT" - }, - "node_modules/@types/prettier": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", - "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", - "license": "MIT" - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", - "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", - "license": "ISC" - }, - "node_modules/@wagmi/core": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-2.22.1.tgz", - "integrity": "sha512-cG/xwQWsBEcKgRTkQVhH29cbpbs/TdcUJVFXCyri3ZknxhMyGv0YEjTcrNpRgt2SaswL1KrvslSNYKKo+5YEAg==", - "license": "MIT", - "dependencies": { - "eventemitter3": "5.0.1", - "mipd": "0.0.7", - "zustand": "5.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "@tanstack/query-core": ">=5.0.0", - "typescript": ">=5.0.4", - "viem": "2.x" - }, - "peerDependenciesMeta": { - "@tanstack/query-core": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/abitype": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.1.0.tgz", - "integrity": "sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3.22.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/aes-js": { - "version": "4.0.0-beta.5", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", - "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "license": "MIT", - "peer": true - }, - "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/apg-js": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", - "integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==", - "license": "BSD-2-Clause" - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/argparse/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", - "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base-x": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", - "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.24", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", - "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/bech32": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", - "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", - "license": "MIT" - }, - "node_modules/bl": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", - "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/blakejs": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", - "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==", - "license": "MIT" - }, - "node_modules/bn.js": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", - "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "license": "MIT" - }, - "node_modules/browserslist": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", - "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.23", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.427", - "node-releases": "^2.0.55", - "update-browserslist-db": "^1.3.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs58": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", - "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", - "license": "MIT", - "dependencies": { - "base-x": "^5.0.0" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-keys": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", - "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", - "license": "MIT", - "dependencies": { - "camelcase": "^5.3.1", - "map-obj": "^4.0.0", - "quick-lru": "^4.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/canonicalize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz", - "integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==", - "license": "Apache-2.0", - "bin": { - "canonicalize": "bin/canonicalize.js" - } - }, - "node_modules/cbor-web": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/cbor-web/-/cbor-web-9.0.2.tgz", - "integrity": "sha512-N6gU2GsJS8RR5gy1d9wQcSPgn9FGJFY7KNvdDRlwHfz6kCxrQr2TDnrjXHmr6TFSl6Fd0FC4zRnityEldjRGvQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cids": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/cids/-/cids-1.1.9.tgz", - "integrity": "sha512-l11hWRfugIcbGuTZwAM5PwpjPPjyb6UZOGwlHSnOBV5o07XhQ4gNpBN67FbODvpjyHtd+0Xs6KNvUcGBiDRsdg==", - "deprecated": "This module has been superseded by the multiformats module", - "license": "MIT", - "dependencies": { - "multibase": "^4.0.1", - "multicodec": "^3.0.1", - "multihashes": "^4.0.1", - "uint8arrays": "^3.0.0" - }, - "engines": { - "node": ">=4.0.0", - "npm": ">=3.0.0" - } - }, - "node_modules/cids/node_modules/uint8arrays": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz", - "integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==", - "license": "MIT", - "dependencies": { - "multiformats": "^9.4.2" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/command-line-args": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", - "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", - "license": "MIT", - "dependencies": { - "array-back": "^3.1.0", - "find-replace": "^3.0.0", - "lodash.camelcase": "^4.3.0", - "typical": "^4.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/command-line-usage": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.3.tgz", - "integrity": "sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==", - "license": "MIT", - "dependencies": { - "array-back": "^4.0.2", - "chalk": "^2.4.2", - "table-layout": "^1.0.2", - "typical": "^5.2.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/command-line-usage/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/array-back": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", - "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/command-line-usage/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/command-line-usage/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/command-line-usage/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/command-line-usage/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/typical": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", - "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decamelize-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", - "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", - "license": "MIT", - "dependencies": { - "decamelize": "^1.1.0", - "map-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decamelize-keys/node_modules/map-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.430", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", - "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", - "license": "ISC" - }, - "node_modules/elliptic": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/elysia": { - "version": "1.2.25", - "resolved": "https://registry.npmjs.org/elysia/-/elysia-1.2.25.tgz", - "integrity": "sha512-WsdQpORJvb4uszzeqYT0lg97knw1iBW1NTzJ1Jm57tiHg+DfAotlWXYbjmvQ039ssV0fYELDHinLLoUazZkEHg==", - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.27", - "cookie": "^1.0.2", - "memoirist": "^0.3.0", - "openapi-types": "^12.1.3" - }, - "peerDependencies": { - "@sinclair/typebox": ">= 0.34.0", - "openapi-types": ">= 12.0.0", - "typescript": ">= 5.0.0" - }, - "peerDependenciesMeta": { - "openapi-types": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/err-code": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", - "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==", - "license": "MIT" - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ethers": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", - "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@adraffy/ens-normalize": "1.11.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.21.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ethers/node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/ethers/node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD", - "peer": true - }, - "node_modules/ethers/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "license": "MIT", - "peer": true - }, - "node_modules/ethers/node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "peer": true, - "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 - } - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" - }, - "node_modules/fast-redact": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", - "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-replace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", - "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", - "license": "MIT", - "dependencies": { - "array-back": "^3.0.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/hamt-sharding": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hamt-sharding/-/hamt-sharding-2.0.1.tgz", - "integrity": "sha512-vnjrmdXG9dDs1m/H4iJ6z0JFI2NtgsW5keRkTcM85NGak69Mkf5PHUqBz+Xs0T4sg0ppvj9O5EGAJo40FTxmmA==", - "license": "MIT", - "dependencies": { - "sparse-array": "^1.3.1", - "uint8arrays": "^3.0.0" - }, - "engines": { - "node": ">=10.0.0", - "npm": ">=6.0.0" - } - }, - "node_modules/hamt-sharding/node_modules/uint8arrays": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz", - "integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==", - "license": "MIT", - "dependencies": { - "multiformats": "^9.4.2" - } - }, - "node_modules/hard-rejection": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", - "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "license": "MIT", - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/interface-ipld-format": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/interface-ipld-format/-/interface-ipld-format-1.0.1.tgz", - "integrity": "sha512-WV/ar+KQJVoQpqRDYdo7YPGYIUHJxCuOEhdvsRpzLqoOIVCqPKdMMYmsLL1nCRsF3yYNio+PAJbCKiv6drrEAg==", - "deprecated": "This module has been superseded by the multiformats module", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "cids": "^1.1.6", - "multicodec": "^3.0.1", - "multihashes": "^4.0.2" - } - }, - "node_modules/ipfs-only-hash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ipfs-only-hash/-/ipfs-only-hash-4.0.0.tgz", - "integrity": "sha512-TE1DZCvfw8i3gcsTq3P4TFx3cKFJ3sluu/J3XINkJhIN9OwJgNMqKA+WnKx6ByCb1IoPXsTp1KM7tupElb6SyA==", - "license": "MIT", - "dependencies": { - "ipfs-unixfs-importer": "^7.0.1", - "meow": "^9.0.0" - }, - "bin": { - "ipfs-only-hash": "cli.js" - } - }, - "node_modules/ipfs-unixfs": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/ipfs-unixfs/-/ipfs-unixfs-4.0.3.tgz", - "integrity": "sha512-hzJ3X4vlKT8FQ3Xc4M1szaFVjsc1ZydN+E4VQ91aXxfpjFn9G2wsMo1EFdAXNq/BUnN5dgqIOMP5zRYr3DTsAw==", - "license": "MIT", - "dependencies": { - "err-code": "^3.0.1", - "protobufjs": "^6.10.2" - }, - "engines": { - "node": ">=14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/ipfs-unixfs-importer": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ipfs-unixfs-importer/-/ipfs-unixfs-importer-7.0.3.tgz", - "integrity": "sha512-qeFOlD3AQtGzr90sr5Tq1Bi8pT5Nr2tSI8z310m7R4JDYgZc6J1PEZO3XZQ8l1kuGoqlAppBZuOYmPEqaHcVQQ==", - "license": "MIT", - "dependencies": { - "bl": "^5.0.0", - "cids": "^1.1.5", - "err-code": "^3.0.1", - "hamt-sharding": "^2.0.0", - "ipfs-unixfs": "^4.0.3", - "ipld-dag-pb": "^0.22.2", - "it-all": "^1.0.5", - "it-batch": "^1.0.8", - "it-first": "^1.0.6", - "it-parallel-batch": "^1.0.9", - "merge-options": "^3.0.4", - "multihashing-async": "^2.1.0", - "rabin-wasm": "^0.1.4", - "uint8arrays": "^2.1.2" - }, - "engines": { - "node": ">=14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/ipld-dag-pb": { - "version": "0.22.3", - "resolved": "https://registry.npmjs.org/ipld-dag-pb/-/ipld-dag-pb-0.22.3.tgz", - "integrity": "sha512-dfG5C5OVAR4FEP7Al2CrHWvAyIM7UhAQrjnOYOIxXGQz5NlEj6wGX0XQf6Ru6or1na6upvV3NQfstapQG8X2rg==", - "deprecated": "This module has been superseded by @ipld/dag-pb and multiformats", - "license": "MIT", - "dependencies": { - "cids": "^1.0.0", - "interface-ipld-format": "^1.0.0", - "multicodec": "^3.0.1", - "multihashing-async": "^2.0.0", - "protobufjs": "^6.10.2", - "stable": "^0.1.8", - "uint8arrays": "^2.0.5" - }, - "engines": { - "node": ">=6.0.0", - "npm": ">=3.0.0" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isows": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", - "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/it-all": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/it-all/-/it-all-1.0.6.tgz", - "integrity": "sha512-3cmCc6Heqe3uWi3CVM/k51fa/XbMFpQVzFoDsV0IZNHSQDyAXl3c4MjHkFX5kF3922OGj7Myv1nSEUgRtcuM1A==", - "license": "ISC" - }, - "node_modules/it-batch": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/it-batch/-/it-batch-1.0.9.tgz", - "integrity": "sha512-7Q7HXewMhNFltTsAMdSz6luNhyhkhEtGGbYek/8Xb/GiqYMtwUmopE1ocPSiJKKp3rM4Dt045sNFoUu+KZGNyA==", - "license": "ISC" - }, - "node_modules/it-first": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/it-first/-/it-first-1.0.7.tgz", - "integrity": "sha512-nvJKZoBpZD/6Rtde6FXqwDqDZGF1sCADmr2Zoc0hZsIvnE449gRFnGctxDf09Bzc/FWnHXAdaHVIetY6lrE0/g==", - "license": "ISC" - }, - "node_modules/it-parallel-batch": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/it-parallel-batch/-/it-parallel-batch-1.0.11.tgz", - "integrity": "sha512-UWsWHv/kqBpMRmyZJzlmZeoAMA0F3SZr08FBdbhtbe+MtoEBgr/ZUAKrnenhXCBrsopy76QjRH2K/V8kNdupbQ==", - "license": "ISC", - "dependencies": { - "it-batch": "^1.0.9" - } - }, - "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0", - "chalk": "^4.1.2", - "expect": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", - "semver": "^7.7.2", - "synckit": "^0.11.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/jose": { - "version": "4.14.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", - "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-sha3": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", - "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", - "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT" - }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/map-obj": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", - "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/memoirist": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/memoirist/-/memoirist-0.3.1.tgz", - "integrity": "sha512-lmk4Z45IuVZPT67nxAdD3rAsNExxMEBFXgCeJGJnoLkYOjmZnJ8Hmi+MGdl9oLKtAENFAAgG8FvV3Z8BNiqy8w==", - "license": "MIT" - }, - "node_modules/meow": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", - "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", - "license": "MIT", - "dependencies": { - "@types/minimist": "^1.2.0", - "camelcase-keys": "^6.2.2", - "decamelize": "^1.2.0", - "decamelize-keys": "^1.1.0", - "hard-rejection": "^2.1.0", - "minimist-options": "4.1.0", - "normalize-package-data": "^3.0.0", - "read-pkg-up": "^7.0.1", - "redent": "^3.0.0", - "trim-newlines": "^3.0.0", - "type-fest": "^0.18.0", - "yargs-parser": "^20.2.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-options": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", - "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", - "license": "MIT", - "dependencies": { - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "license": "MIT" - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minimist-options": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", - "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", - "license": "MIT", - "dependencies": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0", - "kind-of": "^6.0.3" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/minimist-options/node_modules/is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mipd": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/mipd/-/mipd-0.0.7.tgz", - "integrity": "sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wagmi-dev" - } - ], - "license": "MIT", - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/multibase": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/multibase/-/multibase-4.0.6.tgz", - "integrity": "sha512-x23pDe5+svdLz/k5JPGCVdfn7Q5mZVMBETiC+ORfO+sor9Sgs0smJzAjfTbM5tckeCqnaUuMYoz+k3RXMmJClQ==", - "deprecated": "This module has been superseded by the multiformats module", - "license": "MIT", - "dependencies": { - "@multiformats/base-x": "^4.0.1" - }, - "engines": { - "node": ">=12.0.0", - "npm": ">=6.0.0" - } - }, - "node_modules/multicodec": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/multicodec/-/multicodec-3.2.1.tgz", - "integrity": "sha512-+expTPftro8VAW8kfvcuNNNBgb9gPeNYV9dn+z1kJRWF2vih+/S79f2RVeIwmrJBUJ6NT9IUPWnZDQvegEh5pw==", - "deprecated": "This module has been superseded by the multiformats module", - "license": "MIT", - "dependencies": { - "uint8arrays": "^3.0.0", - "varint": "^6.0.0" - } - }, - "node_modules/multicodec/node_modules/uint8arrays": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz", - "integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==", - "license": "MIT", - "dependencies": { - "multiformats": "^9.4.2" - } - }, - "node_modules/multiformats": { - "version": "9.9.0", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", - "license": "(Apache-2.0 AND MIT)" - }, - "node_modules/multihashes": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/multihashes/-/multihashes-4.0.3.tgz", - "integrity": "sha512-0AhMH7Iu95XjDLxIeuCOOE4t9+vQZsACyKZ9Fxw2pcsRmlX4iCn1mby0hS0bb+nQOVpdQYWPpnyusw4da5RPhA==", - "license": "MIT", - "dependencies": { - "multibase": "^4.0.1", - "uint8arrays": "^3.0.0", - "varint": "^5.0.2" - }, - "engines": { - "node": ">=12.0.0", - "npm": ">=6.0.0" - } - }, - "node_modules/multihashes/node_modules/uint8arrays": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz", - "integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==", - "license": "MIT", - "dependencies": { - "multiformats": "^9.4.2" - } - }, - "node_modules/multihashes/node_modules/varint": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz", - "integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==", - "license": "MIT" - }, - "node_modules/multihashing-async": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/multihashing-async/-/multihashing-async-2.1.4.tgz", - "integrity": "sha512-sB1MiQXPSBTNRVSJc2zM157PXgDtud2nMFUEIvBrsq5Wv96sUclMRK/ecjoP1T/W61UJBqt4tCTwMkUpt2Gbzg==", - "license": "MIT", - "dependencies": { - "blakejs": "^1.1.0", - "err-code": "^3.0.0", - "js-sha3": "^0.8.0", - "multihashes": "^4.0.1", - "murmurhash3js-revisited": "^3.0.0", - "uint8arrays": "^3.0.0" - }, - "engines": { - "node": ">=12.0.0", - "npm": ">=6.0.0" - } - }, - "node_modules/multihashing-async/node_modules/uint8arrays": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz", - "integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==", - "license": "MIT", - "dependencies": { - "multiformats": "^9.4.2" - } - }, - "node_modules/murmurhash3js-revisited": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/murmurhash3js-revisited/-/murmurhash3js-revisited-3.0.0.tgz", - "integrity": "sha512-/sF3ee6zvScXMb1XFJ8gDsSnY+X8PbOyjIuBhtgis10W2Jx4ZjIhikUCIF9c4gpJxVnQIsPAFrSwTCuAjicP6g==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "license": "MIT" - }, - "node_modules/node-localstorage": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/node-localstorage/-/node-localstorage-3.0.5.tgz", - "integrity": "sha512-GCwtK33iwVXboZWYcqQHu3aRvXEBwmPkAMRBLeaX86ufhqslyUkLGsi4aW3INEfdQYpUB5M9qtYf3eHvAk2VBg==", - "license": "MIT", - "dependencies": { - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/node-releases": { - "version": "2.0.55", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", - "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT" - }, - "node_modules/ox": { - "version": "0.9.6", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.9.6.tgz", - "integrity": "sha512-8SuCbHPvv2eZLYXrNmC0EC12rdzXQLdhnOMlHDW2wiCPLxBrOOJwX5L5E61by+UjTPOryqQiRSnjIKCI+GykKg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.11.0", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "1.9.1", - "@noble/hashes": "^1.8.0", - "@scure/bip32": "^1.7.0", - "@scure/bip39": "^1.6.0", - "abitype": "^1.0.9", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/ox/node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pino": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz", - "integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0", - "fast-redact": "^3.1.1", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^4.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-caller": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pino-caller/-/pino-caller-4.0.0.tgz", - "integrity": "sha512-z0i/iYp4zH02uIQG8LwFK5dMKTdAYgwZM9LSzoOATJ0H5LTeJ3OZeNBpGget9DpnNaewIt5NkN5YGNvkCZ+JbQ==", - "license": "MIT", - "dependencies": { - "source-map-support": "^0.5.13" - }, - "engines": { - "node": ">6.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "license": "MIT", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/protobufjs": { - "version": "6.11.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", - "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.1", - "@types/node": ">=13.7.0", - "long": "^4.0.0" - }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/quick-lru": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", - "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/rabin-wasm": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/rabin-wasm/-/rabin-wasm-0.1.5.tgz", - "integrity": "sha512-uWgQTo7pim1Rnj5TuWcCewRDTf0PEFTSlaUjWP4eY9EbLV9em08v89oCz/WO+wRxpYuO36XEHp4wgYQnAgOHzA==", - "license": "MIT", - "dependencies": { - "@assemblyscript/loader": "^0.9.4", - "bl": "^5.0.0", - "debug": "^4.3.1", - "minimist": "^1.2.5", - "node-fetch": "^2.6.1", - "readable-stream": "^3.6.0" - }, - "bin": { - "rabin-wasm": "cli/bin.js" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", - "license": "MIT", - "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg/node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "license": "ISC" - }, - "node_modules/read-pkg/node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "node_modules/read-pkg/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/reduce-flatten": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz", - "integrity": "sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/scrypt-js": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", - "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/siwe": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/siwe/-/siwe-2.3.2.tgz", - "integrity": "sha512-aSf+6+Latyttbj5nMu6GF3doMfv2UYj83hhwZgUF20ky6fTS83uVhkQABdIVnEuS8y1bBdk7p6ltb9SmlhTTlA==", - "license": "Apache-2.0", - "dependencies": { - "@spruceid/siwe-parser": "^2.1.2", - "@stablelib/random": "^1.0.1", - "uri-js": "^4.4.1", - "valid-url": "^1.0.9" - }, - "peerDependencies": { - "ethers": "^5.6.8 || ^6.0.8" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sparse-array": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/sparse-array/-/sparse-array-1.3.2.tgz", - "integrity": "sha512-ZT711fePGn3+kQyLuv1fpd3rNSkNF8vd5Kv2D+qnOANeyKs3fx6bUMGWRPvgTTcYV64QMqZKZwcuaQSP3AZ0tg==", - "license": "ISC" - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "license": "CC0-1.0" - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, - "node_modules/stable": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", - "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", - "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", - "license": "MIT" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-format": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", - "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==", - "license": "WTFPL OR MIT" - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/stytch": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/stytch/-/stytch-12.4.0.tgz", - "integrity": "sha512-jyYIfirVnhy3gAtGLEIK5c5tSp5bhi9tUE0JRzItJlwISBW/StMMOvP0hhPUb831EGjV2l1S4YRPg/NqJ+eYNg==", - "license": "MIT", - "dependencies": { - "jose": "^5.6.3", - "undici": "^6.19.5" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/stytch/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/synckit": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", - "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.3.6" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/table-layout": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-1.0.2.tgz", - "integrity": "sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==", - "license": "MIT", - "dependencies": { - "array-back": "^4.0.1", - "deep-extend": "~0.6.0", - "typical": "^5.2.0", - "wordwrapjs": "^4.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/table-layout/node_modules/array-back": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", - "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/table-layout/node_modules/typical": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", - "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/thread-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", - "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "license": "BSD-3-Clause" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/trim-newlines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", - "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ts-command-line-args": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/ts-command-line-args/-/ts-command-line-args-2.5.1.tgz", - "integrity": "sha512-H69ZwTw3rFHb5WYpQya40YAX2/w7Ut75uUECbgBIsLmM+BNuYnxsltfyyLMxy6sEeKxgijLTnQtLd0nKd6+IYw==", - "license": "ISC", - "dependencies": { - "chalk": "^4.1.0", - "command-line-args": "^5.1.1", - "command-line-usage": "^6.1.0", - "string-format": "^2.0.0" - }, - "bin": { - "write-markdown": "dist/write-markdown.js" - } - }, - "node_modules/ts-essentials": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz", - "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", - "license": "MIT", - "peerDependencies": { - "typescript": ">=3.7.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typechain": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/typechain/-/typechain-8.3.2.tgz", - "integrity": "sha512-x/sQYr5w9K7yv3es7jo4KTX05CLxOf7TRWwoHlrjRh8H82G64g+k7VuWPJlgMo6qrjfCulOdfBjiaDtmhFYD/Q==", - "license": "MIT", - "dependencies": { - "@types/prettier": "^2.1.1", - "debug": "^4.3.1", - "fs-extra": "^7.0.0", - "glob": "7.1.7", - "js-sha3": "^0.8.0", - "lodash": "^4.17.15", - "mkdirp": "^1.0.4", - "prettier": "^2.3.1", - "ts-command-line-args": "^2.2.0", - "ts-essentials": "^7.0.1" - }, - "bin": { - "typechain": "dist/cli/cli.js" - }, - "peerDependencies": { - "typescript": ">=4.3.0" - } - }, - "node_modules/typechain/node_modules/glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typestub-ipfs-only-hash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/typestub-ipfs-only-hash/-/typestub-ipfs-only-hash-4.0.0.tgz", - "integrity": "sha512-HKLePX0XiPiyqoueSfvCLL9SIzvKBXjASaRoR0yk/gUbbK7cqejU6/tjhihwmzBCvWbx5aMQ2LYsYIpMK7Ikpg==", - "license": "BSD-2-Clause", - "dependencies": { - "ipfs-only-hash": "^4.0.0" - } - }, - "node_modules/typical": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", - "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/uint8arrays": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-2.1.10.tgz", - "integrity": "sha512-Q9/hhJa2836nQfEJSZTmr+pg9+cDJS9XEAp7N2Vg5MzL3bK/mkMVfjscRGYruP9jNda6MAdf4QD/y78gSzkp6A==", - "license": "MIT", - "dependencies": { - "multiformats": "^9.4.2" - } - }, - "node_modules/undici": { - "version": "6.28.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", - "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/valid-url": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", - "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/varint": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", - "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "license": "MIT" - }, - "node_modules/viem": { - "version": "2.38.3", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.38.3.tgz", - "integrity": "sha512-By2TutLv07iNHHtWqHHzjGipevYsfGqT7KQbGEmqLco1qTJxKnvBbSviqiu6/v/9REV6Q/FpmIxf2Z7/l5AbcQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@noble/curves": "1.9.1", - "@noble/hashes": "1.8.0", - "@scure/bip32": "1.7.0", - "@scure/bip39": "1.6.0", - "abitype": "1.1.0", - "isows": "1.0.7", - "ox": "0.9.6", - "ws": "8.18.3" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/viem/node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/viem/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "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 - } - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/wordwrapjs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-4.0.1.tgz", - "integrity": "sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==", - "license": "MIT", - "dependencies": { - "reduce-flatten": "^2.0.0", - "typical": "^5.2.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/wordwrapjs/node_modules/typical": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", - "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/ws": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", - "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/zod": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz", - "integrity": "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.18.0" - } - }, - "node_modules/zustand": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz", - "integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - } - } -} diff --git a/tool/threshold-spike/package.json b/tool/threshold-spike/package.json deleted file mode 100644 index c7589e4..0000000 --- a/tool/threshold-spike/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@nosus-monad/threshold-spike", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "check": "node conditions.test.mjs", - "dry-run": "node run.mjs", - "connect": "node run.mjs --connect", - "live": "node run.mjs --live" - }, - "dependencies": { - "@lit-protocol/auth": "8.2.3", - "@lit-protocol/lit-client": "8.3.1", - "@lit-protocol/networks": "8.4.1", - "viem": "2.38.3" - } -} diff --git a/tool/threshold-spike/run.mjs b/tool/threshold-spike/run.mjs deleted file mode 100644 index a723831..0000000 --- a/tool/threshold-spike/run.mjs +++ /dev/null @@ -1,231 +0,0 @@ -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - createPublicClient, - createWalletClient, - http, - keccak256, - parseAbi, - stringToHex, -} from "viem"; -import { privateKeyToAccount } from "viem/accounts"; - -import { buildCanDecryptConditions, LIT_ACC_CHAIN } from "./conditions.mjs"; - -const spikeRoot = dirname(fileURLToPath(import.meta.url)); -const live = process.argv.includes("--live") || process.env.THRESHOLD_SPIKE_RUN === "1"; -const connectOnly = process.argv.includes("--connect"); -const contractAddress = process.env.DROPS_CONTRACT_ADDRESS; -const dropId = process.env.SPIKE_DROP_ID ?? `0x${"ab".repeat(32)}`; -const senderKey = process.env.SENDER_PRIVATE_KEY; -const recipientKey = process.env.RECIPIENT_PRIVATE_KEY; -const strangerKey = process.env.STRANGER_PRIVATE_KEY; -const rpcUrl = process.env.MONAD_RPC_URL ?? "https://rpc.testnet.monad.xyz"; -const expirySeconds = BigInt(process.env.SPIKE_EXPIRY_SECONDS ?? "300"); -const checkExpiry = process.env.THRESHOLD_SPIKE_CHECK_EXPIRY === "1"; -const probe = "gate-t1-probe"; - -const monadTestnet = { - id: 10143, - name: "Monad Testnet", - nativeCurrency: { name: "MON", symbol: "MON", decimals: 18 }, - rpcUrls: { default: { http: [rpcUrl] } }, -}; - -const dropsAbi = parseAbi([ - "function seal(bytes32 id, bytes32 ciphertextDigest, address recipient, uint64 expiresAt)", - "function acknowledgeOpen(bytes32 id)", - "function canDecrypt(bytes32 id, address account) view returns (bool)", -]); - -const missing = []; -if (!contractAddress) missing.push("DROPS_CONTRACT_ADDRESS"); -if (!senderKey) missing.push("SENDER_PRIVATE_KEY"); -if (!recipientKey) missing.push("RECIPIENT_PRIVATE_KEY"); -if (!strangerKey) missing.push("STRANGER_PRIVATE_KEY"); - -const conditionContract = contractAddress ?? "0x0000000000000000000000000000000000000001"; -const conditions = buildCanDecryptConditions(conditionContract, dropId); - -console.log("NO SUS — Monad Experiment / Gate T1"); -console.log("Lit ACC chain:", LIT_ACC_CHAIN); -console.log("Selected SDK: @lit-protocol/lit-client 8.3.1, @lit-protocol/networks 8.4.1, @lit-protocol/auth 8.2.3 on naga-dev"); -console.log("Condition:"); -console.log(JSON.stringify(conditions, null, 2)); - -if (!live && !connectOnly) { - console.log("\nDry run only. This does not connect to Lit or Monad and does not enable the product gate."); - console.log("Use --connect for a Lit handshake. The full proof requires THRESHOLD_SPIKE_RUN=1, a deployed contract, and three controlled test wallets."); - process.exit(0); -} - -if (!existsSync(join(spikeRoot, "node_modules", "@lit-protocol", "lit-client"))) { - console.error("Lit client is not installed. Run npm install in tool/threshold-spike."); - process.exit(1); -} - -const [{ createLitClient }, networks, auth] = await Promise.all([ - import("@lit-protocol/lit-client"), - import("@lit-protocol/networks"), - import("@lit-protocol/auth"), -]); - -const network = networks.nagaDev ?? networks.nagaTest; -if (!network) { - console.error("The installed @lit-protocol/networks package does not export nagaDev or nagaTest."); - process.exit(1); -} - -const client = await createLitClient({ network }); -console.log("Connected to Lit naga-dev."); - -let encrypted; -try { - encrypted = await client.encrypt({ - dataToEncrypt: new TextEncoder().encode(probe), - evmContractConditions: conditions, - chain: LIT_ACC_CHAIN, - }); - console.log("Lit encrypt accepted the Monad canDecrypt condition."); - console.log("Returned fields:", Object.keys(encrypted)); -} catch (error) { - console.error("Lit encrypt against monadTestnet failed. Record this as Gate T1 evidence, not a product claim."); - console.error(error instanceof Error ? error.message : error); - await client.disconnect?.(); - process.exit(1); -} - -if (connectOnly && !live) { - await client.disconnect?.(); - console.log("Handshake and encrypt-only completed. Product gate remains spike-required."); - process.exit(0); -} - -if (missing.length) { - await client.disconnect?.(); - console.error(`Live proof refused. Missing ${missing.join(", ")}.`); - process.exit(1); -} - -const sender = privateKeyToAccount(senderKey); -const recipient = privateKeyToAccount(recipientKey); -const stranger = privateKeyToAccount(strangerKey); -const publicClient = createPublicClient({ chain: monadTestnet, transport: http(rpcUrl) }); -const senderClient = createWalletClient({ account: sender, chain: monadTestnet, transport: http(rpcUrl) }); -const recipientClient = createWalletClient({ account: recipient, chain: monadTestnet, transport: http(rpcUrl) }); - -const code = await publicClient.getCode({ address: contractAddress }); -if (!code || code === "0x") { - await client.disconnect?.(); - console.error("Live proof refused: DROPS_CONTRACT_ADDRESS has no deployed bytecode on the configured Monad RPC."); - process.exit(1); -} - -const authManager = auth.createAuthManager({ - storage: auth.storagePlugins.localStorageNode({ - appName: "nosus-monad-threshold-spike", - networkName: "naga-dev", - storagePath: join(spikeRoot, ".lit-auth"), - }), -}); - -async function authContextFor(account) { - return authManager.createEoaAuthContext({ - config: { account }, - litClient: client, - authConfig: { - domain: "localhost", - statement: "NO SUS Monad Gate T1 two-wallet test", - }, - }); -} - -async function decryptAs(account) { - return client.decrypt({ - data: encrypted, - evmContractConditions: conditions, - chain: LIT_ACC_CHAIN, - authContext: await authContextFor(account), - }); -} - -async function expectDenied(label, account) { - try { - await decryptAs(account); - } catch { - console.log(`PASS: ${label} was denied.`); - return; - } - throw new Error(`${label} unexpectedly decrypted the probe.`); -} - -async function waitForSuccess(hash) { - const receipt = await publicClient.waitForTransactionReceipt({ hash }); - if (receipt.status !== "success") throw new Error(`Transaction reverted: ${hash}`); - return receipt; -} - -async function waitUntilExpired(expiresAt) { - while ((await publicClient.getBlock()).timestamp < expiresAt) { - await new Promise((resolve) => setTimeout(resolve, 5_000)); - } -} - -try { - const existing = await publicClient.readContract({ - address: contractAddress, - abi: dropsAbi, - functionName: "canDecrypt", - args: [dropId, recipient.address], - }); - if (existing) throw new Error("SPIKE_DROP_ID already grants access. Use a fresh random 32-byte drop ID."); - - await expectDenied("recipient before acknowledgement", recipient); - - const ciphertextDigest = keccak256(stringToHex(encrypted.ciphertext)); - const now = (await publicClient.getBlock()).timestamp; - const sealHash = await senderClient.writeContract({ - address: contractAddress, - abi: dropsAbi, - functionName: "seal", - args: [dropId, ciphertextDigest, recipient.address, now + expirySeconds], - gas: 190_000n, - }); - await waitForSuccess(sealHash); - console.log("PASS: sender sealed the ciphertext digest."); - - const acknowledgeHash = await recipientClient.writeContract({ - address: contractAddress, - abi: dropsAbi, - functionName: "acknowledgeOpen", - args: [dropId], - gas: 100_000n, - }); - await waitForSuccess(acknowledgeHash); - console.log("PASS: named recipient acknowledged the drop."); - - const recipientResult = await decryptAs(recipient); - const recovered = new TextDecoder().decode(recipientResult.decryptedData); - if (recovered !== probe) throw new Error("Recipient decrypt returned unexpected bytes."); - console.log("PASS: acknowledged recipient decrypted the probe."); - - await expectDenied("stranger after acknowledgement", stranger); - - if (checkExpiry) { - console.log("Waiting for the configured expiry before the final denial check."); - await waitUntilExpired(now + expirySeconds); - await expectDenied("recipient after expiry", recipient); - } else { - console.log("Expiry denial is not included. Re-run with THRESHOLD_SPIKE_CHECK_EXPIRY=1 to record it."); - } - - console.log("Gate T1 live proof completed. Record transaction hashes and results in docs/STATUS.md before changing the product gate."); -} catch (error) { - console.error("Gate T1 live proof failed. Product gate remains spike-required."); - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; -} finally { - await client.disconnect?.(); -} diff --git a/web/AGENTS.md b/web/AGENTS.md deleted file mode 100644 index ae39b6f..0000000 --- a/web/AGENTS.md +++ /dev/null @@ -1,25 +0,0 @@ -# Web scope - -Read the repository-root `AGENTS.md` first. This folder owns the browser -experience only. - -- Keep plaintext and decryption keys out of logs, URLs, local fixtures, and - server routes. -- Do not fake a threshold unlock. The UI must retain its blocked state until - the Lit–Monad testnet gate in `src/lib/threshold.ts` is verified. -- Wallet writes use Monad testnet, an estimated explicit gas limit, and a - visible transaction link. Never add a payment flow. -- Use semantic controls, keyboard focus, strong contrast, and reduced-motion - alternatives. Every screen carries the experiment label. - -Would you like to finalize today's work? If yes, say whether to verify only, build a target, or deploy a named target. - - - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/web/README.md b/web/README.md deleted file mode 100644 index dd7801d..0000000 --- a/web/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# web - -The web experience for **NO SUS — Monad Experiment**. It is a testnet-only -recipient acknowledgement and threshold-decrypt experiment. - -## Entry points - -- `src/app/page.tsx` — sender console and live Receipt Wall -- `src/app/d/[id]/page.tsx` — recipient acknowledgement page -- `src/app/verify/[id]/page.tsx` — public receipt verifier -- `src/lib/monad.ts` — Monad chain and contract ABI -- `src/lib/threshold.ts` — the explicit Lit–Monad technical gate -- `src/lib/lit-conditions.ts` — `canDecrypt` access-control condition; unused for encrypt/decrypt until Gate T1 is verified -- `src/app/globals.css` — local design tokens and the flat NO SUS visual system - -## Public contract - -No screen may claim a note can decrypt until `threshold.ts` is changed from -`spike-required` to `verified` with the two-wallet test evidence recorded in -`../docs/STATUS.md`. The UI must show the experimental/test-data label. - -## Allowed dependencies - -Browser components may import `@/lib/*` and other components. They may not -read server-only environment variables or use the Supabase service-role key. -Only `src/lib/server/*` and route handlers may use it. - -## Visual system - -Read [`../docs/DESIGN_SYSTEM.md`](../docs/DESIGN_SYSTEM.md) before visual work. -Use the local Geist assets and semantic tokens in `globals.css`; do not copy -production NO SUS Burn components or reintroduce gradient/glow styling. - -## Checks - -Run `npm run check` for TypeScript. Run `npm run check` in `../tool/threshold-spike` for the -condition-shape test. Use `npm run dev` for a local preview. -Run `npm run build` only through the finalization gate in the root `AGENTS.md`. diff --git a/web/next-env.d.ts b/web/next-env.d.ts deleted file mode 100644 index a419cbe..0000000 --- a/web/next-env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/next.config.ts b/web/next.config.ts deleted file mode 100644 index 2f6491c..0000000 --- a/web/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - reactStrictMode: true, -}; - -export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index 273ed07..0000000 --- a/web/package-lock.json +++ /dev/null @@ -1,1714 +0,0 @@ -{ - "name": "nosus-monad-web", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "nosus-monad-web", - "version": "0.1.0", - "dependencies": { - "@supabase/supabase-js": "2.116.0", - "next": "16.3.5", - "react": "19.3.0", - "react-dom": "19.3.0", - "viem": "2.56.5" - }, - "devDependencies": { - "@types/node": "25.3.5", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "typescript": "7.0.2" - } - }, - "node_modules/@adraffy/ens-normalize": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", - "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", - "license": "MIT" - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@next/env": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", - "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", - "license": "MIT" - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", - "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", - "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", - "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", - "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", - "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", - "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", - "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", - "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", - "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.9.0", - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@supabase/auth-js": { - "version": "2.116.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.116.0.tgz", - "integrity": "sha512-Cmosty12gyKGK9N3bQb+lMmuAFev5nmUzaR1AsmZHqKOAGzqX1VQzmp49CNPwOx/pw0H9Qqk4rs9yhwTlKpfDg==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.116.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.116.0.tgz", - "integrity": "sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@supabase/phoenix": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", - "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", - "license": "MIT" - }, - "node_modules/@supabase/postgrest-js": { - "version": "2.116.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.116.0.tgz", - "integrity": "sha512-kGpVZTDHxFTJS3tu+rU0iTAZ+4U0bcLVjxwCk8f3gRhjw3qdCZjTBlgYvc4kGH2XccmAzbkKwXL/mrNHMGSc+A==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.116.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.116.0.tgz", - "integrity": "sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==", - "license": "MIT", - "dependencies": { - "@supabase/phoenix": "0.4.5", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.116.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.116.0.tgz", - "integrity": "sha512-6/3hR6vccBP6oGM5B6RfbwZcTCKmQOodd/ZWQdsw8yJsU5zO/a//oBL6yLnmgxcjnHSrelW8rsO7hL5DPybyUQ==", - "license": "MIT", - "dependencies": { - "iceberg-js": "^0.8.1", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.116.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.116.0.tgz", - "integrity": "sha512-YyWmKXt2NspV9iO8FPnlswUFJIRnrLd3oTCb+3ZyYRuKZtBH0xCUDgnUqoyA0fGUxpM/UhfwDjYf/dht/9bp7g==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.116.0", - "@supabase/functions-js": "2.116.0", - "@supabase/postgrest-js": "2.116.0", - "@supabase/realtime-js": "2.116.0", - "@supabase/storage-js": "2.116.0" - }, - "engines": { - "node": ">=22.0.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - } - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@types/node": { - "version": "25.3.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", - "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/abitype": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", - "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3.22.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.24", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", - "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/iceberg-js": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", - "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/isows": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", - "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", - "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", - "license": "MIT", - "dependencies": { - "@next/env": "16.3.5", - "@swc/helpers": "0.5.23", - "baseline-browser-mapping": "^2.9.19", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.5.23", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=20.9.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.5", - "@next/swc-darwin-x64": "16.3.5", - "@next/swc-linux-arm64-gnu": "16.3.5", - "@next/swc-linux-arm64-musl": "16.3.5", - "@next/swc-linux-x64-gnu": "16.3.5", - "@next/swc-linux-x64-musl": "16.3.5", - "@next/swc-win32-arm64-msvc": "16.3.5", - "@next/swc-win32-x64-msvc": "16.3.5", - "sharp": "^0.35.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/ox": { - "version": "0.14.44", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.44.tgz", - "integrity": "sha512-O54qXXHEk4ySamMSyBpI0AeBegS5frxU6+LA6Jb9Sf6kMOxcTNaxYmwZfpOu22DIQsdKfgQxHq8EsAGaoBQScA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.11.0", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "1.9.1", - "@noble/hashes": "^1.8.0", - "@scure/bip32": "^1.7.0", - "@scure/bip39": "^1.6.0", - "abitype": "^1.2.3", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/ox/node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.0" - } - }, - "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/viem": { - "version": "2.56.5", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.56.5.tgz", - "integrity": "sha512-GuEf/oee0PHgy4D6JIA8u0rY1g+sM5nF+p7HyMHO3vjlHC5G/ljfrgnUOnrES9m5Ny/P3RwWCHjDuN2T6HUWzQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@noble/curves": "1.9.1", - "@noble/hashes": "1.8.0", - "@scure/bip32": "1.7.0", - "@scure/bip39": "1.6.0", - "abitype": "1.2.3", - "isows": "1.0.7", - "ox": "0.14.44", - "ws": "8.21.0" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/viem/node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "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 - } - } - } - } -} diff --git a/web/package.json b/web/package.json deleted file mode 100644 index ca7c2f4..0000000 --- a/web/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "nosus-monad-web", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "check": "tsc --noEmit" - }, - "dependencies": { - "@supabase/supabase-js": "2.116.0", - "next": "16.3.5", - "react": "19.3.0", - "react-dom": "19.3.0", - "viem": "2.56.5" - }, - "devDependencies": { - "@types/node": "25.3.5", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "typescript": "7.0.2" - } -} diff --git a/web/src/app/api/drops/[id]/route.ts b/web/src/app/api/drops/[id]/route.ts deleted file mode 100644 index e5003cb..0000000 --- a/web/src/app/api/drops/[id]/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextResponse } from "next/server"; - -import { isDropId } from "@/lib/drop-id"; -import { BackendNotConfiguredError, getExperimentAdmin } from "@/lib/server/supabase"; - -export const dynamic = "force-dynamic"; - -export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { - const { id } = await context.params; - if (!isDropId(id)) { - return NextResponse.json({ error: "Invalid drop identifier." }, { status: 400 }); - } - - try { - const { data, error } = await getExperimentAdmin() - .from("experiment_drops") - .select("id,sender_wallet,recipient_wallet,opener_wallet,ciphertext_digest,expires_at,sealed_tx_hash,opened_tx_hash,created_at,opened_at") - .eq("id", id.toLowerCase()) - .maybeSingle(); - if (error) throw error; - if (!data) return NextResponse.json({ error: "Drop not found." }, { status: 404 }); - - return NextResponse.json({ - id: data.id, - senderWallet: data.sender_wallet, - recipientWallet: data.recipient_wallet, - openerWallet: data.opener_wallet, - ciphertextDigest: data.ciphertext_digest, - expiresAt: data.expires_at, - sealedTxHash: data.sealed_tx_hash, - openedTxHash: data.opened_tx_hash, - createdAt: data.created_at, - openedAt: data.opened_at, - }); - } catch (error) { - const message = - error instanceof BackendNotConfiguredError - ? error.message - : "The verifier backend is temporarily unavailable."; - return NextResponse.json({ error: message }, { status: 503 }); - } -} diff --git a/web/src/app/api/wall/route.ts b/web/src/app/api/wall/route.ts deleted file mode 100644 index a199631..0000000 --- a/web/src/app/api/wall/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextResponse } from "next/server"; - -import { BackendNotConfiguredError, getExperimentAdmin } from "@/lib/server/supabase"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - try { - const { data, error } = await getExperimentAdmin() - .from("experiment_drops") - .select("id,sender_wallet,recipient_wallet,opener_wallet,sealed_tx_hash,opened_tx_hash,created_at,opened_at") - .order("created_at", { ascending: false }) - .limit(40); - - if (error) throw error; - return NextResponse.json({ - items: (data ?? []).map((drop) => ({ - id: drop.id, - senderWallet: drop.sender_wallet, - recipientWallet: drop.recipient_wallet, - openerWallet: drop.opener_wallet, - sealedTxHash: drop.sealed_tx_hash, - openedTxHash: drop.opened_tx_hash, - createdAt: drop.created_at, - openedAt: drop.opened_at, - })), - }); - } catch (error) { - const message = - error instanceof BackendNotConfiguredError - ? error.message - : "The Receipt Wall is temporarily unavailable."; - return NextResponse.json({ error: message }, { status: 503 }); - } -} diff --git a/web/src/app/d/[id]/page.tsx b/web/src/app/d/[id]/page.tsx deleted file mode 100644 index e901eee..0000000 --- a/web/src/app/d/[id]/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { RecipientConsole } from "@/components/recipient-console"; - -export default async function RecipientPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - return ; -} diff --git a/web/src/app/error.tsx b/web/src/app/error.tsx deleted file mode 100644 index 3b2da57..0000000 --- a/web/src/app/error.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -export default function GlobalError({ reset }: { error: Error; reset: () => void }) { - useEffect(() => { - // Error contents may include wallet/provider data, so they are deliberately not logged. - }, []); - - return ( -
-

SAFE FAILURE

-

The experiment paused before handling any note.

- -
- ); -} diff --git a/web/src/app/fonts/fonts.css b/web/src/app/fonts/fonts.css deleted file mode 100644 index f75451a..0000000 --- a/web/src/app/fonts/fonts.css +++ /dev/null @@ -1,11 +0,0 @@ -@font-face{font-family:"Geist";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-fef07dbb0973bf53.woff2)format("woff2");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F} -@font-face{font-family:"Geist";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-8a480f0b521d4e75.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116} -@font-face{font-family:"Geist";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-53b9e256198e5412.woff2)format("woff2");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB} -@font-face{font-family:"Geist";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-7178b3e590c64307.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF} -@font-face{font-family:"Geist";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-caa3a2e1cccd8315.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD} -@font-face{font-family:"Geist Mono";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-mono-5ce348bf30bf5439.woff2)format("woff2");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F} -@font-face{font-family:"Geist Mono";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-mono-4fa387ec64143e14.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116} -@font-face{font-family:"Geist Mono";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-mono-6306c77e7c8268e4.woff2)format("woff2");unicode-range:U+2000-2001,U+2004-2008,U+200A,U+23B8-23BD,U+2500-259F} -@font-face{font-family:"Geist Mono";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-mono-7d817b4c03b0c5f1.woff2)format("woff2");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB} -@font-face{font-family:"Geist Mono";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-mono-bbc41e54d2fcbd21.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF} -@font-face{font-family:"Geist Mono";font-style:normal;font-weight:100 900;font-display:swap;src:url(./geist-mono-797e433ab948586e.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD} diff --git a/web/src/app/fonts/geist-53b9e256198e5412.woff2 b/web/src/app/fonts/geist-53b9e256198e5412.woff2 deleted file mode 100644 index 0bcca1b..0000000 Binary files a/web/src/app/fonts/geist-53b9e256198e5412.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-7178b3e590c64307.woff2 b/web/src/app/fonts/geist-7178b3e590c64307.woff2 deleted file mode 100644 index 8a1d3df..0000000 Binary files a/web/src/app/fonts/geist-7178b3e590c64307.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-8a480f0b521d4e75.woff2 b/web/src/app/fonts/geist-8a480f0b521d4e75.woff2 deleted file mode 100644 index f385e23..0000000 Binary files a/web/src/app/fonts/geist-8a480f0b521d4e75.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-caa3a2e1cccd8315.woff2 b/web/src/app/fonts/geist-caa3a2e1cccd8315.woff2 deleted file mode 100644 index 8f01e76..0000000 Binary files a/web/src/app/fonts/geist-caa3a2e1cccd8315.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-fef07dbb0973bf53.woff2 b/web/src/app/fonts/geist-fef07dbb0973bf53.woff2 deleted file mode 100644 index 7ab581f..0000000 Binary files a/web/src/app/fonts/geist-fef07dbb0973bf53.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-mono-4fa387ec64143e14.woff2 b/web/src/app/fonts/geist-mono-4fa387ec64143e14.woff2 deleted file mode 100644 index 4fc15d1..0000000 Binary files a/web/src/app/fonts/geist-mono-4fa387ec64143e14.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-mono-5ce348bf30bf5439.woff2 b/web/src/app/fonts/geist-mono-5ce348bf30bf5439.woff2 deleted file mode 100644 index c7700cd..0000000 Binary files a/web/src/app/fonts/geist-mono-5ce348bf30bf5439.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-mono-6306c77e7c8268e4.woff2 b/web/src/app/fonts/geist-mono-6306c77e7c8268e4.woff2 deleted file mode 100644 index 29dd20f..0000000 Binary files a/web/src/app/fonts/geist-mono-6306c77e7c8268e4.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-mono-797e433ab948586e.woff2 b/web/src/app/fonts/geist-mono-797e433ab948586e.woff2 deleted file mode 100644 index cf24126..0000000 Binary files a/web/src/app/fonts/geist-mono-797e433ab948586e.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-mono-7d817b4c03b0c5f1.woff2 b/web/src/app/fonts/geist-mono-7d817b4c03b0c5f1.woff2 deleted file mode 100644 index 873f580..0000000 Binary files a/web/src/app/fonts/geist-mono-7d817b4c03b0c5f1.woff2 and /dev/null differ diff --git a/web/src/app/fonts/geist-mono-bbc41e54d2fcbd21.woff2 b/web/src/app/fonts/geist-mono-bbc41e54d2fcbd21.woff2 deleted file mode 100644 index 751a04d..0000000 Binary files a/web/src/app/fonts/geist-mono-bbc41e54d2fcbd21.woff2 and /dev/null differ diff --git a/web/src/app/globals.css b/web/src/app/globals.css deleted file mode 100644 index b0251f9..0000000 --- a/web/src/app/globals.css +++ /dev/null @@ -1,191 +0,0 @@ -@import "./fonts/fonts.css"; - -:root { - /* Raw palette — components use the semantic aliases below. */ - --black: #080808; - --gray-dark: #141414; - --gray: #1e1e1e; - --gray-light: #888888; - --white: #ffffff; - --danger: #ff8a7a; - --page: var(--black); - --surface: var(--gray-dark); - --surface-raised: var(--gray); - --text: var(--white); - --text-muted: var(--gray-light); - --line: rgb(255 255 255 / 18%); - --line-strong: rgb(255 255 255 / 48%); - --focus: var(--white); - --button-primary-bg: var(--white); - --button-primary-text: var(--black); - --radius: 2px; - --sans: "Geist", Arial, sans-serif; - --mono: "Geist Mono", "SFMono-Regular", Consolas, monospace; -} - -* { box-sizing: border-box; } -html { background: var(--page); } -body { margin: 0; background: var(--page); color: var(--text); font-family: var(--sans); font-size: 15px; line-height: 1.5; } -button, input, textarea { font: inherit; } -button { cursor: pointer; } -a { color: inherit; } -button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; } -.app-shell { min-height: 100vh; overflow: hidden; } -.page-shell { min-height: 100vh; } - -.topbar { align-items: center; border-bottom: 1px solid var(--line); display: flex; height: 64px; justify-content: space-between; padding: 0 clamp(18px, 4vw, 68px); } -.wordmark { align-items: center; display: inline-flex; font-family: var(--mono); font-size: 12px; font-weight: 600; gap: 9px; letter-spacing: .08em; text-decoration: none; } -.wordmark-mark { align-items: center; background: var(--white); color: var(--black); display: inline-flex; font-family: var(--sans); font-size: 12px; font-weight: 800; height: 20px; justify-content: center; width: 20px; } -.topbar-actions, .hero-actions, .inline-actions, .footer-links { align-items: center; display: flex; flex-wrap: wrap; gap: 10px; } -.text-link { color: var(--text-muted); font-family: var(--mono); font-size: 11px; letter-spacing: .06em; text-decoration: none; text-transform: uppercase; } -.text-link:hover { color: var(--text); } -.wallet-button, .primary-button, .secondary-button, .icon-button { border: 1px solid var(--line-strong); border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .06em; min-height: 38px; padding: 0 14px; text-transform: uppercase; transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease; } -.wallet-button, .secondary-button, .icon-button { background: transparent; color: var(--white); } -.wallet-button:hover, .secondary-button:hover, .icon-button:hover { background: var(--white); border-color: var(--white); color: var(--black); } -.primary-button { background: var(--button-primary-bg); border-color: var(--white); color: var(--button-primary-text); } -.primary-button:hover { background: var(--gray-light); border-color: var(--gray-light); } -.primary-button:disabled, .secondary-button:disabled, .wallet-button:disabled { cursor: not-allowed; opacity: .42; } - -.page-grid { margin: 0 auto; max-width: 1440px; padding: clamp(28px, 5vw, 76px) clamp(18px, 4vw, 68px) 56px; } -.eyebrow, .panel-kicker, .field-label, .step-index, .metric-label, .footer-label, .orb-status { color: var(--text-muted); font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; } -.experiment-badge { align-items: center; border: 1px solid var(--line); color: var(--text-muted); display: inline-flex; font-family: var(--mono); font-size: 10px; gap: 8px; letter-spacing: .09em; padding: 7px 9px; text-transform: uppercase; } -.experiment-badge::before { background: var(--white); content: ""; display: block; height: 5px; width: 5px; } - -.hero { border-bottom: 1px solid var(--line); display: grid; gap: clamp(34px, 6vw, 92px); grid-template-columns: minmax(0, 1.08fr) minmax(290px, .92fr); padding-bottom: clamp(42px, 7vw, 92px); } -.hero-copy { align-self: center; max-width: 730px; } -.hero h1, .section-heading h2, .page-title { font-size: clamp(44px, 6.3vw, 96px); font-weight: 500; letter-spacing: -.065em; line-height: .93; margin: 18px 0 25px; } -.hero-lede, .section-lede, .page-lede { color: var(--text-muted); font-size: clamp(16px, 1.55vw, 20px); line-height: 1.48; margin: 0; max-width: 570px; } -.hero-actions { margin-top: 31px; } -.trust-line, .gate-note { border-left: 1px solid var(--white); color: var(--text-muted); font-family: var(--mono); font-size: 11px; line-height: 1.65; margin: 31px 0 0; max-width: 570px; padding-left: 13px; } -.honesty-line { border-left: 1px solid var(--white); color: var(--text-muted); font-family: var(--mono); font-size: 11px; line-height: 1.65; margin: 31px 0 0; max-width: 570px; padding-left: 13px; } -.hero-visual { align-items: center; display: flex; justify-content: center; min-height: 400px; } -.hero-orb-wrap { align-items: center; background: var(--surface); border: 1px solid var(--line); display: flex; height: min(34vw, 470px); justify-content: center; min-height: 340px; overflow: hidden; position: relative; width: min(34vw, 470px); } -.hero-grid { border-bottom: 1px solid var(--line); display: grid; gap: clamp(34px, 6vw, 92px); grid-template-columns: minmax(0, 1.08fr) minmax(290px, .92fr); padding: clamp(28px, 5vw, 76px) clamp(18px, 4vw, 68px) clamp(42px, 7vw, 92px); } -.hero-orb-wrap::before, .hero-orb-wrap::after { background: var(--line); content: ""; position: absolute; } -.hero-orb-wrap::before { height: 1px; inset: 50% 0 auto; } -.hero-orb-wrap::after { inset: 0 auto 0 50%; width: 1px; } -.orb-caption { bottom: 15px; color: var(--text-muted); display: flex; font-family: var(--mono); font-size: 10px; gap: 12px; left: 15px; letter-spacing: .08em; position: absolute; text-transform: uppercase; } - -.lock-orb { align-items: center; border: 1px solid var(--line-strong); border-radius: 50%; display: flex; height: 238px; justify-content: center; position: relative; width: 238px; z-index: 1; } -.orb-axis { background: var(--line); position: absolute; } -.orb-axis-horizontal { height: 1px; left: -31px; right: -31px; } -.orb-axis-vertical { bottom: -31px; top: -31px; width: 1px; } -.orb-ring { border: 1px dashed var(--line-strong); border-radius: 50%; inset: 17px; position: absolute; } -.lock-shackle { border: 12px solid var(--white); border-bottom: 0; border-radius: 32px 32px 0 0; height: 64px; left: 50%; position: absolute; top: 58px; transform: translateX(-50%); width: 86px; } -.lock-body { align-items: center; background: var(--white); display: flex; height: 82px; justify-content: center; margin-top: 34px; position: relative; width: 112px; } -.lock-keyhole { background: var(--black); border-radius: 50%; display: block; height: 17px; position: relative; width: 17px; } -.lock-keyhole::after { background: var(--black); content: ""; height: 25px; left: 5px; position: absolute; top: 10px; width: 7px; } -.lock-orb.unlocked .lock-shackle { transform: translateX(-50%) rotate(-23deg) translate(12px, -8px); transform-origin: bottom right; } -.lock-orb.unlocked .lock-body { background: var(--gray-light); } -.orb-status { bottom: 23px; left: 50%; position: absolute; transform: translateX(-50%); } - -.signal-strip { border-bottom: 1px solid var(--line); display: grid; grid-template-columns: repeat(3, 1fr); } -.signal-strip span { border-right: 1px solid var(--line); font-family: var(--mono); font-size: 11px; padding: 16px 18px; } -.signal-strip span:last-child { border-right: 0; } -.signal-strip b { color: var(--text-muted); font-weight: 500; margin-right: 6px; } -.signal-item { border-right: 1px solid var(--line); padding: 16px 18px; } -.signal-item:last-child { border-right: 0; } -.signal-item strong { display: block; font-family: var(--mono); font-size: 11px; font-weight: 500; margin-top: 5px; } -.section { border-bottom: 1px solid var(--line); padding: clamp(54px, 8vw, 104px) 0; } -.section-heading { display: grid; gap: 25px; grid-template-columns: minmax(0, 1fr) minmax(260px, .65fr); margin-bottom: 32px; } -.section-heading h2 { font-size: clamp(34px, 4.8vw, 68px); margin: 0; } -.console-grid, .two-column-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); } -.workspace-grid { display: grid; gap: 16px; grid-template-columns: minmax(0, 1.05fr) minmax(300px, .95fr); padding: clamp(54px, 8vw, 104px) clamp(18px, 4vw, 68px); } -.console-card, .receipt-wall, .drop-panel, .verifier-card { background: var(--surface); border: 1px solid var(--line); padding: clamp(20px, 3vw, 32px); } -.glass-panel { background: var(--surface); border: 1px solid var(--line); padding: clamp(20px, 3vw, 32px); } -.console-card-wide { grid-column: span 2; } -.panel-header, .card-heading, .receipt-row, .metric-row, .field-row { align-items: center; display: flex; justify-content: space-between; } -.panel-header { border-bottom: 1px solid var(--line); margin-bottom: 25px; padding-bottom: 14px; } -.panel-title, .card-title { font-size: 20px; font-weight: 500; letter-spacing: -.03em; margin: 0; } -.panel-heading, .wall-topline { align-items: flex-start; border-bottom: 1px solid var(--line); display: flex; gap: 18px; justify-content: space-between; margin-bottom: 22px; padding-bottom: 15px; } -.panel-heading h2, .wall-topline h2 { font-size: clamp(25px, 3vw, 39px); font-weight: 500; letter-spacing: -.05em; line-height: 1; margin: 7px 0 0; } -.panel-copy { color: var(--text-muted); font-size: 14px; line-height: 1.58; margin: 0 0 24px; } -.step-list, .form-stack, .metric-list { display: grid; } -.form-stack { gap: 18px; } -.step-item { border-top: 1px solid var(--line); display: grid; gap: 16px; grid-template-columns: 34px 1fr auto; padding: 17px 0; } -.step-item:last-child, .metric-row:last-child, .field-row:last-child, .receipt-row:last-child { border-bottom: 1px solid var(--line); } -.step-title { font-size: 14px; font-weight: 500; margin: 0; } -.step-detail, .help-text, .muted-copy { color: var(--text-muted); font-size: 12px; line-height: 1.6; margin: 4px 0 0; } -.status-chip { border: 1px solid var(--line-strong); color: var(--text-muted); font-family: var(--mono); font-size: 9px; letter-spacing: .1em; padding: 5px 7px; text-transform: uppercase; white-space: nowrap; } -.status-chip.ready, .status-chip.opened { background: var(--white); border-color: var(--white); color: var(--black); } -.status-chip.blocked, .status-chip.expired, .status-chip.error { border-color: var(--danger); color: var(--danger); } -.field-label { display: block; margin-bottom: 8px; } -.seal-form label { display: block; font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .1em; margin-bottom: 8px; text-transform: uppercase; } -.seal-form textarea { background: var(--black); border: 1px solid var(--line-strong); border-radius: var(--radius); color: var(--white); display: block; min-height: 128px; padding: 12px; resize: vertical; width: 100%; } -.seal-form textarea::placeholder { color: var(--text-muted); } -.form-footer { align-items: center; color: var(--text-muted); display: flex; font-family: var(--mono); font-size: 11px; justify-content: space-between; margin-top: 12px; } -.text-input, .text-area { background: var(--black); border: 1px solid var(--line-strong); border-radius: var(--radius); color: var(--text); min-height: 42px; padding: 11px 12px; width: 100%; } -.text-area { min-height: 125px; resize: vertical; } -.text-input::placeholder, .text-area::placeholder { color: var(--text-muted); } -.gate-note { margin: 18px 0 0; } -.receipt-wall { min-height: 384px; } -.wall-status { color: var(--text-muted); font-family: var(--mono); font-size: 12px; line-height: 1.6; margin: 0; } -.receipt-list { display: grid; margin-top: 18px; } -.receipt-list { list-style: none; padding: 0; } -.receipt-list li { align-items: center; border-top: 1px solid var(--line); display: grid; font-family: var(--mono); font-size: 11px; gap: 10px; grid-template-columns: auto 1fr auto; padding: 14px 0; } -.receipt-state { border: 1px solid var(--line-strong); font-size: 9px; letter-spacing: .09em; padding: 4px 6px; } -.receipt-state.opened { background: var(--white); border-color: var(--white); color: var(--black); } -.empty-wall { align-items: center; border: 1px dashed var(--line-strong); color: var(--text-muted); display: flex; flex-direction: column; font-family: var(--mono); font-size: 12px; justify-content: center; margin-top: 20px; min-height: 206px; padding: 26px; text-align: center; } -.empty-wall p { line-height: 1.6; max-width: 290px; } -.empty-glyph { color: var(--white); font-size: 30px; } -.receipt-row { border-top: 1px solid var(--line); gap: 14px; padding: 15px 0; } -.receipt-id, .hash-value, .mono-value { font-family: var(--mono); font-size: 11px; overflow-wrap: anywhere; } -.receipt-meta { color: var(--text-muted); font-family: var(--mono); font-size: 10px; margin-top: 3px; } -.live-indicator { align-items: center; color: var(--text-muted); display: inline-flex; font-family: var(--mono); font-size: 10px; gap: 7px; letter-spacing: .08em; text-transform: uppercase; } -.live-indicator i { animation: signal 1.4s steps(2, end) infinite; background: var(--white); display: inline-block; height: 6px; width: 6px; } -.page-header { border-bottom: 1px solid var(--line); margin-bottom: 32px; padding-bottom: 32px; } -.page-title { font-size: clamp(40px, 5.5vw, 78px); margin-bottom: 18px; } -.metric-row, .field-row { border-top: 1px solid var(--line); gap: 20px; padding: 14px 0; } -.metric-value { font-family: var(--mono); font-size: 12px; text-align: right; } -.empty-state { border: 1px dashed var(--line-strong); color: var(--text-muted); font-family: var(--mono); font-size: 12px; padding: 28px; text-align: center; } -.site-footer { color: var(--text-muted); display: flex; font-family: var(--mono); font-size: 10px; justify-content: space-between; letter-spacing: .06em; padding: 24px clamp(18px, 4vw, 68px); text-transform: uppercase; } -.text-button { color: var(--white); font-family: var(--mono); font-size: 11px; letter-spacing: .04em; text-underline-offset: 4px; } -.text-button:hover { color: var(--text-muted); } -.muted { color: var(--text-muted); } -.inline-error { border-left: 1px solid var(--danger); color: var(--danger); font-family: var(--mono); font-size: 11px; line-height: 1.6; margin: 16px 0 0; padding-left: 12px; } -.small-note { color: var(--text-muted); font-family: var(--mono); font-size: 11px; line-height: 1.6; margin: 16px 0 0; } -.wallet-control { align-items: end; display: flex; flex-direction: column; gap: 5px; } -.wallet-pulse { color: var(--danger); font-family: var(--mono); font-size: 10px; } -.recipient-page, .verifier-page, .simple-page { margin: 0 auto; max-width: 1240px; min-height: 100vh; padding: clamp(28px, 5vw, 76px) clamp(18px, 4vw, 68px); } -.recipient-grid { align-items: center; border-bottom: 1px solid var(--line); display: grid; gap: clamp(30px, 8vw, 120px); grid-template-columns: minmax(0, 1fr) minmax(280px, .7fr); padding: 28px 0 58px; } -.recipient-copy h1, .verifier-page h1, .simple-page h1 { font-size: clamp(40px, 5.5vw, 78px); font-weight: 500; letter-spacing: -.06em; line-height: .94; margin: 14px 0 22px; } -.recipient-copy > p:not(.eyebrow):not(.inline-error):not(.small-note), .verifier-lede { color: var(--text-muted); font-size: 17px; line-height: 1.55; max-width: 560px; } -.recipient-orb { align-items: center; background: var(--surface); border: 1px solid var(--line); display: flex; justify-content: center; min-height: 360px; } -.receipt-facts { border-top: 1px solid var(--line); margin: 27px 0; } -.receipt-facts div { border-bottom: 1px solid var(--line); display: grid; gap: 15px; grid-template-columns: 105px 1fr; padding: 12px 0; } -.receipt-facts dt, .verifier-grid dt { color: var(--text-muted); font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; } -.receipt-facts dd, .verifier-grid dd { font-family: var(--mono); font-size: 11px; margin: 0; overflow-wrap: anywhere; } -.blocked-decrypt { margin-top: 18px; } -.blocked-decrypt h2 { font-size: 26px; font-weight: 500; letter-spacing: -.04em; margin: 8px 0; } -.blocked-decrypt p:not(.eyebrow) { color: var(--text-muted); max-width: 720px; } -.receipt-card { background: var(--surface); border: 1px solid var(--line); margin-top: 30px; padding: clamp(20px, 3vw, 32px); } -.receipt-card-header { align-items: center; border-bottom: 1px solid var(--line); display: flex; gap: 16px; justify-content: space-between; padding-bottom: 14px; } -.receipt-card-header code { font-family: var(--mono); font-size: 10px; overflow-wrap: anywhere; text-align: right; } -.verifier-grid { display: grid; gap: 0 28px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 18px 0; } -.verifier-grid div { border-top: 1px solid var(--line); padding: 13px 0; } -.verifier-grid dd { margin-top: 6px; } -.receipt-links { display: flex; flex-wrap: wrap; gap: 18px; } -.loading-line { color: var(--text-muted); font-family: var(--mono); font-size: 12px; } -@keyframes signal { 50% { opacity: .24; } } - -@media (max-width: 800px) { - .topbar { height: auto; min-height: 64px; padding-bottom: 12px; padding-top: 12px; } - .hero, .hero-grid, .section-heading, .console-grid, .two-column-grid, .workspace-grid, .recipient-grid { grid-template-columns: 1fr; } - .hero { padding-bottom: 48px; } - .hero-visual { min-height: 300px; order: -1; } - .hero-orb-wrap { height: min(78vw, 400px); min-height: 290px; width: min(78vw, 400px); } - .console-card-wide { grid-column: auto; } - .signal-strip { grid-template-columns: 1fr; } - .signal-item, .signal-strip span { border-bottom: 1px solid var(--line); border-right: 0; } - .signal-item:last-child, .signal-strip span:last-child { border-bottom: 0; } - .step-item { grid-template-columns: 28px 1fr; } - .step-item .status-chip { grid-column: 2; justify-self: start; } - .site-footer { flex-direction: column; gap: 12px; } - .verifier-grid { grid-template-columns: 1fr; } - .receipt-list li { align-items: start; grid-template-columns: auto 1fr; } - .receipt-list li > span:last-child { grid-column: 2; } -} - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } -} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx deleted file mode 100644 index 1b30413..0000000 --- a/web/src/app/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Metadata } from "next"; - -import "./globals.css"; - -export const metadata: Metadata = { - title: "NO SUS — Monad Experiment", - description: "A testnet-only one-time encrypted-drop experiment.", -}; - -export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { - return ( - - {children} - - ); -} diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx deleted file mode 100644 index 31ffdc5..0000000 --- a/web/src/app/not-found.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import Link from "next/link"; - -import { ExperimentBadge } from "@/components/experiment-badge"; - -export default function NotFound() { - return ( -
- -

404 / DROP NOT FOUND

-

This link does not identify a recorded experiment drop.

- Back to the experiment -
- ); -} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx deleted file mode 100644 index 2eb9654..0000000 --- a/web/src/app/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { HomeDashboard } from "@/components/home-dashboard"; - -export default function HomePage() { - return ; -} diff --git a/web/src/app/verify/[id]/page.tsx b/web/src/app/verify/[id]/page.tsx deleted file mode 100644 index 66a1c6d..0000000 --- a/web/src/app/verify/[id]/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ReceiptVerifier } from "@/components/receipt-verifier"; - -export default async function VerifyPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - return ; -} diff --git a/web/src/components/experiment-badge.tsx b/web/src/components/experiment-badge.tsx deleted file mode 100644 index 962f91c..0000000 --- a/web/src/components/experiment-badge.tsx +++ /dev/null @@ -1,12 +0,0 @@ -type ExperimentBadgeProps = { - compact?: boolean; -}; - -export function ExperimentBadge({ compact = false }: ExperimentBadgeProps) { - return ( -

- - Experimental · Monad Testnet · Personal test data only -

- ); -} diff --git a/web/src/components/home-dashboard.tsx b/web/src/components/home-dashboard.tsx deleted file mode 100644 index 9d16e54..0000000 --- a/web/src/components/home-dashboard.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import Link from "next/link"; - -import { ExperimentBadge } from "@/components/experiment-badge"; -import { LiveReceiptWall } from "@/components/live-receipt-wall"; -import { LockOrb } from "@/components/lock-orb"; -import { SealConsole } from "@/components/seal-console"; -import { WalletControl } from "@/components/wallet-control"; - -export function HomeDashboard() { - return ( -
- - -
-
- -

One acknowledgement. One chance.

-

The lock only listens after the chain does.

-

- A recipient wallet can request a one-time decrypt only after its acknowledgement is - recorded on Monad Testnet. -

-
- Explore the flow - View receipt verifier -
-

- An on-chain receipt records a wallet action — not human comprehension, identity, - screenshot prevention, or legal proof. -

-
-
- -
MONAD TESTNETTHRESHOLD GATE: T1
-
-
- -
- 01 Encrypt in browser - 02 Seal digest on Monad - 03 Recipient acknowledges - 04 Lit checks access -
- -
- - -
-
- ); -} diff --git a/web/src/components/live-receipt-wall.tsx b/web/src/components/live-receipt-wall.tsx deleted file mode 100644 index e91cc3b..0000000 --- a/web/src/components/live-receipt-wall.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -type ReceiptWallItem = { - id: string; - senderWallet: string; - recipientWallet: string | null; - openerWallet: string | null; - sealedTxHash: string | null; - openedTxHash: string | null; - createdAt: string; - openedAt: string | null; -}; - -function abbreviated(value: string | null) { - if (!value) return "Open recipient"; - return `${value.slice(0, 6)}…${value.slice(-4)}`; -} - -export function LiveReceiptWall() { - const [items, setItems] = useState([]); - const [status, setStatus] = useState("Waiting for the isolated backend."); - - useEffect(() => { - let stopped = false; - async function refresh() { - try { - const response = await fetch("/api/wall", { cache: "no-store" }); - if (!response.ok) { - const body = (await response.json()) as { error?: string }; - if (!stopped) setStatus(body.error ?? "Receipt Wall is not configured."); - return; - } - const body = (await response.json()) as { items: ReceiptWallItem[] }; - if (!stopped) { - setItems(body.items); - setStatus(body.items.length ? "Streaming the latest recorded receipts." : "No recorded receipts yet."); - } - } catch { - if (!stopped) setStatus("Receipt Wall cannot reach the local backend yet."); - } - } - void refresh(); - const timer = window.setInterval(refresh, 12_000); - return () => { - stopped = true; - window.clearInterval(timer); - }; - }, []); - - return ( -
-
-
-

Live receipt wall / 02

-

A signal, not surveillance.

-
-