diff --git a/.env.example b/.env.example index ba83a54..9b9ebd7 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,9 @@ SAFE_API_KEY=your-safe-api-key SEPOLIA_OWNER_ADDRESS=0x... SEPOLIA_OWNER_PK=0x... -# Storacha Configuration (optional, uses CLI by default) +# Storacha Configuration +# Current deploy flow uses your local Storacha CLI login + selected space. +# KEY / PROOF are reserved for a future native Storacha integration. # KEY=your-storacha-key # PROOF=your-storacha-delegation-proof diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ad7f66d..9293929 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Secure Deploy to ENS + IPFS +name: Autark Deploy to ENS + IPFS on: push: @@ -29,36 +29,25 @@ jobs: run: npm run build - name: Install Storacha CLI - run: npm install -g @storacha/client + run: npm install -g @storacha/cli - name: Setup Storacha credentials + if: ${{ secrets.STORACHA_TOKEN != '' }} run: | mkdir -p ~/.storacha echo "${{ secrets.STORACHA_TOKEN }}" > ~/.storacha/token - if: env.STORACHA_TOKEN != '' - - - name: Install secure-deploy CLI - run: | - cd path/to/secure-deploy - npm install - npm run build - npm link - name: Deploy to ENS + IPFS run: | - secure-deploy deploy dist \ - --network sepolia \ - --ens-domain rome.eth \ - --rpc-url ${{ secrets.SEPOLIA_RPC_URL }} \ - --safe-address ${{ secrets.SAFE_ADDRESS }} \ - --safe-threshold 2 \ - --owner-address ${{ secrets.SEPOLIA_OWNER_ADDRESS }} \ - --owner-pk ${{ secrets.SEPOLIA_OWNER_PK }} + npm run cli -- deploy dist --network sepolia env: SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }} + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} SAFE_ADDRESS: ${{ secrets.SAFE_ADDRESS }} - SEPOLIA_OWNER_ADDRESS: ${{ secrets.SEPOLIA_OWNER_ADDRESS }} + SEPOLIA_ENS_DOMAIN: ${{ secrets.SEPOLIA_ENS_DOMAIN }} + ENS_DOMAIN: ${{ secrets.ENS_DOMAIN }} SEPOLIA_OWNER_PK: ${{ secrets.SEPOLIA_OWNER_PK }} + SAFE_API_KEY: ${{ secrets.SAFE_API_KEY }} - name: Comment on commit with deployment info uses: actions/github-script@v7 @@ -74,5 +63,5 @@ jobs: body: `πŸš€ **Deployment Initiated**\n\n` + `Commit: ${shortSha}\n` + `Safe Transaction created for approval.\n\n` + - `View pending transactions: https://app.safe.global/sepolia.safe/${{ secrets.SAFE_ADDRESS }}/transactions/queue` + `View pending transactions: https://app.safe.global/transactions/queue?safe=sep:${{ secrets.SAFE_ADDRESS }}` }) diff --git a/README.md b/README.md index a12d44a..86d2311 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,141 @@ # AUTARK -ETHRome25banner -Autark is a crypto-anarchic `DevSecOps` framework for more secure, and self-sovereign frontend deployments; embracing immutable, decentralized, and multi-party-verified frontend governance through Safe + ENS + IPFS. +Autark is a DevSecOps framework for more secure, self-sovereign frontend deployments, combining Safe multisig governance, ENS versioning, and IPFS storage into a verifiable release flow. -[Demo](https://www.youtube.com/watch?v=-pGsHpUI0J0) | [User Guide](https://github.com/MihRazvan/ETHRome_hackathon/blob/main/docs/USER-GUIDE.md) | [Technical Architecture](https://github.com/MihRazvan/ETHRome_hackathon/blob/main/docs/TECHNICAL-ARCHITECTURE.md) | [Flow Chart](https://github.com/MihRazvan/ETHRome_hackathon/blob/main/docs/FLOW-CHART.md) | [Submission](https://taikai.network/ethrome/hackathons/2025/projects/cmgx4r1we02d112kkxt8y1sxi/idea) | [Safe DAO Proposal](https://forum.safe.global/t/grant-proposal-supporting-autark-a-secure-self-sovereign-frontend-deployment-framework-built-on-safe/6799) +**Project Summary:** [SUMMARY.md](./SUMMARY.md) + +[Demo](https://www.youtube.com/watch?v=-pGsHpUI0J0) | [Quickstart](./docs/QUICKSTART.md) | [User Flow](./docs/USER-FLOW.md) | [Git Hooks](./docs/GIT-HOOKS.md) | [Technical Architecture](./docs/TECHNICAL-ARCHITECTURE.md) | [Architecture (Short)](./docs/ARCHITECTURE-SHORT.md) | [Docs Index](./docs/README.md) | [Safe DAO Proposal](https://forum.safe.global/t/grant-proposal-supporting-autark-a-secure-self-sovereign-frontend-deployment-framework-built-on-safe/6799) --- -# Problem First! +## Problem First -Modern `DevOps` pipelines have become too automated, too centralized, and too trusting. +Modern deployment pipelines are fast, centralized, and often trusted too blindly. -A single compromised developer or **CI/CD** token can silently push malicious frontend code to production β€” within minutes β€” across millions of users. Here, the weakest link remains the deployment pipeline. +A single compromised developer machine, CI token, or deployment credential can push malicious frontend code to production in minutes. For onchain applications, that means the frontend becomes the weakest link, even when the smart contracts are sound. -Frame 4 (2) +Autark exists to slow that attack path down and make every release auditable. -**AUTARK** exists to contribute to fixing this. +It introduces: -It reintroduces multi-party verification, cryptographic immutability, and decentralized governance into the deployment lifecycle. We are turning `DevOps` into `DevSecOps`, and `DevSecOps` into a **meta-governance layer for frontends**. +- multi-party approval before a deployment goes live +- immutable, versioned ENS releases instead of mutable overwrite-in-place deploys +- content-addressed IPFS storage that can be independently verified -> **AUTARK** enforces a new rule where nothing goes live without consensus, and once live, new (as well as previous) version lives forever. +> Nothing goes live without consensus, and every approved version remains available as an immutable artifact. --- -# Overview +## Overview -Autark [/Γ΄β€²tΓ€rβ€³k/] derived from [autarky](https://en.wikipedia.org/wiki/Autarky), meaning self-sufficiency; is a crypto-anarchic framework for frontend deployments. +Autark adds a governance layer to frontend deployment. -It transforms how teams ship code by introducing a meta-governance layer for frontends. A trustless, multi-sig process that enforces security at the developer layer while preserving decentralization. +A release is built, uploaded to IPFS, mapped to a versioned ENS subdomain, and gated by Safe multisig approval before execution. In the recommended mode, subdomain creation and contenthash assignment are bundled into a single Safe transaction so the release is atomic. -## Core Principles +### Core Principles -1. **ENFORCE BETTER** -*Every deployment passes through explicit multi-party verification, and immutable cryptographic sealing.* +1. **Enforce Better** + Every deployment passes through explicit review and cryptographic sealing. -2. **REJECT CENTRALIZED GATEKEEPERS** -*No single-point of failure, no opaque CI/CD pipelines.* +2. **Reject Single Points of Failure** + No single developer, machine, or CI token should be able to ship production frontend code alone. -3. **META-GOVERNANCE LAYER FOR FRONTENDS** -*A decentralized review `dev` board encoded through Safe multisig decides what becomes production.* +3. **Version, Don’t Overwrite** + Each release becomes a permanent `vN.parent.eth` record instead of mutating one live address invisibly. -4. **CRUCIAL PART OF THE PIPELINE** -*Autark integrates directly into **GitHub Actions**, enforcing multi-sig approval checkpoints before any code can go live.* +4. **Keep Governance Close to the App** + Frontend deployment is part of application security, not a separate convenience layer. --- -# How it Works? +## How It Works + +Autark replaces implicit trust with a verifiable release flow: + +1. Build static frontend output +2. Upload the build to IPFS via Storacha +3. Detect the next versioned ENS subdomain +4. Create a Safe proposal +5. Review and approve with threshold signers +6. Execute the transaction and publish the immutable release -Autark replaces β€œtrust” with verifiable processes and cryptographic finality: -Frame 5 +In the Safe-owned-parent mode, Autark batches: -> Each release becomes an immutable record, and an auditable artifact of a more secure frontend versioning deployment. +- `setSubnodeRecord` on ENS NameWrapper +- `setContenthash` on the Public Resolver -Explore: detailed [Technical Architecture](https://github.com/MihRazvan/ETHRome_hackathon/blob/main/docs/TECHNICAL-ARCHITECTURE.md) and extended [Flow Chart](https://github.com/MihRazvan/ETHRome_hackathon/blob/main/docs/FLOW-CHART.md). +That means the version is created and pointed to the IPFS CID atomically. + +Explore the detailed flow in [User Flow](./docs/USER-FLOW.md) and the system design in [Technical Architecture](./docs/TECHNICAL-ARCHITECTURE.md). --- -# Quickstart +## Quickstart -``` console +```bash npm install -g autark autark init autark deploy dist ``` -Explore: detailed [User Guide](https://github.com/MihRazvan/ETHRome_hackathon/blob/main/docs/USER-GUIDE.md). +For the full setup path, including Storacha auth, ENS configuration, channels, and auto-deploy hooks, see [Quickstart](./docs/QUICKSTART.md). + +--- + +## Documentation + +Autark now ships with an active docs set on `genesis`: + +- [Quickstart](./docs/QUICKSTART.md) +- [User Flow](./docs/USER-FLOW.md) +- [Git Hooks](./docs/GIT-HOOKS.md) +- [Technical Architecture](./docs/TECHNICAL-ARCHITECTURE.md) +- [Architecture (Short)](./docs/ARCHITECTURE-SHORT.md) +- [Docs Index](./docs/README.md) + +Older long-form hackathon docs remain available in [docs/_legacy](./docs/_legacy/). --- -# Tech Stack +## Tech Stack + +| Component | Technology | Purpose | +| --- | --- | --- | +| Governance | **Safe Multisig** | Threshold approval and release governance | +| Immutability | **ENS NameWrapper** | Fuse-burned, versioned subdomains | +| Storage | **IPFS + Storacha** | Content-addressed decentralized hosting | +| Automation | **Git Hooks / CLI** | Deployment workflow automation | +| Language | **Node.js / TypeScript** | CLI and release tooling | + +--- + +## What We Shipped For PL Genesis + +This hackathon pass updated the original project into the current `0.1.2` implementation. + +### Product and CLI updates + +- added `promote` for moving mutable channels to immutable versions +- added `rollback` as an explicit alias for channel rollback flows +- added `channels` to inspect channel state and create missing channel subdomains via Safe proposals +- improved `setup` so git hooks can run a custom build command before deploy + +### Deployment and infra updates + +- standardized on `autark` config naming while keeping backward compatibility for legacy config names +- improved Storacha CLI integration and error handling for login and space-selection failures +- removed the unused native Storacha provider path to keep one clear upload implementation +- removed the vulnerable Safe starter kit dependency and moved runtime Safe handling to `protocol-kit` + `api-kit` +- fixed the published CLI entrypoint so the globally installed `autark` binary works correctly + +### Demo and documentation updates -| Component | Technology | Purpose | -| ------------ | ------------------------------ | -------------------------------------- | -| Governance | **Safe Multisig** | Threshold approval and meta-governance | -| Immutability | **ENS NameWrapper** | Fuse-burned version locking | -| Storage | **IPFS + Storacha** | Verifiable decentralized hosting | -| Automation | **Git Hooks + GitHub Actions** | DevSecOps enforcement layer | -| Language | **Node.js / TypeScript** | CLI and automation scripting | +- updated the example site for the PL Genesis demo flow +- restored active docs for user flow, git hooks, and technical architecture on the `genesis` branch +- removed old hackathon submission references from the main project entry points -> [Autarky](https://en.wikipedia.org/wiki/Autarky) in code: build sovereign software, enforce your `devops` security. +Autark is now published at version `0.1.2`. --- -Built at [ETHRome](https://www.ethrome.org/) 2025. +Built for the PL Genesis hackathon and continued here as an actively iterated solo project. diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..c1fc8e4 --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,23 @@ +# Project Summary + +Autark is a DevSecOps framework for frontend deployments that replaces blind trust with multisig approval, immutable ENS versioning, and content-addressed IPFS releases. Instead of letting one developer, one CI token, or one deployment credential push code straight to production, Autark forces a governed release flow before anything goes live. + +## The problem + +Frontend deployment pipelines are still one of the weakest links in Web3 security. Even when smart contracts are secure, a compromised developer machine or CI/CD credential can push malicious frontend code to users in minutes. Traditional pipelines optimize for speed, but not for adversarial release governance. + +## The solution + +Autark introduces a security checkpoint before publication. A frontend build is uploaded to IPFS, mapped to a versioned ENS subdomain, and submitted as a Safe proposal before execution. Signers can review the intended release, verify the CID and commit context, and only then approve publication. The result is a deployment flow where every production version is explicit, auditable, and immutable. + +## The Storacha integration + +Storacha is the storage layer that makes each Autark release content-addressed and independently verifiable. Autark uploads the static build output to IPFS through the local Storacha CLI and receives a CID that becomes the release artifact. That CID is then written into ENS as the contenthash for the approved versioned subdomain. Because the deployment points to immutable IPFS content instead of a mutable web host, approved frontend versions remain permanent and inspectable after release. + +## Architecture + +Autark is built in Node.js and TypeScript as a CLI-first workflow. Safe handles governance, ENS NameWrapper handles immutable versioned subdomains and fuse policy, and Storacha provides IPFS-backed artifact storage. In the recommended mode, Autark creates one Safe proposal that atomically creates the next versioned subdomain and sets its resolver contenthash. Mutable channels like `live.parent.eth` can then be promoted or rolled back to immutable `vN.parent.eth` releases through additional Safe proposals. + +## Track + +Submitted for the PL Genesis hackathon. Continued as a solo project in the current `0.1.2` implementation. diff --git a/autark.config.yaml b/autark.config.yaml new file mode 100644 index 0000000..b5a3e4a --- /dev/null +++ b/autark.config.yaml @@ -0,0 +1,28 @@ +# AUTARK Configuration +# You can also use environment variables or CLI flags + +# Network Configuration +network: sepolia +rpcUrl: https://ethereum-sepolia-rpc.publicnode.com + +# ENS Configuration +ensDomain: your-domain.eth + +# Safe Multisig Configuration +safeAddress: 0x... +safeApiKey: your-safe-api-key + +# Owner/Signer Configuration (for Safe operations) +ownerPrivateKey: 0x... + +# Storacha Configuration (optional, uses CLI by default) +# storachaKey: ... +# storachaProof: ... + +# GitHub Configuration (optional) +# githubToken: ghp_... +# githubRepo: owner/repo + +# CLI Options +quiet: false +debug: false diff --git a/docs/ARCHITECTURE-SHORT.md b/docs/ARCHITECTURE-SHORT.md new file mode 100644 index 0000000..577df1b --- /dev/null +++ b/docs/ARCHITECTURE-SHORT.md @@ -0,0 +1,39 @@ +# Architecture (Short) + +Autark adds a governance gate to frontend deployments. + +## Goal + +Prevent single-actor frontend takeover by requiring Safe multisig approval before ENS content is updated. + +## Deployment Pipeline + +1. Build frontend output (`dist/`) +2. Upload output to IPFS via Storacha +3. Resolve next versioned ENS subdomain (`vN.parent.eth`) +4. Create Safe proposal: +- Safe-owns-parent: batch `setSubnodeRecord + setContenthash` +- Personal-owns-parent: create subdomain first, then Safe proposal for `setContenthash` +5. Multisig signers review and execute +6. Subdomain is immutable through NameWrapper fuses + +## Security Properties + +- Multi-party approval checkpoint (Safe threshold) +- Content-addressed artifacts (IPFS CID) +- Versioned immutable releases (`v0`, `v1`, ...) +- On-chain audit trail for release operations + +## Key Modules + +- CLI entry: `src/cli/index.ts` +- Main deploy flow: `src/cli/commands/deploy.ts` +- Config merge/validation: `src/lib/config.ts` +- IPFS upload: `src/lib/ipfs/upload.ts` +- ENS version and planning: `src/lib/ens/version.ts`, `src/lib/ens/deploy.ts` +- Safe integration: `src/lib/safe/client.ts`, `src/lib/ens/safe-batch-deploy.ts` + +## Notes + +- The current repo also contains legacy experimental scripts under `src/test` and `src/core`. +- Long-form architecture and hackathon docs are archived in `docs/_legacy/`. diff --git a/docs/GIT-HOOKS.md b/docs/GIT-HOOKS.md index dafcaa0..0f94fec 100644 --- a/docs/GIT-HOOKS.md +++ b/docs/GIT-HOOKS.md @@ -1,349 +1,60 @@ -# Git Hooks - Automatic Deployment +# Git Hooks -Enable automatic deployments triggered by `git push` using secure-deploy's built-in git hooks. +Autark can install a `pre-push` hook that triggers deployment proposals automatically when you push a selected branch. -## Quick Start - -```bash -# In your project directory -npx secure-deploy setup - -# Follow the prompts: -# - Which branch? [staging] -# - Build directory? [dist] -# - Install hooks? [y] - -# Done! Now deployments happen automatically: -git push origin staging # Auto-deploys! πŸš€ -``` - -## How It Works - -When you run `secure-deploy setup`, it installs a **pre-push git hook** that: - -1. βœ… Detects when you push to the deployment branch (e.g., `staging`) -2. πŸ—οΈ Runs your build if needed -3. πŸ“¦ Uploads to IPFS -4. πŸ” Creates Safe proposal (batched transaction) -5. πŸ“€ Completes the push -6. πŸ”— Shows Safe URL in terminal for approval - -**All output appears in your terminal - no hidden CI/CD!** - -## Setup Options - -### Interactive Setup (Recommended) - -```bash -npx secure-deploy setup -``` - -Prompts you for: -- **Deployment branch**: Which branch triggers deployments (default: `staging`) -- **Build directory**: Where your built files are (default: `dist`) +## Setup -### Non-Interactive Setup +Interactive setup: ```bash -# Specify branch via flag -npx secure-deploy setup --branch main - -# Force overwrite existing hook -npx secure-deploy setup --force +autark setup ``` -## Example Workflows - -### Development β†’ Staging β†’ Production +Example with explicit values: ```bash -# Feature development -git checkout -b feature/new-ui -git commit -m "Add new UI" -git push origin feature/new-ui # No deployment - -# Merge to staging - auto-deploys! -git checkout staging -git merge feature/new-ui -git push origin staging # πŸš€ Deployment triggered! - -# After Safe approval, promote to production -git checkout main -git merge staging -git push origin main # πŸš€ Production deployment! +autark setup --branch genesis --build-command "npm run build" ``` -### Output Example - -```bash -$ git push origin staging - -πŸš€ secure-deploy: Auto-deploying staging branch... - -πŸ“¦ Step 3: Upload to IPFS -βœ” Uploaded: bafybei... - -πŸ” Step 7: Detect Deployment Mode -βœ“ Mode: Safe-owns-parent (batched deployment) +The command installs: -βœ… Step 9: Execute Deployment -βœ” Batch transaction submitted to Safe - Safe TX Hash: 0x1a2b... - -πŸŽ‰ Deployment Proposal Created! - Approve in Safe UI: https://app.safe.global/... - -Enumerating objects: 5, done. -Counting objects: 100% (5/5), done. -To github.com:yourname/yourproject.git - a7f9c2e..b3d5f1a staging -> staging -``` - -## Hook Details - -### What Gets Installed - -The hook is installed at: -``` +```text .git/hooks/pre-push ``` -It only runs when you push to your configured deployment branch. +## What the Hook Does -### Hook Behavior +When you push the configured branch, the hook: -- **Runs before push**: Deployment happens before your code reaches remote -- **Fails safely**: If deployment fails, push is cancelled -- **Branch-specific**: Only triggers on configured branch -- **Build detection**: Auto-runs `npm run build` if needed -- **Terminal output**: All logs visible in your terminal +1. checks the current branch name +2. runs the configured build command +3. verifies the build output directory exists +4. runs `autark deploy ` +5. cancels the push if deployment fails -### Customizing the Hook +## Example Demo Hook -You can manually edit the hook: +For the example-site demo flow, a build command can copy the demo assets into the deployment directory: ```bash -# View the hook -cat .git/hooks/pre-push - -# Edit it -nano .git/hooks/pre-push - -# Or regenerate it -npx secure-deploy setup --force +autark setup --branch genesis --build-command "mkdir -p src/test/fixtures/example-site/dist && cp src/test/fixtures/example-site/index.html src/test/fixtures/example-site/script.js src/test/fixtures/example-site/dist/" ``` -## Managing Hooks - -### View Installed Hook +## View or Remove the Hook ```bash cat .git/hooks/pre-push -``` - -### Temporarily Disable - -```bash -# Skip hooks for one push -git push origin staging --no-verify - -# Or rename the hook -mv .git/hooks/pre-push .git/hooks/pre-push.disabled -``` - -### Re-enable - -```bash -mv .git/hooks/pre-push.disabled .git/hooks/pre-push -``` - -### Completely Remove - -```bash rm .git/hooks/pre-push ``` -### Reinstall - -```bash -npx secure-deploy setup --force -``` - -## Multiple Branches - -Want different branches to deploy to different domains? - -```bash -# Option 1: Multiple hooks (manual) -# Edit .git/hooks/pre-push to check multiple branches - -# Option 2: Use separate repos/branches -# staging repo β†’ staging.yourproject.eth -# main repo β†’ yourproject.eth -``` - -Example multi-branch hook: - -```bash -#!/bin/bash -branch=$(git symbolic-ref --short HEAD 2>/dev/null) - -if [[ "$branch" == "staging" ]]; then - npx secure-deploy deploy dist --ens-domain staging.yourproject.eth -elif [[ "$branch" == "main" ]]; then - npx secure-deploy deploy dist --ens-domain yourproject.eth -fi -``` - -## Troubleshooting - -### Hook doesn't run - -**Check if hook is executable:** -```bash -ls -la .git/hooks/pre-push -# Should show: -rwxr-xr-x - -# If not, make it executable: -chmod +x .git/hooks/pre-push -``` - -**Check if you're pushing the right branch:** -```bash -git branch --show-current -``` - -### Build fails - -The hook runs `npm run build` if the build directory doesn't exist. - -**Make sure you have a build script:** -```json -{ - "scripts": { - "build": "vite build" // or your build command - } -} -``` - -### Deployment fails but push continues - -This shouldn't happen! The hook should cancel the push if deployment fails. +Reinstall with overwrite: -**Check hook exit code:** ```bash -# The hook should have: -if [ $? -ne 0 ]; then - exit 1 # This cancels the push -fi -``` - -### Want to skip deployment once - -```bash -git push origin staging --no-verify -``` - -## Team Collaboration - -### Sharing Hooks with Team - -Git hooks are **not** committed to the repository by default. To share with your team: - -**Option 1: Document in README** -```markdown -## Setup -1. npm install -2. npx secure-deploy setup -``` - -**Option 2: Add to package.json postinstall** -```json -{ - "scripts": { - "postinstall": "npx secure-deploy setup --branch staging --force" - } -} -``` - -**Option 3: Use a hook manager** -- [Husky](https://typicode.github.io/husky/) -- [Lefthook](https://github.com/evilmartians/lefthook) - -## vs. GitHub Actions - -| Feature | Git Hooks (Local) | GitHub Actions (Remote) | -|---------|------------------|------------------------| -| **Runs where** | Your machine | GitHub servers | -| **Sees output** | Terminal | Actions tab | -| **Requires** | Git + Node | GitHub repo | -| **Speed** | Immediate | Waits for runner | -| **Cost** | Free | Free (with limits) | -| **Team approval** | Manual setup | Automatic | - -**Use both!** -- **Git hooks**: Fast local deployments for devs -- **GitHub Actions**: Automated deployments for team - -## Advanced: Post-Commit vs Pre-Push - -Currently, secure-deploy uses **pre-push** hooks. Here's why: - -**Pre-Push (Current)** -- βœ… Runs before code reaches remote -- βœ… Can cancel push if deployment fails -- βœ… Deploy only when sharing code -- ❌ Slower pushes - -**Post-Commit (Alternative)** -- βœ… Faster commits -- βœ… Deploy immediately after committing -- ❌ Can't cancel commit if deployment fails -- ❌ Deploys even if you don't push - -Want post-commit instead? Manually create: -```bash -cat > .git/hooks/post-commit << 'EOF' -#!/bin/bash -branch=$(git symbolic-ref --short HEAD) -if [[ "$branch" == "staging" ]]; then - npx secure-deploy deploy dist -fi -EOF -chmod +x .git/hooks/post-commit -``` - -## Security Considerations - -**Git hooks run locally**, so they: -- βœ… Use your `.env` file (private keys secure) -- βœ… Run with your permissions -- βœ… Only execute on your machine -- ⚠️ Not shared with team by default - -**Never commit:** -- `.env` file -- Private keys -- Safe credentials - -## Summary - -```bash -# One-time setup -npx secure-deploy setup - -# Then deploy anytime with: -git push origin staging - -# View proposal in terminal -# Approve in Safe UI -# Done! πŸŽ‰ +autark setup --force ``` -**Perfect for:** -- ⚑ Fast iteration during development -- πŸ‘οΈ Visibility into deployment process -- πŸ” Secure local execution -- 🎯 Branch-specific deployments +## Notes -**Questions?** Check the [main README](../README.md) or [open an issue](https://github.com/yourrepo/issues)! +- the hook runs from the repository root +- the build command should produce or refresh the deployment directory used by `autark deploy` +- if deployment fails, the push is blocked intentionally diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..2895b79 --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,115 @@ +# Quickstart + +## Prerequisites + +- Node.js `>= 20.10.0` +- Storacha CLI installed, authenticated, and pointed at an active space +- Wrapped ENS parent domain on the target network +- Safe multisig configured + +## Install + +```bash +npm install +npm run build +``` + +Storacha CLI should be ready before deploy: + +```bash +storacha login +storacha space ls +storacha space use +``` + +## Configure + +Create `.env` in the project root: + +```bash +DEPLOY_NETWORK=sepolia +SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com +SEPOLIA_ENS_DOMAIN=your-domain.eth +SAFE_ADDRESS=0x... +SAFE_API_KEY=sk_... +SEPOLIA_OWNER_PK=0x... +``` + +Optionally create `autark.config.yaml` via: + +```bash +npm run cli -- init +``` + +## Deploy + +```bash +npm run cli -- deploy dist +``` + +Common flags: + +```bash +npm run cli -- deploy dist --network sepolia --dry-run +``` + +## Check Status + +```bash +npm run cli -- status +npm run cli -- status --subdomain v0.your-domain.eth +``` + +## Promote A Version To A Stable Channel + +Create a Safe proposal to point `live.your-domain.eth` to an immutable version: + +```bash +npm run cli -- promote --to v2 --channel live --ens-domain your-domain.eth +``` + +`live.your-domain.eth` must already exist as a wrapped subdomain and be owned by your Safe. + +Rollback is the same command, pointing `live` back to an older version: + +```bash +npm run cli -- promote --to v1 --channel live --ens-domain your-domain.eth +npm run cli -- rollback --to v1 --channel live --ens-domain your-domain.eth +``` + +## List Channels + +Inspect common mutable channels and what they point to: + +```bash +npm run cli -- channels --ens-domain your-domain.eth +``` + +Specify custom channels: + +```bash +npm run cli -- channels --channels live,staging,preview.your-domain.eth --ens-domain your-domain.eth +``` + +Create missing channels as Safe proposals: + +```bash +npm run cli -- channels --create live,staging --ens-domain your-domain.eth --dry-run +npm run cli -- channels --create live,staging --ens-domain your-domain.eth +``` + +`--create` supports only direct child labels under your parent domain. + +## Auto Deploy Hook (Optional) + +```bash +npm run cli -- setup +# or pass an explicit build command for custom output dirs +npm run cli -- setup --branch staging --build-command "npm run build" +``` + +This installs `.git/hooks/pre-push` to trigger deploy proposals on a selected branch. + +## Legacy docs + +Older long-form hackathon docs are archived in `docs/_legacy/`. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2ac70f0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,13 @@ +# Docs + +## Active Docs + +- [Quickstart](./QUICKSTART.md) +- [User Flow](./USER-FLOW.md) +- [Git Hooks](./GIT-HOOKS.md) +- [Technical Architecture](./TECHNICAL-ARCHITECTURE.md) +- [Architecture (Short)](./ARCHITECTURE-SHORT.md) + +## Archive + +- [Legacy docs archive](./_legacy/) diff --git a/docs/TECHNICAL-ARCHITECTURE.md b/docs/TECHNICAL-ARCHITECTURE.md index 0548d86..0a35e52 100644 --- a/docs/TECHNICAL-ARCHITECTURE.md +++ b/docs/TECHNICAL-ARCHITECTURE.md @@ -1,460 +1,177 @@ -# Autark Technical Architecture - -**Preventing Frontend Injection Attacks Through Immutable Versioning and Multi-Party Verification** - ---- - -## The Problem We're Solving - -### Frontend Injection Attacks Are Real - -In the Lazarus Group attack on Bybit/Safe (2025), the attack vector was simple but devastating: - -1. Attacker compromises developer machine (malware, phishing) -2. Attacker pushes malicious code directly to main branch -3. CI/CD automatically triggers (trusted process) -4. Code deploys to production immediately (2-10 minutes) -5. ByBits' wallet drained - -### Current "Solutions" Fail - -**Traditional Web2 (Vercel/Netlify):** -``` -Compromised dev β†’ Push to main β†’ Auto-deploy β†’ Live in 2 min ❌ -``` -- Single point of failure -- No Multi-Party verification -- Immediate deployment - ---- - -### Core Innovation - -Slow down the deployment process and create an immutable audit trail through: - -1. **Creating immutable versioned subdomains** - Each version locked forever via ENS fuses -2. **Preventing version waste** - Batched Safe transactions (atomic subdomain creation + content setting) -3. **Requiring multi-party approval** - Safe multisig checkpoint before deployment goes live - -Signers can preview the deployment on IPFS (via the CID in the transaction data) before approving, adding a human verification checkpoint to the automated CI/CD pipeline. - -### The Two-Layer Security Model - -**Layer 1: Multi-Party Approval Checkpoint** -- Safe multisig requires threshold approval (e.g., 2 of 3, 3 of 5) -- Signers can preview deployment on IPFS (decode CID from transaction) -- Slows down automated CI/CD pipeline, forcing human review -- Prevents single compromised developer from deploying alone -- Distributes trust across `dev` team members - -**Layer 2: Cryptographic Immutability** -- ENS `NameWrapper` burns fuses on subdomain -- Once deployed, **nobody** can change it (not even Safe signers) -- Attackers can't modify past deployments even if they compromise Safe later - ---- - -## Architecture Overview - -### The Complete Flow - -``` -Developer Machine (potentially compromised) - ↓ -Git Push to Main (no PR, no review yet) - ↓ -GitHub Actions CI/CD (automated build) - ↓ -AUTARK CLI: - 1. Build β†’ IPFS (content-addressed storage) - 2. Generate CID (permanent address) - 3. Detect next version (v0, v1, v2...) - 4. Create Safe proposal (batched transaction) - ↓ -Safe Transaction Service (stores proposal) - ↓ -Multi-Party VERIFICATION ← This is where we slow down attacks! - β€’ Signers review proposal in Safe UI - β€’ Can decode CID from transaction data - β€’ Preview deployment on IPFS before approving - β€’ Approve or reject - ↓ -Safe Execution (once threshold reached) - ↓ -ENS NameWrapper Transaction (atomic): - 1. Create vX.domain.eth subdomain - 2. Set contenthash β†’ IPFS CID - 3. Burn fuses (CANNOT_UNWRAP | CANNOT_SET_RESOLVER | PARENT_CANNOT_CONTROL) - ↓ -Deployment Live (permanently immutable) - β€’ Users access via: vX.domain.eth.limo - β€’ Content served from IPFS - β€’ Cannot be changed by anyone, ever -``` - ---- - -## How We Use Each Technology - -### IPFS: Content-Addressed Storage - -**Why IPFS?** -- **Content addressing**: Each build gets unique CID based on content hash -- **Distributed**: No single server to take down -- **Verifiable**: CID proves content integrity cryptographically -- **Permanent**: Cannot be deleted or modified - -**Our Implementation:** -- Upload built frontend (dist/) via Storacha CLI -- Receive CID: `bafybeiabc123...` -- Encode CID to ENS contenthash format -- Set contenthash on ENS subdomain -- Users access via IPFS gateways or ENS gateways - -**Why Storacha Specifically?** -- Simple CLI interface (`storacha up dist`) -- Guaranteed persistence (pins to Filecoin) -- Global CDN for fast access -- Free tier for developers - -### ENS: Human-Readable Naming + Immutability - -**Why ENS?** -- **Human-readable**: `v3.myapp.eth` instead of `bafybei...` -- **Blockchain-verified**: Ownership and contenthash on-chain -- **Supports IPFS**: Contenthash field points to IPFS CID -- **NameWrapper fuses**: Can burn permissions permanently - -**Our Implementation:** -- Parent domain: `myapp.eth` (owned by user or Safe) -- Create versioned subdomains: `v0.myapp.eth`, `v1.myapp.eth`, `v2.myapp.eth`... -- Set contenthash on each subdomain to IPFS CID -- Burn fuses to make subdomain immutable: - - `CANNOT_UNWRAP` - Can't unwrap back to registry - - `CANNOT_SET_RESOLVER` - Can't change resolver (contenthash locked) - - `PARENT_CANNOT_CONTROL` - Parent can't revoke subdomain - -**Why `NameWrapper` Fuses?** - -This is the **key to immutability**. Once fuses are burned: -- Subdomain owner (Safe) cannot change contenthash -- Parent domain owner cannot revoke subdomain -- Nobody can unwrap or modify the subdomain -- **Cryptographically enforced permanence** - -Even if an attacker compromises the Safe later, they cannot modify existing versions. They can only deploy new versions (v4, v5...), which go through the same review process. - -### Safe: Threshold-Based Governance - -**Why Safe?** -- **Multisig**: Requires M of N signers (e.g., 2 of 3, 3 of 5, etc.) -- **Transaction batching**: Multiple operations in one transaction -- **Off-chain coordination**: Safe Transaction Service stores proposals -- **Well-tested**: Battle-tested infrastructure used by major protocols - -**Our Implementation:** -- Safe owns parent domain (Safe-owns-parent mode) -- Autark creates batched Safe transaction: - 1. Create subdomain (setSubnodeRecord on NameWrapper) - 2. Set contenthash (setContenthash on PublicResolver) -- Both operations happen atomically (all or nothing) -- Signers review proposal in Safe UI -- Once threshold met, anyone can execute - -**Why Batched Transactions?** - -**Without batching (Personal-owns-parent mode):** -``` -1. Personal wallet creates subdomain v2.myapp.eth βœ“ -2. Safe proposal to set contenthash -3. If rejected β†’ v2 is wasted (subdomain exists but no content) -``` - -**With batching (Safe-owns-parent mode):** -``` -1. Safe proposal creates subdomain + sets contenthash (atomic) -2. If rejected β†’ v2 never created (no wasted versions) -``` - -This prevents version pollution from rejected proposals. - ---- - -## Two Deployment Modes Explained - -### Mode 1: Safe-Owns-Parent (Recommended) - -**Setup:** -- Transfer parent domain to Safe -- Burn CANNOT_UNWRAP fuse on parent - -**How It Works:** -1. Autark detects Safe owns parent domain -2. Creates batched transaction (createSubdomain + setContenthash) -3. Submits to Safe Transaction Service -4. Signers approve -5. Execute β†’ Both operations happen together - -**Advantages:** -- β˜‘ Atomic deployment (all or nothing) -- β˜‘ No wasted versions if rejected -- β˜‘ Full governance over entire domain -- β˜‘ Cleaner version history - -**Trade-offs:** -- Requires transferring domain to Safe (scary for some users) -- All operations require Safe approval (slower) - -### Mode 2: Personal-Owns-Parent - -**Setup:** -- Keep parent domain in personal wallet - -**How It Works:** -1. Autark detects personal wallet owns parent -2. Personal wallet creates subdomain immediately -3. Autark creates Safe proposal to set contenthash -4. Signers approve -5. Execute β†’ Contenthash set - -**Advantages:** -- β˜‘ Don't need to transfer domain to Safe -- β˜‘ Can create subdomains quickly - -**Our Recommendation:** Safe-owns-parent for production (better governance, cleaner versions). - ---- - -## Key Design Decisions - -### 1. Why Versioned Subdomains Instead of Updating One Domain? - -**Problem with single domain:** -``` -myapp.eth β†’ v1 content -User bookmarks myapp.eth -myapp.eth β†’ v2 content (updated) -User's bookmark now points to different content! -``` - -**Solution with versions:** -``` -v0.myapp.eth β†’ v0 content (immutable forever) -v1.myapp.eth β†’ v1 content (immutable forever) -v2.myapp.eth β†’ v2 content (immutable forever) - -Users who want v1 can always access v1 -Users who want latest can use myapp.eth β†’ points to latest -``` - -**Additional benefits:** -- Rollback capability (point myapp.eth to older version) -- Audit trail (every version preserved) -- Security (compromised Safe can't change old versions) -- Testing (deploy v2, test, then update myapp.eth pointer) - -### 2. Why IPFS Instead of Arweave/Other Storage? - -**Considered:** -- Arweave (permanent storage, pay once) -- Traditional CDN (fast, centralized) -- Self-hosted IPFS (free, but need to maintain nodes) - -**Chose IPFS + Storacha because:** -- β˜‘ ENS native support (contenthash field designed for IPFS) -- β˜‘ Storacha handles pinning/persistence -- β˜‘ Free tier sufficient for most projects -- β˜‘ Multiple gateways available (.limo, .link, w3s.link) -- β˜‘ Mature ecosystem and tooling - -**Future:** Could add Arweave as alternative storage option. +# Technical Architecture -### 3. Why ENS Fuses Instead of Smart Contract Locks? +This document describes the current Autark implementation as of version `0.1.2`. -**Considered:** -- Custom smart contract with time locks -- Multi-sig with social consensus -- Snapshot voting for updates +## Goal -**Chose ENS fuses because:** -- β˜‘ Native to ENS (no additional contracts) -- β˜‘ Cryptographically enforced (not social consensus) -- β˜‘ Battle-tested (ENS NameWrapper live on mainnet) -- β˜‘ Cannot be bypassed (even by Safe signers) -- β˜‘ Gas efficient (one-time burn) - -**Trade-off:** Fuses are permanent (by design). Cannot undo once burned. - -### 4. Why Auto-Detection of Deployment Mode? - -**Could have required user to specify:** -```bash -autark deploy dist --mode safe-owns-parent -``` - -**Instead, we auto-detect:** -```typescript -const parentOwner = await getParentDomainOwner(ensDomain) -if (parentOwner === safeAddress) { - // Use batched mode -} else { - // Use two-step mode -} -``` +Autark protects frontend deployments by inserting a governance checkpoint before publication and by making approved releases immutable. -**Why:** -- β˜‘ Less user error -- β˜‘ Clearer UX (tool "just works") -- β˜‘ Can't accidentally use wrong mode +## Main Components ---- - -## Security Model - -### What We Protect Against - -**β˜‘ Compromised Developer Machine** -- Attacker pushes malicious code -- CI/CD builds and uploads to IPFS -- Safe proposal created -- Requires threshold approval (can't deploy alone) -- **Result:** Attack slowed down, requires compromising multiple signers +### 1. CLI layer -**β˜‘ Fast Automated Deployments** -- Traditional CI/CD: Push β†’ Live in 2 minutes -- Our tool: Push β†’ IPFS β†’ Safe proposal β†’ Wait for approvals -- **Result:** Human checkpoint in the automated pipeline - -**β˜‘ Post-Deployment Tampering** -- Attacker compromises Safe after deployment -- Tries to change v3.myapp.eth contenthash -- Fuses prevent modification (transaction reverts) -- **Result:** Past deployments remain safe - -**β˜‘ Accidental Wrong Deployment** -- Developer accidentally merges breaking change -- Safe signers review, spot the issue -- Reject deployment -- **Result:** Bad code never goes live - - -### Defense in Depth - -**Layer 1: Human Checkpoint** -- Safe multisig requires threshold approval -- Signers can preview deployment on IPFS before approving -- Slows down automated pipeline, forcing manual review +Entry point: -**Layer 2: Threshold Requirement** -- Requires multiple approvals (2 of 3, 3 of 5, etc.) -- Attacker must compromise multiple signers -- Reduces single point of failure +- `src/cli/index.ts` -**Layer 3: Cryptographic Immutability** -- ENS fuses burned on subdomain -- Cannot be changed even by Safe signers -- Protects past deployments from future compromises +Main commands: -**Layer 4: Audit Trail** -- All versions preserved forever -- Can compare v3 vs v4 to spot malicious changes -- On-chain record of all deployments +- `deploy` +- `status` +- `setup` +- `promote` +- `rollback` +- `channels` -**Layer 5: Versioning** -- Each deployment is separate subdomain -- Bad deployment doesn't overwrite good one -- Can always rollback by updating pointer +### 2. Config layer ---- +Config is merged with priority: -## Why This Approach Works +1. CLI flags +2. environment variables +3. config file -### The Fundamental Problem with Automated Security +Implementation: -**Traditional approach:** Try to detect malicious code automatically -- Static analysis -- Dependency scanning -- Behavioral analysis +- `src/lib/config.ts` -**Why it fails:** Attackers can evade automated detection -- Obfuscation -- Time bombs (malicious code activates later) -- Supply chain (malicious dependency looks legitimate) +Current canonical config names are: -### Our Approach: Slow Down + Lock Down +- `autark.config.yaml` +- `autark.config.yml` +- `autark.config.json` -Instead of trying to detect attacks automatically, we: +Legacy `secure-deploy.*` names remain supported for backward compatibility. -1. **Slow down the deployment pipeline** with human approval requirement -2. **Require multiple humans** to agree (threshold) -3. **Lock the decision cryptographically** so it can't be changed later +### 3. IPFS upload layer -**This works because:** -- Gives time for team to notice suspicious activity -- Multiple approvals required (attacker must compromise multiple signers) -- Immutability protects against future compromises -- Versioning preserves audit trail +Implementation: -### The Trade-Off: Security vs. Velocity +- `src/lib/ipfs/upload.ts` -**Traditional CI/CD:** -- Push to main β†’ Live in 2 minutes -- Maximum velocity -- Zero security checkpoints +Autark currently uses the local Storacha CLI, not a native in-process Storacha SDK flow. -**Our system:** -- Push to main β†’ Safe proposal β†’ Human review β†’ Approve β†’ Execute -- Minimum 10-30 minutes (realistic: hours if signers not online) -- **Trade velocity for security** +That means the runtime assumptions are: -**Who this is for:** -- β˜‘ Financial dApps (Uniswap, Aave, etc.) -- β˜‘ DAO frontends (where funds are at stake) -- β˜‘ Critical infrastructure (bridges, multisigs) -- x Rapid prototyping / hobby projects -- x Applications where speed > security +- Storacha CLI is installed +- the user is authenticated +- an active Storacha space is selected ---- +### 4. ENS layer -## Future Enhancements +Key modules: -### Post-Hackathon Roadmap +- `src/lib/ens/version.ts` +- `src/lib/ens/deploy.ts` +- `src/lib/ens/execute.ts` +- `src/lib/ens/fuses-check.ts` +- `src/lib/ens/safe-batch-deploy.ts` -**Phase 1: Build Verification** -- Multiple parties build independently -- Compare build hashes (must match) -- Consensus requirement (3/5 builders agree) -- Prevents compromised build pipeline +Responsibilities: -**Phase 2: Time Delays** -- On-chain time lock after approval -- 6-24 hour emergency stop window -- Can cancel if attack discovered -- Additional safety layer +- detect next `vN` release +- build contenthash data for IPFS +- detect ownership mode for parent domain +- create subdomains and set resolver contenthash +- enforce NameWrapper fuse rules -**Phase 3: Enhanced Context in Safe UI** -- Custom Safe App showing decoded transaction data -- Display Git commit info, file diff links -- Preview iframe of IPFS deployment -- Warning banners for unusual patterns +## Deployment Modes ---- +### Safe-owned-parent mode -## Conclusion +Recommended mode. -Autark addresses a **critical gap** in Web3 frontend security: fully automated CI/CD pipelines allow compromised developers to deploy malicious frontends in minutes. +The Safe owns the wrapped parent domain. -**Our contribution:** +Autark creates one batched Safe proposal that: -1. **Human approval checkpoint** - Slows down automated deployment, requires threshold approval -2. **Atomic deployments** - Batched Safe transactions prevent wasted versions -3. **Cryptographic immutability** - ENS fuses lock deployments permanently -4. **Audit trail** - Every version preserved and verifiable +- creates the next versioned subdomain +- sets the contenthash to the uploaded CID -**The key insight:** Don't try to automate attack detection (computers can't beat sophisticated attackers). Instead, slow down the pipeline with a human approval checkpoint, require multiple signers, and lock decisions permanently on-chain. +This is the cleanest mode because rejected proposals do not waste immutable version numbers. -**For teams deploying critical frontends** (DeFi, DAOs, bridges), this trades deployment velocity for security - adding a human verification checkpoint that prevents single compromised developers from deploying malicious code. +### Personal-owned-parent mode ---- +Compatibility mode. -**Built at ETHRome 2025 Hackathon** +The personal wallet owns the parent domain. + +Autark: + +- creates the subdomain directly +- then creates a Safe proposal for the contenthash update + +## Mutable Channel Architecture + +Autark separates immutable releases from mutable entrypoints. + +Immutable releases: + +- `v0.parent.eth` +- `v1.parent.eth` +- `v2.parent.eth` + +Mutable channels: + +- `live.parent.eth` +- `staging.parent.eth` +- `canary.parent.eth` + +Implemented through: + +- `src/cli/commands/promote.ts` +- `src/cli/commands/channels.ts` + +This allows: + +- promotion of a reviewed immutable release to a stable channel +- rollback of a stable channel to an older immutable release +- inspection and creation of channel subdomains through Safe proposals + +## Safe Integration + +Key runtime modules: + +- `src/lib/safe/client.ts` +- `src/lib/ens/safe-batch-deploy.ts` + +Current implementation uses: + +- `@safe-global/protocol-kit` +- `@safe-global/api-kit` + +The vulnerable starter kit dependency was removed in the `0.1.2` line. + +Behavior: + +- threshold `1`: execute immediately +- threshold `>1`: propose transaction in the Safe Transaction Service + +## Git Hook Integration + +Implemented in: + +- `src/cli/commands/setup.ts` + +Autark can install a pre-push hook that: + +- runs a custom build command +- validates the build output directory +- triggers `autark deploy` +- blocks the push if deployment fails + +## Security Properties + +Autark provides the following technical guarantees when used in the recommended path: + +- no single developer can ship a production frontend alone +- every approved release has an IPFS CID and an ENS record +- immutable versioned subdomains remain available after deployment +- channel promotion and rollback are explicit governance actions +- previous versions remain auditable and addressable + +## Limitations + +- Safe Sepolia indexing can be slow and is external to this repo +- Storacha authentication and space selection are handled outside the CLI runtime +- older scripts under `src/test` and `src/core` still exist as experimental utilities and are not the main supported interface diff --git a/docs/USER-FLOW.md b/docs/USER-FLOW.md new file mode 100644 index 0000000..b861e91 --- /dev/null +++ b/docs/USER-FLOW.md @@ -0,0 +1,134 @@ +# User Flow + +This document describes the current Autark release flow as implemented on the `genesis` branch. + +## Core Release Flow + +### 1. Prepare the frontend build + +A frontend project outputs static files such as: + +- `dist/` +- `build/` +- `out/` + +Autark operates on the final static output directory. + +### 2. Upload the build to IPFS + +Autark uploads the build directory to IPFS through the local Storacha CLI. + +Result: + +- a CID is returned +- that CID becomes the content-addressed reference for the release + +### 3. Detect the next immutable version + +Autark scans the parent ENS domain and determines the next available versioned subdomain: + +- `v0.parent.eth` +- `v1.parent.eth` +- `v2.parent.eth` + +### 4. Build the deployment plan + +Autark prepares the required ENS and Safe transaction data: + +- create the next subdomain +- set the resolver contenthash to the uploaded CID +- apply the intended fuse policy + +### 5. Create the Safe proposal + +Autark submits the transaction through Safe. + +There are two modes: + +#### Safe-owned-parent mode + +Recommended mode. + +Autark submits one batched Safe proposal that: + +1. creates the immutable versioned subdomain +2. sets the contenthash to the uploaded IPFS CID + +This is atomic and avoids wasting version numbers on rejected proposals. + +#### Personal-owned-parent mode + +Compatibility mode. + +Autark: + +1. creates the subdomain directly from the personal owner wallet +2. creates a Safe proposal for the contenthash update + +### 6. Review and approve + +Signers review the proposal in Safe. + +At this stage they can verify: + +- the intended domain +- the Safe transaction contents +- the IPFS CID +- the commit metadata printed by the CLI + +### 7. Execute + +After threshold approval, the Safe transaction is executed. + +The release becomes available through: + +- the direct IPFS gateway +- ENS gateways like `.limo` / `.link` +- the immutable versioned ENS subdomain + +## Mutable Channel Flow + +Autark also supports mutable channels such as: + +- `live.parent.eth` +- `staging.parent.eth` +- `canary.parent.eth` + +These channels point to immutable `vN.parent.eth` releases. + +### Promote flow + +```bash +autark promote --to v3 --channel live --ens-domain parent.eth +``` + +This creates a Safe proposal that updates the `live` channel contenthash to the contenthash already stored on `v3.parent.eth`. + +### Rollback flow + +```bash +autark rollback --to v2 --channel live --ens-domain parent.eth +``` + +This is the same operation, but framed explicitly as rollback. + +### Inspect channels + +```bash +autark channels --ens-domain parent.eth +``` + +Autark lists the configured channels, their ownership, contenthash, and matching immutable version when possible. + +## Auto-Deploy Flow + +Autark can install a `pre-push` git hook. + +When enabled, a push to the configured branch will: + +1. run the configured build command +2. verify the build output directory exists +3. call `autark deploy ` +4. block the push if deployment fails + +For details, see [Git Hooks](./GIT-HOOKS.md). diff --git a/docs/CI-CD-SETUP.md b/docs/_legacy/CI-CD-SETUP.md similarity index 89% rename from docs/CI-CD-SETUP.md rename to docs/_legacy/CI-CD-SETUP.md index b2e6cc1..91cf6b7 100644 --- a/docs/CI-CD-SETUP.md +++ b/docs/_legacy/CI-CD-SETUP.md @@ -28,8 +28,9 @@ Go to your repository Settings β†’ Secrets and variables β†’ Actions, and add th |-------------|-------------|---------| | `SEPOLIA_RPC_URL` | Alchemy/Infura RPC endpoint | `https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY` | | `SAFE_ADDRESS` | Your Safe multisig address | `0xA5ED8dd265c8e9154FaBf8E66Cb3aF16002261A3` | -| `SEPOLIA_OWNER_ADDRESS` | Safe signer address | `0xE53eC90471B604f24b5Ab66A61F18e30579D2b1F` | +| `SEPOLIA_ENS_DOMAIN` | ENS parent domain on Sepolia | `myproject.eth` | | `SEPOLIA_OWNER_PK` | Safe signer private key | `0xfd4e02...` | +| `SAFE_API_KEY` | Safe Transaction Service API key | `sk_live_abc123...` | | `STORACHA_TOKEN` | (Optional) Storacha auth token | Get from `storacha token` | **Security Note:** The `SEPOLIA_OWNER_PK` is only used to *propose* transactions to Safe. It cannot execute transactions alone (requires threshold approvals). @@ -40,7 +41,7 @@ Copy the workflow file to your project: ```bash mkdir -p .github/workflows -cp path/to/secure-deploy/.github/workflows/deploy.yml .github/workflows/ +cp path/to/autark/.github/workflows/deploy.yml .github/workflows/ ``` ### 3. Customize Workflow @@ -55,9 +56,7 @@ Edit `.github/workflows/deploy.yml` to match your project: # Update the build output directory - name: Deploy to ENS + IPFS run: | - secure-deploy deploy dist \ # Change 'dist' to your build directory - --network sepolia \ - --ens-domain rome.eth \ # Change to your domain + npm run cli -- deploy dist --network sepolia # Change 'dist' to your build directory ``` ### 4. Deployment Modes @@ -180,12 +179,12 @@ jobs: deploy-staging: if: github.ref == 'refs/heads/develop' steps: - - run: secure-deploy deploy dist --ens-domain staging.rome.eth + - run: autark deploy dist --ens-domain staging.rome.eth deploy-production: if: github.ref == 'refs/heads/main' steps: - - run: secure-deploy deploy dist --ens-domain rome.eth + - run: autark deploy dist --ens-domain rome.eth ``` ### Add Slack/Discord Notifications @@ -207,7 +206,7 @@ jobs: ### View Deployment Status - **GitHub Actions**: Repository β†’ Actions tab -- **Safe Proposals**: https://app.safe.global/sepolia.safe/YOUR_SAFE_ADDRESS/transactions/queue +- **Safe Proposals**: https://app.safe.global/transactions/queue?safe=sep:YOUR_SAFE_ADDRESS - **ENS Status**: `npm run status` ### Debugging Failed Deployments @@ -230,21 +229,21 @@ jobs: ## Example: Complete Setup ```bash -# 1. Setup secure-deploy in your project +# 1. Setup autark in your project cd my-frontend-app -npm install --save-dev @your-org/secure-deploy +npm install -g autark # 2. Add deploy script to package.json { "scripts": { - "deploy": "secure-deploy deploy dist" + "deploy": "autark deploy dist" } } # 3. Copy workflow file mkdir -p .github/workflows curl -o .github/workflows/deploy.yml \ - https://raw.githubusercontent.com/your-org/secure-deploy/main/.github/workflows/deploy.yml + https://raw.githubusercontent.com/MihRazvan/ETHRome_hackathon/main/.github/workflows/deploy.yml # 4. Configure secrets in GitHub UI # (See step 1 above) diff --git a/docs/FLOW-CHART.md b/docs/_legacy/FLOW-CHART.md similarity index 100% rename from docs/FLOW-CHART.md rename to docs/_legacy/FLOW-CHART.md diff --git a/docs/_legacy/GIT-HOOKS.md b/docs/_legacy/GIT-HOOKS.md new file mode 100644 index 0000000..562564c --- /dev/null +++ b/docs/_legacy/GIT-HOOKS.md @@ -0,0 +1,349 @@ +# Git Hooks - Automatic Deployment + +Enable automatic deployments triggered by `git push` using autark's built-in git hooks. + +## Quick Start + +```bash +# In your project directory +autark setup + +# Follow the prompts: +# - Which branch? [staging] +# - Build directory? [dist] +# - Install hooks? [y] + +# Done! Now deployments happen automatically: +git push origin staging # Auto-deploys! πŸš€ +``` + +## How It Works + +When you run `autark setup`, it installs a **pre-push git hook** that: + +1. βœ… Detects when you push to the deployment branch (e.g., `staging`) +2. πŸ—οΈ Runs your build if needed +3. πŸ“¦ Uploads to IPFS +4. πŸ” Creates Safe proposal (batched transaction) +5. πŸ“€ Completes the push +6. πŸ”— Shows Safe URL in terminal for approval + +**All output appears in your terminal - no hidden CI/CD!** + +## Setup Options + +### Interactive Setup (Recommended) + +```bash +autark setup +``` + +Prompts you for: +- **Deployment branch**: Which branch triggers deployments (default: `staging`) +- **Build directory**: Where your built files are (default: `dist`) + +### Non-Interactive Setup + +```bash +# Specify branch via flag +autark setup --branch main + +# Force overwrite existing hook +autark setup --force +``` + +## Example Workflows + +### Development β†’ Staging β†’ Production + +```bash +# Feature development +git checkout -b feature/new-ui +git commit -m "Add new UI" +git push origin feature/new-ui # No deployment + +# Merge to staging - auto-deploys! +git checkout staging +git merge feature/new-ui +git push origin staging # πŸš€ Deployment triggered! + +# After Safe approval, promote to production +git checkout main +git merge staging +git push origin main # πŸš€ Production deployment! +``` + +### Output Example + +```bash +$ git push origin staging + +πŸš€ autark: Auto-deploying staging branch... + +πŸ“¦ Step 3: Upload to IPFS +βœ” Uploaded: bafybei... + +πŸ” Step 7: Detect Deployment Mode +βœ“ Mode: Safe-owns-parent (batched deployment) + +βœ… Step 9: Execute Deployment +βœ” Batch transaction submitted to Safe + Safe TX Hash: 0x1a2b... + +πŸŽ‰ Deployment Proposal Created! + Approve in Safe UI: https://app.safe.global/... + +Enumerating objects: 5, done. +Counting objects: 100% (5/5), done. +To github.com:yourname/yourproject.git + a7f9c2e..b3d5f1a staging -> staging +``` + +## Hook Details + +### What Gets Installed + +The hook is installed at: +``` +.git/hooks/pre-push +``` + +It only runs when you push to your configured deployment branch. + +### Hook Behavior + +- **Runs before push**: Deployment happens before your code reaches remote +- **Fails safely**: If deployment fails, push is cancelled +- **Branch-specific**: Only triggers on configured branch +- **Build detection**: Auto-runs `npm run build` if needed +- **Terminal output**: All logs visible in your terminal + +### Customizing the Hook + +You can manually edit the hook: + +```bash +# View the hook +cat .git/hooks/pre-push + +# Edit it +nano .git/hooks/pre-push + +# Or regenerate it +autark setup --force +``` + +## Managing Hooks + +### View Installed Hook + +```bash +cat .git/hooks/pre-push +``` + +### Temporarily Disable + +```bash +# Skip hooks for one push +git push origin staging --no-verify + +# Or rename the hook +mv .git/hooks/pre-push .git/hooks/pre-push.disabled +``` + +### Re-enable + +```bash +mv .git/hooks/pre-push.disabled .git/hooks/pre-push +``` + +### Completely Remove + +```bash +rm .git/hooks/pre-push +``` + +### Reinstall + +```bash +autark setup --force +``` + +## Multiple Branches + +Want different branches to deploy to different domains? + +```bash +# Option 1: Multiple hooks (manual) +# Edit .git/hooks/pre-push to check multiple branches + +# Option 2: Use separate repos/branches +# staging repo β†’ staging.yourproject.eth +# main repo β†’ yourproject.eth +``` + +Example multi-branch hook: + +```bash +#!/bin/bash +branch=$(git symbolic-ref --short HEAD 2>/dev/null) + +if [[ "$branch" == "staging" ]]; then + autark deploy dist --ens-domain staging.yourproject.eth +elif [[ "$branch" == "main" ]]; then + autark deploy dist --ens-domain yourproject.eth +fi +``` + +## Troubleshooting + +### Hook doesn't run + +**Check if hook is executable:** +```bash +ls -la .git/hooks/pre-push +# Should show: -rwxr-xr-x + +# If not, make it executable: +chmod +x .git/hooks/pre-push +``` + +**Check if you're pushing the right branch:** +```bash +git branch --show-current +``` + +### Build fails + +The hook runs `npm run build` if the build directory doesn't exist. + +**Make sure you have a build script:** +```json +{ + "scripts": { + "build": "vite build" // or your build command + } +} +``` + +### Deployment fails but push continues + +This shouldn't happen! The hook should cancel the push if deployment fails. + +**Check hook exit code:** +```bash +# The hook should have: +if [ $? -ne 0 ]; then + exit 1 # This cancels the push +fi +``` + +### Want to skip deployment once + +```bash +git push origin staging --no-verify +``` + +## Team Collaboration + +### Sharing Hooks with Team + +Git hooks are **not** committed to the repository by default. To share with your team: + +**Option 1: Document in README** +```markdown +## Setup +1. npm install +2. autark setup +``` + +**Option 2: Add to package.json postinstall** +```json +{ + "scripts": { + "postinstall": "autark setup --branch staging --force" + } +} +``` + +**Option 3: Use a hook manager** +- [Husky](https://typicode.github.io/husky/) +- [Lefthook](https://github.com/evilmartians/lefthook) + +## vs. GitHub Actions + +| Feature | Git Hooks (Local) | GitHub Actions (Remote) | +|---------|------------------|------------------------| +| **Runs where** | Your machine | GitHub servers | +| **Sees output** | Terminal | Actions tab | +| **Requires** | Git + Node | GitHub repo | +| **Speed** | Immediate | Waits for runner | +| **Cost** | Free | Free (with limits) | +| **Team approval** | Manual setup | Automatic | + +**Use both!** +- **Git hooks**: Fast local deployments for devs +- **GitHub Actions**: Automated deployments for team + +## Advanced: Post-Commit vs Pre-Push + +Currently, autark uses **pre-push** hooks. Here's why: + +**Pre-Push (Current)** +- βœ… Runs before code reaches remote +- βœ… Can cancel push if deployment fails +- βœ… Deploy only when sharing code +- ❌ Slower pushes + +**Post-Commit (Alternative)** +- βœ… Faster commits +- βœ… Deploy immediately after committing +- ❌ Can't cancel commit if deployment fails +- ❌ Deploys even if you don't push + +Want post-commit instead? Manually create: +```bash +cat > .git/hooks/post-commit << 'EOF' +#!/bin/bash +branch=$(git symbolic-ref --short HEAD) +if [[ "$branch" == "staging" ]]; then + autark deploy dist +fi +EOF +chmod +x .git/hooks/post-commit +``` + +## Security Considerations + +**Git hooks run locally**, so they: +- βœ… Use your `.env` file (private keys secure) +- βœ… Run with your permissions +- βœ… Only execute on your machine +- ⚠️ Not shared with team by default + +**Never commit:** +- `.env` file +- Private keys +- Safe credentials + +## Summary + +```bash +# One-time setup +autark setup + +# Then deploy anytime with: +git push origin staging + +# View proposal in terminal +# Approve in Safe UI +# Done! πŸŽ‰ +``` + +**Perfect for:** +- ⚑ Fast iteration during development +- πŸ‘οΈ Visibility into deployment process +- πŸ” Secure local execution +- 🎯 Branch-specific deployments + +**Questions?** Check the [main README](../README.md) or [open an issue](https://github.com/yourrepo/issues)! diff --git a/docs/_legacy/TECHNICAL-ARCHITECTURE.md b/docs/_legacy/TECHNICAL-ARCHITECTURE.md new file mode 100644 index 0000000..fbdf824 --- /dev/null +++ b/docs/_legacy/TECHNICAL-ARCHITECTURE.md @@ -0,0 +1,460 @@ +# Autark Technical Architecture + +**Preventing Frontend Injection Attacks Through Immutable Versioning and Multi-Party Verification** + +--- + +## The Problem We're Solving + +### Frontend Injection Attacks Are Real + +In the Lazarus Group attack on Bybit/Safe (2025), the attack vector was simple but devastating: + +1. Attacker compromises developer machine (malware, phishing) +2. Attacker pushes malicious code directly to main branch +3. CI/CD automatically triggers (trusted process) +4. Code deploys to production immediately (2-10 minutes) +5. ByBits' wallet drained + +### Current "Solutions" Fail + +**Traditional Web2 (Vercel/Netlify):** +``` +Compromised dev β†’ Push to main β†’ Auto-deploy β†’ Live in 2 min ❌ +``` +- Single point of failure +- No Multi-Party verification +- Immediate deployment + +--- + +### Core Innovation + +Slow down the deployment process and create an immutable audit trail through: + +1. **Creating immutable versioned subdomains** - Each version locked forever via ENS fuses +2. **Preventing version waste** - Batched Safe transactions (atomic subdomain creation + content setting) +3. **Requiring multi-party approval** - Safe multisig checkpoint before deployment goes live + +Signers can preview the deployment on IPFS (via the CID in the transaction data) before approving, adding a human verification checkpoint to the automated CI/CD pipeline. + +### The Two-Layer Security Model + +**Layer 1: Multi-Party Approval Checkpoint** +- Safe multisig requires threshold approval (e.g., 2 of 3, 3 of 5) +- Signers can preview deployment on IPFS (decode CID from transaction) +- Slows down automated CI/CD pipeline, forcing human review +- Prevents single compromised developer from deploying alone +- Distributes trust across `dev` team members + +**Layer 2: Cryptographic Immutability** +- ENS `NameWrapper` burns fuses on subdomain +- Once deployed, **nobody** can change it (not even Safe signers) +- Attackers can't modify past deployments even if they compromise Safe later + +--- + +## Architecture Overview + +### The Complete Flow + +``` +Developer Machine (potentially compromised) + ↓ +Git Push to Main (no PR, no review yet) + ↓ +GitHub Actions CI/CD (automated build) + ↓ +AUTARK CLI: + 1. Build β†’ IPFS (content-addressed storage) + 2. Generate CID (permanent address) + 3. Detect next version (v0, v1, v2...) + 4. Create Safe proposal (batched transaction) + ↓ +Safe Transaction Service (stores proposal) + ↓ +Multi-Party VERIFICATION ← This is where we slow down attacks! + β€’ Signers review proposal in Safe UI + β€’ Can decode CID from transaction data + β€’ Preview deployment on IPFS before approving + β€’ Approve or reject + ↓ +Safe Execution (once threshold reached) + ↓ +ENS NameWrapper Transaction (atomic): + 1. Create vX.domain.eth subdomain + 2. Set contenthash β†’ IPFS CID + 3. Burn fuses (CANNOT_UNWRAP | CANNOT_SET_RESOLVER | PARENT_CANNOT_CONTROL) + ↓ +Deployment Live (permanently immutable) + β€’ Users access via: vX.domain.eth.limo + β€’ Content served from IPFS + β€’ Cannot be changed by anyone, ever +``` + +--- + +## How We Use Each Technology + +### IPFS: Content-Addressed Storage + +**Why IPFS?** +- **Content addressing**: Each build gets unique CID based on content hash +- **Distributed**: No single server to take down +- **Verifiable**: CID proves content integrity cryptographically +- **Permanent**: Cannot be deleted or modified + +**Our Implementation:** +- Upload built frontend (dist/) via Storacha CLI +- Receive CID: `bafybeiabc123...` +- Encode CID to ENS contenthash format +- Set contenthash on ENS subdomain +- Users access via IPFS gateways or ENS gateways + +**Why Storacha Specifically?** +- Simple CLI interface (`storacha up dist`) +- Guaranteed persistence (pins to Filecoin) +- Global CDN for fast access +- Free tier for developers + +### ENS: Human-Readable Naming + Immutability + +**Why ENS?** +- **Human-readable**: `v3.myapp.eth` instead of `bafybei...` +- **Blockchain-verified**: Ownership and contenthash on-chain +- **Supports IPFS**: Contenthash field points to IPFS CID +- **NameWrapper fuses**: Can burn permissions permanently + +**Our Implementation:** +- Parent domain: `myapp.eth` (owned by user or Safe) +- Create versioned subdomains: `v0.myapp.eth`, `v1.myapp.eth`, `v2.myapp.eth`... +- Set contenthash on each subdomain to IPFS CID +- Burn fuses to make subdomain immutable: + - `CANNOT_UNWRAP` - Can't unwrap back to registry + - `CANNOT_SET_RESOLVER` - Can't change resolver (contenthash locked) + - `PARENT_CANNOT_CONTROL` - Parent can't revoke subdomain + +**Why `NameWrapper` Fuses?** + +This is the **key to immutability**. Once fuses are burned: +- Subdomain owner (Safe) cannot change contenthash +- Parent domain owner cannot revoke subdomain +- Nobody can unwrap or modify the subdomain +- **Cryptographically enforced permanence** + +Even if an attacker compromises the Safe later, they cannot modify existing versions. They can only deploy new versions (v4, v5...), which go through the same review process. + +### Safe: Threshold-Based Governance + +**Why Safe?** +- **Multisig**: Requires M of N signers (e.g., 2 of 3, 3 of 5, etc.) +- **Transaction batching**: Multiple operations in one transaction +- **Off-chain coordination**: Safe Transaction Service stores proposals +- **Well-tested**: Battle-tested infrastructure used by major protocols + +**Our Implementation:** +- Safe owns parent domain (Safe-owns-parent mode) +- Autark creates batched Safe transaction: + 1. Create subdomain (setSubnodeRecord on NameWrapper) + 2. Set contenthash (setContenthash on PublicResolver) +- Both operations happen atomically (all or nothing) +- Signers review proposal in Safe UI +- Once threshold met, anyone can execute + +**Why Batched Transactions?** + +**Without batching (Personal-owns-parent mode):** +``` +1. Personal wallet creates subdomain v2.myapp.eth βœ“ +2. Safe proposal to set contenthash +3. If rejected β†’ v2 is wasted (subdomain exists but no content) +``` + +**With batching (Safe-owns-parent mode):** +``` +1. Safe proposal creates subdomain + sets contenthash (atomic) +2. If rejected β†’ v2 never created (no wasted versions) +``` + +This prevents version pollution from rejected proposals. + +--- + +## Two Deployment Modes Explained + +### Mode 1: Safe-Owns-Parent (Recommended) + +**Setup:** +- Transfer parent domain to Safe +- Burn CANNOT_UNWRAP fuse on parent + +**How It Works:** +1. Autark detects Safe owns parent domain +2. Creates batched transaction (createSubdomain + setContenthash) +3. Submits to Safe Transaction Service +4. Signers approve +5. Execute β†’ Both operations happen together + +**Advantages:** +- β˜‘ Atomic deployment (all or nothing) +- β˜‘ No wasted versions if rejected +- β˜‘ Full governance over entire domain +- β˜‘ Cleaner version history + +**Trade-offs:** +- Requires transferring domain to Safe (scary for some users) +- All operations require Safe approval (slower) + +### Mode 2: Personal-Owns-Parent + +**Setup:** +- Keep parent domain in personal wallet + +**How It Works:** +1. Autark detects personal wallet owns parent +2. Personal wallet creates subdomain immediately +3. Autark creates Safe proposal to set contenthash +4. Signers approve +5. Execute β†’ Contenthash set + +**Advantages:** +- β˜‘ Don't need to transfer domain to Safe +- β˜‘ Can create subdomains quickly + +**Our Recommendation:** Safe-owns-parent for production (better governance, cleaner versions). + +--- + +## Key Design Decisions + +### 1. Why Versioned Subdomains Instead of Updating One Domain? + +**Problem with single domain:** +``` +myapp.eth β†’ v1 content +User bookmarks myapp.eth +myapp.eth β†’ v2 content (updated) +User's bookmark now points to different content! +``` + +**Solution with versions:** +``` +v0.myapp.eth β†’ v0 content (immutable forever) +v1.myapp.eth β†’ v1 content (immutable forever) +v2.myapp.eth β†’ v2 content (immutable forever) + +Users who want v1 can always access v1 +Users who want latest can use myapp.eth β†’ points to latest +``` + +**Additional benefits:** +- Rollback capability (point myapp.eth to older version) +- Audit trail (every version preserved) +- Security (compromised Safe can't change old versions) +- Testing (deploy v2, test, then update myapp.eth pointer) + +### 2. Why IPFS Instead of Arweave/Other Storage? + +**Considered:** +- Arweave (permanent storage, pay once) +- Traditional CDN (fast, centralized) +- Self-hosted IPFS (free, but need to maintain nodes) + +**Chose IPFS + Storacha because:** +- β˜‘ ENS native support (contenthash field designed for IPFS) +- β˜‘ Storacha handles pinning/persistence +- β˜‘ Free tier sufficient for most projects +- β˜‘ Multiple gateways available (.limo, .link, w3s.link) +- β˜‘ Mature ecosystem and tooling + +**Future:** Could add Arweave as alternative storage option. + +### 3. Why ENS Fuses Instead of Smart Contract Locks? + +**Considered:** +- Custom smart contract with time locks +- Multi-sig with social consensus +- Snapshot voting for updates + +**Chose ENS fuses because:** +- β˜‘ Native to ENS (no additional contracts) +- β˜‘ Cryptographically enforced (not social consensus) +- β˜‘ Battle-tested (ENS NameWrapper live on mainnet) +- β˜‘ Cannot be bypassed (even by Safe signers) +- β˜‘ Gas efficient (one-time burn) + +**Trade-off:** Fuses are permanent (by design). Cannot undo once burned. + +### 4. Why Auto-Detection of Deployment Mode? + +**Could have required user to specify:** +```bash +autark deploy dist +``` + +**Instead, we auto-detect:** +```typescript +const parentOwner = await getParentDomainOwner(ensDomain) +if (parentOwner === safeAddress) { + // Use batched mode +} else { + // Use two-step mode +} +``` + +**Why:** +- β˜‘ Less user error +- β˜‘ Clearer UX (tool "just works") +- β˜‘ Can't accidentally use wrong mode + +--- + +## Security Model + +### What We Protect Against + +**β˜‘ Compromised Developer Machine** +- Attacker pushes malicious code +- CI/CD builds and uploads to IPFS +- Safe proposal created +- Requires threshold approval (can't deploy alone) +- **Result:** Attack slowed down, requires compromising multiple signers + +**β˜‘ Fast Automated Deployments** +- Traditional CI/CD: Push β†’ Live in 2 minutes +- Our tool: Push β†’ IPFS β†’ Safe proposal β†’ Wait for approvals +- **Result:** Human checkpoint in the automated pipeline + +**β˜‘ Post-Deployment Tampering** +- Attacker compromises Safe after deployment +- Tries to change v3.myapp.eth contenthash +- Fuses prevent modification (transaction reverts) +- **Result:** Past deployments remain safe + +**β˜‘ Accidental Wrong Deployment** +- Developer accidentally merges breaking change +- Safe signers review, spot the issue +- Reject deployment +- **Result:** Bad code never goes live + + +### Defense in Depth + +**Layer 1: Human Checkpoint** +- Safe multisig requires threshold approval +- Signers can preview deployment on IPFS before approving +- Slows down automated pipeline, forcing manual review + +**Layer 2: Threshold Requirement** +- Requires multiple approvals (2 of 3, 3 of 5, etc.) +- Attacker must compromise multiple signers +- Reduces single point of failure + +**Layer 3: Cryptographic Immutability** +- ENS fuses burned on subdomain +- Cannot be changed even by Safe signers +- Protects past deployments from future compromises + +**Layer 4: Audit Trail** +- All versions preserved forever +- Can compare v3 vs v4 to spot malicious changes +- On-chain record of all deployments + +**Layer 5: Versioning** +- Each deployment is separate subdomain +- Bad deployment doesn't overwrite good one +- Can always rollback by updating pointer + +--- + +## Why This Approach Works + +### The Fundamental Problem with Automated Security + +**Traditional approach:** Try to detect malicious code automatically +- Static analysis +- Dependency scanning +- Behavioral analysis + +**Why it fails:** Attackers can evade automated detection +- Obfuscation +- Time bombs (malicious code activates later) +- Supply chain (malicious dependency looks legitimate) + +### Our Approach: Slow Down + Lock Down + +Instead of trying to detect attacks automatically, we: + +1. **Slow down the deployment pipeline** with human approval requirement +2. **Require multiple humans** to agree (threshold) +3. **Lock the decision cryptographically** so it can't be changed later + +**This works because:** +- Gives time for team to notice suspicious activity +- Multiple approvals required (attacker must compromise multiple signers) +- Immutability protects against future compromises +- Versioning preserves audit trail + +### The Trade-Off: Security vs. Velocity + +**Traditional CI/CD:** +- Push to main β†’ Live in 2 minutes +- Maximum velocity +- Zero security checkpoints + +**Our system:** +- Push to main β†’ Safe proposal β†’ Human review β†’ Approve β†’ Execute +- Minimum 10-30 minutes (realistic: hours if signers not online) +- **Trade velocity for security** + +**Who this is for:** +- β˜‘ Financial dApps (Uniswap, Aave, etc.) +- β˜‘ DAO frontends (where funds are at stake) +- β˜‘ Critical infrastructure (bridges, multisigs) +- x Rapid prototyping / hobby projects +- x Applications where speed > security + +--- + +## Future Enhancements + +### Post-Hackathon Roadmap + +**Phase 1: Build Verification** +- Multiple parties build independently +- Compare build hashes (must match) +- Consensus requirement (3/5 builders agree) +- Prevents compromised build pipeline + +**Phase 2: Time Delays** +- On-chain time lock after approval +- 6-24 hour emergency stop window +- Can cancel if attack discovered +- Additional safety layer + +**Phase 3: Enhanced Context in Safe UI** +- Custom Safe App showing decoded transaction data +- Display Git commit info, file diff links +- Preview iframe of IPFS deployment +- Warning banners for unusual patterns + +--- + +## Conclusion + +Autark addresses a **critical gap** in Web3 frontend security: fully automated CI/CD pipelines allow compromised developers to deploy malicious frontends in minutes. + +**Our contribution:** + +1. **Human approval checkpoint** - Slows down automated deployment, requires threshold approval +2. **Atomic deployments** - Batched Safe transactions prevent wasted versions +3. **Cryptographic immutability** - ENS fuses lock deployments permanently +4. **Audit trail** - Every version preserved and verifiable + +**The key insight:** Don't try to automate attack detection (computers can't beat sophisticated attackers). Instead, slow down the pipeline with a human approval checkpoint, require multiple signers, and lock decisions permanently on-chain. + +**For teams deploying critical frontends** (DeFi, DAOs, bridges), this trades deployment velocity for security - adding a human verification checkpoint that prevents single compromised developers from deploying malicious code. + +--- + +**Built at ETHRome 2025 Hackathon** diff --git a/docs/USER-GUIDE.md b/docs/_legacy/USER-GUIDE.md similarity index 98% rename from docs/USER-GUIDE.md rename to docs/_legacy/USER-GUIDE.md index 535102d..d54768a 100644 --- a/docs/USER-GUIDE.md +++ b/docs/_legacy/USER-GUIDE.md @@ -429,14 +429,14 @@ https://ethereum-sepolia-rpc.publicnode.com **Result:** -Creates `.autarkrc.json`: -```json -{ - "ensDomain": "myproject.eth", - "safeAddress": "0xA5ED8dd265c8e9154FaBf8E66Cb3aF16002261A3", - "network": "sepolia", - "rpcUrl": "https://sepolia.infura.io/v3/abc123..." -} +Creates `secure-deploy.config.yaml`: +```yaml +network: sepolia +rpcUrl: https://sepolia.infura.io/v3/abc123... +ensDomain: myproject.eth +safeAddress: 0xA5ED8dd265c8e9154FaBf8E66Cb3aF16002261A3 +safeApiKey: sk_live_abc123def456... +ownerPrivateKey: 0xabc123def456... ``` ### Step 3: Create .env File @@ -456,10 +456,10 @@ SAFE_ADDRESS=0xA5ED8dd265c8e9154FaBf8E66Cb3aF16002261A3 SAFE_API_KEY=sk_live_abc123def456... # ENS domain -ENS_DOMAIN=myproject.eth +SEPOLIA_ENS_DOMAIN=myproject.eth # Private key (one of the Safe signers) -OWNER_PRIVATE_KEY=0xabc123def456... +SEPOLIA_OWNER_PK=0xabc123def456... ``` **Getting your private key:** @@ -479,7 +479,7 @@ OWNER_PRIVATE_KEY=0xabc123def456... ```bash echo ".env" >> .gitignore -echo ".autarkrc.json" >> .gitignore +echo "secure-deploy.config.yaml" >> .gitignore ``` **Verify:** @@ -1168,8 +1168,8 @@ jobs: env: SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }} SAFE_ADDRESS: ${{ secrets.SAFE_ADDRESS }} - ENS_DOMAIN: ${{ secrets.ENS_DOMAIN }} - OWNER_PRIVATE_KEY: ${{ secrets.OWNER_PRIVATE_KEY }} + SEPOLIA_ENS_DOMAIN: ${{ secrets.SEPOLIA_ENS_DOMAIN }} + SEPOLIA_OWNER_PK: ${{ secrets.SEPOLIA_OWNER_PK }} SAFE_API_KEY: ${{ secrets.SAFE_API_KEY }} ``` @@ -1205,8 +1205,8 @@ cat delegation.b64 |------|-------|---------| | `SEPOLIA_RPC_URL` | Your RPC endpoint | `https://sepolia.infura.io/v3/abc123...` | | `SAFE_ADDRESS` | Your Safe address | `0xA5ED8dd265c8e9154FaBf8E66Cb3aF16002261A3` | -| `ENS_DOMAIN` | Your ENS domain | `myproject.eth` | -| `OWNER_PRIVATE_KEY` | Private key of Safe signer | `0xabc123...` | +| `SEPOLIA_ENS_DOMAIN` | Your ENS domain | `myproject.eth` | +| `SEPOLIA_OWNER_PK` | Private key of Safe signer | `0xabc123...` | | `SAFE_API_KEY` | Safe API key | `sk_live_abc123...` | | `STORACHA_DELEGATION` | Base64 encoded delegation | (paste contents of delegation.b64) | @@ -1522,7 +1522,7 @@ cat .env | grep SAFE_ADDRESS **2. Wrong network** ```bash # Verify network -cat .autarkrc.json +cat secure-deploy.config.yaml # Should match network in Safe UI (top right) ``` @@ -1567,12 +1567,12 @@ Error: Invalid private key **Solution:** ```bash # Ensure private key has 0x prefix -OWNER_PRIVATE_KEY=0xabc123... # βœ“ Correct -OWNER_PRIVATE_KEY=abc123... # βœ— Wrong +SEPOLIA_OWNER_PK=0xabc123... # βœ“ Correct +SEPOLIA_OWNER_PK=abc123... # βœ— Wrong # Ensure no quotes in .env -OWNER_PRIVATE_KEY=0xabc123... # βœ“ Correct -OWNER_PRIVATE_KEY="0xabc..." # βœ— Wrong (in .env files) +SEPOLIA_OWNER_PK=0xabc123... # βœ“ Correct +SEPOLIA_OWNER_PK="0xabc..." # βœ— Wrong (in .env files) ``` ### Issue: "Git hook not triggering" diff --git a/package.json b/package.json index 7944a16..124b7f7 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,26 @@ { "name": "autark", - "version": "0.1.0", + "version": "0.1.2", "description": "Decentralized frontend deployment with Safe multisig governance and immutable ENS versioning", "type": "module", "bin": { - "autark": "./dist/cli/index.js" + "autark": "dist/cli/index.js" }, "scripts": { "build": "rm -rf dist && tsc && chmod +x ./dist/cli/index.js", "dev": "tsx src/cli/index.ts", "cli": "tsx src/cli/index.ts", - "test": "node --test", + "test": "tsx --test src/test/unit/**/*.test.ts", + "test:smoke": "tsx --test src/test/unit/**/*.test.ts", "test:ipfs": "tsx src/test/test-ipfs-simple.ts", "test:ens": "tsx src/test/test-ens.ts", "test:complete": "tsx src/test/test-complete-versioning.ts", "check:domain": "tsx src/test/check-domain.ts", "burn-parent-fuses": "tsx src/test/burn-parent-fuses.ts", "deploy:safe": "tsx src/test/deploy-with-safe.ts", - "deploy:safe-sdk": "tsx src/test/deploy-with-safe-sdk.ts", "deploy:safe-direct": "tsx src/test/deploy-safe-direct.ts", "transfer-to-safe": "tsx src/test/transfer-to-safe.ts", "set-contenthash": "tsx src/test/set-contenthash-via-safe.ts", - "set-contenthash-sdk": "tsx src/test/set-contenthash-sdk.ts", "typecheck": "tsc --noEmit" }, "keywords": [ @@ -44,12 +43,12 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/MihRazvan/ETHRome_hackathon.git" + "url": "git+https://github.com/MihRazvan/Autark.git" }, "bugs": { - "url": "https://github.com/MihRazvan/ETHRome_hackathon/issues" + "url": "https://github.com/MihRazvan/Autark/issues" }, - "homepage": "https://github.com/MihRazvan/ETHRome_hackathon#readme", + "homepage": "https://github.com/MihRazvan/Autark#readme", "files": [ "dist", "README.md", @@ -62,14 +61,12 @@ "dependencies": { "@safe-global/api-kit": "^4.0.0", "@safe-global/protocol-kit": "^6.1.1", - "@safe-global/sdk-starter-kit": "^3.0.1", - "@storacha/client": "^1.8.2", + "autark": "^0.1.2", "chalk": "^5.6.2", "commander": "^11.1.0", "cosmiconfig": "^9.0.0", "dotenv": "^17.2.3", "ethers": "^6.15.0", - "files-from-path": "^1.0.4", "multiformats": "^13.4.0", "ora": "^6.3.1", "ox": "^0.8.7", diff --git a/src/cli/commands/channels.ts b/src/cli/commands/channels.ts new file mode 100644 index 0000000..32e031e --- /dev/null +++ b/src/cli/commands/channels.ts @@ -0,0 +1,257 @@ +/** + * Channels command - Inspect mutable channel subdomains and their targets + */ + +import { createPublicClient, encodeFunctionData, http, parseAbi, type Address } from 'viem' +import { namehash, normalize } from 'viem/ens' +import { sepolia, mainnet } from 'viem/chains' +import { Logger } from '../../lib/logger.js' +import { loadConfig, type Config } from '../../lib/config.js' +import { PUBLIC_RESOLVER_ADDRESS } from '../../lib/ens/ens.js' +import { detectNextVersion, getSubdomainInfo } from '../../lib/ens/version.js' +import { getContenthash } from '../../lib/ens/contenthash.js' +import { NAME_WRAPPER_ADDRESS } from '../../lib/ens/namewrapper/wrapper.js' +import { FUSES } from '../../lib/ens/namewrapper/fuses.js' +import { initSafeClient, sendSafeTransaction, getSafeTransactionUrl } from '../../lib/safe/client.js' +import { ConfigError } from '../../lib/errors.js' + +const DEFAULT_CHANNELS = ['live', 'staging', 'canary'] as const +const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60 + +const wrapperAbi = parseAbi([ + 'function setSubnodeRecord(bytes32 parentNode, string label, address owner, address resolver, uint64 ttl, uint32 fuses, uint64 expiry) external returns (bytes32)', +]) + +export interface ChannelsOptions extends Partial { + channels?: string + create?: string | boolean + dryRun?: boolean + resolveVersions?: boolean +} + +function parseChannelList(raw?: string): string[] { + const values = raw + ? raw.split(',').map(v => v.trim()).filter(Boolean) + : [...DEFAULT_CHANNELS] + + return [...new Set(values)] +} + +function toFqdn(channel: string, parentDomain: string): string { + if (channel.includes('.')) return channel + return `${channel}.${parentDomain}` +} + +function resolveCreateChannels(options: ChannelsOptions): string[] { + if (typeof options.create === 'string') { + return parseChannelList(options.create) + } + + return parseChannelList(options.channels) +} + +function getDirectLabel(domain: string, parentDomain: string): string { + const suffix = `.${parentDomain}` + if (!domain.endsWith(suffix)) { + throw new ConfigError( + `Channel "${domain}" is outside parent domain "${parentDomain}". ` + + `Use direct children like "live" or "live.${parentDomain}".` + ) + } + + const label = domain.slice(0, -suffix.length) + if (!label || label.includes('.')) { + throw new ConfigError( + `Channel "${domain}" is not a direct child of "${parentDomain}". ` + + `Only one-label channels are supported by --create.` + ) + } + + return label +} + +/** + * List mutable channels and their current content targets + */ +export async function channelsCommand(options: ChannelsOptions): Promise { + const logger = new Logger({ quiet: options.quiet, debug: options.debug }) + + logger.header('AUTARK') + logger.newline() + + try { + const config = loadConfig(options) + + if (!config.ensDomain || !config.rpcUrl) { + logger.error('Missing required config: ensDomain and rpcUrl') + logger.log('Set via --ens-domain and --rpc-url or config file') + process.exit(1) + } + + const chain = config.network === 'mainnet' ? mainnet : sepolia + const publicClient = createPublicClient({ + chain, + transport: http(config.rpcUrl), + }) + const resolverAddress = PUBLIC_RESOLVER_ADDRESS[config.network] as Address + const createMode = options.create !== undefined && options.create !== false + + const channelInputs = createMode + ? resolveCreateChannels(options) + : parseChannelList(options.channels) + const channelDomains = channelInputs.map(ch => toFqdn(ch, config.ensDomain!)) + + logger.section('Configuration') + logger.table({ + 'Network': config.network, + 'Parent Domain': config.ensDomain, + 'Channels': channelDomains.join(', '), + 'Create Missing': createMode, + 'Dry Run': options.dryRun || false, + 'Resolve Versions': options.resolveVersions !== false, + }) + logger.newline() + + let safeClient: any | undefined + if (createMode) { + if (!config.safeAddress || !config.ownerPrivateKey || !config.safeApiKey) { + throw new ConfigError( + 'Missing required configuration for --create: safeAddress, ownerPrivateKey, safeApiKey\n' + + 'Provide via CLI flags, environment variables, or config file.' + ) + } + + const parentInfo = await getSubdomainInfo(config.ensDomain, publicClient, chain.id) + if (!parentInfo.exists) { + throw new ConfigError(`Parent domain is not wrapped or does not exist: ${config.ensDomain}`) + } + if (parentInfo.owner.toLowerCase() !== config.safeAddress.toLowerCase()) { + throw new ConfigError( + `Parent domain owner is not your configured Safe.\n` + + ` Parent owner: ${parentInfo.owner}\n` + + ` Config Safe: ${config.safeAddress}\n` + + `Transfer parent domain ownership to Safe before using --create.` + ) + } + + logger.section('Create Preconditions') + logger.success(`Parent domain is Safe-owned: ${config.ensDomain}`) + logger.log(` Safe: ${config.safeAddress}`) + logger.newline() + + if (!options.dryRun) { + safeClient = await initSafeClient({ + safeAddress: config.safeAddress as Address, + signerPrivateKey: config.ownerPrivateKey as `0x${string}`, + rpcUrl: config.rpcUrl, + apiKey: config.safeApiKey, + }) + } + } + + const versionByContenthash = new Map() + + if (options.resolveVersions !== false) { + logger.section('Version Index') + const { existing } = await detectNextVersion(config.ensDomain, publicClient, chain.id) + + for (const versionLabel of existing) { + const versionDomain = `${versionLabel}.${config.ensDomain}` + const versionContent = await getContenthash(versionDomain, resolverAddress, publicClient) + if (versionContent.type !== 'empty') { + versionByContenthash.set(versionContent.contenthash.toLowerCase(), versionDomain) + } + } + + logger.log(`Indexed versions: ${existing.length}`) + logger.newline() + } + + logger.section('Channels') + + for (const channelDomain of channelDomains) { + logger.log(channelDomain) + + const info = await getSubdomainInfo(channelDomain, publicClient, chain.id) + if (!info.exists) { + logger.warn(' Missing / not wrapped') + + if (createMode) { + const channelLabel = getDirectLabel(channelDomain, config.ensDomain) + const parentNode = namehash(normalize(config.ensDomain)) + const expiry = Math.floor(Date.now() / 1000) + ONE_YEAR_SECONDS + + const mutableChannelFuses = FUSES.CANNOT_UNWRAP | FUSES.CAN_EXTEND_EXPIRY + + const createSubnodeData = encodeFunctionData({ + abi: wrapperAbi, + functionName: 'setSubnodeRecord', + args: [ + parentNode, + channelLabel, + config.safeAddress as Address, + resolverAddress, + BigInt(0), + mutableChannelFuses, + BigInt(expiry), + ], + }) + + if (options.dryRun) { + logger.log(` [dry-run] Would create channel: ${channelDomain}`) + logger.log(` [dry-run] Fuses: ${mutableChannelFuses}`) + logger.log(` [dry-run] Expiry: ${new Date(expiry * 1000).toISOString()}`) + logger.log(` [dry-run] To: ${NAME_WRAPPER_ADDRESS[chain.id] as Address}`) + } else { + const txResult = await sendSafeTransaction(safeClient, { + to: NAME_WRAPPER_ADDRESS[chain.id] as Address, + value: '0', + data: createSubnodeData, + }) + + logger.success(' Safe proposal created for channel creation') + if (txResult.safeTxHash) { + logger.log(` Safe TX Hash: ${txResult.safeTxHash}`) + } + logger.log(` Safe UI: ${getSafeTransactionUrl(config.safeAddress as Address, chain.id)}`) + } + } + + logger.newline() + continue + } + + logger.log(` Owner: ${info.owner}`) + logger.log(` Fuses: ${info.fuses}`) + logger.log(` Expiry: ${new Date(Number(info.expiry) * 1000).toISOString()}`) + + const content = await getContenthash(channelDomain, resolverAddress, publicClient) + if (content.type === 'empty') { + logger.warn(' Contenthash: empty') + logger.newline() + continue + } + + logger.log(` Contenthash: ${content.contenthash}`) + logger.log(` Type: ${content.type.toUpperCase()}`) + if (content.cid) { + logger.log(` CID: ${content.cid}`) + } + + if (options.resolveVersions !== false) { + const matchedVersion = versionByContenthash.get(content.contenthash.toLowerCase()) + if (matchedVersion) { + logger.success(` Points to version: ${matchedVersion}`) + } else { + logger.log(' Points to: custom content (no matching vN found)') + } + } + + logger.newline() + } + } catch (error: any) { + logger.error('Failed to list channels') + logger.error(error.message || 'Unknown error') + process.exit(1) + } +} diff --git a/src/cli/commands/deploy.ts b/src/cli/commands/deploy.ts index 0000fff..c9e2659 100644 --- a/src/cli/commands/deploy.ts +++ b/src/cli/commands/deploy.ts @@ -89,22 +89,15 @@ export async function deployCommand(options: DeployOptions): Promise { logger.log(` URL: ${ipfsResult.url}`) logger.newline() - // Step 4: Initialize clients - logger.section('πŸ”Œ Step 4: Initialize Clients') + // Step 4: Initialize public client + logger.section('πŸ”Œ Step 4: Initialize Public Client') const chain = config.network === 'mainnet' ? mainnet : sepolia const publicClient = createPublicClient({ chain, transport: http(config.rpcUrl), }) logger.success('Public client initialized') - - const safeClient = await initSafeClient({ - safeAddress: config.safeAddress!, - signerPrivateKey: config.ownerPrivateKey!, - rpcUrl: config.rpcUrl!, - apiKey: config.safeApiKey, - }) - logger.success('Safe client initialized') + logger.log('Safe client will be initialized only if required by deployment mode') logger.newline() // Step 5: Detect next version @@ -296,7 +289,11 @@ export async function deployCommand(options: DeployOptions): Promise { chain.id, batchResult, config.ownerPrivateKey!, - logger + logger, + { + rpcUrl: config.rpcUrl, + safeApiKey: config.safeApiKey, + } ) spinner.succeed('Batch transaction submitted to Safe') @@ -356,6 +353,12 @@ export async function deployCommand(options: DeployOptions): Promise { // Transaction 2: Set contenthash (via Safe) logger.log('Next step: Set contenthash via Safe') + const safeClient = await initSafeClient({ + safeAddress: config.safeAddress!, + signerPrivateKey: config.ownerPrivateKey!, + rpcUrl: config.rpcUrl!, + apiKey: config.safeApiKey, + }) const spinner2 = logger.spinner('Proposing contenthash to Safe...') spinner2.start() const result2 = await sendSafeTransaction(safeClient, plan.setContenthashTx) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index cefb53c..81ec688 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -12,10 +12,10 @@ import { Logger } from '../../lib/logger.js' export async function initCommand(): Promise { const logger = new Logger() - logger.header('πŸ”§ Initialize secure-deploy') + logger.header('πŸ”§ Initialize AUTARK') logger.newline() - const configTemplate = `# Secure Deploy Configuration + const configTemplate = `# AUTARK Configuration # You can also use environment variables or CLI flags # Network Configuration @@ -32,7 +32,9 @@ safeApiKey: your-safe-api-key # Owner/Signer Configuration (for Safe operations) ownerPrivateKey: 0x... -# Storacha Configuration (optional, uses CLI by default) +# Storacha Configuration +# Current deploy flow uses your local Storacha CLI login + selected space. +# The fields below are reserved for a future native Storacha integration. # storachaKey: ... # storachaProof: ... @@ -46,16 +48,16 @@ debug: false ` try { - const configPath = resolve(process.cwd(), 'secure-deploy.config.yaml') + const configPath = resolve(process.cwd(), 'autark.config.yaml') writeFileSync(configPath, configTemplate) - logger.success('Created config file: secure-deploy.config.yaml') + logger.success('Created config file: autark.config.yaml') logger.newline() logger.log('Next steps:') logger.log(' 1. Edit the config file with your values') logger.log(' 2. Or use environment variables (see .env.example)') - logger.log(' 3. Deploy with: secure-deploy deploy ./dist') + logger.log(' 3. Deploy with: autark deploy ./dist') logger.newline() } catch (error: any) { diff --git a/src/cli/commands/promote.ts b/src/cli/commands/promote.ts new file mode 100644 index 0000000..3e45938 --- /dev/null +++ b/src/cli/commands/promote.ts @@ -0,0 +1,216 @@ +/** + * Promote command - Point mutable channel domain to an immutable version via Safe + */ + +import { createPublicClient, encodeFunctionData, http, parseAbi, type Address } from 'viem' +import { normalize, namehash } from 'viem/ens' +import { sepolia, mainnet } from 'viem/chains' +import { Logger } from '../../lib/logger.js' +import { loadConfig, type Config } from '../../lib/config.js' +import { PUBLIC_RESOLVER_ADDRESS } from '../../lib/ens/ens.js' +import { getSubdomainInfo } from '../../lib/ens/version.js' +import { getContenthash } from '../../lib/ens/contenthash.js' +import { initSafeClient, sendSafeTransaction, getSafeTransactionUrl } from '../../lib/safe/client.js' +import { ConfigError, DeployError } from '../../lib/errors.js' +import { getIPFSUrls } from '../../lib/ipfs/upload.js' + +const resolverAbi = parseAbi([ + 'function setContenthash(bytes32 node, bytes contenthash)', +]) + +export interface PromoteOptions extends Partial { + to: string + channel?: string + dryRun?: boolean +} + +function resolveDomain(input: string, ensDomain?: string, kind: 'channel' | 'target' = 'target'): string { + if (input.includes('.')) return input + + if (!ensDomain) { + throw new ConfigError( + `Missing ensDomain for ${kind} resolution. ` + + `Provide --ens-domain, config value, or pass fully qualified domain names.` + ) + } + + return `${input}.${ensDomain}` +} + +/** + * Promote immutable version to mutable channel by updating channel contenthash + */ +export async function promoteCommand(options: PromoteOptions): Promise { + const logger = new Logger({ quiet: options.quiet, debug: options.debug }) + + logger.header('AUTARK') + logger.newline() + + try { + const config = loadConfig(options) + + if (!config.safeAddress || !config.ownerPrivateKey || !config.rpcUrl || !config.safeApiKey) { + throw new ConfigError( + 'Missing required configuration: safeAddress, ownerPrivateKey, rpcUrl, safeApiKey\n' + + 'Provide via CLI flags, environment variables, or config file.' + ) + } + + const chain = config.network === 'mainnet' ? mainnet : sepolia + const publicClient = createPublicClient({ + chain, + transport: http(config.rpcUrl), + }) + + const channelDomain = resolveDomain(options.channel || 'live', config.ensDomain, 'channel') + const targetDomain = resolveDomain(options.to, config.ensDomain, 'target') + const resolverAddress = PUBLIC_RESOLVER_ADDRESS[config.network] as Address + + logger.section('πŸ“‹ Promotion Configuration') + logger.table({ + 'Network': config.network, + 'Safe Address': config.safeAddress, + 'Resolver': resolverAddress, + 'Channel': channelDomain, + 'Target': targetDomain, + }) + logger.newline() + + logger.section('πŸ” Step 1: Verify Target') + const targetInfo = await getSubdomainInfo(targetDomain, publicClient, chain.id) + + if (!targetInfo.exists) { + throw new ConfigError(`Target domain does not exist or is not wrapped: ${targetDomain}`) + } + + const targetContenthash = await getContenthash(targetDomain, resolverAddress, publicClient) + + if (targetContenthash.type === 'empty') { + throw new ConfigError(`Target domain has no contenthash set: ${targetDomain}`) + } + + logger.success(`Target exists: ${targetDomain}`) + logger.log(` Owner: ${targetInfo.owner}`) + logger.log(` Contenthash: ${targetContenthash.contenthash}`) + if (targetContenthash.cid) { + logger.log(` CID: ${targetContenthash.cid}`) + } + logger.newline() + + logger.section('πŸ” Step 2: Verify Channel') + const channelInfo = await getSubdomainInfo(channelDomain, publicClient, chain.id) + + if (!channelInfo.exists) { + throw new ConfigError( + `Channel domain does not exist or is not wrapped: ${channelDomain}\n` + + `Create the channel subdomain first and set owner to your Safe: ${config.safeAddress}` + ) + } + + if (channelInfo.owner.toLowerCase() !== config.safeAddress.toLowerCase()) { + throw new ConfigError( + `Channel owner is not your configured Safe.\n` + + ` Channel owner: ${channelInfo.owner}\n` + + ` Config Safe: ${config.safeAddress}\n` + + `Transfer channel ownership to Safe before promoting.` + ) + } + + logger.success(`Channel verified: ${channelDomain}`) + logger.log(` Owner: ${channelInfo.owner}`) + logger.newline() + + logger.section('πŸ” Step 3: Current Channel State') + const currentChannelContenthash = await getContenthash(channelDomain, resolverAddress, publicClient) + if (currentChannelContenthash.type === 'empty') { + logger.warn('Channel currently has no contenthash set') + } else { + logger.log(`Current contenthash: ${currentChannelContenthash.contenthash}`) + if (currentChannelContenthash.cid) { + logger.log(` Current CID: ${currentChannelContenthash.cid}`) + } + } + logger.newline() + + logger.section('πŸ“ Step 4: Build Promotion Transaction') + const channelNode = namehash(normalize(channelDomain)) + + const setContenthashData = encodeFunctionData({ + abi: resolverAbi, + functionName: 'setContenthash', + args: [channelNode, targetContenthash.contenthash], + }) + + logger.success('Promotion transaction encoded') + logger.log(` Function: setContenthash(${channelDomain}, )`) + logger.log(` Data: ${setContenthashData.slice(0, 24)}...`) + logger.newline() + + if (options.dryRun) { + logger.section('πŸ” DRY RUN - Preview') + logger.log('Promotion proposal preview:') + logger.log(` Channel: ${channelDomain}`) + logger.log(` Target: ${targetDomain}`) + logger.log(` New contenthash: ${targetContenthash.contenthash}`) + logger.newline() + logger.log('Run without --dry-run to create the Safe proposal.') + return + } + + logger.section('βœ… Step 5: Create Safe Proposal') + const safeClient = await initSafeClient({ + safeAddress: config.safeAddress as Address, + signerPrivateKey: config.ownerPrivateKey as `0x${string}`, + rpcUrl: config.rpcUrl, + apiKey: config.safeApiKey, + }) + + const spinner = logger.spinner('Submitting promotion transaction to Safe...') + spinner.start() + + const result = await sendSafeTransaction(safeClient, { + to: resolverAddress, + value: '0', + data: setContenthashData, + }) + + spinner.succeed('Promotion proposal submitted') + logger.newline() + + logger.successBanner('PROMOTION PROPOSAL CREATED') + logger.newline() + logger.success(`Channel: ${channelDomain}`) + logger.log(` Target: ${targetDomain}`) + logger.log(` Safe TX Hash: ${result.safeTxHash || 'N/A'}`) + logger.newline() + + logger.warn('⚠️ Action Required: Approve & Execute') + logger.log(` 1. Open Safe UI: ${getSafeTransactionUrl(config.safeAddress as Address, chain.id)}`) + logger.log(` 2. Review transaction: setContenthash(${channelDomain})`) + logger.log(` 3. Approve and execute with threshold signers`) + logger.newline() + + logger.log('After execution:') + if (targetContenthash.cid) { + const urls = getIPFSUrls(targetContenthash.cid, channelDomain) + logger.log(` ${urls[0]} βœ… (works immediately)`) + for (let i = 1; i < urls.length; i++) { + logger.log(` ${urls[i]}`) + } + } else { + logger.log(` ${channelDomain} will point to ${targetDomain}'s contenthash.`) + } + logger.newline() + } catch (error: any) { + logger.error('Promotion failed') + if (error instanceof DeployError) { + logger.error(error.message) + if (error.code) { + logger.log(` Error code: ${error.code}`) + } + } else { + logger.error(error.message || 'Unknown error') + } + process.exit(1) + } +} diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 4218a4c..63db0a0 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -12,6 +12,7 @@ export interface SetupOptions { debug?: boolean force?: boolean branch?: string + buildCommand?: string } const PRE_PUSH_HOOK_TEMPLATE = `#!/bin/bash @@ -23,16 +24,18 @@ branch=$(git symbolic-ref --short HEAD 2>/dev/null) # Check if this is the deployment branch DEPLOY_BRANCH="{{DEPLOY_BRANCH}}" +BUILD_DIR="{{BUILD_DIR}}" +BUILD_COMMAND="{{BUILD_COMMAND}}" if [[ "$branch" == "$DEPLOY_BRANCH" ]]; then echo "" echo "[> ] autark: Auto-deploying $branch branch..." echo "" - # Check if build directory exists - if [ ! -d "{{BUILD_DIR}}" ]; then - echo "[!] Build directory not found. Running build..." - npm run build + # Run build command before deployment + if [ -n "$BUILD_COMMAND" ]; then + echo "[> ] Running build command..." + eval "$BUILD_COMMAND" if [ $? -ne 0 ]; then echo "[-] Build failed. Push cancelled." @@ -40,8 +43,15 @@ if [[ "$branch" == "$DEPLOY_BRANCH" ]]; then fi fi + # Check that build output exists + if [ ! -d "$BUILD_DIR" ]; then + echo "[-] Build directory not found: $BUILD_DIR" + echo " Update hook build command or build output directory." + exit 1 + fi + # Run deployment using autark CLI - autark deploy {{BUILD_DIR}} + autark deploy "$BUILD_DIR" # Check if deployment succeeded if [ $? -ne 0 ]; then @@ -90,6 +100,7 @@ export async function setupCommand(options: SetupOptions): Promise { // Ask for build directory const buildDir = await rl.question('Build output directory? [dist]: ') || 'dist' + const buildCommand = options.buildCommand || await rl.question('Build command before deploy? [npm run build]: ') || 'npm run build' rl.close() @@ -97,6 +108,7 @@ export async function setupCommand(options: SetupOptions): Promise { logger.log('Configuration:') logger.log(` Deployment branch: ${deployBranch}`) logger.log(` Build directory: ${buildDir}`) + logger.log(` Build command: ${buildCommand}`) logger.newline() // Confirm @@ -122,9 +134,15 @@ export async function setupCommand(options: SetupOptions): Promise { } // Generate pre-push hook + const escapedBuildDir = buildDir.replace(/"/g, '\\"') + const escapedBuildCommand = buildCommand + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + const hookContent = PRE_PUSH_HOOK_TEMPLATE .replace(/\{\{DEPLOY_BRANCH\}\}/g, deployBranch) - .replace(/\{\{BUILD_DIR\}\}/g, buildDir) + .replace(/\{\{BUILD_DIR\}\}/g, escapedBuildDir) + .replace(/\{\{BUILD_COMMAND\}\}/g, escapedBuildCommand) const hookPath = join(hooksDir, 'pre-push') @@ -171,7 +189,7 @@ export async function setupCommand(options: SetupOptions): Promise { logger.section('πŸ”§ Managing hooks') logger.log(' View hook: cat .git/hooks/pre-push') logger.log(' Remove hook: rm .git/hooks/pre-push') - logger.log(' Reinstall: npx secure-deploy setup --force') + logger.log(' Reinstall: autark setup --force') logger.newline() } catch (error: any) { diff --git a/src/cli/index.ts b/src/cli/index.ts index 4eac1fd..c57ec3a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,12 +9,14 @@ import { config } from 'dotenv' import { Command } from 'commander' // Load .env file -config() +config({ quiet: true }) import { deployCommand } from './commands/deploy.js' import { statusCommand } from './commands/status.js' import { initCommand } from './commands/init.js' import { setupCommand } from './commands/setup.js' -import { readFileSync } from 'fs' +import { promoteCommand } from './commands/promote.js' +import { channelsCommand } from './commands/channels.js' +import { readFileSync, realpathSync } from 'fs' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' @@ -23,65 +25,145 @@ const packageJson = JSON.parse( readFileSync(resolve(__dirname, '../../package.json'), 'utf-8') ) -const program = new Command() +export interface CliHandlers { + deploy: typeof deployCommand + status: typeof statusCommand + init: typeof initCommand + setup: typeof setupCommand + promote: typeof promoteCommand + channels: typeof channelsCommand +} -program - .name('autark') - .description('Deploy frontends with Safe multisig + immutable ENS versioning') - .version(packageJson.version) +export function createProgram(handlers: CliHandlers = { + deploy: deployCommand, + status: statusCommand, + init: initCommand, + setup: setupCommand, + promote: promoteCommand, + channels: channelsCommand, +}): Command { + const program = new Command() -// Deploy command -program - .command('deploy') - .description('Deploy a directory to IPFS and ENS via Safe') - .argument('', 'Directory to deploy') - .option('--ens-domain ', 'ENS parent domain') - .option('--safe-address
', 'Safe multisig address') - .option('--owner-private-key ', 'Owner private key for Safe signing') - .option('--rpc-url ', 'RPC URL') - .option('--safe-api-key ', 'Safe API key') - .option('--network ', 'Network (mainnet, sepolia, goerli)', 'sepolia') - .option('--skip-git-check', 'Skip git working directory check') - .option('--dry-run', 'Preview deployment without executing') - .option('--quiet', 'Minimal output') - .option('--debug', 'Debug output') - .action(async (directory, options) => { - await deployCommand({ directory, ...options }) - }) + program + .name('autark') + .description('Deploy frontends with Safe multisig + immutable ENS versioning') + .version(packageJson.version) -// Status command -program - .command('status') - .description('Check deployment status') - .option('--subdomain ', 'Check specific subdomain') - .option('--ens-domain ', 'ENS parent domain') - .option('--rpc-url ', 'RPC URL') - .option('--network ', 'Network (mainnet, sepolia, goerli)', 'sepolia') - .option('--quiet', 'Minimal output') - .option('--debug', 'Debug output') - .action(async (options) => { - await statusCommand(options) - }) + // Deploy command + program + .command('deploy') + .description('Deploy a directory to IPFS and ENS via Safe') + .argument('', 'Directory to deploy') + .option('--ens-domain ', 'ENS parent domain') + .option('--safe-address
', 'Safe multisig address') + .option('--owner-private-key ', 'Owner private key for Safe signing') + .option('--rpc-url ', 'RPC URL') + .option('--safe-api-key ', 'Safe API key') + .option('--network ', 'Network (mainnet, sepolia, goerli)', 'sepolia') + .option('--skip-git-check', 'Skip git working directory check') + .option('--dry-run', 'Preview deployment without executing') + .option('--quiet', 'Minimal output') + .option('--debug', 'Debug output') + .action(async (directory, options) => { + await handlers.deploy({ directory, ...options }) + }) -// Init command -program - .command('init') - .description('Initialize configuration file') - .action(async () => { - await initCommand() - }) + // Status command + program + .command('status') + .description('Check deployment status') + .option('--subdomain ', 'Check specific subdomain') + .option('--ens-domain ', 'ENS parent domain') + .option('--rpc-url ', 'RPC URL') + .option('--network ', 'Network (mainnet, sepolia, goerli)', 'sepolia') + .option('--quiet', 'Minimal output') + .option('--debug', 'Debug output') + .action(async (options) => { + await handlers.status(options) + }) -// Setup command -program - .command('setup') - .description('Setup git hooks for automatic deployment') - .option('--branch ', 'Branch to trigger deployments (default: staging)') - .option('--force', 'Overwrite existing hooks') - .option('--quiet', 'Minimal output') - .option('--debug', 'Debug output') - .action(async (options) => { - await setupCommand(options) - }) + // Init command + program + .command('init') + .description('Initialize configuration file') + .action(async () => { + await handlers.init() + }) -// Parse CLI arguments -program.parse() + // Setup command + program + .command('setup') + .description('Setup git hooks for automatic deployment') + .option('--branch ', 'Branch to trigger deployments (default: staging)') + .option('--build-command ', 'Command to build deployment output before deploy (default: npm run build)') + .option('--force', 'Overwrite existing hooks') + .option('--quiet', 'Minimal output') + .option('--debug', 'Debug output') + .action(async (options) => { + await handlers.setup(options) + }) + + // Promote command + program + .command('promote') + .description('Promote immutable version to mutable channel via Safe') + .requiredOption('--to ', 'Target version label or full domain (e.g. v2 or v2.app.eth)') + .option('--channel ', 'Channel label or full domain to update (default: live)', 'live') + .option('--ens-domain ', 'ENS parent domain (needed for non-FQDN channel/target)') + .option('--safe-address
', 'Safe multisig address') + .option('--owner-private-key ', 'Owner private key for Safe signing') + .option('--rpc-url ', 'RPC URL') + .option('--safe-api-key ', 'Safe API key') + .option('--network ', 'Network (mainnet, sepolia, goerli)', 'sepolia') + .option('--dry-run', 'Preview promotion without creating Safe proposal') + .option('--quiet', 'Minimal output') + .option('--debug', 'Debug output') + .action(async (options) => { + await handlers.promote(options) + }) + + // Rollback command (alias for promote) + program + .command('rollback') + .description('Rollback a mutable channel to a previous immutable version (alias for promote)') + .requiredOption('--to ', 'Target version label or full domain (e.g. v1 or v1.app.eth)') + .option('--channel ', 'Channel label or full domain to update (default: live)', 'live') + .option('--ens-domain ', 'ENS parent domain (needed for non-FQDN channel/target)') + .option('--safe-address
', 'Safe multisig address') + .option('--owner-private-key ', 'Owner private key for Safe signing') + .option('--rpc-url ', 'RPC URL') + .option('--safe-api-key ', 'Safe API key') + .option('--network ', 'Network (mainnet, sepolia, goerli)', 'sepolia') + .option('--dry-run', 'Preview rollback without creating Safe proposal') + .option('--quiet', 'Minimal output') + .option('--debug', 'Debug output') + .action(async (options) => { + await handlers.promote(options) + }) + + // Channels command + program + .command('channels') + .description('List mutable channel subdomains and their current targets') + .option('--channels ', 'Comma-separated channel labels/domains (default: live,staging,canary)') + .option('--create [list]', 'Create missing channels via Safe proposal (optional comma-separated list)') + .option('--ens-domain ', 'ENS parent domain') + .option('--safe-address
', 'Safe multisig address (required with --create)') + .option('--owner-private-key ', 'Owner private key for Safe signing (required with --create)') + .option('--safe-api-key ', 'Safe API key (required with --create)') + .option('--rpc-url ', 'RPC URL') + .option('--network ', 'Network (mainnet, sepolia, goerli)', 'sepolia') + .option('--dry-run', 'Preview channel creation without creating Safe proposals') + .option('--no-resolve-versions', 'Skip mapping channel contenthash to vN domains') + .option('--quiet', 'Minimal output') + .option('--debug', 'Debug output') + .action(async (options) => { + await handlers.channels(options) + }) + + return program +} + +if (process.argv[1] && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)) { + createProgram().parse() +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 9b4912b..79d93aa 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -55,11 +55,24 @@ export interface DeployConfig extends Config { */ export function loadConfig(cliOptions: Partial = {}): Config { // Load from config file (lowest priority) - const explorer = cosmiconfigSync('secure-deploy', { + const explorer = cosmiconfigSync('autark', { searchPlaces: [ + 'autark.config.yaml', + 'autark.config.yml', + 'autark.config.js', + 'autark.config.json', + '.autarkrc', + '.autarkrc.yaml', + '.autarkrc.yml', + '.autarkrc.json', + // Legacy names (backward compatibility) + 'secure-deploy.config.yaml', + 'secure-deploy.config.yml', 'secure-deploy.config.js', 'secure-deploy.config.json', '.secure-deployrc', + '.secure-deployrc.yaml', + '.secure-deployrc.yml', '.secure-deployrc.json', 'package.json', ], diff --git a/src/lib/ens/safe-batch-deploy.ts b/src/lib/ens/safe-batch-deploy.ts index 6d29c97..77dcdf2 100644 --- a/src/lib/ens/safe-batch-deploy.ts +++ b/src/lib/ens/safe-batch-deploy.ts @@ -5,6 +5,7 @@ import { encodeFunctionData, parseAbi, type Address, type Hex } from 'viem' import { normalize, namehash } from 'viem/ens' import { NAME_WRAPPER_ADDRESS } from './namewrapper/wrapper.js' +import { FUSES } from './namewrapper/fuses.js' import { PUBLIC_RESOLVER_ADDRESS } from './ens.js' import { encodeContenthash } from './contenthash-encode.js' import { ENSError } from '../errors.js' @@ -61,7 +62,10 @@ export async function encodeSafeBatchDeploy( const resolverAddress = PUBLIC_RESOLVER_ADDRESS[chainId === 11155111 ? 'sepolia' : 'mainnet'] as Address // Fuses: CANNOT_UNWRAP | CANNOT_SET_RESOLVER | PARENT_CANNOT_CONTROL - const fuses = 0x0001 | 0x0080 | 0x10000 + const fuses = + FUSES.CANNOT_UNWRAP | + FUSES.CANNOT_SET_RESOLVER | + FUSES.PARENT_CANNOT_CONTROL const createSubdomainData = encodeFunctionData({ abi: wrapperAbi, @@ -112,7 +116,11 @@ export async function submitToSafeService( chainId: number, batchResult: SafeBatchResult, signerPrivateKey: `0x${string}`, - logger: Logger + logger: Logger, + options?: { + rpcUrl?: string + safeApiKey?: string + } ): Promise { const spinner = logger.spinner('Submitting batch to Safe Transaction Service...') spinner.start() @@ -124,9 +132,11 @@ export async function submitToSafeService( const { ethers } = await import('ethers') // Setup RPC URL - const rpcUrl = chainId === 11155111 - ? process.env.SEPOLIA_RPC_URL - : process.env.MAINNET_RPC_URL + const rpcUrl = + options?.rpcUrl || + (chainId === 11155111 + ? process.env.SEPOLIA_RPC_URL + : process.env.MAINNET_RPC_URL) if (!rpcUrl) { throw new Error('RPC URL not configured') @@ -137,7 +147,7 @@ export async function submitToSafeService( const signer = new ethers.Wallet(signerPrivateKey, provider) // Create Safe API Kit instance - const safeApiKey = process.env.SAFE_API_KEY + const safeApiKey = options?.safeApiKey || process.env.SAFE_API_KEY if (!safeApiKey) { throw new Error('SAFE_API_KEY not configured. Get one at https://developer.safe.global') } diff --git a/src/lib/ipfs/upload.ts b/src/lib/ipfs/upload.ts index 6664379..8c086af 100644 --- a/src/lib/ipfs/upload.ts +++ b/src/lib/ipfs/upload.ts @@ -13,6 +13,71 @@ export interface IPFSUploadResult { url: string } +type ExecLikeError = Error & { + stdout?: string | Buffer + stderr?: string | Buffer +} + +const CID_PATTERN = /(bafy[a-z0-9]{20,}|Qm[A-Za-z0-9]{44})/i + +export function extractStorachaCid(output: string): string | null { + const match = output.match(CID_PATTERN) + return match?.[1] ?? null +} + +function getErrorText(error: unknown): string { + const execError = error as ExecLikeError + const parts = [ + execError?.message, + typeof execError?.stdout === 'string' ? execError.stdout : execError?.stdout?.toString(), + typeof execError?.stderr === 'string' ? execError.stderr : execError?.stderr?.toString(), + ].filter(Boolean) + + return parts.join('\n') +} + +export function formatStorachaUploadError(error: unknown): string { + const text = getErrorText(error) + const normalized = text.toLowerCase() + + if ( + normalized.includes('missing current space') || + normalized.includes('setcurrentspace') || + normalized.includes('createSpace()'.toLowerCase()) || + normalized.includes('no space') + ) { + return [ + 'Storacha CLI is authenticated, but no current space is selected.', + 'Run:', + ' storacha space ls', + ' storacha space use ', + 'Or create one with:', + ' storacha space create autark', + ].join('\n') + } + + if ( + normalized.includes('not logged in') || + normalized.includes('unauthorized') || + normalized.includes('authentication') || + normalized.includes('login') + ) { + return [ + 'Storacha CLI is not authenticated.', + 'Run:', + ' storacha login ', + ' storacha space use ', + ].join('\n') + } + + const firstLine = text.split('\n').map(line => line.trim()).find(Boolean) + if (firstLine) { + return `Upload failed: ${firstLine}` + } + + return 'Upload failed: Storacha CLI returned an unknown error' +} + /** * Upload directory to IPFS via Storacha CLI */ @@ -34,7 +99,7 @@ export async function uploadToIPFS( } catch { spinner.fail() throw new IPFSError( - 'Storacha CLI not found. Install with: npm install -g @storacha/client' + 'Storacha CLI not found. Install with: npm install -g @storacha/cli' ) } @@ -45,14 +110,15 @@ export async function uploadToIPFS( }) // Extract CID from output - const match = output.match(/bafy[a-z0-9]+/i) - if (!match) { + const cid = extractStorachaCid(output) + if (!cid) { spinner.fail() - throw new IPFSError('Failed to extract CID from Storacha output') + throw new IPFSError( + 'Upload finished, but no CID could be parsed from Storacha CLI output.\n' + + 'Run `storacha up ` manually to inspect the current CLI output.' + ) } - const cid = match[0] - // Try to get size (best effort) let size = 0 try { @@ -78,7 +144,7 @@ export async function uploadToIPFS( if (error instanceof IPFSError) { throw error } - throw new IPFSError(`Upload failed: ${error.message}`) + throw new IPFSError(formatStorachaUploadError(error)) } } diff --git a/src/lib/safe/client.ts b/src/lib/safe/client.ts index 76805d5..68a20ca 100644 --- a/src/lib/safe/client.ts +++ b/src/lib/safe/client.ts @@ -2,7 +2,7 @@ * Safe multisig client wrapper */ -import { createSafeClient } from '@safe-global/sdk-starter-kit' +import { Wallet } from 'ethers' import type { Address, Hex } from 'viem' import { SafeError } from '../errors.js' @@ -25,10 +25,18 @@ export interface SafeTransactionResult { success: boolean } +export interface SafeClientInstance { + protocolKit: any + apiKit: any + safeAddress: Address + signerAddress: Address + threshold: number +} + /** * Create and initialize Safe client */ -export async function initSafeClient(config: SafeClientConfig) { +export async function initSafeClient(config: SafeClientConfig): Promise { if (!config.apiKey) { throw new SafeError( 'Safe API key is required. Get one at: https://developer.safe.global' @@ -36,37 +44,70 @@ export async function initSafeClient(config: SafeClientConfig) { } try { - const client = await createSafeClient({ + const { default: Safe } = await import('@safe-global/protocol-kit') + const { default: SafeApiKit } = await import('@safe-global/api-kit') + + const protocolKit = await Safe.init({ provider: config.rpcUrl, signer: config.signerPrivateKey, safeAddress: config.safeAddress, + }) + + const apiKit = new SafeApiKit({ apiKey: config.apiKey, + chainId: await protocolKit.getChainId(), }) - return client + return { + protocolKit, + apiKit, + safeAddress: config.safeAddress, + signerAddress: new Wallet(config.signerPrivateKey).address as Address, + threshold: await protocolKit.getThreshold(), + } } catch (error: any) { throw new SafeError(`Failed to initialize Safe client: ${error.message}`) } } /** - * Send transaction through Safe - * For threshold=1, this will execute immediately - * For threshold>1, this will propose and require approvals in UI + * Send transaction through Safe. + * For threshold=1, this executes immediately. + * For threshold>1, this creates a proposal in the Safe Transaction Service. */ export async function sendSafeTransaction( - client: any, + client: SafeClientInstance, transaction: SafeTransaction ): Promise { try { - // Use send() which handles both immediate execution (threshold=1) - // and creating proposals (threshold>1) - const result = await client.send({ + const safeTransaction = await client.protocolKit.createTransaction({ transactions: [transaction], }) + const safeTxHash = await client.protocolKit.getTransactionHash(safeTransaction) + + if (client.threshold <= 1) { + const signedTransaction = await client.protocolKit.signTransaction(safeTransaction) + await client.protocolKit.executeTransaction(signedTransaction) + + return { + safeTxHash, + success: true, + } + } + + const signature = await client.protocolKit.signHash(safeTxHash) + + await client.apiKit.proposeTransaction({ + safeAddress: client.safeAddress, + safeTransactionData: safeTransaction.data, + safeTxHash, + senderAddress: client.signerAddress, + senderSignature: signature.data, + }) + return { - safeTxHash: result?.transactions?.safeTxHash || result?.safeTxHash, + safeTxHash, success: true, } } catch (error: any) { diff --git a/src/providers/ipfs.ts b/src/providers/ipfs.ts deleted file mode 100644 index 8e42ad6..0000000 --- a/src/providers/ipfs.ts +++ /dev/null @@ -1,53 +0,0 @@ -// IPFS Upload provider - Storacha (w3up) -// Adapted from Blumen: https://github.com/StauroDEV/blumen - -import type { CID } from 'multiformats/cid' -import { setup } from './storacha/setup.js' -import { uploadCAR } from './storacha/upload-car.js' - -export type UploadOptions = { - token: string - proof: string -} - -export async function uploadToIPFS( - car: Blob, - options: UploadOptions -): Promise { - const { token, proof } = options - - if (!token) { - throw new Error('STORACHA_TOKEN environment variable is required') - } - if (!proof) { - throw new Error('STORACHA_PROOF environment variable is required') - } - - // Setup the agent and space - const { agent, space } = await setup({ pk: token, proof }) - - if (!space) { - throw new Error('No Storacha space found') - } - - const abilities = ['space/blob/add', 'space/index/add', 'upload/add'] as const - - try { - const cid = await uploadCAR( - { - issuer: agent.issuer, - proofs: agent.proofs( - abilities.map((can) => ({ can, with: space.did() })), - ), - with: space.did(), - }, - car, - ) - - return cid - } catch (e) { - throw new Error(`Failed to upload to IPFS via Storacha: ${(e as Error).message}`, { - cause: e, - }) - } -} diff --git a/src/providers/storacha/actions/blob-add.ts b/src/providers/storacha/actions/blob-add.ts deleted file mode 100644 index e052d9b..0000000 --- a/src/providers/storacha/actions/blob-add.ts +++ /dev/null @@ -1,260 +0,0 @@ -import * as BlobCapabilities from '@storacha/capabilities/blob' -import * as HTTPCapabilities from '@storacha/capabilities/http' -import * as SpaceBlobCapabilities from '@storacha/capabilities/space/blob' -import type { - BlobAccept, - BlobAcceptFailure, - BlobAcceptSuccess, -} from '@storacha/capabilities/types' -import * as UCAN from '@storacha/capabilities/ucan' -import { SpaceDID } from '@storacha/capabilities/utils' -import * as W3sBlobCapabilities from '@storacha/capabilities/web3.storage/blob' -import { Delegation, Receipt } from '@ucanto/core' -import type * as Interface from '@ucanto/interface' -import type { Invocation } from '@ucanto/interface' -import { ed25519 } from '@ucanto/principal' -import type { MultihashDigest } from 'multiformats' -import { retry } from '../../retry.js' -import { connection } from '../agent.js' -import { REQUEST_RETRIES, uploadServicePrincipal } from '../constants.js' -import { poll } from '../receipts.js' -import type * as API from '../types.js' - -function parseBlobAddReceiptNext< - Ok extends object, - Err extends object, - Ran extends Interface.Invocation>, - Alg extends Interface.SigAlg, ->(receipt: Interface.Receipt) { - const forkInvocations = receipt.fx.fork as Invocation[] - const allocateTask = - forkInvocations.find( - (fork) => fork.capabilities[0].can === BlobCapabilities.allocate.can, - ) ?? - forkInvocations.find( - (fork) => fork.capabilities[0].can === W3sBlobCapabilities.allocate.can, - ) - const concludefxs = forkInvocations.filter( - (fork) => fork.capabilities[0].can === UCAN.conclude.can, - ) - const putTask = forkInvocations.find( - (fork) => fork.capabilities[0].can === HTTPCapabilities.put.can, - ) - - const acceptTask = (forkInvocations.find( - (fork) => fork.capabilities[0].can === BlobCapabilities.accept.can, - ) ?? - forkInvocations.find( - (fork) => fork.capabilities[0].can === W3sBlobCapabilities.accept.can, - )) as Interface.Invocation | undefined - - if (!allocateTask || !concludefxs.length || !putTask || !acceptTask) { - throw new Error('mandatory effects not received') - } - - // Decode receipts available - const nextReceipts = concludefxs.map((fx) => { - const receiptBlocks = new Map() - for (const block of fx.iterateIPLDBlocks()) { - receiptBlocks.set(`${block.cid}`, block) - } - return Receipt.view({ - // @ts-expect-error object of type unknown - root: fx.capabilities[0].nb.receipt, - blocks: receiptBlocks, - }) - }) as Interface.Receipt[] - - const allocateReceipt = nextReceipts.find((receipt) => - receipt.ran.link().equals(allocateTask.cid), - ) - const putReceipt = nextReceipts.find((receipt) => - receipt.ran.link().equals(putTask.cid), - ) - - const acceptReceipt = nextReceipts.find((receipt) => - receipt.ran.link().equals(acceptTask.cid), - ) as Interface.Receipt | undefined - - if (!allocateReceipt) { - throw new Error('mandatory effects not received') - } - - return { - allocate: { - task: allocateTask, - receipt: allocateReceipt, - }, - put: { - task: putTask, - receipt: putReceipt, - }, - accept: { - task: acceptTask, - receipt: acceptReceipt, - }, - } -} - -/** - * Store a blob to the service. The issuer needs the `blob/add` - * delegated capability. - * - * Required delegated capability proofs: `blob/add` - */ -export async function add( - { issuer, with: resource, proofs }: API.InvocationConfig, - digest: MultihashDigest, - bytes: Uint8Array, -) { - const size = bytes.length - - const result = await retry( - async () => { - return await SpaceBlobCapabilities.add - .invoke({ - issuer, - audience: uploadServicePrincipal, - with: SpaceDID.from(resource), - nb: input(digest, size), - proofs, - }) - .execute(connection) - }, - { - onFailedAttempt: console.warn, - retries: REQUEST_RETRIES, - }, - ) - - if (!result.out.ok) { - throw new Error(`failed ${SpaceBlobCapabilities.add.can} invocation`, { - cause: result.out.error, - }) - } - - const nextTasks = parseBlobAddReceiptNext(result) - - const { receipt: allocateReceipt } = nextTasks.allocate - - if (!allocateReceipt.out.ok) { - throw new Error(`failed ${SpaceBlobCapabilities.add.can} invocation`, { - cause: allocateReceipt.out.error, - }) - } - - const { address } = allocateReceipt.out.ok as unknown as { address: Request } - if (address) { - await retry( - async () => { - const res = await fetch(address.url, { - method: 'PUT', - mode: 'cors', - body: bytes as BodyInit, - headers: address.headers, - // @ts-expect-error - this is needed by recent versions of node - see https://github.com/bluesky-social/atproto/pull/470 for more info - duplex: 'half', - }) - // do not retry client errors - if (res.status >= 400 && res.status < 500) { - throw new DOMException(`upload failed: ${res.status}`, 'AbortError') - } - if (!res.ok) { - throw new Error(`upload failed: ${res.status}`) - } - await res.arrayBuffer() - }, - { - retries: REQUEST_RETRIES, - }, - ) - } - - // Invoke `conclude` with `http/put` receipt - const { receipt: httpPutReceipt } = nextTasks.put - if (!httpPutReceipt?.out.ok) { - const derivedSigner = ed25519.from( - nextTasks.put.task.facts[0].keys as Interface.SignerArchive< - Interface.DID, - typeof ed25519.signatureCode - >, - ) - const newHttpPutReceipt = await Receipt.issue({ - issuer: derivedSigner, - ran: nextTasks.put.task.cid, - result: { ok: {} }, - }) - const receiptBlocks: Interface.Transport.Block[] = [] - const receiptCids: Interface.Link[] = [] - for (const block of newHttpPutReceipt.iterateIPLDBlocks()) { - receiptBlocks.push(block) - receiptCids.push(block.cid) - } - const httpPutConcludeInvocation = UCAN.conclude.invoke({ - issuer, - audience: uploadServicePrincipal, - with: issuer.toDIDKey(), - nb: { - receipt: newHttpPutReceipt.link(), - }, - expiration: Infinity, - facts: [ - { - ...receiptCids, - }, - ], - }) - for (const block of receiptBlocks) httpPutConcludeInvocation.attach(block) - - const ucanConclude = await httpPutConcludeInvocation.execute(connection) - if (!ucanConclude.out.ok) { - throw new Error( - `failed ${UCAN.conclude.can} for ${HTTPCapabilities.put.can} invocation`, - { - cause: ucanConclude.out.error, - }, - ) - } - } - - // Ensure the blob has been accepted - let { receipt: acceptReceipt } = nextTasks.accept - - if (!acceptReceipt || !acceptReceipt.out.ok) { - acceptReceipt = (await poll( - nextTasks.accept.task.link(), - )) as unknown as Interface.Receipt - if (acceptReceipt.out.error) { - throw new Error(`${BlobCapabilities.accept.can} failure`, { - cause: acceptReceipt.out.error, - }) - } - } - - const blocks = new Map( - [...acceptReceipt.iterateIPLDBlocks()].map((block) => [ - `${block.cid}`, - block, - ]), - ) - - const site = Delegation.view({ - root: acceptReceipt.out.ok?.site as Interface.UCANLink, - blocks, - }) - - return { site } -} - -/** Returns the ability used by an invocation. */ -export const ability = SpaceBlobCapabilities.add.can - -/** - * Returns required input to the invocation. - */ -export const input = (digest: MultihashDigest, size: number) => ({ - blob: { - digest: digest.bytes, - size, - }, -}) diff --git a/src/providers/storacha/actions/car.ts b/src/providers/storacha/actions/car.ts deleted file mode 100644 index 5628da4..0000000 --- a/src/providers/storacha/actions/car.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { CarBlockIterator } from '@ipld/car' -import type * as CarDecoder from '@ipld/car/decoder' -import { CarWriter } from '@ipld/car/writer' -import * as dagCBOR from '@ipld/dag-cbor' -import type { CID } from 'multiformats/cid' -import varint from 'varint' -import type { AnyLink } from '../types.js' - -export async function decode(car: Blob) { - const iterator = await CarBlockIterator.fromIterable(car.stream()) - const blocks: CarDecoder.Block[] = [] - for await (const block of iterator) { - blocks.push(block) - } - const roots = (await iterator.getRoots()) as unknown as AnyLink[] - return { blocks, roots } -} - -export const code = 0x0202 -/** Byte length of a CBOR encoded CAR header with zero roots. */ -const NO_ROOTS_HEADER_LENGTH = 18 - -export function headerEncodingLength(root?: AnyLink) { - if (!root) return NO_ROOTS_HEADER_LENGTH - const headerLength = dagCBOR.encode({ version: 1, roots: [root] }).length - const varintLength = varint.encodingLength(headerLength) - return varintLength + headerLength -} - -export function blockHeaderEncodingLength(block: CarDecoder.Block) { - const payloadLength = block.cid.bytes.length + block.bytes.length - const varintLength = varint.encodingLength(payloadLength) - return varintLength + block.cid.bytes.length -} - -export async function encode( - blocks: Iterable | AsyncIterable, - root?: AnyLink, -) { - const { writer, out } = CarWriter.create(root as CID) - let error: Error | undefined - void (async () => { - try { - for await (const block of blocks) { - await writer.put(block) - } - } catch (err) { - error = err as Error - } finally { - await writer.close() - } - })() - const chunks: BlobPart[] = [] - for await (const chunk of out) chunks.push(chunk as BlobPart) - if (error != null) throw error - const roots = root != null ? [root] : [] - return Object.assign(new Blob(chunks), { version: 1, roots }) -} diff --git a/src/providers/storacha/actions/index-add.ts b/src/providers/storacha/actions/index-add.ts deleted file mode 100644 index 3246b9d..0000000 --- a/src/providers/storacha/actions/index-add.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as IndexCapabilities from '@storacha/capabilities/space/index' -import type { CARLink } from '@storacha/capabilities/types' -import { SpaceDID } from '@storacha/capabilities/utils' -import { retry } from '../../retry.js' -import { connection } from '../agent.js' -import { uploadServicePrincipal } from '../constants.js' -import type { InvocationConfig } from '../types.js' -/** - * Register an "index" with the service. The issuer needs the `index/add` - * delegated capability. - * - * Required delegated capability proofs: `index/add` - */ -export async function add( - { issuer, with: resource, proofs }: InvocationConfig, - index: CARLink, -) { - const result = await retry( - async () => { - return await IndexCapabilities.add - .invoke({ - issuer, - audience: uploadServicePrincipal, - with: SpaceDID.from(resource), - nb: { index }, - proofs, - }) - .execute(connection) - }, - { - onFailedAttempt: console.warn, - retries: 3, - }, - ) - if (!result.out.ok) { - throw new Error(`failed ${IndexCapabilities.add.can} invocation`, { - cause: result.out.error, - }) - } - return result.out.ok -} -/** Returns the ability used by an invocation. */ -export const ability = IndexCapabilities.add.can diff --git a/src/providers/storacha/actions/upload-add.ts b/src/providers/storacha/actions/upload-add.ts deleted file mode 100644 index 2275c69..0000000 --- a/src/providers/storacha/actions/upload-add.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { UploadAddSuccess } from '@storacha/capabilities/types' -import * as UploadCapabilities from '@storacha/capabilities/upload' -import { SpaceDID } from '@storacha/capabilities/utils' -import type { UnknownLink } from 'multiformats/link' -import { retry } from '../../retry.js' -import { connection } from '../agent.js' -import { uploadServicePrincipal } from '../constants.js' -import type { CARLink, InvocationConfig } from '../types.js' -/** - * Register an "upload" with the service. The issuer needs the `upload/add` - * delegated capability. - * - * Required delegated capability proofs: `upload/add` - */ -export async function add( - { issuer, with: resource, proofs }: InvocationConfig, - root: UnknownLink, - shards: CARLink[], -): Promise { - const result = await retry( - async () => { - return await UploadCapabilities.add - .invoke({ - issuer, - audience: uploadServicePrincipal, - with: SpaceDID.from(resource), - nb: { root, shards }, - proofs, - }) - .execute(connection) - }, - { - onFailedAttempt: console.warn, - retries: 3, - }, - ) - if (!result.out.ok) { - throw new Error(`failed ${UploadCapabilities.add.can} invocation`, { - cause: result.out.error, - }) - } - return result.out.ok -} -/** Returns the ability used by an invocation. */ -export const ability = UploadCapabilities.add.can diff --git a/src/providers/storacha/agent-data.ts b/src/providers/storacha/agent-data.ts deleted file mode 100644 index 7bcf6fe..0000000 --- a/src/providers/storacha/agent-data.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type * as Ucanto from '@ucanto/interface' -import type { Delegation } from '@ucanto/interface' -import { StoreMemory } from './memory-store.js' -import type { AgentMeta, DelegationMeta, SpaceMeta } from './types.js' - -/** - * Data schema used internally by the agent. - */ -interface AgentDataModel { - meta: AgentMeta - principal: Ucanto.Signer> - - delegations: Map< - string, - { meta: DelegationMeta; delegation: Ucanto.Delegation } - > -} - -/** - * Agent data that is safe to pass to structuredClone() and persisted by stores. - */ -type AgentDataExport = Pick & { - principal: Ucanto.SignerArchive - delegations: Map< - string, - { - meta: DelegationMeta - delegation: Array<{ cid: string; bytes: ArrayBuffer }> - } - > -} - -export class AgentData implements AgentDataModel { - #save: (data: AgentDataExport) => Promise | void - principal: Ucanto.Signer<`did:key:${string}`, Ucanto.SigAlg> - delegations: Map< - string, - { meta: DelegationMeta; delegation: Ucanto.Delegation } - > - meta: AgentMeta - spaces: Map<`did:${string}:${string}`, SpaceMeta> = new Map() - - constructor( - data: AgentDataModel, - { store }: { store?: StoreMemory } = {}, - ) { - this.meta = data.meta - this.principal = data.principal - this.delegations = data.delegations - this.#save = (data) => (store ? store.save(data) : undefined) - } - - /** - * Create a new AgentData instance from the passed initialization data. - */ - static async create( - init: Pick & - Partial>, - ) { - const store = new StoreMemory() - const agentData = new AgentData( - { - meta: { name: 'agent', type: 'device', ...init.meta }, - principal: init.principal, - delegations: new Map(), - }, - { store }, - ) - - await store.save(agentData.export()) - - return agentData - } - - /** - * Export data in a format safe to pass to `structuredClone()`. - */ - export() { - const raw: AgentDataExport = { - meta: this.meta, - principal: this.principal.toArchive(), - delegations: new Map(), - } - for (const [key, value] of this.delegations) { - raw.delegations.set(key, { - meta: value.meta, - delegation: [...value.delegation.export()].map((b) => ({ - cid: b.cid.toString(), - bytes: b.bytes.buffer.slice( - b.bytes.byteOffset, - b.bytes.byteOffset + b.bytes.byteLength, - ) as ArrayBuffer, - })), - }) - } - return raw - } - - async addDelegation(delegation: Delegation, meta: DelegationMeta = {}) { - this.delegations.set(delegation.cid.toString(), { - delegation, - meta: meta, - }) - await this.#save(this.export()) - } -} diff --git a/src/providers/storacha/agent.ts b/src/providers/storacha/agent.ts deleted file mode 100644 index 37dc49a..0000000 --- a/src/providers/storacha/agent.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - type Ability, - type Capability, - type ConnectionView, - connect, - type Delegation, -} from '@ucanto/client' -import type { API } from '@ucanto/core' -import { outbound as CAR_outbound } from '@ucanto/transport/car' -import * as HTTP from '@ucanto/transport/http' -import type { AgentData } from './agent-data.js' -import { uploadServicePrincipal, uploadServiceURL } from './constants.js' -import { canDelegateCapability, isExpired, isTooEarly } from './delegations.js' -import { fromDelegation } from './space.js' -import type { DelegationMeta, ResourceQuery, Service } from './types.js' - -interface CapabilityQuery { - can: Ability - with: ResourceQuery - nb?: unknown -} - -export const connection: ConnectionView = connect({ - id: uploadServicePrincipal, - codec: CAR_outbound, - channel: HTTP.open({ - url: uploadServiceURL, - method: 'POST', - }), -}) - -export class Agent { - #data: AgentData - connection: ConnectionView - - constructor(data: AgentData) { - this.connection = connection - this.#data = data - } - - get issuer() { - return this.#data.principal - } - - #delegations(caps: CapabilityQuery[]) { - const _caps = new Set(caps) - const values: { delegation: API.Delegation; meta: DelegationMeta }[] = [] - for (const [, value] of this.#data.delegations) { - if (!isExpired(value.delegation) && !isTooEarly(value.delegation)) { - // check if we need to filter for caps - if (Array.isArray(caps) && caps.length > 0) { - for (const cap of _caps) { - if (canDelegateCapability(value.delegation, cap as Capability)) { - values.push(value) - } - } - } else { - values.push(value) - } - } - } - return values - } - - proofs(caps: CapabilityQuery[]) { - const authorizations: Map< - string, - API.Delegation - > = new Map() - for (const { delegation } of this.#delegations(caps)) { - if (delegation.audience.did() === this.issuer.did()) { - authorizations.set(delegation.cid.toString(), delegation) - } - } - - return [...authorizations.values()] - } - - async importSpaceFromDelegation(delegation: Delegation) { - const space = fromDelegation(delegation) - - this.#data.spaces.set(space.did(), { - ...space.meta, - name: space.meta.name || '', - }) - - await this.#data.addDelegation(delegation) - - return space - } -} diff --git a/src/providers/storacha/capabilities.ts b/src/providers/storacha/capabilities.ts deleted file mode 100644 index c75b7e3..0000000 --- a/src/providers/storacha/capabilities.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { API } from '@ucanto/core' - -function parseAbility(ability: API.Ability) { - const [namespace, ...segments] = ability.split('/') - return { namespace, segments } -} - -export function canDelegateAbility( - parent: API.Ability, - child: API.Ability, -): boolean { - const parsedParent = parseAbility(parent) - const parsedChild = parseAbility(child) - // Parent is wildcard - if (parsedParent.namespace === '*' && parsedParent.segments.length === 0) - return true - // Child is wild card so it can not be delegated from anything - if (parsedChild.namespace === '*' && parsedChild.segments.length === 0) - return false - // namespaces don't match - if (parsedParent.namespace !== parsedChild.namespace) return false - // given that namespaces match and parent first segment is wildcard - if (parsedParent.segments[0] === '*') return true - // Array equality - if (parsedParent.segments.length !== parsedChild.segments.length) return false - // all segments must match - return parsedParent.segments.reduce( - (acc, v, i) => acc && parsedChild.segments[i] === v, - true, - ) -} diff --git a/src/providers/storacha/constants.ts b/src/providers/storacha/constants.ts deleted file mode 100644 index 9793af6..0000000 --- a/src/providers/storacha/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as DID from '@ipld/dag-ucan/did' - -export const uploadServiceURL = new URL('https://up.storacha.network') -export const uploadServicePrincipal = DID.parse('did:web:up.storacha.network') -export const receiptsEndpoint = 'https://up.storacha.network/receipt/' - -export const REQUEST_RETRIES = 3 diff --git a/src/providers/storacha/delegations.ts b/src/providers/storacha/delegations.ts deleted file mode 100644 index bcf74d0..0000000 --- a/src/providers/storacha/delegations.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as ucanto from '@ucanto/core' -import type * as Ucanto from '@ucanto/interface' -import { canDelegateAbility } from './capabilities.js' -import type { ResourceQuery } from './types.js' - -export function isExpired(delegation: Ucanto.Delegation): boolean { - return ( - delegation.expiration === undefined || - delegation.expiration <= Math.floor(Date.now() / 1000) - ) -} - -export function isTooEarly(delegation: Ucanto.Delegation): boolean { - return delegation.notBefore - ? delegation.notBefore > Math.floor(Date.now() / 1000) - : false -} - -const matchResource = (resource: string, query: ResourceQuery): boolean => { - if (typeof query === 'string') return query === 'ucan:*' || resource === query - return query.test(resource) -} - -export function canDelegateCapability( - delegation: Ucanto.Delegation, - capability: Ucanto.Capability, -): boolean { - const allowsCapabilities = ucanto.Delegation.allows(delegation) - for (const [uri, abilities] of Object.entries(allowsCapabilities)) { - if (matchResource(uri, capability.with)) { - for (const can of Object.keys(abilities) as Ucanto.Ability[]) - if (canDelegateAbility(can, capability.can)) return true - } - } - return false -} diff --git a/src/providers/storacha/memory-store.ts b/src/providers/storacha/memory-store.ts deleted file mode 100644 index d9aebde..0000000 --- a/src/providers/storacha/memory-store.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Driver } from './types.js' - -export class StoreMemory = Record> - implements Driver -{ - #data: T | undefined - - constructor() { - this.#data = undefined - } - - async save(data: T) { - this.#data = { ...data } - } - - async load(): Promise { - if (this.#data === undefined) return - if (Object.keys(this.#data).length === 0) return - return this.#data - } -} diff --git a/src/providers/storacha/proof.ts b/src/providers/storacha/proof.ts deleted file mode 100644 index c30c517..0000000 --- a/src/providers/storacha/proof.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Delegation } from '@ucanto/client' -import { extract } from '@ucanto/core/delegation' -import * as CAR from '@ucanto/transport/car' -import { base64 } from 'multiformats/bases/base64' -import { identity } from 'multiformats/hashes/identity' -import * as Link from 'multiformats/link' - -/** - * Parses a base64 encoded CIDv1 CAR of proofs (delegations). - */ -export const parse = async (str: string): Promise => { - const cid = Link.parse(str, base64) - if (cid.code !== CAR.codec.code) { - throw new Error(`non CAR codec found: 0x${cid.code.toString(16)}`) - } - if (cid.multihash.code !== identity.code) { - throw new Error( - `non identity multihash: 0x${cid.multihash.code.toString(16)}`, - ) - } - - const { ok, error } = await extract(cid.multihash.digest) - if (error) throw new Error('failed to extract delegation', { cause: error }) - return ok -} diff --git a/src/providers/storacha/receipts.ts b/src/providers/storacha/receipts.ts deleted file mode 100644 index d5bec3b..0000000 --- a/src/providers/storacha/receipts.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { isDelegation, Receipt } from '@ucanto/core' -import type { Block, Capability, UCANLink } from '@ucanto/interface' -import { CAR } from '@ucanto/transport' -import type { UnknownLink } from 'multiformats' -import { retry } from '../retry.js' -import { REQUEST_RETRIES, receiptsEndpoint } from './constants.js' - -class ReceiptNotFound extends Error { - name = 'ReceiptNotFound' as const - taskCid: UnknownLink - - constructor(taskCid: UnknownLink) { - super() - this.taskCid = taskCid - } - - get reason() { - return `receipt not found for task ${this.taskCid} in the indexed workflow` - } -} - -class ReceiptMissing extends Error { - name = 'ReceiptMissing' as const - taskCid: UnknownLink - - constructor(taskCid: UnknownLink) { - super() - this.taskCid = taskCid - } - - get reason() { - return `receipt missing for task ${this.taskCid}` - } -} - -export async function poll(taskCid: UCANLink<[C]>) { - return await retry( - async () => { - const res = await get(taskCid) - if (res.error) { - if (res.error.name === 'ReceiptNotFound') { - throw res.error - } else { - throw new DOMException( - `failed to fetch receipt for task: ${taskCid}`, - 'AbortError', - ) - } - } - return res.ok - }, - { - onFailedAttempt: console.warn, - retries: REQUEST_RETRIES, - }, - ) -} -/** - * Get a receipt for an executed task by its CID. - */ -export async function get(taskCid: UCANLink<[C]>) { - const endpoint = receiptsEndpoint - - // Fetch receipt from endpoint - const url = new URL(taskCid.toString(), endpoint) - const workflowResponse = await fetch(url) - /* c8 ignore start */ - if (workflowResponse.status === 404) { - return { - error: new ReceiptNotFound(taskCid), - } - } - /* c8 ignore stop */ - // Get receipt from Message Archive - const agentMessageBytes = new Uint8Array(await workflowResponse.arrayBuffer()) - // Decode message - const agentMessage = await CAR.request.decode({ - body: agentMessageBytes, - headers: {}, - }) - // Get receipt from the potential multiple receipts in the message - - const receipt = agentMessage.receipts.get(`${taskCid}`) - if (!receipt) { - // This could be an agent message containing an invocation for ucan/conclude - // that includes the receipt as an attached block, or it may contain a - // receipt for ucan/conclude that includes the receipt as an attached block. - const blocks = new Map>() - for (const b of agentMessage.iterateIPLDBlocks()) { - blocks.set(b.cid.toString(), b) - } - const invocations = [...agentMessage.invocations] - for (const receipt of agentMessage.receipts.values()) { - if (isDelegation(receipt.ran)) { - invocations.push(receipt.ran) - } - } - for (const inv of invocations) { - if (inv.capabilities[0]?.can !== 'ucan/conclude') continue - const root = Object(inv.capabilities[0].nb).receipt - const receipt = root ? Receipt.view({ root, blocks }, null) : null - if (!receipt) continue - const ran = isDelegation(receipt.ran) ? receipt.ran.cid : receipt.ran - if (ran.toString() === taskCid.toString()) { - return { ok: receipt } - } - } - return { - error: new ReceiptMissing(taskCid), - } - } - return { - ok: receipt, - } -} diff --git a/src/providers/storacha/setup.ts b/src/providers/storacha/setup.ts deleted file mode 100644 index 2d439fd..0000000 --- a/src/providers/storacha/setup.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Signer } from '@ucanto/principal/ed25519' -import { Agent } from './agent.js' -import { AgentData } from './agent-data.js' -import * as Proof from './proof.js' -import type { SharedSpace } from './space.js' - -export async function setup({ - pk, - proof, -}: { - pk: string - proof: string -}): Promise<{ agent: Agent; space: SharedSpace }> { - const agentData = await AgentData.create({ principal: Signer.parse(pk) }) - const agent = new Agent(agentData) - try { - const space = await agent.importSpaceFromDelegation( - await Proof.parse(proof), - ) - - return { agent, space } - } catch (e) { - throw new Error('Failed to parse UCAN proof', { cause: e }) - } -} diff --git a/src/providers/storacha/space.ts b/src/providers/storacha/space.ts deleted file mode 100644 index 8f6860f..0000000 --- a/src/providers/storacha/space.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Capabilities, Fact } from '@ucanto/client' -import { DID } from '@ucanto/core/schema' - -type Delegation = { facts: Fact[]; capabilities: Capabilities } - -type SharedSpaceModel = { - id: `did:key:${string}` - delegation: Delegation - meta: { name?: string } -} - -export class SharedSpace { - model: SharedSpaceModel - constructor(model: SharedSpaceModel) { - this.model = model - } - - get meta() { - return this.model.meta - } - - did() { - return this.model.id - } -} - -export const fromDelegation = ({ - facts, - capabilities, -}: Delegation): SharedSpace => { - const result = DID.match({ method: 'key' }).read(capabilities[0].with) - if (result.error) { - throw new Error( - `Invalid delegation, expected capabilities[0].with to be DID, ${result.error}`, - { cause: result.error }, - ) - } - - const meta: { name?: string } = facts[0]?.space ?? {} - - return new SharedSpace({ - id: result.ok, - delegation: { facts, capabilities }, - meta, - }) -} diff --git a/src/providers/storacha/types.ts b/src/providers/storacha/types.ts deleted file mode 100644 index 0ffdff3..0000000 --- a/src/providers/storacha/types.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { - AssertLocation, - SpaceBlobAdd, - SpaceBlobAddFailure, - SpaceBlobAddSuccess, - SpaceBlobGet, - SpaceBlobGetFailure, - SpaceBlobGetSuccess, - SpaceBlobList, - SpaceBlobListFailure, - SpaceBlobListSuccess, - SpaceBlobRemove, - SpaceBlobRemoveFailure, - SpaceBlobRemoveSuccess, - SpaceBlobReplicate, - SpaceBlobReplicateFailure, - SpaceBlobReplicateSuccess, - SpaceIndexAdd, - SpaceIndexAddFailure, - SpaceIndexAddSuccess, - UCANConclude, - UCANConcludeFailure, - UCANConcludeSuccess, - UploadAdd, - UploadAddSuccess, - UploadGet, - UploadGetFailure, - UploadGetSuccess, - UploadList, - UploadListSuccess, - UploadRemove, - UploadRemoveSuccess, - UsageReport, - UsageReportFailure, - UsageReportSuccess, -} from '@storacha/capabilities/types' -import type { StorefrontService } from '@storacha/filecoin-client/storefront' -import type * as Client from '@ucanto/client' -import type { - DID, - Failure, - MultihashDigest, - Proof, - Signer, -} from '@ucanto/interface' -import type { CAR } from '@ucanto/transport' -import type { Version } from 'multiformats' - -/** - * Agent metadata used to describe an agent ("audience") - * with a more human and UI friendly data - */ -export interface AgentMeta { - name: string - description?: string - url?: URL - image?: URL - type: 'device' | 'app' | 'service' -} - -/** - * Delegation metadata - */ -export interface DelegationMeta { - /** - * Audience metadata to be easier to build UIs with human readable data - * Normally used with delegations issued to third parties or other devices. - */ - audience?: AgentMeta -} - -export interface Driver { - /** - * Persist data to the driver's backend - */ - save: (data: T) => Promise - /** - * Loads data from the driver's backend - */ - load: () => Promise -} - -/** - * Space metadata - */ -export interface SpaceMeta { - /** - * Human readable name for the space - */ - name: string -} - -export type ResourceQuery = Client.Resource | RegExp - -export type Position = [offset: number, length: number] - -export interface InvocationConfig { - /** - * Signing authority that is issuing the UCAN invocation(s). - */ - issuer: Signer - /** - * The resource the invocation applies to. - */ - with: DID - /** - * Proof(s) the issuer has the capability to perform the action. - */ - proofs: Proof[] -} - -export type SliceDigest = MultihashDigest - -export interface Service extends StorefrontService { - ucan: { - conclude: Client.ServiceMethod< - UCANConclude, - UCANConcludeSuccess, - UCANConcludeFailure - > - } - space: { - blob: { - add: Client.ServiceMethod< - SpaceBlobAdd, - SpaceBlobAddSuccess, - SpaceBlobAddFailure - > - remove: Client.ServiceMethod< - SpaceBlobRemove, - SpaceBlobRemoveSuccess, - SpaceBlobRemoveFailure - > - list: Client.ServiceMethod< - SpaceBlobList, - SpaceBlobListSuccess, - SpaceBlobListFailure - > - get: { - 0: { - 1: Client.ServiceMethod< - SpaceBlobGet, - SpaceBlobGetSuccess, - SpaceBlobGetFailure - > - } - } - replicate: Client.ServiceMethod< - SpaceBlobReplicate, - SpaceBlobReplicateSuccess, - SpaceBlobReplicateFailure - > - } - index: { - add: Client.ServiceMethod< - SpaceIndexAdd, - SpaceIndexAddSuccess, - SpaceIndexAddFailure - > - } - } - upload: { - add: Client.ServiceMethod - get: Client.ServiceMethod - remove: Client.ServiceMethod - list: Client.ServiceMethod - } - usage: { - report: Client.ServiceMethod< - UsageReport, - UsageReportSuccess, - UsageReportFailure - > - } -} -export interface BlobAddOk { - site: Client.Delegation<[AssertLocation]> -} - -/** - * Any IPLD link. - */ -export type AnyLink = Client.Link - -export type CARLink = Client.Link diff --git a/src/providers/storacha/upload-car.ts b/src/providers/storacha/upload-car.ts deleted file mode 100644 index 2916b0d..0000000 --- a/src/providers/storacha/upload-car.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ShardedDAGIndex } from '@storacha/blob-index' -import { Storefront } from '@storacha/filecoin-client' -import * as PieceHasher from '@web3-storage/data-segment/multihash' -import * as Piece from '@web3-storage/data-segment/piece' -import * as raw from 'multiformats/codecs/raw' -import { sha256 } from 'multiformats/hashes/sha2' -import * as Link from 'multiformats/link' -import * as BlobAdd from './actions/blob-add.js' -import * as CAR from './actions/car.js' -import * as Index from './actions/index-add.js' -import * as Upload from './actions/upload-add.js' -import { uploadServicePrincipal } from './constants.js' -import type { InvocationConfig, Position, SliceDigest } from './types.js' - -/** - * Minimal upload of a CAR as a single shard (no sharding, no dedupe, no progress callbacks). - * Mirrors the non-streaming small-CAR path from @storacha/upload-client. - */ -export const uploadCAR = async (conf: InvocationConfig, car: Blob) => { - const { blocks, roots } = await CAR.decode(car) - const root = roots[0] - - const slices: Map = new Map() - let currentLength = 0 - const emptyHeaderLength = CAR.headerEncodingLength() - for (const block of blocks) { - const blockHeaderLength = CAR.blockHeaderEncodingLength(block) - slices.set(block.cid.multihash, [ - emptyHeaderLength + currentLength + blockHeaderLength, - block.bytes.length, - ]) - currentLength += blockHeaderLength + block.bytes.length - } - const headerLengthWithRoot = CAR.headerEncodingLength(root) - const diff = headerLengthWithRoot - emptyHeaderLength - if (diff !== 0) { - for (const slice of slices.values()) slice[0] += diff - } - - const singleCar = await CAR.encode(blocks, root) - const bytes = new Uint8Array(await singleCar.arrayBuffer()) - const digest = await sha256.digest(bytes) - - await BlobAdd.add(conf, digest, bytes) - - const multihashDigest = PieceHasher.digest(bytes) - const piece = Piece.fromDigest(multihashDigest).link - const content = Link.create(raw.code, digest) - const offer = await Storefront.filecoinOffer( - { - issuer: conf.issuer, - audience: uploadServicePrincipal, - with: conf.issuer.did(), - proofs: conf.proofs, - }, - content, - piece, - {}, - ) - if (offer.out?.error) { - throw new Error( - 'failed to offer piece for aggregation into filecoin deal', - { - cause: offer.out.error, - }, - ) - } - - const carCid = Link.create(CAR.code, digest) - const index = ShardedDAGIndex.create(root) - for (const [slice, pos] of slices) index.setSlice(digest, slice, pos) - index.setSlice(digest, digest, [0, singleCar.size]) - - const indexBytes = await index.archive() - if (!indexBytes.ok) { - throw new Error('failed to archive DAG index', { cause: indexBytes.error }) - } - const indexDigest = await sha256.digest(indexBytes.ok) - const indexLink = Link.create(CAR.code, indexDigest) - - await BlobAdd.add(conf, indexDigest, indexBytes.ok) - - await Index.add(conf, indexLink) - await Upload.add(conf, root, [carCid]) - - return root -} diff --git a/src/test/fixtures/example-site/index.html b/src/test/fixtures/example-site/index.html index 7c90b92..1abca4a 100644 --- a/src/test/fixtures/example-site/index.html +++ b/src/test/fixtures/example-site/index.html @@ -3,7 +3,7 @@ - Autark - ETHRome Hackathon + Autark - PL Genesis Hackathon