Skill: vinhbui3004/aioz-storage (version 1.0.0)

--- name: aioz-storage description: Deploy static websites to AIOZ Storage with built-in templates or custom sites. metadata: {"openclaw":{"requires":{"bins":["node","npx","curl"]},"os":["linux","darwin"]}} --- # AIOZ Static Website Deploy Deploy a static website to AIOZ decentralized storage. Supports 4 built-in templates or user's own static site. ## FLOW OVERVIEW ``` 1. Login (email + password) → Bearer token + accountId 2. Choose template or custom site 3. Clone template & customize CONFIG 4. Get bucket info (name + 12-word passphrase) 5. Get rootZKey from API 6. Generate grant via grant-cli.ts 7. Register S3 credentials from grant 8. Upload files to S3 9. Create static website via API 10. Site live at https://<bucket>.sites.aiozstorage.app ``` ## STEP 1: LOGIN Ask user for AIOZ Storage email and password. ```bash curl -s 'https://api.aiozstorage.network/api/v1/login' \ -H 'accept: application/json' \ -H 'content-type: application/json' \ -H 'origin: https://aiozstorage.network' \ -H 'referer: https://aiozstorage.network/' \ --data-raw '{"email":"<EMAIL>","password":"<PASSWORD>"}' ``` Response: `data.access_token` (Bearer token), `data.account.id` (accountId). Store `BEARER_TOKEN` and `ACCOUNT_ID`. ## STEP 2: CHOOSE TEMPLATE Ask user which template or if they have their own static site. Templates from https://github.com/AIOZStorage/aioz-storage-docs/tree/main/examples: 1. **landing** — OLED dark theme, X/Twitter style landing page. CONFIG in `assets/main.js`. 2. **landing-alt** — Purple gradient landing page. CONFIG in `assets/main.js`. Supports `*italic*` gradient text in headlines. 3. **portfolio** — Clean minimal portfolio for devs/designers. CONFIG in `assets/main.js`. Image carousel for projects. 4. **documents** — Full documentation site with Markdown content. CONFIG in `assets/js/config.js`. Search, TOC, syntax highlighting. 5. **Custom** — User provides their own static files. ## STEP 3: CLONE TEMPLATE & CUSTOMIZE Clone the chosen template: ```bash git clone --depth 1 https://github.com/AIOZStorage/aioz-storage-docs.git /tmp/aioz-storage-docs cp -r /tmp/aioz-storage-docs/examples/<template_name> ./<project_folder> ``` Then customize the CONFIG object based on template type: ### Template: landing Config: `assets/main.js` → `CONFIG` object. Theme: `"dark"` (OLED black) or `"light"`. Key fields to customize: - `brand.name` — brand/product name - `meta.title`, `meta.description` — SEO - `hero.eyebrow` — pill badge (or null to hide), `hero.title`, `hero.sub` - `hero.primaryCta` — `{ label, href }`, `hero.secondaryCta` - `sections.*` — toggle: highlights, logos, features, useCases, testimonials, pricingTop, pricingBot, faq - `highlights[]` — `{ number, label }` stats (numbers animate on scroll) - `logos.items[]` — company name strings - `features` — `{ label, title, sub, items: [{ title, desc }] }` - `useCases` — `{ label, title, sub, items: [{ tag, title, desc }] }` - `testimonials[]` — `{ quote, name, role, company, avatar: { initials, bg } }` - `pricing.plans[]` — `{ name, price, period, desc, isPopular, cta: { label, href }, features[] }` - `faq.items[]` — `{ q, a }` - `cta.form.mode` — `"redirect"` | `"mailto"` | `"webhook"` - `footer.brandDesc`, `footer.columns[]`, `footer.socials[]` - Colors in `assets/style.css` `:root` → `--color-primary: #1d9bf0` ### Template: landing-alt Config: `assets/main.js` → `CONFIG` object. Theme: `"light"` | `"dark"` | `"system"`. Key fields to customize: - `brand.name`, `brand.tagline` - `hero.badge` — pill badge (or null), `hero.headline` (wrap words in `*asterisks*` for gradient italic text), `hero.subheadline` - `hero.primaryCta`, `hero.secondaryCta`, `hero.ctaNote` - `sections.*` — toggle: stats, problemSolution, features, useCases, logos, testimonials, pricing, faq - `stats[]` — `{ number, label }` (numbers animate on scroll) - `problemSolution` — `{ problem: { label, title, items[] }, solution: { label, title, items[] } }` - `features.items[]` — `{ size, gradient, kicker, title, desc, tag, visual }` (bento grid, size: "wide"|"narrow"|"half"|"third"|"full") - `pricing.plans[]` — `{ name, price, period, desc, isPopular, cta, features[] }` - `cta.form.mode` — `"redirect"` | `"mailto"` | `"webhook"` - Colors in `assets/style.css` → `--color-primary: #635bff`, `--color-gradient-start`, `--color-gradient-end` ### Template: portfolio Config: `assets/main.js` → `CONFIG` object. Theme: `"light"` | `"dark"` | `"auto"`. Key fields to customize: - `personal.firstName`, `personal.lastName`, `personal.title`, `personal.email` - `personal.location` — `{ city, country }` - `about.subtitle`, `about.bio` (array of paragraphs), `about.focusAreas` (array of skill strings) - `experience[]` — `{ company, tagline, period, position, description, longDescription, location, industry, website: { label, url } }` - `projects[]` — `{ title, tags[], year, images[], description, techNote, link: { label, url } }` (multiple images = carousel) - `social[]` — `{ platform, url }` - `theme.defaultMode`, `theme.showToggle` - `sections` — toggle: showAbout, showExperience, showProjects, showFocusAreas - `footer.cta`, `footer.copyright` (null = auto-generate) - `meta.title`, `meta.description`, `meta.ogImage` - Images in `assets/images/` (1600×900 for projects, 1200×630 for OG) ### Template: documents Config: `assets/js/config.js` → `CONFIG` object. Theme: `"light"` | `"dark"` | `"auto"`. Key fields to customize: - `site.title`, `site.description`, `site.version` - `site.logo.light`, `site.logo.dark` - `sidebar[]` — `{ group, collapsed, items: [{ label, path }] }` (path = filename without .md) - `header.nav[]` — `{ label, href }`, `header.links[]` — `{ icon, href }` - `footer.copyright`, `footer.columns[]` - `features` — toggle: search, tableOfContents, feedback, prevNext, copyCodeButton, scrollToTop - `features.editLink` — `{ enabled, baseUrl, text }` - `features.tocDepth` — 2 or 3 Content: create `.md` files in `/docs/` with YAML front matter: ```yaml --- title: Page Title description: Brief description order: 1 hidden: false --- ``` Callouts: `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!DANGER]` Code blocks with syntax highlighting (javascript, typescript, python, bash, json, html, css, yaml, sql, go, rust, java, etc.) Hash routing: `index.html#/page-name` → loads `docs/page-name.md` ## STEP 4: GET BUCKET INFO Ask user for: 1. **Bucket name** — their bucket on aiozstorage.network 2. **12-word seed phrase** — BIP39 passphrase from bucket creation IMPORTANT: AIOZ Storage does NOT store passphrases. If lost, bucket access is lost forever. See: https://aiozstorage.network/docs/tutorials/manage-buckets ## STEP 5: GET ROOT ZKEY ```bash curl -s 'https://api.aiozstorage.network/api/v1/zkeys' \ -H 'accept: application/json' \ -H 'content-type: application/json' \ -H 'authorization: Bearer <BEARER_TOKEN>' \ -H 'origin: https://aiozstorage.network' \ -H 'referer: https://aiozstorage.network/' \ --data-raw '{"name":"deploy-key","force":true}' ``` Extract: `ROOT_ZKEY` = `data.zkey` ## STEP 6: GENERATE GRANT ### Prerequisites (one-time setup in {baseDir}) ```bash cd {baseDir} && npm install ``` This installs `argon2-browser` and `ts-node`. No `build:sjcl` needed — the CLI uses Node.js built-in `crypto`. ### Run grant-cli.ts (RECOMMENDED: JSON output for bot) Two grants are needed: 1. **Upload grant** — permissions `1,2,3` (Read+Write+List) for S3 upload 2. **Website grant** — permissions `1,3` (Read+List only) for website creation **Upload grant:** ```bash npx ts-node {baseDir}/grant-cli.ts \ --mode per-bucket \ --zkey "<ROOT_ZKEY>" \ --account "<ACCOUNT_ID>" \ --url w3s \ --duration 0 \ --bucket "<BUCKET_NAME>" \ --passphrase "<12_WORD_SEED_PHRASE>" \ --permissions 1,2,3 \ --output json \ --quiet ``` **Website grant:** ```bash npx ts-node {baseDir}/grant-cli.ts \ --mode per-bucket \ --zkey "<ROOT_ZKEY>" \ --account "<ACCOUNT_ID>" \ --url w3s \ --duration 0 \ --bucket "<BUCKET_NAME>" \ --passphrase "<12_WORD_SEED_PHRASE>" \ --permissions 1,3 \ --output json \ --quiet ``` Output (single JSON line to stdout): ```json {"grant":"<GRANT_STRING>","zkey":"<ZKEY_STRING>"} ``` Parse JSON to extract `UPLOAD_GRANT` and `WEBSITE_GRANT`. IMPORTANT: Website API rejects grants with Write (2) or Delete (4) permissions. ### Alternative: config file ```bash echo '{"mode":"per-bucket","rootZKey":"<ROOT_ZKEY>","accountId":"<ACCOUNT_ID>","url":"w3s","duration":0,"buckets":[{"name":"<BUCKET_NAME>","passphrase":"<PASSPHRASE>","permissions":["1","2","3"]}]}' > /tmp/upload-grant-config.json npx ts-node {baseDir}/grant-cli.ts --config /tmp/upload-grant-config.json --output json --quiet echo '{"mode":"per-bucket","rootZKey":"<ROOT_ZKEY>","accountId":"<ACCOUNT_ID>","url":"w3s","duration":0,"buckets":[{"name":"<BUCKET_NAME>","passphrase":"<PASSPHRASE>","permissions":["1","3"]}]}' > /tmp/website-grant-config.json npx ts-node {baseDir}/grant-cli.ts --config /tmp/website-grant-config.json --output json --quiet ``` ### CLI flags | Flag | Required | Description | |------|----------|-------------| | `--mode` | Yes | `per-bucket` or `all-buckets` | | `--zkey` | Yes | Root ZKey (base64url) | | `--account` | Yes | Account UUID | | `--url` | No | Service URL (default: `w3s`) | | `--duration` | No | ms, 0 = no expiry (default: `0`) | | `--bucket` | per-bucket | Bucket name | | `--passphrase` | Yes | 12-word seed phrase | | `--permissions` | per-bucket | `1,2,3,4` (1=Read 2=Write 3=List 4=Delete) | | `--config` | No | Path to JSON config file | | `--output` | No | `json` for JSON output | | `--quiet` | No | Suppress logs, stdout only | ## STEP 7: REGISTER S3 CREDENTIALS Use the **upload grant** (permissions 1,2,3): ```bash curl -s 'https://reg-api.aiozstorage.network/api/v1/access' \ -H 'accept: application/json' \ -H 'content-type: application/json' \ -H 'authorization: Bearer <BEARER_TOKEN>' \ -H 'origin: https://aiozstorage.network' \ -H 'referer: https://aiozstorage.network/' \ --data-raw '{"grant":"<GRANT>","public":false}' ``` Extract: `ACCESS_KEY_ID` = `data.access_key_id`, `SECRET_KEY` = `data.secret_key` ## STEP 8: UPLOAD FILES TO S3 S3 endpoint: `https://s3.aiozstorage.network` | Region: `us-east-1` | PathStyle: `true` ### Using AWS CLI ```bash AWS_ACCESS_KEY_ID="<ACCESS_KEY_ID>" \ AWS_SECRET_ACCESS_KEY="<SECRET_KEY>" \ aws s3 sync ./<project_folder>/ s3://<BUCKET_NAME>/ \ --endpoint-url https://s3.aiozstorage.network \ --region us-east-1 ``` ### Using Node.js (@aws-sdk/client-s3) Use `S3Client` with `{ region: "us-east-1", endpoint: "https://s3.aiozstorage.network", forcePathStyle: true, credentials: { accessKeyId, secretAccessKey } }`. Upload each file with `PutObjectCommand`, setting correct Content-Type: `.html`→`text/html` `.css`→`text/css` `.js`→`application/javascript` `.json`→`application/json` `.svg`→`image/svg+xml` `.png`→`image/png` `.jpg/.jpeg`→`image/jpeg` `.webp`→`image/webp` `.ico`→`image/x-icon` `.md`→`text/markdown` `.woff2`→`font/woff2` default→`application/octet-stream` ## STEP 9: CREATE STATIC WEBSITE Use the **website grant** (permissions 1,3 — Read+List only): ```bash curl -s 'https://api.aiozstorage.app/api/v1/websites' \ -H 'accept: application/json' \ -H 'content-type: application/json' \ -H 'authorization: Bearer <BEARER_TOKEN>' \ -H 'origin: https://aiozstorage.network' \ -H 'referer: https://aiozstorage.network/' \ --data-raw '{ "bucket_name":"<BUCKET_NAME>", "enabled":true, "index_document":"index.html", "error_document":"404.html", "use_default_error":true, "error_pages":{}, "grant":"<WEBSITE_GRANT>" }' ``` IMPORTANT: Website API rejects grants with Write (2) or Delete (4) permissions. Use `--permissions 1,3` for this grant. API domain is `api.aiozstorage.app` (not `.network`). ## STEP 10: DONE Site is live at: `https://<BUCKET_NAME>.sites.aiozstorage.app` ## API DOMAINS - `api.aiozstorage.network` — login, zkeys - `reg-api.aiozstorage.network` — S3 credential registration - `s3.aiozstorage.network` — S3 upload endpoint - `api.aiozstorage.app` — static website creation - `<bucket>.sites.aiozstorage.app` — static website URL ## ERROR HANDLING - Login fails → check email/password - ZKey creation fails → Bearer token expired, re-login - Grant generation fails → verify rootZKey, accountId, bucket name, passphrase - S3 upload fails → check credentials, endpoint, bucket name - Website creation fails → check grant, bucket name, Bearer token - Site 404 → ensure index.html at bucket root (not in subfolder) ## IMPORTANT NOTES - `grant` encodes both authorization (Macaroon/ZKey) and encryption key (EKey from passphrase) - Two grants needed: upload grant (permissions 1,2,3) and website grant (permissions 1,3) - Website API rejects grants with Write (2) or Delete (4) permissions - AIOZ Storage is S3-compatible — any AWS SDK works - Bearer tokens expire — re-login on 401 - grant-cli.ts requires Node.js >= 18 - argon2-browser WASM loading is auto-patched in the CLI - All template asset paths are relative (`./assets/...`) — works from any base URL - For documents template, content is in `/docs/*.md` with YAML front matter - For landing/landing-alt/portfolio, all content is in CONFIG object in `assets/main.js`

path: package.json
content: {
    "name": "aioz-storage-skill",
    "version": "1.0.0",
    "private": true,
    "description": "AIOZ Storage static website deploy skill - grant CLI",
    "scripts": {
        "grant": "npx ts-node grant-cli.ts"
    },
    "dependencies": {
        "argon2-browser": "^1.18.0"
    },
    "devDependencies": {
        "ts-node": "^10.9.2",
        "typescript": "^5.3.3"
    }
}

path: grant-cli.ts
content: #!/usr/bin/env npx ts-node
// @ts-nocheck
/**
 * Grant CLI - standalone grant generator with CLI interface
 *
 * Usage:
 *   # From config file
 *   npx ts-node grant-cli.ts --config config.json
 *
 *   # Per-bucket mode via flags
 *   npx ts-node grant-cli.ts \
 *     --mode per-bucket \
 *     --zkey <rootZKey> \
 *     --account <accountId> \
 *     --url w3s \
 *     --duration 0 \
 *     --bucket vinh \
 *     --passphrase "conduct muffin auto ..." \
 *     --permissions 1,2,3 \
 *     --prefix ""
 *
 *   # All-buckets mode via flags
 *   npx ts-node grant-cli.ts \
 *     --mode all-buckets \
 *     --zkey <rootZKey> \
 *     --account <accountId> \
 *     --url w3s \
 *     --duration 86400000 \
 *     --passphrase "your passphrase"
 *
 *   # Output format
 *   --output json     # JSON output (default: text)
 *   --quiet           # Only output grant and zkey, no logs
 */

import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { fileURLToPath } from "url";

// ts-node may run this file in ESM mode; __dirname is not defined there.
// Define __filename/__dirname compat to keep path logic working.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// ─── Arg parser ─────────────────────────────────────────────────────────────

function parseArgs(argv: string[]): Map<string, string[]> {
    const args = new Map<string, string[]>();
    let current: string | null = null;

    for (const arg of argv.slice(2)) {
        if (arg.startsWith("--")) {
            current = arg.slice(2);
            if (!args.has(current)) args.set(current, []);
        } else if (current) {
            args.get(current)!.push(arg);
        }
    }
    return args;
}

function getArg(args: Map<string, string[]>, key: string): string | undefined {
    const vals = args.get(key);
    return vals && vals.length > 0 ? vals.join(" ") : undefined;
}

function hasFlag(args: Map<string, string[]>, key: string): boolean {
    return args.has(key);
}

function printUsage() {
    console.log(`
Grant CLI - Standalone grant generator

USAGE:
  npx ts-node grant-cli.ts --config <file>
  npx ts-node grant-cli.ts --mode <mode> --zkey <key> --account <id> [options]

FROM CONFIG FILE:
  --config <path>         Path to config JSON file

PER-BUCKET MODE:
  --mode per-bucket
  --zkey <rootZKey>       Root ZKey (base64url)
  --account <accountId>   Account UUID
  --url <url>             Service URL (e.g. w3s)
  --duration <ms>         Duration in ms (0 = no expiry)
  --bucket <name>         Bucket name (repeatable for multiple buckets)
  --passphrase <phrase>   Passphrase for encryption key derivation
  --permissions <ops>     Comma-separated: 1=Read,2=Write,3=List,4=Delete
  --prefix <prefix>       Optional path prefix

  For multiple buckets, use --bucket multiple times with corresponding
  --passphrase and --permissions (in order):
    --bucket a --passphrase "pass a" --permissions 1
    --bucket b --passphrase "pass b" --permissions 1,2,3

ALL-BUCKETS MODE:
  --mode all-buckets
  --zkey <rootZKey>
  --account <accountId>
  --url <url>
  --duration <ms>
  --passphrase <phrase>

OUTPUT OPTIONS:
  --output json           Output as JSON (default: text)
  --quiet                 Suppress info logs, only output result
  --help                  Show this help
`);
}

// ─── Config types ───────────────────────────────────────────────────────────

interface EncryptBucket {
    name: string;
    permissions: string[];
    passphrase: string;
    prefix?: string;
}

interface PerBucketConfig {
    mode: "per-bucket";
    rootZKey: string;
    accountId: string;
    url: string;
    duration: number;
    buckets: EncryptBucket[];
}

interface AllBucketsConfig {
    mode: "all-buckets";
    rootZKey: string;
    accountId: string;
    url: string;
    duration: number;
    passphrase: string;
}

type Config = PerBucketConfig | AllBucketsConfig;

// ─── Build config from CLI args ─────────────────────────────────────────────

function buildConfigFromArgs(args: Map<string, string[]>): Config {
    const mode = getArg(args, "mode");
    const zkey = getArg(args, "zkey");
    const account = getArg(args, "account");
    const url = getArg(args, "url") || "w3s";
    const duration = parseInt(getArg(args, "duration") || "0", 10);

    if (!mode) throw new Error("--mode is required (per-bucket or all-buckets)");
    if (!zkey) throw new Error("--zkey is required");
    if (!account) throw new Error("--account is required");

    if (mode === "per-bucket") {
        const bucketNames = args.get("bucket") || [];
        const passphrases = args.get("passphrase") || [];
        const permsList = args.get("permissions") || [];
        const prefixes = args.get("prefix") || [];

        if (bucketNames.length === 0) {
            throw new Error("--bucket is required for per-bucket mode");
        }
        if (passphrases.length === 0) {
            throw new Error("--passphrase is required");
        }

        // If single passphrase/permissions, apply to all buckets
        const buckets: EncryptBucket[] = bucketNames.map((name, i) => ({
            name,
            passphrase: passphrases[i] || passphrases[0],
            permissions: (permsList[i] || permsList[0] || "1").split(","),
            prefix: prefixes[i] || prefixes[0] || undefined,
        }));

        return {
            mode: "per-bucket",
            rootZKey: zkey,
            accountId: account,
            url,
            duration,
            buckets,
        };
    } else if (mode === "all-buckets") {
        const passphrase = getArg(args, "passphrase");
        if (!passphrase) throw new Error("--passphrase is required for all-buckets mode");

        return {
            mode: "all-buckets",
            rootZKey: zkey,
            accountId: account,
            url,
            duration,
            passphrase,
        };
    } else {
        throw new Error(`Invalid mode: ${mode}. Use "per-bucket" or "all-buckets"`);
    }
}

// ─── Base64 URL helpers ─────────────────────────────────────────────────────

function b64urlEncode(buf: Uint8Array): string {
    return Buffer.from(buf)
        .toString("base64")
        .replace(/\+/g, "-")
        .replace(/\//g, "_")
        .replace(/=+$/, "");
}

function b64urlDecode(str: string): Uint8Array {
    const s = str
        .replace(/-/g, "+")
        .replace(/_/g, "/")
        .padEnd(str.length + ((4 - (str.length % 4)) % 4), "=");
    return new Uint8Array(Buffer.from(s, "base64"));
}

function b64Encode(buf: Uint8Array): string {
    return Buffer.from(buf).toString("base64");
}

function b64Decode(str: string): Uint8Array {
    return new Uint8Array(Buffer.from(str, "base64"));
}

// ─── Crypto helpers ─────────────────────────────────────────────────────────

function hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array {
    return new Uint8Array(crypto.createHmac("sha256", key).update(data).digest());
}

function hmacSha512(key: Uint8Array, data: Uint8Array): Uint8Array {
    return new Uint8Array(crypto.createHmac("sha512", key).update(data).digest());
}

function aesGcmEncrypt(plaintext: Uint8Array, key: Uint8Array, nonce: Uint8Array): Uint8Array {
    const iv = nonce.slice(0, 12);
    const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
    const encrypted = cipher.update(plaintext);
    const final = cipher.final();
    const tag = cipher.getAuthTag();
    const result = new Uint8Array(encrypted.length + final.length + tag.length);
    result.set(encrypted, 0);
    result.set(final, encrypted.length);
    result.set(tag, encrypted.length + final.length);
    return result;
}

// ─── Protobuf writer ────────────────────────────────────────────────────────

class PbWriter {
    buf: number[] = [];
    varint(v: number) {
        while (v > 0x7f) { this.buf.push((v & 0x7f) | 0x80); v >>>= 7; }
        this.buf.push(v);
    }
    fieldVarint(f: number, val: number) {
        if (val === 0) return;
        this.varint((f << 3) | 0); this.varint(val);
    }
    fieldBytes(f: number, data: Uint8Array) {
        if (data.length === 0) return;
        this.varint((f << 3) | 2); this.varint(data.length);
        for (let i = 0; i < data.length; i++) this.buf.push(data[i]);
    }
    fieldString(f: number, s: string) {
        if (!s) return;
        this.fieldBytes(f, new TextEncoder().encode(s));
    }
    fieldBool(f: number, val: boolean) {
        if (!val) return;
        this.varint((f << 3) | 0); this.varint(1);
    }
    fieldMessage(f: number, sub: Uint8Array) {
        this.varint((f << 3) | 2); this.varint(sub.length);
        for (let i = 0; i < sub.length; i++) this.buf.push(sub[i]);
    }
    finish(): Uint8Array { return new Uint8Array(this.buf); }
}

// ─── Protobuf encoders ─────────────────────────────────────────────────────

function encodeTimestamp(d: Date): Uint8Array {
    const w = new PbWriter();
    w.fieldVarint(1, Math.floor(d.getTime() / 1000));
    w.fieldVarint(2, (d.getTime() % 1000) * 1_000_000);
    return w.finish();
}

function encodeTimeCaveat(from: Date | null, to: Date | null): Uint8Array {
    const w = new PbWriter();
    if (to) w.fieldMessage(1, encodeTimestamp(to));
    if (from) w.fieldMessage(2, encodeTimestamp(from));
    return w.finish();
}

function encodeOpCaveat(scopes: { paths: { bucket: string; prefix: Uint8Array }[]; ops: number[] }[]): Uint8Array {
    const w = new PbWriter();
    for (const scope of scopes) {
        const sw = new PbWriter();
        for (const p of scope.paths) {
            const pw = new PbWriter();
            pw.fieldString(1, p.bucket);
            pw.fieldBytes(2, p.prefix);
            sw.fieldMessage(1, pw.finish());
        }
        if (scope.ops.length > 0) {
            const ow = new PbWriter();
            for (const op of scope.ops) ow.varint(op);
            sw.fieldMessage(2, ow.finish());
        }
        w.fieldMessage(1, sw.finish());
    }
    return w.finish();
}

interface EKeyData {
    encrAlg: number; encrBlockSize: number;
    raw: Uint8Array; path: Uint8Array; content: boolean;
}

function encodeEKeyProto(ek: EKeyData): Uint8Array {
    const w = new PbWriter();
    w.fieldVarint(1, ek.encrAlg); w.fieldVarint(2, ek.encrBlockSize);
    w.fieldBytes(3, ek.raw); w.fieldBytes(4, ek.path); w.fieldBool(5, ek.content);
    return w.finish();
}

function encodeZKeyProto(mcr: Uint8Array): Uint8Array {
    const w = new PbWriter(); w.fieldBytes(1, mcr); return w.finish();
}

function encodeGrantProto(url: string, mcr: Uint8Array, eKeysMap: Map<string, EKeyData[]>): Uint8Array {
    const w = new PbWriter();
    w.fieldMessage(1, encodeZKeyProto(mcr));
    for (const [bucket, ekeys] of eKeysMap) {
        const ew = new PbWriter();
        ew.fieldString(1, bucket);
        const lw = new PbWriter();
        for (const ek of ekeys) lw.fieldMessage(1, encodeEKeyProto(ek));
        ew.fieldMessage(2, lw.finish());
        w.fieldMessage(2, ew.finish());
    }
    w.fieldString(3, url);
    return w.finish();
}

// ─── Macaroon V2 binary ────────────────────────────────────────────────────

const F_EOS = 0, F_LOC = 1, F_ID = 2, F_VID = 4, F_SIG = 6;

function readUvarint(buf: Uint8Array, off: number): [number, number] {
    let x = 0, shift = 0, i = off;
    while (i < buf.length) {
        const b = buf[i];
        if (b < 0x80) return [(x | (b << shift)) >>> 0, i + 1];
        x |= (b & 0x7f) << shift; shift += 7; i++;
    }
    throw new Error("varint overflow");
}

function writeUvarint(v: number): number[] {
    const b: number[] = [];
    while (v >= 0x80) { b.push((v & 0xff) | 0x80); v >>>= 7; }
    b.push(v | 0); return b;
}

interface Caveat { identifier: Uint8Array; vid?: Uint8Array; location?: string; }
interface Macaroon { version: number; location?: string; identifier: Uint8Array; caveats: Caveat[]; signature: Uint8Array; }

function importMacaroonV2(data: Uint8Array): Macaroon {
    let o = 0;
    if (data[o++] !== 2) throw new Error("Not V2 macaroon");

    const readField = (expect?: number) => {
        const ft = data[o++];
        if (ft === F_EOS) return { type: F_EOS, data: new Uint8Array() };
        if (expect !== undefined && ft !== expect) { o--; return null; }
        const [len, no] = readUvarint(data, o); o = no;
        const d = data.slice(o, o + len); o += len;
        return { type: ft, data: d };
    };

    let loc: string | undefined;
    const lf = readField(F_LOC);
    if (lf) loc = new TextDecoder().decode(lf.data);

    const idf = readField(F_ID);
    if (!idf) throw new Error("Missing identifier");
    readField(F_EOS);

    const caveats: Caveat[] = [];
    while (true) {
        if (data[o] === F_EOS) { o++; break; }
        let cl: string | undefined;
        const clf = readField(F_LOC);
        if (clf) cl = new TextDecoder().decode(clf.data);
        const cid = readField(F_ID);
        if (!cid) throw new Error("Missing caveat id");
        let cv: Uint8Array | undefined;
        const vf = readField(F_VID);
        if (vf) cv = vf.data;
        readField(F_EOS);
        caveats.push({ identifier: cid.data, vid: cv, location: cl });
    }

    const sf = readField(F_SIG);
    if (!sf) throw new Error("Missing signature");
    return { version: 2, location: loc, identifier: idf.data, caveats, signature: sf.data };
}

function exportMacaroonV2(m: Macaroon): Uint8Array {
    const p: number[] = [2];
    const af = (ft: number, d?: Uint8Array) => {
        p.push(ft);
        if (ft !== F_EOS && d) { p.push(...writeUvarint(d.length)); for (let i = 0; i < d.length; i++) p.push(d[i]); }
    };
    if (m.location) af(F_LOC, new TextEncoder().encode(m.location));
    af(F_ID, m.identifier); af(F_EOS);
    for (const c of m.caveats) {
        if (c.location) af(F_LOC, new TextEncoder().encode(c.location));
        af(F_ID, c.identifier);
        if (c.vid) af(F_VID, c.vid);
        af(F_EOS);
    }
    af(F_EOS); af(F_SIG, m.signature);
    return new Uint8Array(p);
}

function addCaveat(m: Macaroon, condB64: string) {
    // Original macaroon.js stores caveat identifier as UTF-8 bytes of the base64 string
    // (not the decoded raw bytes). This matches how addFirstPartyCaveat works:
    // it calls stringToBytes(base64String) = utf8Encoder.encode(base64String)
    const cond = new TextEncoder().encode(condB64);
    m.caveats.push({ identifier: cond });
    m.signature = hmacSha256(m.signature, cond);
}

// ─── EKey derivation ────────────────────────────────────────────────────────

const SLASH = 0x2f;

function* pathIter(p: Uint8Array): Generator<Uint8Array> {
    let c = 0;
    while (p.length > c) {
        const r = p.slice(c);
        const i = r.indexOf(SLASH);
        if (i === -1) { c += r.length; yield r; continue; }
        c += i + 1; yield r.slice(0, i);
    }
    if (p.length === 0 || p[p.length - 1] === SLASH) yield new Uint8Array();
}

function encodeEncComp(enc: Uint8Array): Uint8Array {
    if (enc.length === 0) return new Uint8Array([0x01]);
    let r = new Uint8Array([0x02]);
    for (let i = 0; i < enc.length; i++) {
        let t: Uint8Array;
        const b = enc[i];
        if (b === 0x2e || b === 0x2f || b === 0xfe || b === 0xff || b === 0x00 || b === 0x01) {
            t = new Uint8Array(r.length + 2); t.set(r);
            if (b === 0x2e) t.set([0x2e, 1], r.length);
            else if (b === 0x2f) t.set([0x2e, 2], r.length);
            else if (b === 0xfe) t.set([0xfe, 1], r.length);
            else if (b === 0xff) t.set([0xfe, 2], r.length);
            else if (b === 0x00) t.set([0x01, 1], r.length);
            else t.set([0x01, 2], r.length);
        } else {
            t = new Uint8Array(r.length + 1); t.set(r);
            t.set([b], r.length);
        }
        r = t;
    }
    return r;
}

interface EK { encrAlg: number; encrBlockSize: number; raw: Uint8Array; path: Uint8Array; content: boolean; }

function ekDeriveKey(ek: EK, content: Uint8Array): EK {
    const h = hmacSha512(ek.raw, content);
    return { ...ek, raw: h.slice(0, 32), path: new Uint8Array(ek.path), content: false };
}

function ekDerivePathComp(ek: EK, comp: Uint8Array): EK {
    const nonce = new Uint8Array(24);
    const enc = aesGcmEncrypt(comp, ek.raw, nonce);
    const ecomp = encodeEncComp(enc);
    const newPath = ek.path.length === 0 ? ecomp : new Uint8Array([...ek.path, SLASH, ...ecomp]);
    const prefix = new TextEncoder().encode("p:");
    const nk = ekDeriveKey(ek, new Uint8Array([...prefix, ...comp]));
    nk.path = newPath;
    return nk;
}

function ekDerivePath(ek: EK, p: Uint8Array): EK {
    let k: EK = { ...ek, path: new Uint8Array(ek.path) };
    for (const comp of pathIter(p)) k = ekDerivePathComp(k, comp);
    return k;
}

async function deriveEKey([REDACTED_SECRET], salt: string, bucketPath: string): Promise<EK> {
    const { default: argon2 } = await import("argon2-browser");
    const pw = new TextEncoder().encode(password);
    const sl = new TextEncoder().encode(salt);
    const mixed = hmacSha256(pw, sl);
    const pSalt = hmacSha256(mixed, new TextEncoder().encode(""));

    const result = await argon2.hash({
        pass: password, salt: pSalt, type: 1,
        time: 1, mem: 64 * 1024, parallelism: 1, hashLen: 32,
    });

    const base: EK = { encrAlg: 1, encrBlockSize: 128016, raw: result.hash, path: new Uint8Array(), content: false };
    return bucketPath ? ekDerivePath(base, new TextEncoder().encode(bucketPath)) : base;
}

// ─── Core: generate grant from config ───────────────────────────────────────

async function generateGrant(config: Config): Promise<{ grant: string; zkey: string; info: string[] }> {
    const info: string[] = [];

    // Decode ZKey
    const zkProto = b64urlDecode(config.rootZKey);
    let off = 0;
    const tag = zkProto[off++];
    if ((tag >>> 3) !== 1 || (tag & 0x7) !== 2) throw new Error("Invalid ZKeyProto");
    const [len, no] = readUvarint(zkProto, off); off = no;
    const mcrBin = zkProto.slice(off, off + len);
    const mcr = importMacaroonV2(mcrBin);

    // Time caveat
    const from = new Date();
    const to = config.duration ? new Date(Date.now() + config.duration) : null;
    const tc = encodeTimeCaveat(from, to);
    const tm = new Uint8Array(tc.length + 1); tm[0] = 2; tm.set(tc, 1);
    addCaveat(mcr, b64Encode(tm));
    info.push(`Duration: ${from.toISOString()} -> ${to?.toISOString() || "no expiry"}`);

    // EKeys
    const eKeysMap = new Map<string, EKeyData[]>();

    if (config.mode === "per-bucket") {
        // Op caveat
        const scopes = config.buckets.map(b => ({
            paths: [{ bucket: b.name, prefix: new TextEncoder().encode(b.prefix ?? "") }],
            ops: b.permissions.map(Number),
        }));
        const oc = encodeOpCaveat(scopes);
        const om = new Uint8Array(oc.length + 1); om[0] = 1; om.set(oc, 1);
        addCaveat(mcr, b64Encode(om));

        for (const b of config.buckets) {
            const ek = await deriveEKey(b.passphrase, config.accountId, b.name);
            eKeysMap.set(b.name, [ek]);
            info.push(`Bucket: ${b.name} [permissions: ${b.permissions.join(",")}]`);
        }
    } else {
        const ek = await deriveEKey(config.passphrase, config.accountId, "");
        eKeysMap.set("*", [ek]);
        info.push("Bucket: * (all buckets)");
    }

    const mcrOut = exportMacaroonV2(mcr);
    return {
        grant: b64urlEncode(encodeGrantProto(config.url, mcrOut, eKeysMap)),
        zkey: b64urlEncode(encodeZKeyProto(mcrOut)),
        info,
    };
}

// ─── Main ───────────────────────────────────────────────────────────────────

async function main() {
    const args = parseArgs(process.argv);

    if (hasFlag(args, "help") || process.argv.length <= 2) {
        printUsage();
        process.exit(0);
    }

    // Fix argon2-browser WASM
    const wasmPath = path.join(__dirname, "node_modules", "argon2-browser", "dist", "argon2.wasm");
    if (fs.existsSync(wasmPath)) {
        const wasmBuf = fs.readFileSync(wasmPath);
        const origFetch = globalThis.fetch;
        (globalThis as any).fetch = (input: any, init: any) => {
            if (typeof input === "string" && input.includes("argon2.wasm"))
                return Promise.resolve(new Response(new Uint8Array(wasmBuf), { status: 200, headers: { "Content-Type": "application/wasm" } }));
            return origFetch(input, init);
        };
    }

    // Build config
    let config: Config;
    const configPath = getArg(args, "config");
    if (configPath) {
        if (!fs.existsSync(configPath)) { console.error(`Config not found: ${configPath}`); process.exit(1); }
        config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
    } else {
        config = buildConfigFromArgs(args);
    }

    if (!config.rootZKey || !config.accountId || !config.url) {
        console.error("Missing required: rootZKey/--zkey, accountId/--account, url/--url");
        process.exit(1);
    }

    const quiet = hasFlag(args, "quiet");
    const jsonOutput = getArg(args, "output") === "json";

    if (!quiet) {
        console.error(`Mode: ${config.mode} | Account: ${config.accountId} | URL: ${config.url}`);
    }

    const result = await generateGrant(config);

    if (!quiet) {
        result.info.forEach(l => console.error(`  ${l}`));
    }

    if (jsonOutput) {
        console.log(JSON.stringify({ grant: result.grant, zkey: result.zkey }));
    } else {
        if (!quiet) {
            console.log("\n" + "=".repeat(60));
            console.log("GRANT:");
            console.log("=".repeat(60));
        }
        console.log(result.grant);
        if (!quiet) {
            console.log("=".repeat(60));
            console.log("\nZKEY:");
            console.log("=".repeat(60));
        } else {
            console.log(result.zkey);
        }
        if (!quiet) {
            console.log(result.zkey);
            console.log("=".repeat(60));
            console.log(`\nGrant: ${result.grant.length} chars | ZKey: ${result.zkey.length} chars`);
        }
    }
}

main().catch(err => { console.error("Error:", err.message || err); process.exit(1); });

path: package-lock.json
content: {
    "name": "aioz-storage-skill",
    "version": "1.0.0",
    "lockfileVersion": 3,
    "requires": true,
    "packages": {
        "": {
            "name": "aioz-storage-skill",
            "version": "1.0.0",
            "dependencies": {
                "argon2-browser": "^1.18.0"
            },
            "devDependencies": {
                "ts-node": "^10.9.2",
                "typescript": "^5.3.3"
            }
        },
        "node_modules/@cspotcode/source-map-support": {
            "version": "0.8.1",
            "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT",
            "dependencies": {
                "@jridgewell/trace-mapping": "0.3.9"
            },
            "engines": {
                "node": ">=12"
            }
        },
        "node_modules/@jridgewell/resolve-uri": {
            "version": "3.1.2",
            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT",
            "engines": {
                "node": ">=6.0.0"
            }
        },
        "node_modules/@jridgewell/sourcemap-codec": {
            "version": "1.5.5",
            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT"
        },
        "node_modules/@jridgewell/trace-mapping": {
            "version": "0.3.9",
            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT",
            "dependencies": {
                "@jridgewell/resolve-uri": "^3.0.3",
                "@jridgewell/sourcemap-codec": "^1.4.10"
            }
        },
        "node_modules/@tsconfig/node10": {
            "version": "1.0.12",
            "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT"
        },
        "node_modules/@tsconfig/node12": {
            "version": "1.0.11",
            "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT"
        },
        "node_modules/@tsconfig/node14": {
            "version": "1.0.3",
            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT"
        },
        "node_modules/@tsconfig/node16": {
            "version": "1.0.4",
            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT"
        },
        "node_modules/@types/node": {
            "version": "25.5.0",
            "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT",
            "peer": true,
            "dependencies": {
                "undici-types": "~7.18.0"
            }
        },
        "node_modules/acorn": {
            "version": "8.16.0",
            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT",
            "bin": {
                "acorn": "bin/acorn"
            },
            "engines": {
                "node": ">=0.4.0"
            }
        },
        "node_modules/acorn-walk": {
            "version": "8.3.5",
            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT",
            "dependencies": {
                "acorn": "^8.11.0"
            },
            "engines": {
                "node": ">=0.4.0"
            }
        },
        "node_modules/arg": {
            "version": "4.1.3",
            "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT"
        },
        "node_modules/argon2-browser": {
            "version": "1.18.0",
            "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz",
            "integrity": [REDACTED_SECRET],
            "license": "MIT"
        },
        "node_modules/create-require": {
            "version": "1.1.1",
            "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT"
        },
        "node_modules/diff": {
            "version": "4.0.4",
            "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "BSD-3-Clause",
            "engines": {
                "node": ">=0.3.1"
            }
        },
        "node_modules/make-error": {
            "version": "1.3.6",
            "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "ISC"
        },
        "node_modules/ts-node": {
            "version": "10.9.2",
            "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT",
            "dependencies": {
                "@cspotcode/source-map-support": "^0.8.0",
                "@tsconfig/node10": "^1.0.7",
                "@tsconfig/node12": "^1.0.7",
                "@tsconfig/node14": "^1.0.0",
                "@tsconfig/node16": "^1.0.2",
                "acorn": "^8.4.1",
                "acorn-walk": "^8.1.1",
                "arg": "^4.1.0",
                "create-require": "^1.1.0",
                "diff": "^4.0.1",
                "make-error": "^1.1.1",
                "v8-compile-cache-lib": "^3.0.1",
                "yn": "3.1.1"
            },
            "bin": {
                "ts-node": "dist/bin.js",
                "ts-node-cwd": "dist/bin-cwd.js",
                "ts-node-esm": "dist/bin-esm.js",
                "ts-node-script": "dist/bin-script.js",
                "ts-node-transpile-only": "dist/bin-transpile.js",
                "ts-script": "dist/bin-script-deprecated.js"
            },
            "peerDependencies": {
                "@swc/core": ">=1.2.50",
                "@swc/wasm": ">=1.2.50",
                "@types/node": "*",
                "typescript": ">=2.7"
            },
            "peerDependenciesMeta": {
                "@swc/core": {
                    "optional": true
                },
                "@swc/wasm": {
                    "optional": true
                }
            }
        },
        "node_modules/typescript": {
            "version": "5.9.3",
            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "Apache-2.0",
            "bin": {
                "tsc": "bin/tsc",
                "tsserver": "bin/tsserver"
            },
            "engines": {
                "node": ">=14.17"
            }
        },
        "node_modules/undici-types": {
            "version": "7.18.2",
            "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT",
            "peer": true
        },
        "node_modules/v8-compile-cache-lib": {
            "version": "3.0.1",
            "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT"
        },
        "node_modules/yn": {
            "version": "3.1.1",
            "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
            "integrity": [REDACTED_SECRET],
            "dev": true,
            "license": "MIT",
            "engines": {
                "node": ">=6"
            }
        }
    }
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)