Skill: dannylai999/clawsite-ai (version 1.0.5)

--- name: clawsite-ai description: >- Static website hosting for AI agents. Get a dedicated <slug>.clawsite.ai URL with HTTPS, deploy a zip of HTML / CSS / JS / images in one API call, atomic full-site replace, automatic CDN cache invalidation. Use when your agent generates static content (portfolios, news pages, link hubs, single-purpose landing pages) and wants to share it as a public URL. Free tier included, no credit card needed. version: 1.0.5 metadata: openclaw: requires: env: - CLAWSITE_API_KEY - CLAWSITE_SITE_ID primaryEnv: CLAWSITE_API_KEY homepage: https://clawsite.ai --- # Clawsite.ai — Static Website Hosting for AI Agents ## When to Use Clawsite - Your agent generates static HTML / CSS / JS / images and wants to publish them as a public URL - You want a zero-config hosting account with HTTPS, CloudFront CDN, and atomic deploys - You want a memorable random URL like `happy-otter-42.clawsite.ai` (the slug is auto-generated; you can't pick it) ## Quick Start **Your sandbox already has an account provisioned.** Check these env vars before doing anything else: | Env var | Required? | Purpose | |---|---|---| | `CLAWSITE_API_KEY` | **required** | Bearer token for all authenticated endpoints (`csk_live_*` format) | | `CLAWSITE_SITE_ID` | **required** | Your assigned site identifier (`site_<ulid>` format) | | `CLAWSITE_URL` | informational | Your live site URL, the one to share with the user (e.g. `https://happy-otter-42.clawsite.ai`). If unset, derive from `GET /v1/sites`. | | `CLAWSITE_API_URL` | optional | API base URL. **Defaults to `https://api.clawsite.ai` if unset.** Dev sandboxes override to `https://api.dev.clawsite.ai`. | If `CLAWSITE_API_KEY` or `CLAWSITE_SITE_ID` is unset, see "Standalone Registration" at the bottom. > Examples below use `$CLAWSITE_API_URL` literally; if it's unset, fall back to `https://api.clawsite.ai`. **API base: `$CLAWSITE_API_URL`/v1** All authenticated endpoints require `Authorization: Bearer $CLAWSITE_API_KEY`. ### 1. Deploy a directory of static files The deploy endpoint takes a `.zip` of your site contents (max 4 MB compressed; expanded contents must fit the per-site quota — see "Quotas" below). POST $CLAWSITE_API_URL/v1/sites/$CLAWSITE_SITE_ID/deploy Authorization: Bearer $CLAWSITE_API_KEY Content-Type: application/zip (body: raw bytes of the .zip) Workflow: 1. Create your files in a directory: ``` site/ index.html style.css app.js images/logo.png ``` 2. Zip the **contents** of the directory (no parent directory inside the zip): ``` cd site/ && zip -r ../site.zip . ``` 3. POST the zip: ``` curl -X POST "$CLAWSITE_API_URL/v1/sites/$CLAWSITE_SITE_ID/deploy" \ -H "Authorization: Bearer $CLAWSITE_API_KEY" \ -H "Content-Type: application/zip" \ --data-binary "@site.zip" ``` -> Returns: `siteId`, `url`, `fileCount`, `sizeBytes`, `deployedAt` (Unix seconds) ```json { "siteId": "site_...", "url": "https://happy-otter-42.clawsite.ai", "fileCount": 12, "sizeBytes": 458231, "deployedAt": 1744732800 } ``` **Atomic full-site replacement:** any files from the previous deploy that are NOT in the new zip get deleted. Deploy = full snapshot, not incremental upload. **Cache:** every deploy automatically invalidates CloudFront cache. Manual purge below is rarely needed. **Routing:** the path inside the zip becomes the URL path. `index.html` at the zip root is served at `/`. Subdirectories work: `images/logo.png` is at `/images/logo.png`. For pretty URLs without `.html`, name files like `about/index.html` and link as `/about/`. ### 2. Show the user their site The site is live at `$CLAWSITE_URL` immediately after a successful deploy. Tell the user: > "Your site is live at $CLAWSITE_URL" ### 3. Purge CloudFront cache (rarely needed) POST $CLAWSITE_API_URL/v1/sites/$CLAWSITE_SITE_ID/purge-cache Authorization: Bearer $CLAWSITE_API_KEY (no body) -> Returns: `siteId`, `purgedAt` ```json { "siteId": "site_...", "purgedAt": 1744732800 } ``` Use this only if the cache is serving stale content unrelated to a deploy. Normal deploys auto-invalidate. ### 4. List sites and check quota usage GET $CLAWSITE_API_URL/v1/sites Authorization: Bearer $CLAWSITE_API_KEY -> Returns: array of `{ siteId, slug, url, sizeBytes, fileCount, lastDeployAt }` ```json { "sites": [{ "siteId": "site_...", "slug": "happy-otter-42", "url": "https://happy-otter-42.clawsite.ai", "sizeBytes": 458231, "fileCount": 12, "lastDeployAt": 1744732800 }] } ``` Use this to verify your `CLAWSITE_SITE_ID` matches and to inspect current usage vs quotas. ## Other Endpoints ### Delete a site DELETE $CLAWSITE_API_URL/v1/sites/{siteId} Authorization: Bearer $CLAWSITE_API_KEY Permanently deletes the site and every file under it. The slug is tombstoned (kept reserved forever) so the URL can never be re-used by another account — this prevents URL takeover of a previously-shared link. ## Standalone Registration (no sandbox env vars) If you're running outside a ZenClaw sandbox and `CLAWSITE_API_KEY` isn't pre-set, register via email OTP. Verification is delegated to MBID (MixerBox ID). **Step 1** — request a 6-digit code via email: POST $CLAWSITE_API_URL/v1/register Content-Type: application/json ```json { "email": "[REDACTED_SECRET]" } ``` -> Returns: `{ "challengeId": "<JWT>" }` **Step 2** — verify (after the 6-digit code arrives in your inbox): POST $CLAWSITE_API_URL/v1/register Content-Type: application/json ```json { "challengeId": "<JWT from step 1>", "code": "123456" } ``` -> Returns: `accountId`, `apiKey`, and a default `sites[]` entry. **Save the `apiKey` immediately — it cannot be recovered.** You can also create additional sites later via: POST $CLAWSITE_API_URL/v1/sites Authorization: Bearer $apiKey (no body needed; slug is auto-assigned) > Note: in v1 the per-account site quota is 1, so this returns 409 `quota_exceeded` if you already have one site. ## Quotas (v1) | Item | Limit | |---|---| | Sites per account | **1** | | Storage per site | **3 MB** (uncompressed total) | | Max single file | **1 MB** | | Max files per site | **100** | | Max compressed zip body | **4 MB** | | Deploy frequency | **30 / hour** per account | | Purge frequency | **15 / hour** per account | | Bandwidth | **unlimited** | **Allowed file extensions:** `html`, `css`, `js`, `json`, `svg`, `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico`, `woff2`, `txt`, `md`. Anything else (e.g. `.php`, `.exe`, `.py`) → 400 `unsupported_extension`. ## Errors All errors return JSON: ```json { "error": { "code": "<machine-code>", "message": "<human-readable>" } } ``` | Code | HTTP | Cause | |---|---|---| | `unauthorized` | 401 | Missing or invalid API key | | `missing_fields` | 400 | Required fields absent or malformed (e.g. invalid email shape on register) | | `not_found` | 404 | Site doesn't belong to your account, or doesn't exist | | `quota_exceeded` | 409 | Sites limit, storage limit, or rate limit hit | | `unsupported_extension` | 400 | File extension not in the whitelist above | | `file_too_large` | 400 | Single file > 1 MB, or zip body > 4 MB | | `invalid_zip` | 400 | Body not a valid zip, missing body, or contains paths with `..` / absolute paths | | `mbid_error` | upstream | Forwarded from MBID's email-verify endpoints (`mx_record_not_found`, `domain_typo`, `too_many_request`, `incorrect_verification_code`) — only relevant during email registration | | `internal_error` | 500 | Server-side inconsistent state. Should never happen — report if seen. | ### 🛑 How to handle `409 quota_exceeded` on `/deploy` — DO NOT RE-REGISTER A `409 quota_exceeded` on `POST /v1/sites/{siteId}/deploy` means **you hit the per-hour deploy rate limit (30/hr per account)**. It is NOT an authentication or account problem. Your `CLAWSITE_API_KEY` and `CLAWSITE_SITE_ID` are perfectly valid. The ONLY correct response is to **tell the user the rate limit was hit and they can try again in less than 1 hour**. For example: > "I've hit ClawSite's deploy rate limit (30 deploys/hour per account). Your site is still live at $CLAWSITE_URL — try the next deploy in a few minutes / next hour, whichever comes first." **Forbidden actions on 409 (these will destroy the user's existing site):** - ❌ Do NOT `DELETE` the existing site to "free up" or "reset" quota — DELETE permanently tombstones the slug (the URL is lost forever) and does **not** clear the quota counter. - ❌ Do NOT call `/v1/register` again hoping for a fresh `apiKey` — `/v1/register` is idempotent on MBID identity; it returns the SAME account and SAME `apiKey` regardless of how many times you call it. Calling it does not bypass quota. - ❌ Do NOT use the "Standalone Registration" section below — that is ONLY for users running outside a pre-provisioned sandbox (i.e. when `CLAWSITE_API_KEY` is genuinely unset). If you have a `CLAWSITE_API_KEY` env var, you are already registered; do not register again. - ❌ Do NOT try to "DELETE + re-register fresh" to dodge the quota — see the bold warning under the Idempotency note below. **Same rules apply to `429 / 503 / 500` from `/deploy`**: report to the user, do not destructive-reset the account. **Idempotency note:** `/v1/register` is idempotent on MBID identity. Re-calling it with the same MBID-verified email (or partner-mode `mbidUserId`) returns the existing `accountId`, the same `apiKey` that was issued on first register, and the existing `sites`. There is no rotation API. **⚠️ DANGER — only delete a site if the user *explicitly* asked to delete it:** if you genuinely need a fresh slug (rare — the user said "give me a new URL"), `DELETE /v1/sites/{siteId}` then re-register. This is **destructive and irreversible**: the deleted slug is permanently tombstoned (anti URL-takeover) and can never be re-used. The user loses any URL they previously shared. **Never** do this as a side-effect of error handling — only when the user has explicitly told you to delete the existing site.

path: CLAUDE.md
content: # ClawSite

Agent-first static website hosting microservice, modeled after [ClawMail](https://github.com/MixerBox/clawmail).

## Status (2026-04-29)

✅ **v1 deployed to dev + prod, smoke-tested live, partner mode confirmed working end-to-end.**

| Env | API | Site URL pattern | Partner secret on |
|---|---|---|---|
| Dev / staging | `https://api.dev.clawsite.ai` | `<slug>.dev.clawsite.ai` | `claws-instance-controller-staging` Lambda env (`PARTNER_CLAWSITE_SECRET`) |
| Prod | `https://api.clawsite.ai` | `<slug>.clawsite.ai` | `claws-instance-controller` Lambda env (`PARTNER_CLAWSITE_SECRET`) |

CloudFront distribution IDs: dev `E3VZOWBAWTVYBK`, prod `ESXYWGNPX39MP`.

Both envs live in AWS account `974718210214` (us-east-1), same account as ClawMail. Route53 zone `clawsite.ai` is shared between dev/prod.

**Email-OTP path is implemented but inert until MBID provisions a `serverKey` for the `clawsite-dev` / `clawsite-prod` apps in MBID `TABLE_APP`.** Once that's done, set `MBID_SERVER_KEY` on the api Lambda env (Terraform var: `mbid_server_key`) and email registration unlocks. Partner mode (the only path ZenClaw uses today) doesn't depend on this.

## Reference repos

- `~/Work/MixerBox/clawmail/` — the structural model. When in doubt about a pattern (`globalThis.__routes`, Vitest mocks, Terraform), look at how ClawMail does it.
- `~/Work/MixerBox/zenclaw/` — the first partner. Plan 2 (`configure-clawsite` controller action + sandbox env injection + `clawsite-ai` skill install) lands there.
- `~/Work/MixerBox/microservice/microservice/kubernetes/livapp/apps/mb-id/` — MBID service we delegate email verification to.

## Quick reference

- **Language:** TypeScript (Node.js 20, ES2022 modules)
- **Test:** `npx vitest run`
- **Typecheck:** `npx tsc --noEmit`
- **Build:** `node build.mjs` (outputs `.mjs` to `dist/`)
- **Deploy** (Terraform: `terraform/` dir, two workspaces `dev` + `prod`):
  ```bash
  # build + zip Lambda artifact (load-bearing filename: api.zip)
  node build.mjs
  cd dist && zip -j api.zip api.mjs api.mjs.map

  # dev — uses claws-instance-controller-staging partner secret
  cd ../terraform && terraform workspace select dev && terraform apply \
    -var "site_domain=dev.clawsite.ai" \
    -var "api_domain=api.dev.clawsite.ai" \
    -var "environment=staging" \
    -var "partner_secret=$(aws lambda get-function-configuration \
      --function-name claws-instance-controller-staging \
      --query 'Environment.Variables.PARTNER_CLAWSITE_SECRET' --output text)"

  # prod — uses claws-instance-controller partner secret
  terraform workspace select prod && terraform apply \
    -var "site_domain=clawsite.ai" \
    -var "api_domain=api.clawsite.ai" \
    -var "environment=prod" \
    -var "mbid_api_base_url=https://api.id.mixerbox.com" \
    -var "partner_secret=$(aws lambda get-function-configuration \
      --function-name claws-instance-controller \
      --query 'Environment.Variables.PARTNER_CLAWSITE_SECRET' --output text)"
  ```

  The `partner_secret` flow: ZenClaw's controller Lambda holds `PARTNER_CLAWSITE_SECRET` as an env var (set once via `aws lambda update-function-configuration`); ClawSite's Terraform reads it at apply time so the two sides cannot drift. Same pattern as ClawMail's `PARTNER_CLAWMAIL_SECRET`.
- **Hot-patch Lambda code only** (when only src/ changed, no infra): `node build.mjs && cd dist && zip -j api.zip api.mjs api.mjs.map && for fn in clawsite-staging-api clawsite-api; do aws lambda update-function-code --function-name "$fn" --zip-file fileb://api.zip; done`
- **Publish skill to ClawHub:** `clawhub publish .` (run from repo root; `SKILL.md` is the manifest)
- **Spec:** [`docs/superpowers/specs/2026-04-15-clawsite-design.md`](docs/superpowers/specs/2026-04-15-clawsite-design.md)

### Resource naming convention

`local.prefix` in `terraform/main.tf` derives the AWS resource name prefix from `var.environment`:

- `var.environment = "prod"` → `local.prefix = "clawsite"` → `clawsite-api`, `clawsite-manifest`, `clawsite-sites-<account>`
- anything else (typically `staging` for dev workspace) → `local.prefix = "clawsite-${env}"` → `clawsite-staging-api`, `clawsite-staging-manifest`, `clawsite-staging-sites-<account>`

The dev workspace uses `environment=staging` to match ZenClaw's `claws-instance-controller-staging` controller naming. **`-var environment=...` is required on every apply** (no default — pass `prod` for prod, `staging` for dev).

## Architecture (single Lambda)

| Lambda | Trigger | Purpose |
|---|---|---|
| `api` | API Gateway HTTP API (`ANY /{proxy+}`) | All REST endpoints — register, sites CRUD, deploy, purge-cache |

There used to be a `cleanup` Lambda intended for daily housekeeping, but it had no EventBridge trigger and nothing to clean (quota counters self-expire via DDB TTL; slug tombstones are by-design permanent). Removed 2026-04-30. If real housekeeping logic emerges later, re-add the Lambda + EventBridge rule together.

Storage:
- **DynamoDB** single table `clawsite[-staging]-manifest` — accounts, API keys, sites, slug reservations, quota counters. GSIs: `byApiKeyHash`, `byMbid`.
- **S3** `clawsite[-staging]-sites-<account-id>` — static site files, partitioned by slug prefix.
- **CloudFront** distribution + CloudFront Function for `<slug>` host → S3 prefix routing.

## MBID integration (email OTP)

Email OTP registration delegates to **MBID** (`api.id.{dev.,}mixerbox.com`). ClawSite calls:
- `POST /api/request_email_verify` → returns a `verifyToken` JWT we pass back as our `challengeId`
- `PUT /api/verify_email` → returns `user.uuid` which becomes our `mbidUserId`

Net effect: **every ClawSite account is keyed by an MBID UUID**. Partner-mode registration (ZenClaw) and email-OTP registration both deduplicate via the same `byMbid` GSI — one user, one ClawSite account, regardless of how they arrived.

To activate email-OTP path:
1. Register `clawsite-dev` and `clawsite-prod` as apps in MBID's `TABLE_APP` to obtain `serverKey` values
2. Update Lambda env vars: `MBID_SERVER_KEY` (the serverKey for that env) and `MBID_API_BASE_URL` (already set per env)

Email-OTP code is in place with mocked tests; real serverKey just unlocks the production path.

### Removed from original spec (no longer relevant)

The spec originally called for self-hosted SES + SQS + a `deliver-email` Lambda. The MBID rework dropped all of that:

- ❌ No SES domain identity / DKIM / SPF / DMARC
- ❌ No SQS verification queue / DLQ
- ❌ No `deliver-email` Lambda (architecture went 3 → 2 Lambdas)
- ❌ No DynamoDB `challenge#*` items (MBID's own `verifyToken` JWT is stateless on our side)

If re-reading the spec, treat any reference to those items as historical.

## Branching

- `main` — what's deployed to prod (auto-deploy is manual via `terraform apply` on prod workspace)
- `develop` — staging-equivalent; merges back to `main` after smoke tests
- Bug fixes / features land via `fix/*` or `feat/*` branches → develop → main

## Project structure

```
src/
├── handlers/
│   ├── api.ts              # API Gateway entry; regex route table on globalThis.__routes
│   └── routes/             # one file per endpoint group, registered via side-effect imports
│       ├── register.ts
│       ├── sites.ts
│       ├── deploy.ts
│       └── purge.ts
├── lib/                    # AWS-thin utilities
│   ├── auth.ts             # SHA-256 + timing-safe apiKey verify + partner secret check
│   ├── cloudfront.ts       # invalidatePrefix() helper
│   ├── dynamo.ts           # DDB DocumentClient + PK/SK builders + table/GSI names + hourWindow()
│   ├── errors.ts           # AppError class, JSON response helpers, CORS headers
│   ├── id.ts               # acc_, site_, csk_live_* ID generators
│   ├── mbid.ts             # MBID thin client (requestEmailVerify, verifyEmail)
│   ├── quota.ts            # Hourly window counters with atomic increments
│   ├── s3.ts               # S3 client + bucket name
│   ├── slug.ts             # Random adjective-animal-NN slug + fallback hex slug
│   ├── validate.ts         # File extension whitelist, size limits, QUOTA constants
│   └── zip.ts              # Streaming zip extraction with validation
└── middleware/
    └── auth.ts             # Bearer apiKey → AuthContext via byApiKeyHash GSI
build.mjs                   # esbuild bundler (entry: api.ts, cleanup.ts)
terraform/                  # AWS infra; workspaces dev + prod
tests/                      # Vitest tree mirrors src/
docs/superpowers/{specs,plans}/  # Design specs and execution plans
```

## Key patterns

### Route registration
Routes register via side-effect imports in `src/handlers/api.ts`. Each route file calls `route(method, path, handler)` at module level. Routes are stored in `globalThis.__routes` to avoid ESM circular-import TDZ issues — same pattern as ClawMail.

Public (no-auth) routes: `route('POST', '/path', handler, { public: true })` — currently only `/v1/register`.

The `route()` function defensively initializes `globalThis.__routes` because esbuild bundling can place dependency module top-level code before the entry's initialization. See comment in `api.ts` for context.

### Auth
`Authorization: Bearer <token>`:
- `csk_live_*` → SHA-256 hash → DynamoDB GSI lookup `byApiKeyHash` (per-account API key)
- Partner secret → on `POST /v1/register` only, matched in handler before falling through to email-mode body shapes

### Single-table DynamoDB
Active PK/SK prefixes (live in code):
- `account#<id>` + `meta` / `apikey#<hash>` / `site#<id>` / `quota#<action>#<window>`
- `slug#<slug>` + `meta` (global slug reservation; status flips to `tombstoned` on site delete and stays forever — prevents URL takeover)

`account#<id>` + `meta` includes a `sitesCount` numeric field, atomically updated by site CRUD operations to enforce the per-account site quota race-free (see `src/handlers/routes/sites.ts`).

GSIs: `byApiKeyHash` (apiKey lookup → accountId), `byMbid` (MBID UUID → accountId for cross-flow idempotency).

### Quota enforcement
- Per-account site count: atomic via `sitesCount` counter on account meta + conditional update inside the site-creation TransactWrite. Can never race.
- Per-hour deploy / purge frequency: hourly window counter in `src/lib/quota.ts`, gated on a single conditional `ADD count :one` inside the action handler.

### ClawHub publishing
`SKILL.md` at the repo root is the agent-facing API doc + ClawHub manifest. Published via `clawhub publish .`. Skill slug: `clawsite-ai`. Once Plan 2 lands, ZenClaw sandboxes will `clawhub install clawsite-ai` during provisioning.

### Testing
Vitest with `vi.mock('@lib/dynamo')`. Route handler tests also mock `@handlers/api` to export a no-op `route` function, avoiding circular imports. Path aliases live in both `tsconfig.json` and `vitest.config.ts` — keep them in sync.

## Known gaps / follow-ups

- **Plan 2 (ZenClaw integration)** is in flight in `~/Work/MixerBox/zenclaw` on `feat/configure-clawsite-action`. Adds the `configure-clawsite` controller action + manifest fields. Sandbox-side env injection + `clawhub install clawsite-ai` are still TODO.


path: README.md
content: # ClawSite

Agent-first static website hosting microservice. Lets an AI agent claim hosting space and ship a site with zero human interaction.

- **Production domain:** `clawsite.ai` — API at `api.clawsite.ai`
- **Development domain:** `dev.clawsite.ai` — API at `api.dev.clawsite.ai`
- **First partner:** ZenClaw (`MixerBox/zenclaw`)
- **Skill:** `clawsite-ai` (published to ClawHub)
- **Sibling service:** [`MixerBox/clawmail`](https://github.com/MixerBox/clawmail) — same operational shape (Terraform, esbuild, single-Lambda + CloudFront-fronted S3, ClawHub-published skill)

See `CLAUDE.md` for the architecture / deploy reference, `docs/superpowers/specs/` for the design spec, `tests/e2e/README.md` for live-API smoke tests.

## Status

✅ Deployed live to dev + prod. Both AWS account `974718210214` (us-east-1).

| Env | API | Site URL pattern |
|---|---|---|
| Dev / staging | `https://api.dev.clawsite.ai` | `<slug>.dev.clawsite.ai` |
| Prod | `https://api.clawsite.ai` | `<slug>.clawsite.ai` |

ZenClaw partner-mode integration is the active code path. Email-OTP registration is implemented but inert until MBID provisions a `serverKey` for the `clawsite-dev` / `clawsite-prod` apps.


path: package.json
content: {
  "name": "clawsite",
  "version": "0.1.0",
  "description": "Agent-first static website hosting microservice (clawsite.ai)",
  "type": "module",
  "scripts": {
    "build": "node build.mjs",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "vitest run --config vitest.e2e.config.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@aws-sdk/client-cloudfront": "^3.1007.0",
    "@aws-sdk/client-dynamodb": "^3.1007.0",
    "@aws-sdk/client-s3": "^3.1007.0",
    "@aws-sdk/lib-dynamodb": "^3.1007.0",
    "ulid": "^3.0.2",
    "yauzl": "^3.2.0"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.161",
    "@types/node": "^25.4.0",
    "@types/yauzl": "^2.10.3",
    "@types/yazl": "^3.3.1",
    "esbuild": "^0.27.3",
    "typescript": "^5.9.3",
    "vitest": "^4.0.18",
    "yazl": "^3.3.1"
  }
}


path: vitest.e2e.config.ts
content: import { defineConfig } from 'vitest/config';
import path from 'path';

/**
 * E2E test config — runs against a live ClawSite API (dev or prod).
 *
 * Usage:
 *   CLAWSITE_E2E_API_URL=https://api.dev.clawsite.ai \
 *   CLAWSITE_E2E_PARTNER_SECRET=<secret> \
 *   npm run test:e2e
 *
 * If env vars are unset, individual tests `describe.skip` themselves so the
 * suite still runs (and reports "skipped") rather than crashing on import.
 *
 * Each test creates a fresh account against a randomly-generated MBID UUID
 * and cleans up its sites in afterAll. Worst case (afterAll fails): a
 * leftover account row in DDB pinned to a UUID nobody else uses.
 */
export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['tests/e2e/**/*.test.ts'],
    // E2E hits the network — long timeouts. CloudFront propagation alone
    // routinely takes 5-20s, so we budget 60s per test, 120s for the
    // full lifecycle (which runs ~6 sequential network calls + cache wait).
    testTimeout: 120_000,
    hookTimeout: 60_000,
    // Run sequentially — each test creates DDB / S3 / CloudFront state, and
    // the CloudFront purge quota (5/hr per account) means parallel test
    // accounts could trip rate limits if anything reuses creds.
    fileParallelism: false,
    sequence: { concurrent: false },
  },
  resolve: {
    alias: {
      '@lib': path.resolve(__dirname, './src/lib'),
      '@handlers': path.resolve(__dirname, './src/handlers'),
      '@middleware': path.resolve(__dirname, './src/middleware'),
    },
  },
});


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