+1
-3
.dockerignore
+1
-3
.dockerignore
+1
.gitignore
+1
.gitignore
-3
.gitmodules
-3
.gitmodules
-1
.tangled/workflows/deploy-wisp.yml
-1
.tangled/workflows/deploy-wisp.yml
+4
.tangled/workflows/test.yml
+4
.tangled/workflows/test.yml
···
14
14
- name: install dependencies
15
15
command: |
16
16
export PATH="$HOME/.nix-profile/bin:$PATH"
17
+
18
+
# have to regenerate otherwise it wont install necessary dependencies to run
19
+
rm -rf bun.lock package-lock.json
20
+
bun install @oven/bun-linux-aarch64
17
21
bun install
18
22
19
23
- name: run all tests
+10
-19
Dockerfile
+10
-19
Dockerfile
···
5
5
WORKDIR /app
6
6
7
7
# Copy package files
8
-
COPY package.json bun.lock* ./
8
+
COPY package.json ./
9
+
10
+
# Copy Bun configuration
11
+
COPY bunfig.toml ./
12
+
13
+
COPY tsconfig.json ./
9
14
10
15
# Install dependencies
11
-
RUN bun install --frozen-lockfile
16
+
RUN bun install
12
17
13
18
# Copy source code
14
19
COPY src ./src
15
20
COPY public ./public
16
21
17
-
# Build the application (if needed)
18
-
RUN bun build \
19
-
--compile \
20
-
--minify \
21
-
--outfile server \
22
-
src/index.ts
23
-
24
-
FROM scratch AS runtime
25
-
WORKDIR /app
26
-
COPY --from=base /app/server /app/server
27
-
28
-
# Set environment variables (can be overridden at runtime)
29
-
ENV PORT=3000
22
+
ENV PORT=8000
30
23
ENV NODE_ENV=production
31
24
32
-
# Expose the application port
33
-
EXPOSE 3000
25
+
EXPOSE 8000
34
26
35
-
# Start the application
36
-
CMD ["./server"]
27
+
CMD ["bun", "start"]
+99
-5
README.md
+99
-5
README.md
···
1
1
# Wisp.place
2
-
A static site hosting service built on the AT Protocol. [https://wisp.place](https://wisp.place)
3
2
4
-
/src is the main backend
3
+
Decentralized static site hosting on the AT Protocol. [https://wisp.place](https://wisp.place)
4
+
5
+
## What is this?
5
6
6
-
/hosting-service is the microservice that serves on-disk caches of sites pulled from the firehose and pdses
7
+
Host static sites in your AT Protocol repo, served with CDN distribution. Your PDS holds the cryptographically signed manifest and files - the source of truth. Hosting services index and serve them fast.
7
8
8
-
/cli is the wisp-cli, a way to upload sites directly to the pds
9
+
## Quick Start
9
10
10
-
full readme soon
11
+
```bash
12
+
# Using the web interface
13
+
Visit https://wisp.place and sign in
14
+
15
+
# Or use the CLI
16
+
cd cli
17
+
cargo build --release
18
+
./target/release/wisp-cli your-handle.bsky.social --path ./my-site --site my-site
19
+
```
20
+
21
+
Your site appears at `https://sites.wisp.place/{your-did}/{site-name}` or your custom domain.
22
+
23
+
## Architecture
24
+
25
+
- **`/src`** - Main backend (OAuth, site management, custom domains)
26
+
- **`/hosting-service`** - Microservice that serves cached sites from disk
27
+
- **`/cli`** - Rust CLI for direct PDS uploads
28
+
- **`/public`** - React frontend
29
+
30
+
### How it works
31
+
32
+
1. Sites stored as `place.wisp.fs` records in your AT Protocol repo
33
+
2. Files compressed (gzip) and base64-encoded as blobs
34
+
3. Hosting service watches firehose, caches sites locally
35
+
4. Sites served via custom domains or `*.wisp.place` subdomains
36
+
37
+
## Development
38
+
39
+
```bash
40
+
# Backend
41
+
bun install
42
+
bun run src/index.ts
43
+
44
+
# Hosting service
45
+
cd hosting-service
46
+
npm run start
47
+
48
+
# CLI
49
+
cd cli
50
+
cargo build
51
+
```
52
+
53
+
## Features
54
+
55
+
### URL Redirects and Rewrites
56
+
57
+
The hosting service supports Netlify-style `_redirects` files for managing URLs. Place a `_redirects` file in your site root to enable:
58
+
59
+
- **301/302 Redirects**: Permanent and temporary URL redirects
60
+
- **200 Rewrites**: Serve different content without changing the URL
61
+
- **404 Custom Pages**: Custom error pages for specific paths
62
+
- **Splats & Placeholders**: Dynamic path matching (`/blog/:year/:month/:day`, `/news/*`)
63
+
- **Query Parameter Matching**: Redirect based on URL parameters
64
+
- **Conditional Redirects**: Route by country, language, or cookie presence
65
+
- **Force Redirects**: Override existing files with redirects
66
+
67
+
Example `_redirects`:
68
+
```
69
+
# Single-page app routing (React, Vue, etc.)
70
+
/* /index.html 200
71
+
72
+
# Simple redirects
73
+
/home /
74
+
/old-blog/* /blog/:splat
75
+
76
+
# API proxy
77
+
/api/* https://api.example.com/:splat 200
78
+
79
+
# Country-based routing
80
+
/ /us/ 302 Country=us
81
+
/ /uk/ 302 Country=gb
82
+
```
83
+
84
+
## Limits
85
+
86
+
- Max file size: 100MB (PDS limit)
87
+
- Max files: 2000
88
+
89
+
## Tech Stack
90
+
91
+
- Backend: Bun + Elysia + PostgreSQL
92
+
- Frontend: React 19 + Tailwind 4 + Radix UI
93
+
- Hosting: Node microservice using Hono
94
+
- CLI: Rust + Jacquard (AT Protocol library)
95
+
- Protocol: AT Protocol OAuth + custom lexicons
96
+
97
+
## License
98
+
99
+
MIT
100
+
101
+
## Links
102
+
103
+
- [AT Protocol](https://atproto.com)
104
+
- [Jacquard Library](https://tangled.org/@nonbinary.computer/jacquard)
+152
-212
bun.lock
+152
-212
bun.lock
···
1
1
{
2
2
"lockfileVersion": 1,
3
+
"configVersion": 0,
3
4
"workspaces": {
4
5
"": {
5
6
"name": "elysia-static",
···
13
14
"@elysiajs/openapi": "^1.4.11",
14
15
"@elysiajs/opentelemetry": "^1.4.6",
15
16
"@elysiajs/static": "^1.4.2",
17
+
"@radix-ui/react-checkbox": "^1.3.3",
16
18
"@radix-ui/react-dialog": "^1.1.15",
17
19
"@radix-ui/react-label": "^2.1.7",
18
20
"@radix-ui/react-radio-group": "^1.3.8",
19
21
"@radix-ui/react-slot": "^1.2.3",
20
22
"@radix-ui/react-tabs": "^1.1.13",
21
23
"@tanstack/react-query": "^5.90.2",
24
+
"actor-typeahead": "^0.1.1",
25
+
"atproto-ui": "^0.11.3",
22
26
"class-variance-authority": "^0.7.1",
23
27
"clsx": "^2.1.1",
24
28
"elysia": "latest",
25
29
"iron-session": "^8.0.4",
26
30
"lucide-react": "^0.546.0",
31
+
"multiformats": "^13.4.1",
32
+
"prismjs": "^1.30.0",
27
33
"react": "^19.2.0",
28
34
"react-dom": "^19.2.0",
29
-
"react-shiki": "^0.9.0",
30
35
"tailwind-merge": "^3.3.1",
31
36
"tailwindcss": "4",
32
37
"tw-animate-css": "^1.4.0",
···
38
43
"@types/react-dom": "^19.2.1",
39
44
"bun-plugin-tailwind": "^0.1.2",
40
45
"bun-types": "latest",
46
+
"esbuild": "0.26.0",
41
47
},
42
48
},
43
49
},
44
50
"trustedDependencies": [
45
51
"core-js",
52
+
"cbor-extract",
53
+
"bun",
46
54
"protobufjs",
47
55
],
48
56
"packages": {
57
+
"@atcute/atproto": ["@atcute/atproto@3.1.9", "", { "dependencies": { "@atcute/lexicons": "^1.2.2" } }, "sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w=="],
58
+
59
+
"@atcute/bluesky": ["@atcute/bluesky@3.2.10", "", { "dependencies": { "@atcute/atproto": "^3.1.9", "@atcute/lexicons": "^1.2.2" } }, "sha512-qwQWTzRf3umnh2u41gdU+xWYkbzGlKDupc3zeOB+YjmuP1N9wEaUhwS8H7vgrqr0xC9SGNDjeUVcjC4m5BPLBg=="],
60
+
61
+
"@atcute/client": ["@atcute/client@4.0.5", "", { "dependencies": { "@atcute/identity": "^1.1.1", "@atcute/lexicons": "^1.2.2" } }, "sha512-R8Qen8goGmEkynYGg2m6XFlVmz0GTDvQ+9w+4QqOob+XMk8/WDpF4aImev7WKEde/rV2gjcqW7zM8E6W9NShDA=="],
62
+
63
+
"@atcute/identity": ["@atcute/identity@1.1.1", "", { "dependencies": { "@atcute/lexicons": "^1.2.2", "@badrap/valita": "^0.4.6" } }, "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q=="],
64
+
65
+
"@atcute/identity-resolver": ["@atcute/identity-resolver@1.1.4", "", { "dependencies": { "@atcute/lexicons": "^1.2.2", "@atcute/util-fetch": "^1.0.3", "@badrap/valita": "^0.4.6" }, "peerDependencies": { "@atcute/identity": "^1.0.0" } }, "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA=="],
66
+
67
+
"@atcute/lexicons": ["@atcute/lexicons@1.2.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "esm-env": "^1.2.2" } }, "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA=="],
68
+
69
+
"@atcute/tangled": ["@atcute/tangled@1.0.10", "", { "dependencies": { "@atcute/atproto": "^3.1.8", "@atcute/lexicons": "^1.2.2" } }, "sha512-DGconZIN5TpLBah+aHGbWI1tMsL7XzyVEbr/fW4CbcLWYKICU6SAUZ0YnZ+5GvltjlORWHUy7hfftvoh4zodIA=="],
70
+
71
+
"@atcute/util-fetch": ["@atcute/util-fetch@1.0.3", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ=="],
72
+
49
73
"@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.2", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ=="],
50
74
51
75
"@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="],
52
76
53
-
"@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.1.10", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-o7hGaonA71A6p7O107VhM6UBUN/g9tTyYohMp1q0Kf6xQ4npnuZYRSHSf2g6reSfGQJ1GoFNjBObETTT1ge/jQ=="],
77
+
"@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="],
54
78
55
79
"@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.2", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-KIerCzh3qb+zZoqWbIvTlvBY0XPq0r56kwViaJY/LTe/3oPO2JaqlYKS/F4dByWBhHK6YoUOJ0sWrh6PMJl40A=="],
56
80
57
-
"@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.20", "", { "dependencies": { "@atproto-labs/fetch-node": "0.1.10", "@atproto-labs/handle-resolver": "0.3.2", "@atproto/did": "0.2.1" } }, "sha512-094EL61XN9M7vm22cloSOxk/gcTRaCK52vN7BYgXgdoEI8uJJMTFXenQqu+LRGwiCcjvyclcBqbaz0DzJep50Q=="],
81
+
"@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.21", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.2", "@atproto/did": "0.2.1" } }, "sha512-fuJy5Px5pGF3lJX/ATdurbT8tbmaFWtf+PPxAQDFy7ot2no3t+iaAgymhyxYymrssOuWs6BwOP8tyF3VrfdwtQ=="],
58
82
59
83
"@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.2", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver": "0.3.2" } }, "sha512-MYxO9pe0WsFyi5HFdKAwqIqHfiF2kBPoVhAIuH/4PYHzGr799ED47xLhNMxR3ZUYrJm5+TQzWXypGZ0Btw1Ffw=="],
60
84
···
64
88
65
89
"@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="],
66
90
67
-
"@atproto/api": ["@atproto/api@0.17.3", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-pdQXhUAapNPdmN00W0vX5ta/aMkHqfgBHATt20X02XwxQpY2AnrPm2Iog4FyjsZqoHooAtCNV/NWJ4xfddJzsg=="],
91
+
"@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
68
92
69
93
"@atproto/common": ["@atproto/common@0.4.12", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@ipld/dag-cbor": "^7.0.3", "cbor-x": "^1.5.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-NC+TULLQiqs6MvNymhQS5WDms3SlbIKGLf4n33tpftRJcalh507rI+snbcUb7TLIkKw7VO17qMqxEXtIdd5auQ=="],
70
94
···
80
104
81
105
"@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="],
82
106
83
-
"@atproto/lex-cli": ["@atproto/lex-cli@0.9.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-zun4jhD1dbjD7IHtLIjh/1UsMx+6E8+OyOT2GXYAKIj9N6wmLKM/v2OeQBKxcyqUmtRi57lxWnGikWjjU7pplQ=="],
107
+
"@atproto/lex-cli": ["@atproto/lex-cli@0.9.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-EedEKmURoSP735YwSDHsFrLOhZ4P2it8goCHv5ApWi/R9DFpOKOpmYfIXJ9MAprK8cw+yBnjDJbzpLJy7UXlTg=="],
84
108
85
109
"@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
86
110
87
-
"@atproto/oauth-client": ["@atproto/oauth-client@0.5.7", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.4.2", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-pDvbvy9DCxrAJv7bAbBUzWrHZKhFy091HvEMZhr+EyZA6gSCGYmmQJG/coDj0oICSVQeafAZd+IxR0YUCWwmEg=="],
111
+
"@atproto/oauth-client": ["@atproto/oauth-client@0.5.8", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.5.0", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-7YEym6d97+Dd73qGdkQTXi5La8xvCQxwRUDzzlR/NVAARa9a4YP7MCmqBJVeP2anT0By+DSAPyPDLTsxcjIcCg=="],
88
112
89
-
"@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.9", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver-node": "0.1.20", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.7", "@atproto/oauth-types": "0.4.2" } }, "sha512-JdzwDQ8Gczl0lgfJNm7lG7omkJ4yu99IuGkkRWixpEvKY/jY/mDZaho+3pfd29SrUvwQOOx4Bm4l7DGeYwxxyA=="],
113
+
"@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.10", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver-node": "0.1.21", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.8", "@atproto/oauth-types": "0.5.0" } }, "sha512-6khKlJqu1Ed5rt3rzcTD5hymB6JUjKdOHWYXwiphw4inkAIo6GxLCighI4eGOqZorYk2j8ueeTNB6KsgH0kcRw=="],
90
114
91
-
"@atproto/oauth-types": ["@atproto/oauth-types@0.4.2", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-gcfNTyFsPJcYDf79M0iKHykWqzxloscioKoerdIN3MTS3htiNOSgZjm2p8ho7pdrElLzea3qktuhTQI39j1XFQ=="],
115
+
"@atproto/oauth-types": ["@atproto/oauth-types@0.5.0", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-33xz7HcXhbl+XRqbIMVu3GE02iK1nKe2oMWENASsfZEYbCz2b9ZOarOFuwi7g4LKqpGowGp0iRKsQHFcq4SDaQ=="],
92
116
93
117
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
94
118
···
96
120
97
121
"@atproto/xrpc-server": ["@atproto/xrpc-server@0.9.5", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@atproto/xrpc": "^0.7.5", "cbor-x": "^1.5.1", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "uint8arrays": "3.0.0", "ws": "^8.12.0", "zod": "^3.23.8" } }, "sha512-V0srjUgy6mQ5yf9+MSNBLs457m4qclEaWZsnqIE7RfYywvntexTAbMoo7J7ONfTNwdmA9Gw4oLak2z2cDAET4w=="],
98
122
123
+
"@badrap/valita": ["@badrap/valita@0.4.6", "", {}, "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="],
124
+
99
125
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
100
126
101
127
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w=="],
···
112
138
113
139
"@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-pb0SCzBfFbFSYA/U40HHO7R+YrcXBJXOWgL20eSViK33ol1e20ru2/KUaZYo5IMUn63yaTJI/bQERuQ+77ND8g=="],
114
140
115
-
"@elysiajs/eden": ["@elysiajs/eden@1.4.3", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-mX0v5cTvTJiDsDWNEEyuoqudOvW5J+tXsvp/ZOJXJF3iCIEJI0Brvm78ymPrvwiOG4nUr3lS8BxUfbNf32DSXA=="],
141
+
"@elysiajs/eden": ["@elysiajs/eden@1.4.4", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-/LVqflmgUcCiXb8rz1iRq9Rx3SWfIV/EkoNqDFGMx+TvOyo8QHAygFXAVQz7RHs+jk6n6mEgpI6KlKBANoErsQ=="],
116
142
117
143
"@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="],
118
144
119
145
"@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="],
120
146
121
-
"@elysiajs/static": ["@elysiajs/static@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lAEvdxeBhU/jX/hTzfoP+1AtqhsKNCwW4Q+tfNwAShWU6s4ZPQxR1hLoHBveeApofJt4HWEq/tBGvfFz3ODuKg=="],
147
+
"@elysiajs/static": ["@elysiajs/static@1.4.6", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-cd61aY/DHOVhlnBjzTBX8E1XANIrsCH8MwEGHeLMaZzNrz0gD4Q8Qsde2dFMzu81I7ZDaaZ2Rim9blSLtUrYBg=="],
148
+
149
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.26.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-hj0sKNCQOOo2fgyII3clmJXP28VhgDfU5iy3GNHlWO76KG6N7x4D9ezH5lJtQTG+1J6MFDAJXC1qsI+W+LvZoA=="],
150
+
151
+
"@esbuild/android-arm": ["@esbuild/android-arm@0.26.0", "", { "os": "android", "cpu": "arm" }, "sha512-C0hkDsYNHZkBtPxxDx177JN90/1MiCpvBNjz1f5yWJo1+5+c5zr8apjastpEG+wtPjo9FFtGG7owSsAxyKiHxA=="],
152
+
153
+
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.26.0", "", { "os": "android", "cpu": "arm64" }, "sha512-DDnoJ5eoa13L8zPh87PUlRd/IyFaIKOlRbxiwcSbeumcJ7UZKdtuMCHa1Q27LWQggug6W4m28i4/O2qiQQ5NZQ=="],
154
+
155
+
"@esbuild/android-x64": ["@esbuild/android-x64@0.26.0", "", { "os": "android", "cpu": "x64" }, "sha512-bKDkGXGZnj0T70cRpgmv549x38Vr2O3UWLbjT2qmIkdIWcmlg8yebcFWoT9Dku7b5OV3UqPEuNKRzlNhjwUJ9A=="],
156
+
157
+
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.26.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Z3naJgOuAIB0RLlJkYc81An3rTlQ/IeRdrU3dOea8h/PvZSgitZV+thNuIccw0MuK1GmIAnAmd5TrMZad8FTQ=="],
158
+
159
+
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.26.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-OPnYj0zpYW0tHusMefyaMvNYQX5pNQuSsHFTHUBNp3vVXupwqpxofcjVsUx11CQhGVkGeXjC3WLjh91hgBG2xw=="],
160
+
161
+
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.26.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jix2fa6GQeZhO1sCKNaNMjfj5hbOvoL2F5t+w6gEPxALumkpOV/wq7oUBMHBn2hY2dOm+mEV/K+xfZy3mrsxNQ=="],
162
+
163
+
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.26.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tccJaH5xHJD/239LjbVvJwf6T4kSzbk6wPFerF0uwWlkw/u7HL+wnAzAH5GB2irGhYemDgiNTp8wJzhAHQ64oA=="],
164
+
165
+
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.26.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JY8NyU31SyRmRpuc5W8PQarAx4TvuYbyxbPIpHAZdr/0g4iBr8KwQBS4kiiamGl2f42BBecHusYCsyxi7Kn8UQ=="],
166
+
167
+
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.26.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IMJYN7FSkLttYyTbsbme0Ra14cBO5z47kpamo16IwggzzATFY2lcZAwkbcNkWiAduKrTgFJP7fW5cBI7FzcuNQ=="],
168
+
169
+
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.26.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-XITaGqGVLgk8WOHw8We9Z1L0lbLFip8LyQzKYFKO4zFo1PFaaSKsbNjvkb7O8kEXytmSGRkYpE8LLVpPJpsSlw=="],
170
+
171
+
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.26.0", "", { "os": "linux", "cpu": "none" }, "sha512-MkggfbDIczStUJwq9wU7gQ7kO33d8j9lWuOCDifN9t47+PeI+9m2QVh51EI/zZQ1spZtFMC1nzBJ+qNGCjJnsg=="],
172
+
173
+
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.26.0", "", { "os": "linux", "cpu": "none" }, "sha512-fUYup12HZWAeccNLhQ5HwNBPr4zXCPgUWzEq2Rfw7UwqwfQrFZ0SR/JljaURR8xIh9t+o1lNUFTECUTmaP7yKA=="],
174
+
175
+
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.26.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-MzRKhM0Ip+//VYwC8tialCiwUQ4G65WfALtJEFyU0GKJzfTYoPBw5XNWf0SLbCUYQbxTKamlVwPmcw4DgZzFxg=="],
176
+
177
+
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.26.0", "", { "os": "linux", "cpu": "none" }, "sha512-QhCc32CwI1I4Jrg1enCv292sm3YJprW8WHHlyxJhae/dVs+KRWkbvz2Nynl5HmZDW/m9ZxrXayHzjzVNvQMGQA=="],
178
+
179
+
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.26.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-1D6vi6lfI18aNT1aTf2HV+RIlm6fxtlAp8eOJ4mmnbYmZ4boz8zYDar86sIYNh0wmiLJEbW/EocaKAX6Yso2fw=="],
122
180
123
-
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="],
181
+
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.26.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rnDcepj7LjrKFvZkx+WrBv6wECeYACcFjdNPvVPojCPJD8nHpb3pv3AuR9CXgdnjH1O23btICj0rsp0L9wAnHA=="],
182
+
183
+
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.26.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FSWmgGp0mDNjEXXFcsf12BmVrb+sZBBBlyh3LwB/B9ac3Kkc8x5D2WimYW9N7SUkolui8JzVnVlWh7ZmjCpnxw=="],
184
+
185
+
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.26.0", "", { "os": "none", "cpu": "x64" }, "sha512-0QfciUDFryD39QoSPUDshj4uNEjQhp73+3pbSAaxjV2qGOEDsM67P7KbJq7LzHoVl46oqhIhJ1S+skKGR7lMXA=="],
186
+
187
+
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.26.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-vmAK+nHhIZWImwJ3RNw9hX3fU4UGN/OqbSE0imqljNbUQC3GvVJ1jpwYoTfD6mmXmQaxdJY6Hn4jQbLGJKg5Yw=="],
188
+
189
+
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.26.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-GPXF7RMkJ7o9bTyUsnyNtrFMqgM3X+uM/LWw4CeHIjqc32fm0Ir6jKDnWHpj8xHFstgWDUYseSABK9KCkHGnpg=="],
190
+
191
+
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.26.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nUHZ5jEYqbBthbiBksbmHTlbb5eElyVfs/s1iHQ8rLBq1eWsd5maOnDpCocw1OM8kFK747d1Xms8dXJHtduxSw=="],
192
+
193
+
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.26.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-TMg3KCTCYYaVO+R6P5mSORhcNDDlemUVnUbb8QkboUtOhb5JWKAzd5uMIMECJQOxHZ/R+N8HHtDF5ylzLfMiLw=="],
194
+
195
+
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.26.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-apqYgoAUd6ZCb9Phcs8zN32q6l0ZQzQBdVXOofa6WvHDlSOhwCWgSfVQabGViThS40Y1NA4SCvQickgZMFZRlA=="],
196
+
197
+
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.26.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-FGJAcImbJNZzLWu7U6WB0iKHl4RuY4TsXEwxJPl9UZLS47agIZuILZEX3Pagfw7I4J3ddflomt9f0apfaJSbaw=="],
198
+
199
+
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.26.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WAckBKaVnmFqbEhbymrPK7M086DQMpL1XoRbpmN0iW8k5JSXjDRQBhcZNa0VweItknLq9eAeCL34jK7/CDcw7A=="],
200
+
201
+
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="],
124
202
125
203
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
126
204
···
186
264
187
265
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="],
188
266
189
-
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
267
+
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="],
190
268
191
-
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="],
269
+
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-licBDIbbLP5L5/S0+bwtJynso94XD3KyqSP48K59Sq7Mude6C7dR5ZujZm4Ut4BwZqUFfNOfYNMWBU5nlL7t1A=="],
192
270
193
-
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="],
271
+
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-hn8lLzsYyyh6ULo2E8v2SqtrWOkdQKJwapeVy1rDw7juTTeHY3KDudGWf4mVYteC9riZU6HD88Fn3nGwyX0eIg=="],
194
272
195
-
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+FSr/ub5vA/EkD3fMhHJUzYioSf/sXd50OGxNDAntVxcDu4tXL/81Ka3R/gkZmjznpLFIzovU/1Ts+b7dlkrfw=="],
273
+
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UHxdtbyxdtNJUNcXtIrjx3Lmq8ji3KywlXtIHV/0vn9A8W5mulqOcryqUWMFVH9JTIIzmNn6Q/qVmXHTME63Ww=="],
196
274
197
-
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WHthS/eLkCNcp9pk4W8aubRl9fIUgt2XhHyLrP0GClB1FVvmodu/zIOtG0NXNpzlzB8+gglOkGo4dPjfVf4Z+g=="],
275
+
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5uZzxzvHU/z+3cZwN/A0H8G+enQ+9FkeJVZkE2fwK2XhiJZFUGAuWajCpy7GepvOWlqV7VjPaKi2+Qmr4IX7nQ=="],
198
276
199
-
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HT5sr7N8NDYbQRjAnT7ISpx64y+ewZZRQozOJb0+KQObKvg4UUNXGm4Pn1xA4/WPMZDDazjO8E2vtOQw1nJlAQ=="],
277
+
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-OD9DYkjes7WXieBn4zQZGXWhRVZhIEWMDGCetZ3H4vxIuweZ++iul/CNX5jdpNXaJ17myb1ROMvmRbrqW44j3w=="],
200
278
201
-
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sGEWoJQXO4GDr0x4t/yJQ/Bq1yNkOdX9tHbZZ+DBGJt3z3r7jeb4Digv8xQUk6gdTFC9vnGHuin+KW3/yD1Aww=="],
279
+
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EoEuRP9bxAxVKuvi6tZ0ZENjueP4lvjz0mKsMzdG0kwg/2apGKiirH1l0RIcdmvfDGGuDmNiv/XBpkoXq1x8ug=="],
202
280
203
-
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OmlEH3nlxQyv7HOvTH21vyNAZGv9DIPnrTznzvKiOQxkOphhCyKvPTlF13ydw4s/i18iwaUrhHy+YG9HSSxa4Q=="],
281
+
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m9Ov9YH8KjRLui87eNtQQFKVnjGsNk3xgbrR9c8d2FS3NfZSxmVjSeBvEsDjzNf1TXLDriHb/NYOlpiMf/QzDg=="],
204
282
205
-
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rtzUEzCynl3Rhgn/iR9DQezSFiZMcAXAbU+xfROqsweMGKwvwIA2ckyyckO08psEP8XcUZTs3LT9CH7PnaMiEA=="],
283
+
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-3TuOsRVoG8K+soQWRo+Cp5ACpRs6rTFSu5tAqc/6WrqwbNWmqjov/eWJPTgz3gPXnC7uNKVG7RxxAmV8r2EYTQ=="],
206
284
207
-
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hrr7mDvUjMX1tuJaXz448tMsgKIqGJBY8+rJqztKOw1U5+a/v2w5HuIIW1ce7ut0ZwEn+KIDvAujlPvpH33vpQ=="],
285
+
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-q8Hto8hcpofPJjvuvjuwyYvhOaAzPw1F5vRUUeOJDmDwZ4lZhANFM0rUwchMzfWUJCD6jg8/EVQ8MiixnZWU0A=="],
208
286
209
-
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xXwtpZVVP7T+vkxcF/TUVVOGRjEfkByO4mKveKYb4xnHWV4u4NnV0oNmzyMKkvmj10to5j2h0oZxA4ZVVv4gfA=="],
287
+
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nZJUa5NprPYQ4Ii4cMwtP9PzlJJTp1XhxJ+A9eSn1Jfr6YygVWyN2KLjenyI93IcuBouBAaepDAVZZjH2lFBhg=="],
210
288
211
-
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="],
289
+
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-s00T99MjB+xLOWq+t+wVaVBrry+oBOZNiTJijt+bmkp/MJptYS3FGvs7a+nkjLNzoNDoWQcXgKew6AaHES37Bg=="],
212
290
213
291
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
214
292
···
231
309
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
232
310
233
311
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
312
+
313
+
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
234
314
235
315
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
236
316
···
250
330
251
331
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
252
332
253
-
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
333
+
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
254
334
255
335
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
256
336
···
262
342
263
343
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
264
344
265
-
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
345
+
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
266
346
267
347
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
268
348
···
280
360
281
361
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
282
362
283
-
"@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="],
363
+
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
284
364
285
-
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="],
365
+
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
286
366
287
-
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="],
367
+
"@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="],
288
368
289
-
"@shikijs/langs": ["@shikijs/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="],
290
-
291
-
"@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="],
292
-
293
-
"@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
294
-
295
-
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
296
-
297
-
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
298
-
299
-
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
300
-
301
-
"@tanstack/react-query": ["@tanstack/react-query@5.90.2", "", { "dependencies": { "@tanstack/query-core": "5.90.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw=="],
369
+
"@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="],
302
370
303
371
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
304
372
···
306
374
307
375
"@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
308
376
309
-
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
310
-
311
-
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
312
-
313
-
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
314
-
315
-
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
316
-
317
-
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
318
-
319
-
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
320
-
321
-
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
377
+
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
322
378
323
379
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
324
380
325
-
"@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
381
+
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
326
382
327
383
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
328
384
329
-
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
330
-
331
-
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
332
-
333
385
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
334
386
335
387
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
···
338
390
339
391
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
340
392
393
+
"actor-typeahead": ["actor-typeahead@0.1.1", "", {}, "sha512-ilsBwzplKwMSBiO6Tg6RdaZ5xxqgXds5jCQuHV+ib9Aq3ja9g0T7u2Y1PmihotmS7l5RxhpGI/tPm3ljoRDRwg=="],
394
+
341
395
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
342
396
343
397
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
···
347
401
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
348
402
349
403
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
404
+
405
+
"atproto-ui": ["atproto-ui@0.11.3", "", { "dependencies": { "@atcute/atproto": "^3.1.7", "@atcute/bluesky": "^3.2.3", "@atcute/client": "^4.0.3", "@atcute/identity-resolver": "^1.1.3", "@atcute/tangled": "^1.0.10" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-NIBsORuo9lpCpr1SNKcKhNvqOVpsEy9IoHqFe1CM9gNTArpQL1hUcoP1Cou9a1O5qzCul9kaiu5xBHnB81I/WQ=="],
350
406
351
407
"await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="],
352
408
···
360
416
361
417
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
362
418
363
-
"bun": ["bun@1.3.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.0", "@oven/bun-darwin-x64": "1.3.0", "@oven/bun-darwin-x64-baseline": "1.3.0", "@oven/bun-linux-aarch64": "1.3.0", "@oven/bun-linux-aarch64-musl": "1.3.0", "@oven/bun-linux-x64": "1.3.0", "@oven/bun-linux-x64-baseline": "1.3.0", "@oven/bun-linux-x64-musl": "1.3.0", "@oven/bun-linux-x64-musl-baseline": "1.3.0", "@oven/bun-windows-x64": "1.3.0", "@oven/bun-windows-x64-baseline": "1.3.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-YI7mFs7iWc/VsGsh2aw6eAPD2cjzn1j+LKdYVk09x1CrdTWKYIHyd+dG5iQoN9//3hCDoZj8U6vKpZzEf5UARA=="],
419
+
"bun": ["bun@1.3.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.2", "@oven/bun-darwin-x64": "1.3.2", "@oven/bun-darwin-x64-baseline": "1.3.2", "@oven/bun-linux-aarch64": "1.3.2", "@oven/bun-linux-aarch64-musl": "1.3.2", "@oven/bun-linux-x64": "1.3.2", "@oven/bun-linux-x64-baseline": "1.3.2", "@oven/bun-linux-x64-musl": "1.3.2", "@oven/bun-linux-x64-musl-baseline": "1.3.2", "@oven/bun-windows-x64": "1.3.2", "@oven/bun-windows-x64-baseline": "1.3.2" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-x75mPJiEfhO1j4Tfc65+PtW6ZyrAB6yTZInydnjDZXF9u9PRAnr6OK3v0Q9dpDl0dxRHkXlYvJ8tteJxc8t4Sw=="],
364
420
365
421
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
366
422
367
-
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
423
+
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
368
424
369
425
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
370
426
···
378
434
379
435
"cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="],
380
436
381
-
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
382
-
383
437
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
384
438
385
-
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
386
-
387
-
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
388
-
389
-
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
390
-
391
-
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
392
-
393
439
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
394
440
395
441
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
···
404
450
405
451
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
406
452
407
-
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
408
-
409
453
"commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
410
454
411
455
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
···
422
466
423
467
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
424
468
425
-
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
426
-
427
469
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
428
-
429
-
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
430
470
431
471
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
432
472
···
434
474
435
475
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
436
476
437
-
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
438
-
439
477
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
440
478
441
479
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
442
480
443
-
"elysia": ["elysia@1.4.11", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-cphuzQj0fRw1ICRvwHy2H3xQio9bycaZUVHnDHJQnKqBfMNlZ+Hzj6TMmt9lc0Az0mvbCnPXWVF7y1MCRhUuOA=="],
481
+
"elysia": ["elysia@1.4.15", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-RaDqqZdLuC4UJetfVRQ4Z5aVpGgEtQ+pZnsbI4ZzEaf3l/MzuHcqSVoL/Fue3d6qE4RV9HMB2rAZaHyPIxkyzg=="],
444
482
445
483
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
446
484
···
452
490
453
491
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
454
492
493
+
"esbuild": ["esbuild@0.26.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.26.0", "@esbuild/android-arm": "0.26.0", "@esbuild/android-arm64": "0.26.0", "@esbuild/android-x64": "0.26.0", "@esbuild/darwin-arm64": "0.26.0", "@esbuild/darwin-x64": "0.26.0", "@esbuild/freebsd-arm64": "0.26.0", "@esbuild/freebsd-x64": "0.26.0", "@esbuild/linux-arm": "0.26.0", "@esbuild/linux-arm64": "0.26.0", "@esbuild/linux-ia32": "0.26.0", "@esbuild/linux-loong64": "0.26.0", "@esbuild/linux-mips64el": "0.26.0", "@esbuild/linux-ppc64": "0.26.0", "@esbuild/linux-riscv64": "0.26.0", "@esbuild/linux-s390x": "0.26.0", "@esbuild/linux-x64": "0.26.0", "@esbuild/netbsd-arm64": "0.26.0", "@esbuild/netbsd-x64": "0.26.0", "@esbuild/openbsd-arm64": "0.26.0", "@esbuild/openbsd-x64": "0.26.0", "@esbuild/openharmony-arm64": "0.26.0", "@esbuild/sunos-x64": "0.26.0", "@esbuild/win32-arm64": "0.26.0", "@esbuild/win32-ia32": "0.26.0", "@esbuild/win32-x64": "0.26.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-3Hq7jri+tRrVWha+ZeIVhl4qJRha/XjRNSopvTsOaCvfPHrflTYTcUFcEjMKdxofsXXsdc4zjg5NOTnL4Gl57Q=="],
494
+
455
495
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
456
496
457
497
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
458
498
459
-
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
499
+
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
460
500
461
501
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
462
502
···
504
544
505
545
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
506
546
507
-
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
508
-
509
-
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
510
-
511
-
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
512
-
513
-
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
514
-
515
547
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
516
548
517
549
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
···
522
554
523
555
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
524
556
525
-
"inline-style-parser": ["inline-style-parser@0.2.6", "", {}, "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg=="],
526
-
527
557
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
528
558
529
559
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
530
560
531
561
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
532
562
533
-
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
534
-
535
-
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
536
-
537
563
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
538
564
539
-
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
540
-
541
565
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
542
-
543
-
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
544
566
545
567
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
546
568
···
550
572
551
573
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
552
574
553
-
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
554
-
555
575
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
556
576
557
577
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
558
578
559
579
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
560
580
561
-
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
581
+
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
562
582
563
-
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
564
-
565
-
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
566
-
567
-
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
568
-
569
-
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
570
-
571
-
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
572
-
573
-
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
574
-
575
-
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
576
-
577
-
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
583
+
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
578
584
579
585
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
580
586
581
587
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
582
588
583
-
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
584
-
585
-
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
586
-
587
-
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
588
-
589
-
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
590
-
591
-
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
592
-
593
-
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
594
-
595
-
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
596
-
597
-
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
598
-
599
-
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
600
-
601
-
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
602
-
603
-
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
604
-
605
-
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
606
-
607
-
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
608
-
609
-
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
610
-
611
-
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
612
-
613
-
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
614
-
615
-
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
616
-
617
-
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
618
-
619
-
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
620
-
621
-
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
622
-
623
-
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
624
-
625
589
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
626
590
627
591
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
···
634
598
635
599
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
636
600
637
-
"multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
601
+
"multiformats": ["multiformats@13.4.1", "", {}, "sha512-VqO6OSvLrFVAYYjgsr8tyv62/rCQhPgsZUXLTqoFLSgdkgiUYKYeArbt1uWLlEpkjxQe+P0+sHlbPEte1Bi06Q=="],
638
602
639
603
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
640
604
···
646
610
647
611
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
648
612
649
-
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
650
-
651
-
"oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="],
652
-
653
613
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
654
-
655
-
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
656
614
657
615
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
658
616
···
672
630
673
631
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
674
632
633
+
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
634
+
675
635
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
676
636
677
637
"process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="],
678
-
679
-
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
680
638
681
639
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
682
640
···
699
657
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
700
658
701
659
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
702
-
703
-
"react-shiki": ["react-shiki@0.9.0", "", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^3.11.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5t+vHGglJioG3LU6uTKFaiOC+KNW7haL8e22ZHSP7m174ZD/X2KgCVJcxvcUOM3FiqjPQD09AyS9/+RcOh3PmA=="],
704
660
705
661
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
706
662
···
708
664
709
665
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
710
666
711
-
"regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="],
712
-
713
-
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
714
-
715
-
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
716
-
717
667
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
718
668
719
669
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
···
734
684
735
685
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
736
686
737
-
"shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
738
-
739
687
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
740
688
741
689
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
···
748
696
749
697
"sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg=="],
750
698
751
-
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
752
-
753
699
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
754
700
755
701
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
···
757
703
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
758
704
759
705
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
760
-
761
-
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
762
706
763
707
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
764
708
765
709
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
766
710
767
-
"style-to-js": ["style-to-js@1.1.19", "", { "dependencies": { "style-to-object": "1.0.12" } }, "sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ=="],
768
-
769
-
"style-to-object": ["style-to-object@1.0.12", "", { "dependencies": { "inline-style-parser": "0.2.6" } }, "sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw=="],
770
-
771
711
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
772
712
773
713
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
774
714
775
715
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
776
716
777
-
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
717
+
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
778
718
779
719
"thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw=="],
780
720
781
721
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
782
722
783
-
"tlds": ["tlds@1.260.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ=="],
723
+
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
784
724
785
725
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
786
726
787
727
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
788
-
789
-
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
790
728
791
729
"ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="],
792
730
···
806
744
807
745
"undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="],
808
746
809
-
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
810
-
811
-
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
812
-
813
-
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
814
-
815
-
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
816
-
817
-
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
818
-
819
-
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
747
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
820
748
821
749
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
822
750
···
828
756
829
757
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
830
758
831
-
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
832
-
833
-
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
834
-
835
759
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
836
760
837
761
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
···
848
772
849
773
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
850
774
851
-
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
775
+
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
776
+
777
+
"@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
778
+
779
+
"@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
780
+
781
+
"@atproto/jwk/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
782
+
783
+
"@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
784
+
785
+
"@atproto/oauth-client/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
786
+
787
+
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
788
+
789
+
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
790
+
791
+
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
792
+
793
+
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
794
+
795
+
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
852
796
853
797
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
854
798
···
856
800
857
801
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
858
802
859
-
"micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
860
-
861
-
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
862
-
863
803
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
864
804
865
805
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
···
868
808
869
809
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
870
810
871
-
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
811
+
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
872
812
873
-
"micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
813
+
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
874
814
875
815
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
876
816
}
+627
-67
cli/Cargo.lock
+627
-67
cli/Cargo.lock
···
139
139
140
140
[[package]]
141
141
name = "async-compression"
142
-
version = "0.4.32"
142
+
version = "0.4.33"
143
143
source = "registry+https://github.com/rust-lang/crates.io-index"
144
-
checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0"
144
+
checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2"
145
145
dependencies = [
146
146
"compression-codecs",
147
147
"compression-core",
···
158
158
dependencies = [
159
159
"proc-macro2",
160
160
"quote",
161
-
"syn 2.0.108",
161
+
"syn 2.0.110",
162
162
]
163
163
164
164
[[package]]
···
174
174
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
175
175
176
176
[[package]]
177
+
name = "axum"
178
+
version = "0.7.9"
179
+
source = "registry+https://github.com/rust-lang/crates.io-index"
180
+
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
181
+
dependencies = [
182
+
"async-trait",
183
+
"axum-core",
184
+
"bytes",
185
+
"futures-util",
186
+
"http",
187
+
"http-body",
188
+
"http-body-util",
189
+
"hyper",
190
+
"hyper-util",
191
+
"itoa",
192
+
"matchit",
193
+
"memchr",
194
+
"mime",
195
+
"percent-encoding",
196
+
"pin-project-lite",
197
+
"rustversion",
198
+
"serde",
199
+
"serde_json",
200
+
"serde_path_to_error",
201
+
"serde_urlencoded",
202
+
"sync_wrapper",
203
+
"tokio",
204
+
"tower 0.5.2",
205
+
"tower-layer",
206
+
"tower-service",
207
+
"tracing",
208
+
]
209
+
210
+
[[package]]
211
+
name = "axum-core"
212
+
version = "0.4.5"
213
+
source = "registry+https://github.com/rust-lang/crates.io-index"
214
+
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
215
+
dependencies = [
216
+
"async-trait",
217
+
"bytes",
218
+
"futures-util",
219
+
"http",
220
+
"http-body",
221
+
"http-body-util",
222
+
"mime",
223
+
"pin-project-lite",
224
+
"rustversion",
225
+
"sync_wrapper",
226
+
"tower-layer",
227
+
"tower-service",
228
+
"tracing",
229
+
]
230
+
231
+
[[package]]
177
232
name = "backtrace"
178
233
version = "0.3.76"
179
234
source = "registry+https://github.com/rust-lang/crates.io-index"
···
274
329
"proc-macro2",
275
330
"quote",
276
331
"rustversion",
277
-
"syn 2.0.108",
332
+
"syn 2.0.110",
278
333
]
279
334
280
335
[[package]]
···
348
403
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
349
404
350
405
[[package]]
406
+
name = "byteorder"
407
+
version = "1.5.0"
408
+
source = "registry+https://github.com/rust-lang/crates.io-index"
409
+
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
410
+
411
+
[[package]]
351
412
name = "bytes"
352
413
version = "1.10.1"
353
414
source = "registry+https://github.com/rust-lang/crates.io-index"
···
367
428
368
429
[[package]]
369
430
name = "cc"
370
-
version = "1.2.44"
431
+
version = "1.2.45"
371
432
source = "registry+https://github.com/rust-lang/crates.io-index"
372
-
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
433
+
checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe"
373
434
dependencies = [
374
435
"find-msvc-tools",
375
436
"shlex",
···
494
555
"heck 0.5.0",
495
556
"proc-macro2",
496
557
"quote",
497
-
"syn 2.0.108",
558
+
"syn 2.0.110",
498
559
]
499
560
500
561
[[package]]
···
521
582
522
583
[[package]]
523
584
name = "compression-codecs"
524
-
version = "0.4.31"
585
+
version = "0.4.32"
525
586
source = "registry+https://github.com/rust-lang/crates.io-index"
526
-
checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23"
587
+
checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b"
527
588
dependencies = [
528
589
"compression-core",
529
590
"flate2",
···
532
593
533
594
[[package]]
534
595
name = "compression-core"
535
-
version = "0.4.29"
596
+
version = "0.4.30"
536
597
source = "registry+https://github.com/rust-lang/crates.io-index"
537
-
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
598
+
checksum = "3a9b614a5787ef0c8802a55766480563cb3a93b435898c422ed2a359cf811582"
538
599
539
600
[[package]]
540
601
name = "const-oid"
···
549
610
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
550
611
551
612
[[package]]
613
+
name = "cordyceps"
614
+
version = "0.3.4"
615
+
source = "registry+https://github.com/rust-lang/crates.io-index"
616
+
checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a"
617
+
dependencies = [
618
+
"loom",
619
+
"tracing",
620
+
]
621
+
622
+
[[package]]
552
623
name = "core-foundation"
553
624
version = "0.9.4"
554
625
source = "registry+https://github.com/rust-lang/crates.io-index"
···
665
736
"proc-macro2",
666
737
"quote",
667
738
"strsim",
668
-
"syn 2.0.108",
739
+
"syn 2.0.110",
669
740
]
670
741
671
742
[[package]]
···
676
747
dependencies = [
677
748
"darling_core",
678
749
"quote",
679
-
"syn 2.0.108",
750
+
"syn 2.0.110",
680
751
]
681
752
682
753
[[package]]
···
716
787
checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976"
717
788
dependencies = [
718
789
"data-encoding",
719
-
"syn 2.0.108",
790
+
"syn 2.0.110",
720
791
]
721
792
722
793
[[package]]
···
751
822
]
752
823
753
824
[[package]]
825
+
name = "derive_more"
826
+
version = "1.0.0"
827
+
source = "registry+https://github.com/rust-lang/crates.io-index"
828
+
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
829
+
dependencies = [
830
+
"derive_more-impl",
831
+
]
832
+
833
+
[[package]]
834
+
name = "derive_more-impl"
835
+
version = "1.0.0"
836
+
source = "registry+https://github.com/rust-lang/crates.io-index"
837
+
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
838
+
dependencies = [
839
+
"proc-macro2",
840
+
"quote",
841
+
"syn 2.0.110",
842
+
"unicode-xid",
843
+
]
844
+
845
+
[[package]]
846
+
name = "diatomic-waker"
847
+
version = "0.2.3"
848
+
source = "registry+https://github.com/rust-lang/crates.io-index"
849
+
checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c"
850
+
851
+
[[package]]
754
852
name = "digest"
755
853
version = "0.10.7"
756
854
source = "registry+https://github.com/rust-lang/crates.io-index"
···
791
889
dependencies = [
792
890
"proc-macro2",
793
891
"quote",
794
-
"syn 2.0.108",
892
+
"syn 2.0.110",
795
893
]
796
894
797
895
[[package]]
···
852
950
"heck 0.5.0",
853
951
"proc-macro2",
854
952
"quote",
855
-
"syn 2.0.108",
953
+
"syn 2.0.110",
856
954
]
857
955
858
956
[[package]]
···
956
1054
]
957
1055
958
1056
[[package]]
1057
+
name = "futures-buffered"
1058
+
version = "0.2.12"
1059
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1060
+
checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd"
1061
+
dependencies = [
1062
+
"cordyceps",
1063
+
"diatomic-waker",
1064
+
"futures-core",
1065
+
"pin-project-lite",
1066
+
"spin 0.10.0",
1067
+
]
1068
+
1069
+
[[package]]
959
1070
name = "futures-channel"
960
1071
version = "0.3.31"
961
1072
source = "registry+https://github.com/rust-lang/crates.io-index"
···
989
1100
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
990
1101
991
1102
[[package]]
1103
+
name = "futures-lite"
1104
+
version = "2.6.1"
1105
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1106
+
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
1107
+
dependencies = [
1108
+
"fastrand",
1109
+
"futures-core",
1110
+
"futures-io",
1111
+
"parking",
1112
+
"pin-project-lite",
1113
+
]
1114
+
1115
+
[[package]]
992
1116
name = "futures-macro"
993
1117
version = "0.3.31"
994
1118
source = "registry+https://github.com/rust-lang/crates.io-index"
···
996
1120
dependencies = [
997
1121
"proc-macro2",
998
1122
"quote",
999
-
"syn 2.0.108",
1123
+
"syn 2.0.110",
1000
1124
]
1001
1125
1002
1126
[[package]]
···
1027
1151
"pin-project-lite",
1028
1152
"pin-utils",
1029
1153
"slab",
1154
+
]
1155
+
1156
+
[[package]]
1157
+
name = "generator"
1158
+
version = "0.8.7"
1159
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1160
+
checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2"
1161
+
dependencies = [
1162
+
"cc",
1163
+
"cfg-if",
1164
+
"libc",
1165
+
"log",
1166
+
"rustversion",
1167
+
"windows",
1030
1168
]
1031
1169
1032
1170
[[package]]
···
1236
1374
"markup5ever",
1237
1375
"proc-macro2",
1238
1376
"quote",
1239
-
"syn 2.0.108",
1377
+
"syn 2.0.110",
1240
1378
]
1241
1379
1242
1380
[[package]]
···
1274
1412
]
1275
1413
1276
1414
[[package]]
1415
+
name = "http-range-header"
1416
+
version = "0.4.2"
1417
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1418
+
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
1419
+
1420
+
[[package]]
1277
1421
name = "httparse"
1278
1422
version = "1.10.1"
1279
1423
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1287
1431
1288
1432
[[package]]
1289
1433
name = "hyper"
1290
-
version = "1.7.0"
1434
+
version = "1.8.0"
1291
1435
source = "registry+https://github.com/rust-lang/crates.io-index"
1292
-
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
1436
+
checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f"
1293
1437
dependencies = [
1294
1438
"atomic-waker",
1295
1439
"bytes",
···
1299
1443
"http",
1300
1444
"http-body",
1301
1445
"httparse",
1446
+
"httpdate",
1302
1447
"itoa",
1303
1448
"pin-project-lite",
1304
1449
"pin-utils",
···
1362
1507
"js-sys",
1363
1508
"log",
1364
1509
"wasm-bindgen",
1365
-
"windows-core",
1510
+
"windows-core 0.62.2",
1366
1511
]
1367
1512
1368
1513
[[package]]
···
1554
1699
1555
1700
[[package]]
1556
1701
name = "iri-string"
1557
-
version = "0.7.8"
1702
+
version = "0.7.9"
1558
1703
source = "registry+https://github.com/rust-lang/crates.io-index"
1559
-
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
1704
+
checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
1560
1705
dependencies = [
1561
1706
"memchr",
1562
1707
"serde",
···
1583
1728
[[package]]
1584
1729
name = "jacquard"
1585
1730
version = "0.9.0"
1731
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1586
1732
dependencies = [
1587
1733
"bytes",
1588
1734
"getrandom 0.2.16",
···
1610
1756
[[package]]
1611
1757
name = "jacquard-api"
1612
1758
version = "0.9.0"
1759
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1613
1760
dependencies = [
1614
1761
"bon",
1615
1762
"bytes",
···
1627
1774
[[package]]
1628
1775
name = "jacquard-common"
1629
1776
version = "0.9.0"
1777
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1630
1778
dependencies = [
1631
1779
"base64 0.22.1",
1632
1780
"bon",
1633
1781
"bytes",
1634
1782
"chrono",
1783
+
"ciborium",
1635
1784
"cid",
1785
+
"futures",
1636
1786
"getrandom 0.2.16",
1637
1787
"getrandom 0.3.4",
1638
1788
"http",
···
1642
1792
"miette",
1643
1793
"multibase",
1644
1794
"multihash",
1795
+
"n0-future",
1645
1796
"ouroboros",
1646
1797
"p256",
1647
1798
"rand 0.9.2",
···
1655
1806
"smol_str",
1656
1807
"thiserror 2.0.17",
1657
1808
"tokio",
1809
+
"tokio-tungstenite-wasm",
1658
1810
"tokio-util",
1659
1811
"trait-variant",
1660
1812
"url",
···
1663
1815
[[package]]
1664
1816
name = "jacquard-derive"
1665
1817
version = "0.9.0"
1818
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1666
1819
dependencies = [
1667
1820
"heck 0.5.0",
1668
1821
"jacquard-lexicon",
1669
1822
"proc-macro2",
1670
1823
"quote",
1671
-
"syn 2.0.108",
1824
+
"syn 2.0.110",
1672
1825
]
1673
1826
1674
1827
[[package]]
1675
1828
name = "jacquard-identity"
1676
-
version = "0.9.0"
1829
+
version = "0.9.1"
1830
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1677
1831
dependencies = [
1678
1832
"bon",
1679
1833
"bytes",
···
1698
1852
1699
1853
[[package]]
1700
1854
name = "jacquard-lexicon"
1701
-
version = "0.9.0"
1855
+
version = "0.9.1"
1856
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1702
1857
dependencies = [
1703
1858
"cid",
1704
1859
"dashmap",
···
1716
1871
"serde_repr",
1717
1872
"serde_with",
1718
1873
"sha2",
1719
-
"syn 2.0.108",
1874
+
"syn 2.0.110",
1720
1875
"thiserror 2.0.17",
1721
1876
"unicode-segmentation",
1722
1877
]
···
1724
1879
[[package]]
1725
1880
name = "jacquard-oauth"
1726
1881
version = "0.9.0"
1882
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1727
1883
dependencies = [
1728
1884
"base64 0.22.1",
1729
1885
"bytes",
···
1849
2005
source = "registry+https://github.com/rust-lang/crates.io-index"
1850
2006
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1851
2007
dependencies = [
1852
-
"spin",
2008
+
"spin 0.9.8",
1853
2009
]
1854
2010
1855
2011
[[package]]
···
1909
2065
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
1910
2066
1911
2067
[[package]]
2068
+
name = "loom"
2069
+
version = "0.7.2"
2070
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2071
+
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
2072
+
dependencies = [
2073
+
"cfg-if",
2074
+
"generator",
2075
+
"scoped-tls",
2076
+
"tracing",
2077
+
"tracing-subscriber",
2078
+
]
2079
+
2080
+
[[package]]
1912
2081
name = "lru-cache"
1913
2082
version = "0.1.2"
1914
2083
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1965
2134
"quote",
1966
2135
"syn 1.0.109",
1967
2136
]
2137
+
2138
+
[[package]]
2139
+
name = "matchers"
2140
+
version = "0.2.0"
2141
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2142
+
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
2143
+
dependencies = [
2144
+
"regex-automata",
2145
+
]
2146
+
2147
+
[[package]]
2148
+
name = "matchit"
2149
+
version = "0.7.3"
2150
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2151
+
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
1968
2152
1969
2153
[[package]]
1970
2154
name = "memchr"
···
1999
2183
dependencies = [
2000
2184
"proc-macro2",
2001
2185
"quote",
2002
-
"syn 2.0.108",
2186
+
"syn 2.0.110",
2003
2187
]
2004
2188
2005
2189
[[package]]
···
2101
2285
]
2102
2286
2103
2287
[[package]]
2288
+
name = "n0-future"
2289
+
version = "0.1.3"
2290
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2291
+
checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794"
2292
+
dependencies = [
2293
+
"cfg_aliases",
2294
+
"derive_more",
2295
+
"futures-buffered",
2296
+
"futures-lite",
2297
+
"futures-util",
2298
+
"js-sys",
2299
+
"pin-project",
2300
+
"send_wrapper",
2301
+
"tokio",
2302
+
"tokio-util",
2303
+
"wasm-bindgen",
2304
+
"wasm-bindgen-futures",
2305
+
"web-time",
2306
+
]
2307
+
2308
+
[[package]]
2104
2309
name = "ndk-context"
2105
2310
version = "0.1.1"
2106
2311
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2123
2328
]
2124
2329
2125
2330
[[package]]
2331
+
name = "nu-ansi-term"
2332
+
version = "0.50.3"
2333
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2334
+
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2335
+
dependencies = [
2336
+
"windows-sys 0.61.2",
2337
+
]
2338
+
2339
+
[[package]]
2126
2340
name = "num-bigint-dig"
2127
-
version = "0.8.5"
2341
+
version = "0.8.6"
2128
2342
source = "registry+https://github.com/rust-lang/crates.io-index"
2129
-
checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b"
2343
+
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
2130
2344
dependencies = [
2131
2345
"lazy_static",
2132
2346
"libm",
···
2240
2454
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
2241
2455
2242
2456
[[package]]
2457
+
name = "openssl-probe"
2458
+
version = "0.1.6"
2459
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2460
+
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
2461
+
2462
+
[[package]]
2243
2463
name = "option-ext"
2244
2464
version = "0.2.0"
2245
2465
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2266
2486
"proc-macro2",
2267
2487
"proc-macro2-diagnostics",
2268
2488
"quote",
2269
-
"syn 2.0.108",
2489
+
"syn 2.0.110",
2270
2490
]
2271
2491
2272
2492
[[package]]
···
2296
2516
"elliptic-curve",
2297
2517
"primeorder",
2298
2518
]
2519
+
2520
+
[[package]]
2521
+
name = "parking"
2522
+
version = "2.2.1"
2523
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2524
+
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2299
2525
2300
2526
[[package]]
2301
2527
name = "parking_lot"
···
2374
2600
]
2375
2601
2376
2602
[[package]]
2603
+
name = "pin-project"
2604
+
version = "1.1.10"
2605
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2606
+
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
2607
+
dependencies = [
2608
+
"pin-project-internal",
2609
+
]
2610
+
2611
+
[[package]]
2612
+
name = "pin-project-internal"
2613
+
version = "1.1.10"
2614
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2615
+
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
2616
+
dependencies = [
2617
+
"proc-macro2",
2618
+
"quote",
2619
+
"syn 2.0.110",
2620
+
]
2621
+
2622
+
[[package]]
2377
2623
name = "pin-project-lite"
2378
2624
version = "0.2.16"
2379
2625
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2443
2689
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
2444
2690
dependencies = [
2445
2691
"proc-macro2",
2446
-
"syn 2.0.108",
2692
+
"syn 2.0.110",
2447
2693
]
2448
2694
2449
2695
[[package]]
···
2496
2742
dependencies = [
2497
2743
"proc-macro2",
2498
2744
"quote",
2499
-
"syn 2.0.108",
2745
+
"syn 2.0.110",
2500
2746
"version_check",
2501
2747
"yansi",
2502
2748
]
···
2564
2810
2565
2811
[[package]]
2566
2812
name = "quote"
2567
-
version = "1.0.41"
2813
+
version = "1.0.42"
2568
2814
source = "registry+https://github.com/rust-lang/crates.io-index"
2569
-
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
2815
+
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
2570
2816
dependencies = [
2571
2817
"proc-macro2",
2572
2818
]
···
2679
2925
dependencies = [
2680
2926
"proc-macro2",
2681
2927
"quote",
2682
-
"syn 2.0.108",
2928
+
"syn 2.0.110",
2683
2929
]
2684
2930
2685
2931
[[package]]
···
2745
2991
"tokio",
2746
2992
"tokio-rustls",
2747
2993
"tokio-util",
2748
-
"tower",
2749
-
"tower-http",
2994
+
"tower 0.5.2",
2995
+
"tower-http 0.6.6",
2750
2996
"tower-service",
2751
2997
"url",
2752
2998
"wasm-bindgen",
···
2857
3103
2858
3104
[[package]]
2859
3105
name = "rustls"
2860
-
version = "0.23.34"
3106
+
version = "0.23.35"
2861
3107
source = "registry+https://github.com/rust-lang/crates.io-index"
2862
-
checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7"
3108
+
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
2863
3109
dependencies = [
2864
3110
"once_cell",
2865
3111
"ring",
···
2867
3113
"rustls-webpki",
2868
3114
"subtle",
2869
3115
"zeroize",
3116
+
]
3117
+
3118
+
[[package]]
3119
+
name = "rustls-native-certs"
3120
+
version = "0.8.2"
3121
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3122
+
checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923"
3123
+
dependencies = [
3124
+
"openssl-probe",
3125
+
"rustls-pki-types",
3126
+
"schannel",
3127
+
"security-framework",
2870
3128
]
2871
3129
2872
3130
[[package]]
···
2918
3176
]
2919
3177
2920
3178
[[package]]
3179
+
name = "schannel"
3180
+
version = "0.1.28"
3181
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3182
+
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
3183
+
dependencies = [
3184
+
"windows-sys 0.61.2",
3185
+
]
3186
+
3187
+
[[package]]
2921
3188
name = "schemars"
2922
3189
version = "0.9.0"
2923
3190
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2931
3198
2932
3199
[[package]]
2933
3200
name = "schemars"
2934
-
version = "1.0.4"
3201
+
version = "1.1.0"
2935
3202
source = "registry+https://github.com/rust-lang/crates.io-index"
2936
-
checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
3203
+
checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289"
2937
3204
dependencies = [
2938
3205
"dyn-clone",
2939
3206
"ref-cast",
···
2942
3209
]
2943
3210
2944
3211
[[package]]
3212
+
name = "scoped-tls"
3213
+
version = "1.0.1"
3214
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3215
+
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
3216
+
3217
+
[[package]]
2945
3218
name = "scopeguard"
2946
3219
version = "1.2.0"
2947
3220
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2962
3235
]
2963
3236
2964
3237
[[package]]
3238
+
name = "security-framework"
3239
+
version = "3.5.1"
3240
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3241
+
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
3242
+
dependencies = [
3243
+
"bitflags",
3244
+
"core-foundation 0.10.1",
3245
+
"core-foundation-sys",
3246
+
"libc",
3247
+
"security-framework-sys",
3248
+
]
3249
+
3250
+
[[package]]
3251
+
name = "security-framework-sys"
3252
+
version = "2.15.0"
3253
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3254
+
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
3255
+
dependencies = [
3256
+
"core-foundation-sys",
3257
+
"libc",
3258
+
]
3259
+
3260
+
[[package]]
3261
+
name = "send_wrapper"
3262
+
version = "0.6.0"
3263
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3264
+
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
3265
+
3266
+
[[package]]
2965
3267
name = "serde"
2966
3268
version = "1.0.228"
2967
3269
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2998
3300
dependencies = [
2999
3301
"proc-macro2",
3000
3302
"quote",
3001
-
"syn 2.0.108",
3303
+
"syn 2.0.110",
3002
3304
]
3003
3305
3004
3306
[[package]]
···
3040
3342
]
3041
3343
3042
3344
[[package]]
3345
+
name = "serde_path_to_error"
3346
+
version = "0.1.20"
3347
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3348
+
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
3349
+
dependencies = [
3350
+
"itoa",
3351
+
"serde",
3352
+
"serde_core",
3353
+
]
3354
+
3355
+
[[package]]
3043
3356
name = "serde_repr"
3044
3357
version = "0.1.20"
3045
3358
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3047
3360
dependencies = [
3048
3361
"proc-macro2",
3049
3362
"quote",
3050
-
"syn 2.0.108",
3363
+
"syn 2.0.110",
3051
3364
]
3052
3365
3053
3366
[[package]]
···
3074
3387
"indexmap 1.9.3",
3075
3388
"indexmap 2.12.0",
3076
3389
"schemars 0.9.0",
3077
-
"schemars 1.0.4",
3390
+
"schemars 1.1.0",
3078
3391
"serde_core",
3079
3392
"serde_json",
3080
3393
"serde_with_macros",
···
3090
3403
"darling",
3091
3404
"proc-macro2",
3092
3405
"quote",
3093
-
"syn 2.0.108",
3406
+
"syn 2.0.110",
3407
+
]
3408
+
3409
+
[[package]]
3410
+
name = "sha1"
3411
+
version = "0.10.6"
3412
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3413
+
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
3414
+
dependencies = [
3415
+
"cfg-if",
3416
+
"cpufeatures",
3417
+
"digest",
3094
3418
]
3095
3419
3096
3420
[[package]]
···
3108
3432
"cfg-if",
3109
3433
"cpufeatures",
3110
3434
"digest",
3435
+
]
3436
+
3437
+
[[package]]
3438
+
name = "sharded-slab"
3439
+
version = "0.1.7"
3440
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3441
+
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
3442
+
dependencies = [
3443
+
"lazy_static",
3111
3444
]
3112
3445
3113
3446
[[package]]
···
3205
3538
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
3206
3539
3207
3540
[[package]]
3541
+
name = "spin"
3542
+
version = "0.10.0"
3543
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3544
+
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
3545
+
3546
+
[[package]]
3208
3547
name = "spki"
3209
3548
version = "0.7.3"
3210
3549
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3236
3575
"quote",
3237
3576
"serde",
3238
3577
"sha2",
3239
-
"syn 2.0.108",
3578
+
"syn 2.0.110",
3240
3579
"thiserror 1.0.69",
3241
3580
]
3242
3581
···
3317
3656
3318
3657
[[package]]
3319
3658
name = "syn"
3320
-
version = "2.0.108"
3659
+
version = "2.0.110"
3321
3660
source = "registry+https://github.com/rust-lang/crates.io-index"
3322
-
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
3661
+
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
3323
3662
dependencies = [
3324
3663
"proc-macro2",
3325
3664
"quote",
···
3343
3682
dependencies = [
3344
3683
"proc-macro2",
3345
3684
"quote",
3346
-
"syn 2.0.108",
3685
+
"syn 2.0.110",
3347
3686
]
3348
3687
3349
3688
[[package]]
···
3443
3782
dependencies = [
3444
3783
"proc-macro2",
3445
3784
"quote",
3446
-
"syn 2.0.108",
3785
+
"syn 2.0.110",
3447
3786
]
3448
3787
3449
3788
[[package]]
···
3454
3793
dependencies = [
3455
3794
"proc-macro2",
3456
3795
"quote",
3457
-
"syn 2.0.108",
3796
+
"syn 2.0.110",
3797
+
]
3798
+
3799
+
[[package]]
3800
+
name = "thread_local"
3801
+
version = "1.1.9"
3802
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3803
+
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
3804
+
dependencies = [
3805
+
"cfg-if",
3458
3806
]
3459
3807
3460
3808
[[package]]
···
3561
3909
dependencies = [
3562
3910
"proc-macro2",
3563
3911
"quote",
3564
-
"syn 2.0.108",
3912
+
"syn 2.0.110",
3565
3913
]
3566
3914
3567
3915
[[package]]
···
3575
3923
]
3576
3924
3577
3925
[[package]]
3926
+
name = "tokio-tungstenite"
3927
+
version = "0.24.0"
3928
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3929
+
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
3930
+
dependencies = [
3931
+
"futures-util",
3932
+
"log",
3933
+
"rustls",
3934
+
"rustls-native-certs",
3935
+
"rustls-pki-types",
3936
+
"tokio",
3937
+
"tokio-rustls",
3938
+
"tungstenite",
3939
+
]
3940
+
3941
+
[[package]]
3942
+
name = "tokio-tungstenite-wasm"
3943
+
version = "0.4.0"
3944
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3945
+
checksum = "e21a5c399399c3db9f08d8297ac12b500e86bca82e930253fdc62eaf9c0de6ae"
3946
+
dependencies = [
3947
+
"futures-channel",
3948
+
"futures-util",
3949
+
"http",
3950
+
"httparse",
3951
+
"js-sys",
3952
+
"rustls",
3953
+
"thiserror 1.0.69",
3954
+
"tokio",
3955
+
"tokio-tungstenite",
3956
+
"wasm-bindgen",
3957
+
"web-sys",
3958
+
]
3959
+
3960
+
[[package]]
3578
3961
name = "tokio-util"
3579
-
version = "0.7.16"
3962
+
version = "0.7.17"
3580
3963
source = "registry+https://github.com/rust-lang/crates.io-index"
3581
-
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
3964
+
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
3582
3965
dependencies = [
3583
3966
"bytes",
3584
3967
"futures-core",
3585
3968
"futures-sink",
3969
+
"futures-util",
3586
3970
"pin-project-lite",
3587
3971
"tokio",
3588
3972
]
3589
3973
3590
3974
[[package]]
3591
3975
name = "tower"
3976
+
version = "0.4.13"
3977
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3978
+
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
3979
+
dependencies = [
3980
+
"tower-layer",
3981
+
"tower-service",
3982
+
"tracing",
3983
+
]
3984
+
3985
+
[[package]]
3986
+
name = "tower"
3592
3987
version = "0.5.2"
3593
3988
source = "registry+https://github.com/rust-lang/crates.io-index"
3594
3989
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
···
3600
3995
"tokio",
3601
3996
"tower-layer",
3602
3997
"tower-service",
3998
+
"tracing",
3999
+
]
4000
+
4001
+
[[package]]
4002
+
name = "tower-http"
4003
+
version = "0.5.2"
4004
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4005
+
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
4006
+
dependencies = [
4007
+
"async-compression",
4008
+
"bitflags",
4009
+
"bytes",
4010
+
"futures-core",
4011
+
"futures-util",
4012
+
"http",
4013
+
"http-body",
4014
+
"http-body-util",
4015
+
"http-range-header",
4016
+
"httpdate",
4017
+
"mime",
4018
+
"mime_guess",
4019
+
"percent-encoding",
4020
+
"pin-project-lite",
4021
+
"tokio",
4022
+
"tokio-util",
4023
+
"tower-layer",
4024
+
"tower-service",
4025
+
"tracing",
3603
4026
]
3604
4027
3605
4028
[[package]]
···
3615
4038
"http-body",
3616
4039
"iri-string",
3617
4040
"pin-project-lite",
3618
-
"tower",
4041
+
"tower 0.5.2",
3619
4042
"tower-layer",
3620
4043
"tower-service",
3621
4044
]
···
3638
4061
source = "registry+https://github.com/rust-lang/crates.io-index"
3639
4062
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
3640
4063
dependencies = [
4064
+
"log",
3641
4065
"pin-project-lite",
3642
4066
"tracing-attributes",
3643
4067
"tracing-core",
···
3651
4075
dependencies = [
3652
4076
"proc-macro2",
3653
4077
"quote",
3654
-
"syn 2.0.108",
4078
+
"syn 2.0.110",
3655
4079
]
3656
4080
3657
4081
[[package]]
···
3661
4085
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
3662
4086
dependencies = [
3663
4087
"once_cell",
4088
+
"valuable",
4089
+
]
4090
+
4091
+
[[package]]
4092
+
name = "tracing-log"
4093
+
version = "0.2.0"
4094
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4095
+
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
4096
+
dependencies = [
4097
+
"log",
4098
+
"once_cell",
4099
+
"tracing-core",
4100
+
]
4101
+
4102
+
[[package]]
4103
+
name = "tracing-subscriber"
4104
+
version = "0.3.20"
4105
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4106
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
4107
+
dependencies = [
4108
+
"matchers",
4109
+
"nu-ansi-term",
4110
+
"once_cell",
4111
+
"regex-automata",
4112
+
"sharded-slab",
4113
+
"smallvec",
4114
+
"thread_local",
4115
+
"tracing",
4116
+
"tracing-core",
4117
+
"tracing-log",
3664
4118
]
3665
4119
3666
4120
[[package]]
···
3671
4125
dependencies = [
3672
4126
"proc-macro2",
3673
4127
"quote",
3674
-
"syn 2.0.108",
4128
+
"syn 2.0.110",
3675
4129
]
3676
4130
3677
4131
[[package]]
···
3687
4141
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
3688
4142
3689
4143
[[package]]
4144
+
name = "tungstenite"
4145
+
version = "0.24.0"
4146
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4147
+
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
4148
+
dependencies = [
4149
+
"byteorder",
4150
+
"bytes",
4151
+
"data-encoding",
4152
+
"http",
4153
+
"httparse",
4154
+
"log",
4155
+
"rand 0.8.5",
4156
+
"rustls",
4157
+
"rustls-pki-types",
4158
+
"sha1",
4159
+
"thiserror 1.0.69",
4160
+
"utf-8",
4161
+
]
4162
+
4163
+
[[package]]
3690
4164
name = "twoway"
3691
4165
version = "0.1.8"
3692
4166
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3736
4210
version = "0.2.2"
3737
4211
source = "registry+https://github.com/rust-lang/crates.io-index"
3738
4212
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
4213
+
4214
+
[[package]]
4215
+
name = "unicode-xid"
4216
+
version = "0.2.6"
4217
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4218
+
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
3739
4219
3740
4220
[[package]]
3741
4221
name = "unsigned-varint"
···
3786
4266
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
3787
4267
3788
4268
[[package]]
4269
+
name = "valuable"
4270
+
version = "0.1.1"
4271
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4272
+
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
4273
+
4274
+
[[package]]
3789
4275
name = "version_check"
3790
4276
version = "0.9.5"
3791
4277
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3870
4356
"bumpalo",
3871
4357
"proc-macro2",
3872
4358
"quote",
3873
-
"syn 2.0.108",
4359
+
"syn 2.0.110",
3874
4360
"wasm-bindgen-shared",
3875
4361
]
3876
4362
···
3969
4455
]
3970
4456
3971
4457
[[package]]
4458
+
name = "windows"
4459
+
version = "0.61.3"
4460
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4461
+
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
4462
+
dependencies = [
4463
+
"windows-collections",
4464
+
"windows-core 0.61.2",
4465
+
"windows-future",
4466
+
"windows-link 0.1.3",
4467
+
"windows-numerics",
4468
+
]
4469
+
4470
+
[[package]]
4471
+
name = "windows-collections"
4472
+
version = "0.2.0"
4473
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4474
+
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
4475
+
dependencies = [
4476
+
"windows-core 0.61.2",
4477
+
]
4478
+
4479
+
[[package]]
4480
+
name = "windows-core"
4481
+
version = "0.61.2"
4482
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4483
+
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
4484
+
dependencies = [
4485
+
"windows-implement",
4486
+
"windows-interface",
4487
+
"windows-link 0.1.3",
4488
+
"windows-result 0.3.4",
4489
+
"windows-strings 0.4.2",
4490
+
]
4491
+
4492
+
[[package]]
3972
4493
name = "windows-core"
3973
4494
version = "0.62.2"
3974
4495
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3982
4503
]
3983
4504
3984
4505
[[package]]
4506
+
name = "windows-future"
4507
+
version = "0.2.1"
4508
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4509
+
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
4510
+
dependencies = [
4511
+
"windows-core 0.61.2",
4512
+
"windows-link 0.1.3",
4513
+
"windows-threading",
4514
+
]
4515
+
4516
+
[[package]]
3985
4517
name = "windows-implement"
3986
4518
version = "0.60.2"
3987
4519
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3989
4521
dependencies = [
3990
4522
"proc-macro2",
3991
4523
"quote",
3992
-
"syn 2.0.108",
4524
+
"syn 2.0.110",
3993
4525
]
3994
4526
3995
4527
[[package]]
···
4000
4532
dependencies = [
4001
4533
"proc-macro2",
4002
4534
"quote",
4003
-
"syn 2.0.108",
4535
+
"syn 2.0.110",
4004
4536
]
4005
4537
4006
4538
[[package]]
···
4014
4546
version = "0.2.1"
4015
4547
source = "registry+https://github.com/rust-lang/crates.io-index"
4016
4548
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
4549
+
4550
+
[[package]]
4551
+
name = "windows-numerics"
4552
+
version = "0.2.0"
4553
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4554
+
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
4555
+
dependencies = [
4556
+
"windows-core 0.61.2",
4557
+
"windows-link 0.1.3",
4558
+
]
4017
4559
4018
4560
[[package]]
4019
4561
name = "windows-registry"
···
4171
4713
]
4172
4714
4173
4715
[[package]]
4716
+
name = "windows-threading"
4717
+
version = "0.1.0"
4718
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4719
+
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
4720
+
dependencies = [
4721
+
"windows-link 0.1.3",
4722
+
]
4723
+
4724
+
[[package]]
4174
4725
name = "windows_aarch64_gnullvm"
4175
4726
version = "0.42.2"
4176
4727
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4362
4913
4363
4914
[[package]]
4364
4915
name = "wisp-cli"
4365
-
version = "0.1.0"
4916
+
version = "0.2.0"
4366
4917
dependencies = [
4918
+
"axum",
4367
4919
"base64 0.22.1",
4368
4920
"bytes",
4921
+
"chrono",
4369
4922
"clap",
4370
4923
"flate2",
4371
4924
"futures",
···
4378
4931
"jacquard-oauth",
4379
4932
"miette",
4380
4933
"mime_guess",
4934
+
"multibase",
4935
+
"multihash",
4936
+
"n0-future",
4381
4937
"reqwest",
4382
4938
"rustversion",
4383
4939
"serde",
4384
4940
"serde_json",
4941
+
"sha2",
4385
4942
"shellexpand",
4386
4943
"tokio",
4944
+
"tower 0.4.13",
4945
+
"tower-http 0.5.2",
4946
+
"url",
4387
4947
"walkdir",
4388
4948
]
4389
4949
···
4435
4995
dependencies = [
4436
4996
"proc-macro2",
4437
4997
"quote",
4438
-
"syn 2.0.108",
4998
+
"syn 2.0.110",
4439
4999
"synstructure",
4440
5000
]
4441
5001
···
4456
5016
dependencies = [
4457
5017
"proc-macro2",
4458
5018
"quote",
4459
-
"syn 2.0.108",
5019
+
"syn 2.0.110",
4460
5020
]
4461
5021
4462
5022
[[package]]
···
4476
5036
dependencies = [
4477
5037
"proc-macro2",
4478
5038
"quote",
4479
-
"syn 2.0.108",
5039
+
"syn 2.0.110",
4480
5040
"synstructure",
4481
5041
]
4482
5042
···
4519
5079
dependencies = [
4520
5080
"proc-macro2",
4521
5081
"quote",
4522
-
"syn 2.0.108",
5082
+
"syn 2.0.110",
4523
5083
]
+17
-8
cli/Cargo.toml
+17
-8
cli/Cargo.toml
···
1
1
[package]
2
2
name = "wisp-cli"
3
-
version = "0.1.0"
3
+
version = "0.2.0"
4
4
edition = "2024"
5
5
6
6
[features]
···
8
8
place_wisp = []
9
9
10
10
[dependencies]
11
-
jacquard = { path = "jacquard/crates/jacquard", features = ["loopback"] }
12
-
jacquard-oauth = { path = "jacquard/crates/jacquard-oauth" }
13
-
jacquard-api = { path = "jacquard/crates/jacquard-api" }
14
-
jacquard-common = { path = "jacquard/crates/jacquard-common" }
15
-
jacquard-identity = { path = "jacquard/crates/jacquard-identity", features = ["dns"] }
16
-
jacquard-derive = { path = "jacquard/crates/jacquard-derive" }
17
-
jacquard-lexicon = { path = "jacquard/crates/jacquard-lexicon" }
11
+
jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] }
12
+
jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
13
+
jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
14
+
jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["websocket"] }
15
+
jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] }
16
+
jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
17
+
jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
18
18
clap = { version = "4.5.51", features = ["derive"] }
19
19
tokio = { version = "1.48", features = ["full"] }
20
20
miette = { version = "7.6.0", features = ["fancy"] }
···
30
30
mime_guess = "2.0"
31
31
bytes = "1.10"
32
32
futures = "0.3.31"
33
+
multihash = "0.19.3"
34
+
multibase = "0.9"
35
+
sha2 = "0.10"
36
+
axum = "0.7"
37
+
tower-http = { version = "0.5", features = ["fs", "compression-gzip"] }
38
+
tower = "0.4"
39
+
n0-future = "0.1"
40
+
chrono = "0.4"
41
+
url = "2.5"
+271
cli/README.md
+271
cli/README.md
···
1
+
# Wisp CLI
2
+
3
+
A command-line tool for deploying static sites to your AT Protocol repo to be served on [wisp.place](https://wisp.place), an AT indexer to serve such sites.
4
+
5
+
## Why?
6
+
7
+
The PDS serves as a way to verfiably, cryptographically prove that you own your site. That it was you (or at least someone who controls your account) who uploaded it. It is also a manifest of each file in the site to ensure file integrity. Keeping hosting seperate ensures that you could move your site across other servers or even serverless solutions to ensure speedy delievery while keeping it backed by an absolute source of truth being the manifest record and the blobs of each file in your repo.
8
+
9
+
## Features
10
+
11
+
- Deploy static sites directly to your AT Protocol repo
12
+
- Supports both OAuth and app password authentication
13
+
- Preserves directory structure and file integrity
14
+
15
+
## Soon
16
+
17
+
-- Host sites
18
+
-- Manage and delete sites
19
+
-- Metrics and logs for self hosting.
20
+
21
+
## Installation
22
+
23
+
### From Source
24
+
25
+
```bash
26
+
cargo build --release
27
+
```
28
+
29
+
Check out the build scripts for cross complation using nix-shell.
30
+
31
+
The binary will be available at `target/release/wisp-cli`.
32
+
33
+
## Usage
34
+
35
+
### Basic Deployment
36
+
37
+
Deploy the current directory:
38
+
39
+
```bash
40
+
wisp-cli nekomimi.ppet --path . --site my-site
41
+
```
42
+
43
+
Deploy a specific directory:
44
+
45
+
```bash
46
+
wisp-cli alice.bsky.social --path ./dist/ --site my-site
47
+
```
48
+
49
+
### Authentication Methods
50
+
51
+
#### OAuth (Recommended)
52
+
53
+
By default, the CLI uses OAuth authentication with a local loopback server:
54
+
55
+
```bash
56
+
wisp-cli alice.bsky.social --path ./my-site --site my-site
57
+
```
58
+
59
+
This will:
60
+
1. Open your browser for authentication
61
+
2. Save the session to a file (default: `/tmp/wisp-oauth-session.json`)
62
+
3. Reuse the session for future deployments
63
+
64
+
Specify a custom session file location:
65
+
66
+
```bash
67
+
wisp-cli alice.bsky.social --path ./my-site --site my-site --store ~/.wisp-session.json
68
+
```
69
+
70
+
#### App Password
71
+
72
+
For headless environments or CI/CD, use an app password:
73
+
74
+
```bash
75
+
wisp-cli alice.bsky.social --path ./my-site --site my-site --password YOUR_APP_PASSWORD
76
+
```
77
+
78
+
**Note:** When using `--password`, the `--store` option is ignored.
79
+
80
+
## Command-Line Options
81
+
82
+
```
83
+
wisp-cli [OPTIONS] <INPUT>
84
+
85
+
Arguments:
86
+
<INPUT> Handle (e.g., alice.bsky.social), DID, or PDS URL
87
+
88
+
Options:
89
+
-p, --path <PATH> Path to the directory containing your static site [default: .]
90
+
-s, --site <SITE> Site name (defaults to directory name)
91
+
--store <STORE> Path to auth store file (only used with OAuth) [default: /tmp/wisp-oauth-session.json]
92
+
--password <PASSWORD> App Password for authentication (alternative to OAuth)
93
+
-h, --help Print help
94
+
-V, --version Print version
95
+
```
96
+
97
+
## How It Works
98
+
99
+
1. **Authentication**: Authenticates using OAuth or app password
100
+
2. **File Processing**:
101
+
- Recursively walks the directory tree
102
+
- Skips hidden files (starting with `.`)
103
+
- Detects MIME types automatically
104
+
- Compresses files with gzip
105
+
- Base64 encodes compressed content
106
+
3. **Upload**:
107
+
- Uploads files as blobs to your PDS
108
+
- Processes up to 5 files concurrently
109
+
- Creates a `place.wisp.fs` record with the site manifest
110
+
4. **Deployment**: Site is immediately available at `https://sites.wisp.place/{did}/{site-name}`
111
+
112
+
## File Processing
113
+
114
+
All files are automatically:
115
+
116
+
- **Compressed** with gzip (level 9)
117
+
- **Base64 encoded** to bypass PDS content sniffing
118
+
- **Uploaded** as `application/octet-stream` blobs
119
+
- **Stored** with original MIME type metadata
120
+
121
+
The hosting service automatically decompresses non HTML/CSS/JS files when serving them.
122
+
123
+
## Limitations
124
+
125
+
- **Max file size**: 100MB per file (after compression) (this is a PDS limit, but not enforced by the CLI in case yours is higher)
126
+
- **Max file count**: 2000 files
127
+
- **Site name** must follow AT Protocol rkey format rules (alphanumeric, hyphens, underscores)
128
+
129
+
## Deploy with CI/CD
130
+
131
+
### GitHub Actions
132
+
133
+
```yaml
134
+
name: Deploy to Wisp
135
+
on:
136
+
push:
137
+
branches: [main]
138
+
139
+
jobs:
140
+
deploy:
141
+
runs-on: ubuntu-latest
142
+
steps:
143
+
- uses: actions/checkout@v3
144
+
145
+
- name: Setup Node
146
+
uses: actions/setup-node@v3
147
+
with:
148
+
node-version: '25'
149
+
150
+
- name: Install dependencies
151
+
run: npm install
152
+
153
+
- name: Build site
154
+
run: npm run build
155
+
156
+
- name: Download Wisp CLI
157
+
run: |
158
+
curl -L https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
159
+
chmod +x wisp-cli
160
+
161
+
- name: Deploy to Wisp
162
+
env:
163
+
WISP_APP_PASSWORD: ${{ secrets.WISP_APP_PASSWORD }}
164
+
run: |
165
+
./wisp-cli alice.bsky.social \
166
+
--path ./dist \
167
+
--site my-site \
168
+
--password "$WISP_APP_PASSWORD"
169
+
```
170
+
171
+
### Tangled.org
172
+
173
+
```yaml
174
+
when:
175
+
- event: ['push']
176
+
branch: ['main']
177
+
- event: ['manual']
178
+
179
+
engine: 'nixery'
180
+
181
+
clone:
182
+
skip: false
183
+
depth: 1
184
+
submodules: false
185
+
186
+
dependencies:
187
+
nixpkgs:
188
+
- nodejs
189
+
- coreutils
190
+
- curl
191
+
github:NixOS/nixpkgs/nixpkgs-unstable:
192
+
- bun
193
+
194
+
environment:
195
+
SITE_PATH: 'dist'
196
+
SITE_NAME: 'my-site'
197
+
WISP_HANDLE: 'your-handle.bsky.social'
198
+
199
+
steps:
200
+
- name: build site
201
+
command: |
202
+
export PATH="$HOME/.nix-profile/bin:$PATH"
203
+
204
+
# regenerate lockfile
205
+
rm package-lock.json bun.lock
206
+
bun install @rolldown/binding-linux-arm64-gnu --save-optional
207
+
bun install
208
+
209
+
# build with vite
210
+
bun node_modules/.bin/vite build
211
+
212
+
- name: deploy to wisp
213
+
command: |
214
+
# Download Wisp CLI
215
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
216
+
chmod +x wisp-cli
217
+
218
+
# Deploy to Wisp
219
+
./wisp-cli \
220
+
"$WISP_HANDLE" \
221
+
--path "$SITE_PATH" \
222
+
--site "$SITE_NAME" \
223
+
--password "$WISP_APP_PASSWORD"
224
+
```
225
+
226
+
### Generic Shell Script
227
+
228
+
```bash
229
+
# Use app password from environment variable
230
+
wisp-cli alice.bsky.social --path ./dist --site my-site --password "$WISP_APP_PASSWORD"
231
+
```
232
+
233
+
## Output
234
+
235
+
Upon successful deployment, you'll see:
236
+
237
+
```
238
+
Deployed site 'my-site': at://did:plc:abc123xyz/place.wisp.fs/my-site
239
+
Available at: https://sites.wisp.place/did:plc:abc123xyz/my-site
240
+
```
241
+
242
+
### Dependencies
243
+
244
+
- **jacquard**: AT Protocol client library
245
+
- **clap**: Command-line argument parsing
246
+
- **tokio**: Async runtime
247
+
- **flate2**: Gzip compression
248
+
- **base64**: Base64 encoding
249
+
- **walkdir**: Directory traversal
250
+
- **mime_guess**: MIME type detection
251
+
252
+
## License
253
+
254
+
MIT License
255
+
256
+
## Contributing
257
+
258
+
Just don't give me entirely claude slop especailly not in the PR description itself. You should be responsible for code you submit and aware of what it even is you're submitting.
259
+
260
+
## Links
261
+
262
+
- **Website**: https://wisp.place
263
+
- **Main Repository**: https://tangled.org/@nekomimi.pet/wisp.place-monorepo
264
+
- **AT Protocol**: https://atproto.com
265
+
- **Jacquard Library**: https://tangled.org/@nonbinary.computer/jacquard
266
+
267
+
## Support
268
+
269
+
For issues and questions:
270
+
- Check the main wisp.place documentation
271
+
- Open an issue in the main repository
+85
cli/src/blob_map.rs
+85
cli/src/blob_map.rs
···
1
+
use jacquard_common::types::blob::BlobRef;
2
+
use jacquard_common::IntoStatic;
3
+
use std::collections::HashMap;
4
+
5
+
use crate::place_wisp::fs::{Directory, EntryNode};
6
+
7
+
/// Extract blob information from a directory tree
8
+
/// Returns a map of file paths to their blob refs and CIDs
9
+
///
10
+
/// This mirrors the TypeScript implementation in src/lib/wisp-utils.ts lines 275-302
11
+
pub fn extract_blob_map(
12
+
directory: &Directory,
13
+
) -> HashMap<String, (BlobRef<'static>, String)> {
14
+
extract_blob_map_recursive(directory, String::new())
15
+
}
16
+
17
+
fn extract_blob_map_recursive(
18
+
directory: &Directory,
19
+
current_path: String,
20
+
) -> HashMap<String, (BlobRef<'static>, String)> {
21
+
let mut blob_map = HashMap::new();
22
+
23
+
for entry in &directory.entries {
24
+
let full_path = if current_path.is_empty() {
25
+
entry.name.to_string()
26
+
} else {
27
+
format!("{}/{}", current_path, entry.name)
28
+
};
29
+
30
+
match &entry.node {
31
+
EntryNode::File(file_node) => {
32
+
// Extract CID from blob ref
33
+
// BlobRef is an enum with Blob variant, which has a ref field (CidLink)
34
+
let blob_ref = &file_node.blob;
35
+
let cid_string = blob_ref.blob().r#ref.to_string();
36
+
37
+
// Store with full path (mirrors TypeScript implementation)
38
+
blob_map.insert(
39
+
full_path,
40
+
(blob_ref.clone().into_static(), cid_string)
41
+
);
42
+
}
43
+
EntryNode::Directory(subdir) => {
44
+
let sub_map = extract_blob_map_recursive(subdir, full_path);
45
+
blob_map.extend(sub_map);
46
+
}
47
+
EntryNode::Unknown(_) => {
48
+
// Skip unknown node types
49
+
}
50
+
}
51
+
}
52
+
53
+
blob_map
54
+
}
55
+
56
+
/// Normalize file path by removing base folder prefix
57
+
/// Example: "cobblemon/index.html" -> "index.html"
58
+
///
59
+
/// Note: This function is kept for reference but is no longer used in production code.
60
+
/// The TypeScript server has a similar normalization (src/routes/wisp.ts line 291) to handle
61
+
/// uploads that include a base folder prefix, but our CLI doesn't need this since we
62
+
/// track full paths consistently.
63
+
#[allow(dead_code)]
64
+
pub fn normalize_path(path: &str) -> String {
65
+
// Remove base folder prefix (everything before first /)
66
+
if let Some(idx) = path.find('/') {
67
+
path[idx + 1..].to_string()
68
+
} else {
69
+
path.to_string()
70
+
}
71
+
}
72
+
73
+
#[cfg(test)]
74
+
mod tests {
75
+
use super::*;
76
+
77
+
#[test]
78
+
fn test_normalize_path() {
79
+
assert_eq!(normalize_path("index.html"), "index.html");
80
+
assert_eq!(normalize_path("cobblemon/index.html"), "index.html");
81
+
assert_eq!(normalize_path("folder/subfolder/file.txt"), "subfolder/file.txt");
82
+
assert_eq!(normalize_path("a/b/c/d.txt"), "b/c/d.txt");
83
+
}
84
+
}
85
+
+66
cli/src/cid.rs
+66
cli/src/cid.rs
···
1
+
use jacquard_common::types::cid::IpldCid;
2
+
use sha2::{Digest, Sha256};
3
+
4
+
/// Compute CID (Content Identifier) for blob content
5
+
/// Uses the same algorithm as AT Protocol: CIDv1 with raw codec (0x55) and SHA-256
6
+
///
7
+
/// CRITICAL: This must be called on BASE64-ENCODED GZIPPED content, not just gzipped content
8
+
///
9
+
/// Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
10
+
pub fn compute_cid(content: &[u8]) -> String {
11
+
// Use node crypto to compute sha256 hash (same as AT Protocol)
12
+
let hash = Sha256::digest(content);
13
+
14
+
// Create multihash (code 0x12 = sha2-256)
15
+
let multihash = multihash::Multihash::wrap(0x12, &hash)
16
+
.expect("SHA-256 hash should always fit in multihash");
17
+
18
+
// Create CIDv1 with raw codec (0x55)
19
+
let cid = IpldCid::new_v1(0x55, multihash);
20
+
21
+
// Convert to base32 string representation
22
+
cid.to_string_of_base(multibase::Base::Base32Lower)
23
+
.unwrap_or_else(|_| cid.to_string())
24
+
}
25
+
26
+
#[cfg(test)]
27
+
mod tests {
28
+
use super::*;
29
+
use base64::Engine;
30
+
31
+
#[test]
32
+
fn test_compute_cid() {
33
+
// Test with a simple string: "hello"
34
+
let content = b"hello";
35
+
let cid = compute_cid(content);
36
+
37
+
// CID should start with 'baf' for raw codec base32
38
+
assert!(cid.starts_with("baf"));
39
+
}
40
+
41
+
#[test]
42
+
fn test_compute_cid_base64_encoded() {
43
+
// Simulate the actual use case: gzipped then base64 encoded
44
+
use flate2::write::GzEncoder;
45
+
use flate2::Compression;
46
+
use std::io::Write;
47
+
48
+
let original = b"hello world";
49
+
50
+
// Gzip compress
51
+
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
52
+
encoder.write_all(original).unwrap();
53
+
let gzipped = encoder.finish().unwrap();
54
+
55
+
// Base64 encode the gzipped data
56
+
let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
57
+
58
+
// Compute CID on the base64 bytes
59
+
let cid = compute_cid(&base64_bytes);
60
+
61
+
// Should be a valid CID
62
+
assert!(cid.starts_with("baf"));
63
+
assert!(cid.len() > 10);
64
+
}
65
+
}
66
+
+71
cli/src/download.rs
+71
cli/src/download.rs
···
1
+
use base64::Engine;
2
+
use bytes::Bytes;
3
+
use flate2::read::GzDecoder;
4
+
use jacquard_common::types::blob::BlobRef;
5
+
use miette::IntoDiagnostic;
6
+
use std::io::Read;
7
+
use url::Url;
8
+
9
+
/// Download a blob from the PDS
10
+
pub async fn download_blob(pds_url: &Url, blob_ref: &BlobRef<'_>, did: &str) -> miette::Result<Bytes> {
11
+
// Extract CID from blob ref
12
+
let cid = blob_ref.blob().r#ref.to_string();
13
+
14
+
// Construct blob download URL
15
+
// The correct endpoint is: /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}
16
+
let blob_url = pds_url
17
+
.join(&format!("/xrpc/com.atproto.sync.getBlob?did={}&cid={}", did, cid))
18
+
.into_diagnostic()?;
19
+
20
+
let client = reqwest::Client::new();
21
+
let response = client
22
+
.get(blob_url)
23
+
.send()
24
+
.await
25
+
.into_diagnostic()?;
26
+
27
+
if !response.status().is_success() {
28
+
return Err(miette::miette!(
29
+
"Failed to download blob: {}",
30
+
response.status()
31
+
));
32
+
}
33
+
34
+
let bytes = response.bytes().await.into_diagnostic()?;
35
+
Ok(bytes)
36
+
}
37
+
38
+
/// Decompress and decode a blob (base64 + gzip)
39
+
pub fn decompress_blob(data: &[u8], is_base64: bool, is_gzipped: bool) -> miette::Result<Vec<u8>> {
40
+
let mut current_data = data.to_vec();
41
+
42
+
// First, decode base64 if needed
43
+
if is_base64 {
44
+
current_data = base64::prelude::BASE64_STANDARD
45
+
.decode(¤t_data)
46
+
.into_diagnostic()?;
47
+
}
48
+
49
+
// Then, decompress gzip if needed
50
+
if is_gzipped {
51
+
let mut decoder = GzDecoder::new(¤t_data[..]);
52
+
let mut decompressed = Vec::new();
53
+
decoder.read_to_end(&mut decompressed).into_diagnostic()?;
54
+
current_data = decompressed;
55
+
}
56
+
57
+
Ok(current_data)
58
+
}
59
+
60
+
/// Download and decompress a blob
61
+
pub async fn download_and_decompress_blob(
62
+
pds_url: &Url,
63
+
blob_ref: &BlobRef<'_>,
64
+
did: &str,
65
+
is_base64: bool,
66
+
is_gzipped: bool,
67
+
) -> miette::Result<Vec<u8>> {
68
+
let data = download_blob(pds_url, blob_ref, did).await?;
69
+
decompress_blob(&data, is_base64, is_gzipped)
70
+
}
71
+
+243
-56
cli/src/main.rs
+243
-56
cli/src/main.rs
···
1
1
mod builder_types;
2
2
mod place_wisp;
3
+
mod cid;
4
+
mod blob_map;
5
+
mod metadata;
6
+
mod download;
7
+
mod pull;
8
+
mod serve;
3
9
4
-
use clap::Parser;
10
+
use clap::{Parser, Subcommand};
5
11
use jacquard::CowStr;
6
-
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession};
12
+
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession};
7
13
use jacquard::oauth::client::OAuthClient;
8
14
use jacquard::oauth::loopback::LoopbackConfig;
9
15
use jacquard::prelude::IdentityResolver;
···
11
17
use jacquard_common::types::blob::MimeType;
12
18
use miette::IntoDiagnostic;
13
19
use std::path::{Path, PathBuf};
20
+
use std::collections::HashMap;
14
21
use flate2::Compression;
15
22
use flate2::write::GzEncoder;
16
23
use std::io::Write;
···
20
27
use place_wisp::fs::*;
21
28
22
29
#[derive(Parser, Debug)]
23
-
#[command(author, version, about = "Deploy a static site to wisp.place")]
30
+
#[command(author, version, about = "wisp.place CLI tool")]
24
31
struct Args {
32
+
#[command(subcommand)]
33
+
command: Option<Commands>,
34
+
35
+
// Deploy arguments (when no subcommand is specified)
25
36
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
26
-
input: CowStr<'static>,
37
+
#[arg(global = true, conflicts_with = "command")]
38
+
input: Option<CowStr<'static>>,
27
39
28
40
/// Path to the directory containing your static site
29
-
#[arg(short, long, default_value = ".")]
30
-
path: PathBuf,
41
+
#[arg(short, long, global = true, conflicts_with = "command")]
42
+
path: Option<PathBuf>,
31
43
32
44
/// Site name (defaults to directory name)
33
-
#[arg(short, long)]
45
+
#[arg(short, long, global = true, conflicts_with = "command")]
34
46
site: Option<String>,
35
47
36
-
/// Path to auth store file (will be created if missing, only used with OAuth)
37
-
#[arg(long, default_value = "/tmp/wisp-oauth-session.json")]
38
-
store: String,
48
+
/// Path to auth store file
49
+
#[arg(long, global = true, conflicts_with = "command")]
50
+
store: Option<String>,
39
51
40
-
/// App Password for authentication (alternative to OAuth)
41
-
#[arg(long)]
52
+
/// App Password for authentication
53
+
#[arg(long, global = true, conflicts_with = "command")]
42
54
password: Option<CowStr<'static>>,
43
55
}
44
56
57
+
#[derive(Subcommand, Debug)]
58
+
enum Commands {
59
+
/// Deploy a static site to wisp.place (default command)
60
+
Deploy {
61
+
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
62
+
input: CowStr<'static>,
63
+
64
+
/// Path to the directory containing your static site
65
+
#[arg(short, long, default_value = ".")]
66
+
path: PathBuf,
67
+
68
+
/// Site name (defaults to directory name)
69
+
#[arg(short, long)]
70
+
site: Option<String>,
71
+
72
+
/// Path to auth store file (will be created if missing, only used with OAuth)
73
+
#[arg(long, default_value = "/tmp/wisp-oauth-session.json")]
74
+
store: String,
75
+
76
+
/// App Password for authentication (alternative to OAuth)
77
+
#[arg(long)]
78
+
password: Option<CowStr<'static>>,
79
+
},
80
+
/// Pull a site from the PDS to a local directory
81
+
Pull {
82
+
/// Handle (e.g., alice.bsky.social) or DID
83
+
input: CowStr<'static>,
84
+
85
+
/// Site name (record key)
86
+
#[arg(short, long)]
87
+
site: String,
88
+
89
+
/// Output directory for the downloaded site
90
+
#[arg(short, long, default_value = ".")]
91
+
output: PathBuf,
92
+
},
93
+
/// Serve a site locally with real-time firehose updates
94
+
Serve {
95
+
/// Handle (e.g., alice.bsky.social) or DID
96
+
input: CowStr<'static>,
97
+
98
+
/// Site name (record key)
99
+
#[arg(short, long)]
100
+
site: String,
101
+
102
+
/// Output directory for the site files
103
+
#[arg(short, long, default_value = ".")]
104
+
output: PathBuf,
105
+
106
+
/// Port to serve on
107
+
#[arg(short, long, default_value = "8080")]
108
+
port: u16,
109
+
},
110
+
}
111
+
45
112
#[tokio::main]
46
113
async fn main() -> miette::Result<()> {
47
114
let args = Args::parse();
48
115
49
-
// Dispatch to appropriate authentication method
50
-
if let Some(password) = args.password {
51
-
run_with_app_password(args.input, password, args.path, args.site).await
52
-
} else {
53
-
run_with_oauth(args.input, args.store, args.path, args.site).await
116
+
match args.command {
117
+
Some(Commands::Deploy { input, path, site, store, password }) => {
118
+
// Dispatch to appropriate authentication method
119
+
if let Some(password) = password {
120
+
run_with_app_password(input, password, path, site).await
121
+
} else {
122
+
run_with_oauth(input, store, path, site).await
123
+
}
124
+
}
125
+
Some(Commands::Pull { input, site, output }) => {
126
+
pull::pull_site(input, CowStr::from(site), output).await
127
+
}
128
+
Some(Commands::Serve { input, site, output, port }) => {
129
+
serve::serve_site(input, CowStr::from(site), output, port).await
130
+
}
131
+
None => {
132
+
// Legacy mode: if input is provided, assume deploy command
133
+
if let Some(input) = args.input {
134
+
let path = args.path.unwrap_or_else(|| PathBuf::from("."));
135
+
let store = args.store.unwrap_or_else(|| "/tmp/wisp-oauth-session.json".to_string());
136
+
137
+
// Dispatch to appropriate authentication method
138
+
if let Some(password) = args.password {
139
+
run_with_app_password(input, password, path, args.site).await
140
+
} else {
141
+
run_with_oauth(input, store, path, args.site).await
142
+
}
143
+
} else {
144
+
// No command and no input, show help
145
+
use clap::CommandFactory;
146
+
Args::command().print_help().into_diagnostic()?;
147
+
Ok(())
148
+
}
149
+
}
54
150
}
55
151
}
56
152
···
107
203
108
204
println!("Deploying site '{}'...", site_name);
109
205
110
-
// Build directory tree
111
-
let root_dir = build_directory(agent, &path).await?;
206
+
// Try to fetch existing manifest for incremental updates
207
+
let existing_blob_map: HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)> = {
208
+
use jacquard_common::types::string::AtUri;
209
+
210
+
// Get the DID for this session
211
+
let session_info = agent.session_info().await;
212
+
if let Some((did, _)) = session_info {
213
+
// Construct the AT URI for the record
214
+
let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name);
215
+
if let Ok(uri) = AtUri::new(&uri_string) {
216
+
match agent.get_record::<Fs>(&uri).await {
217
+
Ok(response) => {
218
+
match response.into_output() {
219
+
Ok(record_output) => {
220
+
let existing_manifest = record_output.value;
221
+
let blob_map = blob_map::extract_blob_map(&existing_manifest.root);
222
+
println!("Found existing manifest with {} files, checking for changes...", blob_map.len());
223
+
blob_map
224
+
}
225
+
Err(_) => {
226
+
println!("No existing manifest found, uploading all files...");
227
+
HashMap::new()
228
+
}
229
+
}
230
+
}
231
+
Err(_) => {
232
+
// Record doesn't exist yet - this is a new site
233
+
println!("No existing manifest found, uploading all files...");
234
+
HashMap::new()
235
+
}
236
+
}
237
+
} else {
238
+
println!("No existing manifest found (invalid URI), uploading all files...");
239
+
HashMap::new()
240
+
}
241
+
} else {
242
+
println!("No existing manifest found (could not get DID), uploading all files...");
243
+
HashMap::new()
244
+
}
245
+
};
112
246
113
-
// Count total files
114
-
let file_count = count_files(&root_dir);
247
+
// Build directory tree
248
+
let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?;
249
+
let uploaded_count = total_files - reused_count;
115
250
116
251
// Create the Fs record
117
252
let fs_record = Fs::new()
118
253
.site(CowStr::from(site_name.clone()))
119
254
.root(root_dir)
120
-
.file_count(file_count as i64)
255
+
.file_count(total_files as i64)
121
256
.created_at(Datetime::now())
122
257
.build();
123
258
···
132
267
.and_then(|s| s.split('/').next())
133
268
.ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?;
134
269
135
-
println!("Deployed site '{}': {}", site_name, output.uri);
136
-
println!("Available at: https://sites.wisp.place/{}/{}", did, site_name);
270
+
println!("\nโ Deployed site '{}': {}", site_name, output.uri);
271
+
println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count);
272
+
println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name);
137
273
138
274
Ok(())
139
275
}
140
276
141
277
/// Recursively build a Directory from a filesystem path
278
+
/// current_path is the path from the root of the site (e.g., "" for root, "config" for config dir)
142
279
fn build_directory<'a>(
143
280
agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>,
144
281
dir_path: &'a Path,
145
-
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>>
282
+
existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
283
+
current_path: String,
284
+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>>
146
285
{
147
286
Box::pin(async move {
148
287
// Collect all directory entries first
···
170
309
let metadata = entry.metadata().into_diagnostic()?;
171
310
172
311
if metadata.is_file() {
173
-
file_tasks.push((name_str, path));
312
+
// Construct full path for this file (for blob map lookup)
313
+
let full_path = if current_path.is_empty() {
314
+
name_str.clone()
315
+
} else {
316
+
format!("{}/{}", current_path, name_str)
317
+
};
318
+
file_tasks.push((name_str, path, full_path));
174
319
} else if metadata.is_dir() {
175
320
dir_tasks.push((name_str, path));
176
321
}
177
322
}
178
323
179
324
// Process files concurrently with a limit of 5
180
-
let file_entries: Vec<Entry> = stream::iter(file_tasks)
181
-
.map(|(name, path)| async move {
182
-
let file_node = process_file(agent, &path).await?;
183
-
Ok::<_, miette::Report>(Entry::new()
325
+
let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks)
326
+
.map(|(name, path, full_path)| async move {
327
+
let (file_node, reused) = process_file(agent, &path, &full_path, existing_blobs).await?;
328
+
let entry = Entry::new()
184
329
.name(CowStr::from(name))
185
330
.node(EntryNode::File(Box::new(file_node)))
186
-
.build())
331
+
.build();
332
+
Ok::<_, miette::Report>((entry, reused))
187
333
})
188
334
.buffer_unordered(5)
189
335
.collect::<Vec<_>>()
190
336
.await
191
337
.into_iter()
192
338
.collect::<miette::Result<Vec<_>>>()?;
339
+
340
+
let mut file_entries = Vec::new();
341
+
let mut reused_count = 0;
342
+
let mut total_files = 0;
343
+
344
+
for (entry, reused) in file_results {
345
+
file_entries.push(entry);
346
+
total_files += 1;
347
+
if reused {
348
+
reused_count += 1;
349
+
}
350
+
}
193
351
194
352
// Process directories recursively (sequentially to avoid too much nesting)
195
353
let mut dir_entries = Vec::new();
196
354
for (name, path) in dir_tasks {
197
-
let subdir = build_directory(agent, &path).await?;
355
+
// Construct full path for subdirectory
356
+
let subdir_path = if current_path.is_empty() {
357
+
name.clone()
358
+
} else {
359
+
format!("{}/{}", current_path, name)
360
+
};
361
+
let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs, subdir_path).await?;
198
362
dir_entries.push(Entry::new()
199
363
.name(CowStr::from(name))
200
364
.node(EntryNode::Directory(Box::new(subdir)))
201
365
.build());
366
+
total_files += sub_total;
367
+
reused_count += sub_reused;
202
368
}
203
369
204
370
// Combine file and directory entries
205
371
let mut entries = file_entries;
206
372
entries.extend(dir_entries);
207
373
208
-
Ok(Directory::new()
374
+
let directory = Directory::new()
209
375
.r#type(CowStr::from("directory"))
210
376
.entries(entries)
211
-
.build())
377
+
.build();
378
+
379
+
Ok((directory, total_files, reused_count))
212
380
})
213
381
}
214
382
215
-
/// Process a single file: gzip -> base64 -> upload blob
383
+
/// Process a single file: gzip -> base64 -> upload blob (or reuse existing)
384
+
/// Returns (File, reused: bool)
385
+
/// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup
216
386
async fn process_file(
217
387
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
218
388
file_path: &Path,
219
-
) -> miette::Result<File<'static>>
389
+
file_path_key: &str,
390
+
existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
391
+
) -> miette::Result<(File<'static>, bool)>
220
392
{
221
393
// Read file
222
394
let file_data = std::fs::read(file_path).into_diagnostic()?;
···
234
406
// Base64 encode the gzipped data
235
407
let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
236
408
237
-
// Upload blob as octet-stream
409
+
// Compute CID for this file (CRITICAL: on base64-encoded gzipped content)
410
+
let file_cid = cid::compute_cid(&base64_bytes);
411
+
412
+
// Check if we have an existing blob with the same CID
413
+
let existing_blob = existing_blobs.get(file_path_key);
414
+
415
+
if let Some((existing_blob_ref, existing_cid)) = existing_blob {
416
+
if existing_cid == &file_cid {
417
+
// CIDs match - reuse existing blob
418
+
println!(" โ Reusing blob for {} (CID: {})", file_path_key, file_cid);
419
+
return Ok((
420
+
File::new()
421
+
.r#type(CowStr::from("file"))
422
+
.blob(existing_blob_ref.clone())
423
+
.encoding(CowStr::from("gzip"))
424
+
.mime_type(CowStr::from(original_mime))
425
+
.base64(true)
426
+
.build(),
427
+
true
428
+
));
429
+
}
430
+
}
431
+
432
+
// File is new or changed - upload it
433
+
println!(" โ Uploading {} ({} bytes, CID: {})", file_path_key, base64_bytes.len(), file_cid);
238
434
let blob = agent.upload_blob(
239
435
base64_bytes,
240
436
MimeType::new_static("application/octet-stream"),
241
437
).await?;
242
438
243
-
Ok(File::new()
244
-
.r#type(CowStr::from("file"))
245
-
.blob(blob)
246
-
.encoding(CowStr::from("gzip"))
247
-
.mime_type(CowStr::from(original_mime))
248
-
.base64(true)
249
-
.build())
439
+
Ok((
440
+
File::new()
441
+
.r#type(CowStr::from("file"))
442
+
.blob(blob)
443
+
.encoding(CowStr::from("gzip"))
444
+
.mime_type(CowStr::from(original_mime))
445
+
.base64(true)
446
+
.build(),
447
+
false
448
+
))
250
449
}
251
450
252
-
/// Count total files in a directory tree
253
-
fn count_files(dir: &Directory) -> usize {
254
-
let mut count = 0;
255
-
for entry in &dir.entries {
256
-
match &entry.node {
257
-
EntryNode::File(_) => count += 1,
258
-
EntryNode::Directory(subdir) => count += count_files(subdir),
259
-
_ => {} // Unknown variants
260
-
}
261
-
}
262
-
count
263
-
}
+46
cli/src/metadata.rs
+46
cli/src/metadata.rs
···
1
+
use serde::{Deserialize, Serialize};
2
+
use std::collections::HashMap;
3
+
use std::path::Path;
4
+
use miette::IntoDiagnostic;
5
+
6
+
/// Metadata tracking file CIDs for incremental updates
7
+
#[derive(Debug, Clone, Serialize, Deserialize)]
8
+
pub struct SiteMetadata {
9
+
/// Record CID from the PDS
10
+
pub record_cid: String,
11
+
/// Map of file paths to their blob CIDs
12
+
pub file_cids: HashMap<String, String>,
13
+
/// Timestamp when the site was last synced
14
+
pub last_sync: i64,
15
+
}
16
+
17
+
impl SiteMetadata {
18
+
pub fn new(record_cid: String, file_cids: HashMap<String, String>) -> Self {
19
+
Self {
20
+
record_cid,
21
+
file_cids,
22
+
last_sync: chrono::Utc::now().timestamp(),
23
+
}
24
+
}
25
+
26
+
/// Load metadata from a directory
27
+
pub fn load(dir: &Path) -> miette::Result<Option<Self>> {
28
+
let metadata_path = dir.join(".wisp-metadata.json");
29
+
if !metadata_path.exists() {
30
+
return Ok(None);
31
+
}
32
+
33
+
let contents = std::fs::read_to_string(&metadata_path).into_diagnostic()?;
34
+
let metadata: SiteMetadata = serde_json::from_str(&contents).into_diagnostic()?;
35
+
Ok(Some(metadata))
36
+
}
37
+
38
+
/// Save metadata to a directory
39
+
pub fn save(&self, dir: &Path) -> miette::Result<()> {
40
+
let metadata_path = dir.join(".wisp-metadata.json");
41
+
let contents = serde_json::to_string_pretty(self).into_diagnostic()?;
42
+
std::fs::write(&metadata_path, contents).into_diagnostic()?;
43
+
Ok(())
44
+
}
45
+
}
46
+
+305
cli/src/pull.rs
+305
cli/src/pull.rs
···
1
+
use crate::blob_map;
2
+
use crate::download;
3
+
use crate::metadata::SiteMetadata;
4
+
use crate::place_wisp::fs::*;
5
+
use jacquard::CowStr;
6
+
use jacquard::prelude::IdentityResolver;
7
+
use jacquard_common::types::string::Did;
8
+
use jacquard_common::xrpc::XrpcExt;
9
+
use jacquard_identity::PublicResolver;
10
+
use miette::IntoDiagnostic;
11
+
use std::collections::HashMap;
12
+
use std::path::{Path, PathBuf};
13
+
use url::Url;
14
+
15
+
/// Pull a site from the PDS to a local directory
16
+
pub async fn pull_site(
17
+
input: CowStr<'static>,
18
+
rkey: CowStr<'static>,
19
+
output_dir: PathBuf,
20
+
) -> miette::Result<()> {
21
+
println!("Pulling site {} from {}...", rkey, input);
22
+
23
+
// Resolve handle to DID if needed
24
+
let resolver = PublicResolver::default();
25
+
let did = if input.starts_with("did:") {
26
+
Did::new(&input).into_diagnostic()?
27
+
} else {
28
+
// It's a handle, resolve it
29
+
let handle = jacquard_common::types::string::Handle::new(&input).into_diagnostic()?;
30
+
resolver.resolve_handle(&handle).await.into_diagnostic()?
31
+
};
32
+
33
+
// Resolve PDS endpoint for the DID
34
+
let pds_url = resolver.pds_for_did(&did).await.into_diagnostic()?;
35
+
println!("Resolved PDS: {}", pds_url);
36
+
37
+
// Fetch the place.wisp.fs record
38
+
39
+
println!("Fetching record from PDS...");
40
+
let client = reqwest::Client::new();
41
+
42
+
// Use com.atproto.repo.getRecord
43
+
use jacquard::api::com_atproto::repo::get_record::GetRecord;
44
+
use jacquard_common::types::string::Rkey as RkeyType;
45
+
let rkey_parsed = RkeyType::new(&rkey).into_diagnostic()?;
46
+
47
+
use jacquard_common::types::ident::AtIdentifier;
48
+
use jacquard_common::types::string::RecordKey;
49
+
let request = GetRecord::new()
50
+
.repo(AtIdentifier::Did(did.clone()))
51
+
.collection(CowStr::from("place.wisp.fs"))
52
+
.rkey(RecordKey::from(rkey_parsed))
53
+
.build();
54
+
55
+
let response = client
56
+
.xrpc(pds_url.clone())
57
+
.send(&request)
58
+
.await
59
+
.into_diagnostic()?;
60
+
61
+
let record_output = response.into_output().into_diagnostic()?;
62
+
let record_cid = record_output.cid.as_ref().map(|c| c.to_string()).unwrap_or_default();
63
+
64
+
// Parse the record value as Fs
65
+
use jacquard_common::types::value::from_data;
66
+
let fs_record: Fs = from_data(&record_output.value).into_diagnostic()?;
67
+
68
+
let file_count = fs_record.file_count.map(|c| c.to_string()).unwrap_or_else(|| "?".to_string());
69
+
println!("Found site '{}' with {} files", fs_record.site, file_count);
70
+
71
+
// Load existing metadata for incremental updates
72
+
let existing_metadata = SiteMetadata::load(&output_dir)?;
73
+
let existing_file_cids = existing_metadata
74
+
.as_ref()
75
+
.map(|m| m.file_cids.clone())
76
+
.unwrap_or_default();
77
+
78
+
// Extract blob map from the new manifest
79
+
let new_blob_map = blob_map::extract_blob_map(&fs_record.root);
80
+
let new_file_cids: HashMap<String, String> = new_blob_map
81
+
.iter()
82
+
.map(|(path, (_blob_ref, cid))| (path.clone(), cid.clone()))
83
+
.collect();
84
+
85
+
// Clean up any leftover temp directories from previous failed attempts
86
+
let parent = output_dir.parent().unwrap_or_else(|| std::path::Path::new("."));
87
+
let output_name = output_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("site")).to_string_lossy();
88
+
let temp_prefix = format!(".tmp-{}-", output_name);
89
+
90
+
if let Ok(entries) = parent.read_dir() {
91
+
for entry in entries.flatten() {
92
+
let name = entry.file_name();
93
+
if name.to_string_lossy().starts_with(&temp_prefix) {
94
+
let _ = std::fs::remove_dir_all(entry.path());
95
+
}
96
+
}
97
+
}
98
+
99
+
// Check if we need to update (but only if output directory actually exists with files)
100
+
if let Some(metadata) = &existing_metadata {
101
+
if metadata.record_cid == record_cid {
102
+
// Verify that the output directory actually exists and has content
103
+
let has_content = output_dir.exists() &&
104
+
output_dir.read_dir()
105
+
.map(|mut entries| entries.any(|e| {
106
+
if let Ok(entry) = e {
107
+
!entry.file_name().to_string_lossy().starts_with(".wisp-metadata")
108
+
} else {
109
+
false
110
+
}
111
+
}))
112
+
.unwrap_or(false);
113
+
114
+
if has_content {
115
+
println!("Site is already up to date!");
116
+
return Ok(());
117
+
}
118
+
}
119
+
}
120
+
121
+
// Create temporary directory for atomic update
122
+
// Place temp dir in parent directory to avoid issues with non-existent output_dir
123
+
let parent = output_dir.parent().unwrap_or_else(|| std::path::Path::new("."));
124
+
let temp_dir_name = format!(
125
+
".tmp-{}-{}",
126
+
output_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("site")).to_string_lossy(),
127
+
chrono::Utc::now().timestamp()
128
+
);
129
+
let temp_dir = parent.join(temp_dir_name);
130
+
std::fs::create_dir_all(&temp_dir).into_diagnostic()?;
131
+
132
+
println!("Downloading files...");
133
+
let mut downloaded = 0;
134
+
let mut reused = 0;
135
+
136
+
// Download files recursively
137
+
let download_result = download_directory(
138
+
&fs_record.root,
139
+
&temp_dir,
140
+
&pds_url,
141
+
did.as_str(),
142
+
&new_blob_map,
143
+
&existing_file_cids,
144
+
&output_dir,
145
+
String::new(),
146
+
&mut downloaded,
147
+
&mut reused,
148
+
)
149
+
.await;
150
+
151
+
// If download failed, clean up temp directory
152
+
if let Err(e) = download_result {
153
+
let _ = std::fs::remove_dir_all(&temp_dir);
154
+
return Err(e);
155
+
}
156
+
157
+
println!(
158
+
"Downloaded {} files, reused {} files",
159
+
downloaded, reused
160
+
);
161
+
162
+
// Save metadata
163
+
let metadata = SiteMetadata::new(record_cid, new_file_cids);
164
+
metadata.save(&temp_dir)?;
165
+
166
+
// Move files from temp to output directory
167
+
let output_abs = std::fs::canonicalize(&output_dir).unwrap_or_else(|_| output_dir.clone());
168
+
let current_dir = std::env::current_dir().into_diagnostic()?;
169
+
170
+
// Special handling for pulling to current directory
171
+
if output_abs == current_dir {
172
+
// Move files from temp to current directory
173
+
for entry in std::fs::read_dir(&temp_dir).into_diagnostic()? {
174
+
let entry = entry.into_diagnostic()?;
175
+
let dest = current_dir.join(entry.file_name());
176
+
177
+
// Remove existing file/dir if it exists
178
+
if dest.exists() {
179
+
if dest.is_dir() {
180
+
std::fs::remove_dir_all(&dest).into_diagnostic()?;
181
+
} else {
182
+
std::fs::remove_file(&dest).into_diagnostic()?;
183
+
}
184
+
}
185
+
186
+
// Move from temp to current dir
187
+
std::fs::rename(entry.path(), dest).into_diagnostic()?;
188
+
}
189
+
190
+
// Clean up temp directory
191
+
std::fs::remove_dir_all(&temp_dir).into_diagnostic()?;
192
+
} else {
193
+
// If output directory exists and has content, remove it first
194
+
if output_dir.exists() {
195
+
std::fs::remove_dir_all(&output_dir).into_diagnostic()?;
196
+
}
197
+
198
+
// Ensure parent directory exists
199
+
if let Some(parent) = output_dir.parent() {
200
+
if !parent.as_os_str().is_empty() && !parent.exists() {
201
+
std::fs::create_dir_all(parent).into_diagnostic()?;
202
+
}
203
+
}
204
+
205
+
// Rename temp to final location
206
+
match std::fs::rename(&temp_dir, &output_dir) {
207
+
Ok(_) => {},
208
+
Err(e) => {
209
+
// Clean up temp directory on failure
210
+
let _ = std::fs::remove_dir_all(&temp_dir);
211
+
return Err(miette::miette!("Failed to move temp directory: {}", e));
212
+
}
213
+
}
214
+
}
215
+
216
+
println!("โ Site pulled successfully to {}", output_dir.display());
217
+
218
+
Ok(())
219
+
}
220
+
221
+
/// Recursively download a directory
222
+
fn download_directory<'a>(
223
+
dir: &'a Directory<'_>,
224
+
output_dir: &'a Path,
225
+
pds_url: &'a Url,
226
+
did: &'a str,
227
+
new_blob_map: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
228
+
existing_file_cids: &'a HashMap<String, String>,
229
+
existing_output_dir: &'a Path,
230
+
path_prefix: String,
231
+
downloaded: &'a mut usize,
232
+
reused: &'a mut usize,
233
+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send + 'a>> {
234
+
Box::pin(async move {
235
+
for entry in &dir.entries {
236
+
let entry_name = entry.name.as_str();
237
+
let current_path = if path_prefix.is_empty() {
238
+
entry_name.to_string()
239
+
} else {
240
+
format!("{}/{}", path_prefix, entry_name)
241
+
};
242
+
243
+
match &entry.node {
244
+
EntryNode::File(file) => {
245
+
let output_path = output_dir.join(entry_name);
246
+
247
+
// Check if file CID matches existing
248
+
if let Some((_blob_ref, new_cid)) = new_blob_map.get(¤t_path) {
249
+
if let Some(existing_cid) = existing_file_cids.get(¤t_path) {
250
+
if existing_cid == new_cid {
251
+
// File unchanged, copy from existing directory
252
+
let existing_path = existing_output_dir.join(¤t_path);
253
+
if existing_path.exists() {
254
+
std::fs::copy(&existing_path, &output_path).into_diagnostic()?;
255
+
*reused += 1;
256
+
println!(" โ Reused {}", current_path);
257
+
continue;
258
+
}
259
+
}
260
+
}
261
+
}
262
+
263
+
// File is new or changed, download it
264
+
println!(" โ Downloading {}", current_path);
265
+
let data = download::download_and_decompress_blob(
266
+
pds_url,
267
+
&file.blob,
268
+
did,
269
+
file.base64.unwrap_or(false),
270
+
file.encoding.as_ref().map(|e| e.as_str() == "gzip").unwrap_or(false),
271
+
)
272
+
.await?;
273
+
274
+
std::fs::write(&output_path, data).into_diagnostic()?;
275
+
*downloaded += 1;
276
+
}
277
+
EntryNode::Directory(subdir) => {
278
+
let subdir_path = output_dir.join(entry_name);
279
+
std::fs::create_dir_all(&subdir_path).into_diagnostic()?;
280
+
281
+
download_directory(
282
+
subdir,
283
+
&subdir_path,
284
+
pds_url,
285
+
did,
286
+
new_blob_map,
287
+
existing_file_cids,
288
+
existing_output_dir,
289
+
current_path,
290
+
downloaded,
291
+
reused,
292
+
)
293
+
.await?;
294
+
}
295
+
EntryNode::Unknown(_) => {
296
+
// Skip unknown node types
297
+
println!(" โ Skipping unknown node type for {}", current_path);
298
+
}
299
+
}
300
+
}
301
+
302
+
Ok(())
303
+
})
304
+
}
305
+
+202
cli/src/serve.rs
+202
cli/src/serve.rs
···
1
+
use crate::pull::pull_site;
2
+
use axum::Router;
3
+
use jacquard::CowStr;
4
+
use jacquard_common::jetstream::{CommitOperation, JetstreamMessage, JetstreamParams};
5
+
use jacquard_common::types::string::Did;
6
+
use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient};
7
+
use miette::IntoDiagnostic;
8
+
use n0_future::StreamExt;
9
+
use std::path::PathBuf;
10
+
use std::sync::Arc;
11
+
use tokio::sync::RwLock;
12
+
use tower_http::compression::CompressionLayer;
13
+
use tower_http::services::ServeDir;
14
+
use url::Url;
15
+
16
+
/// Shared state for the server
17
+
#[derive(Clone)]
18
+
struct ServerState {
19
+
did: CowStr<'static>,
20
+
rkey: CowStr<'static>,
21
+
output_dir: PathBuf,
22
+
last_cid: Arc<RwLock<Option<String>>>,
23
+
}
24
+
25
+
/// Serve a site locally with real-time firehose updates
26
+
pub async fn serve_site(
27
+
input: CowStr<'static>,
28
+
rkey: CowStr<'static>,
29
+
output_dir: PathBuf,
30
+
port: u16,
31
+
) -> miette::Result<()> {
32
+
println!("Serving site {} from {} on port {}...", rkey, input, port);
33
+
34
+
// Resolve handle to DID if needed
35
+
use jacquard_identity::PublicResolver;
36
+
use jacquard::prelude::IdentityResolver;
37
+
38
+
let resolver = PublicResolver::default();
39
+
let did = if input.starts_with("did:") {
40
+
Did::new(&input).into_diagnostic()?
41
+
} else {
42
+
// It's a handle, resolve it
43
+
let handle = jacquard_common::types::string::Handle::new(&input).into_diagnostic()?;
44
+
resolver.resolve_handle(&handle).await.into_diagnostic()?
45
+
};
46
+
47
+
println!("Resolved to DID: {}", did.as_str());
48
+
49
+
// Create output directory if it doesn't exist
50
+
std::fs::create_dir_all(&output_dir).into_diagnostic()?;
51
+
52
+
// Initial pull of the site
53
+
println!("Performing initial pull...");
54
+
let did_str = CowStr::from(did.as_str().to_string());
55
+
pull_site(did_str.clone(), rkey.clone(), output_dir.clone()).await?;
56
+
57
+
// Create shared state
58
+
let state = ServerState {
59
+
did: did_str.clone(),
60
+
rkey: rkey.clone(),
61
+
output_dir: output_dir.clone(),
62
+
last_cid: Arc::new(RwLock::new(None)),
63
+
};
64
+
65
+
// Start firehose listener in background
66
+
let firehose_state = state.clone();
67
+
tokio::spawn(async move {
68
+
if let Err(e) = watch_firehose(firehose_state).await {
69
+
eprintln!("Firehose error: {}", e);
70
+
}
71
+
});
72
+
73
+
// Create HTTP server with gzip compression
74
+
let app = Router::new()
75
+
.fallback_service(
76
+
ServeDir::new(&output_dir)
77
+
.precompressed_gzip()
78
+
)
79
+
.layer(CompressionLayer::new())
80
+
.with_state(state);
81
+
82
+
let addr = format!("0.0.0.0:{}", port);
83
+
let listener = tokio::net::TcpListener::bind(&addr)
84
+
.await
85
+
.into_diagnostic()?;
86
+
87
+
println!("\nโ Server running at http://localhost:{}", port);
88
+
println!(" Watching for updates on the firehose...\n");
89
+
90
+
axum::serve(listener, app).await.into_diagnostic()?;
91
+
92
+
Ok(())
93
+
}
94
+
95
+
/// Watch the firehose for updates to the specific site
96
+
fn watch_firehose(state: ServerState) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send>> {
97
+
Box::pin(async move {
98
+
let jetstream_url = Url::parse("wss://jetstream1.us-east.fire.hose.cam")
99
+
.into_diagnostic()?;
100
+
101
+
println!("[Firehose] Connecting to Jetstream...");
102
+
103
+
// Create subscription client
104
+
let client = TungsteniteSubscriptionClient::from_base_uri(jetstream_url);
105
+
106
+
// Subscribe with no filters (we'll filter manually)
107
+
// Jetstream doesn't support filtering by collection in the params builder
108
+
let params = JetstreamParams::new().build();
109
+
110
+
let stream = client.subscribe(¶ms).await.into_diagnostic()?;
111
+
println!("[Firehose] Connected! Watching for updates...");
112
+
113
+
// Convert to typed message stream
114
+
let (_sink, mut messages) = stream.into_stream();
115
+
116
+
loop {
117
+
match messages.next().await {
118
+
Some(Ok(msg)) => {
119
+
if let Err(e) = handle_firehose_message(&state, msg).await {
120
+
eprintln!("[Firehose] Error handling message: {}", e);
121
+
}
122
+
}
123
+
Some(Err(e)) => {
124
+
eprintln!("[Firehose] Stream error: {}", e);
125
+
// Try to reconnect after a delay
126
+
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
127
+
return Box::pin(watch_firehose(state)).await;
128
+
}
129
+
None => {
130
+
println!("[Firehose] Stream ended, reconnecting...");
131
+
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
132
+
return Box::pin(watch_firehose(state)).await;
133
+
}
134
+
}
135
+
}
136
+
})
137
+
}
138
+
139
+
/// Handle a firehose message
140
+
async fn handle_firehose_message(
141
+
state: &ServerState,
142
+
msg: JetstreamMessage<'_>,
143
+
) -> miette::Result<()> {
144
+
match msg {
145
+
JetstreamMessage::Commit {
146
+
did,
147
+
commit,
148
+
..
149
+
} => {
150
+
// Check if this is our site
151
+
if did.as_str() == state.did.as_str()
152
+
&& commit.collection.as_str() == "place.wisp.fs"
153
+
&& commit.rkey.as_str() == state.rkey.as_str()
154
+
{
155
+
match commit.operation {
156
+
CommitOperation::Create | CommitOperation::Update => {
157
+
let new_cid = commit.cid.as_ref().map(|c| c.to_string());
158
+
159
+
// Check if CID changed
160
+
let should_update = {
161
+
let last_cid = state.last_cid.read().await;
162
+
new_cid != *last_cid
163
+
};
164
+
165
+
if should_update {
166
+
println!("\n[Update] Detected change to site {} (CID: {:?})", state.rkey, new_cid);
167
+
println!("[Update] Pulling latest version...");
168
+
169
+
// Pull the updated site
170
+
match pull_site(
171
+
state.did.clone(),
172
+
state.rkey.clone(),
173
+
state.output_dir.clone(),
174
+
)
175
+
.await
176
+
{
177
+
Ok(_) => {
178
+
// Update last CID
179
+
let mut last_cid = state.last_cid.write().await;
180
+
*last_cid = new_cid;
181
+
println!("[Update] โ Site updated successfully!\n");
182
+
}
183
+
Err(e) => {
184
+
eprintln!("[Update] Failed to pull site: {}", e);
185
+
}
186
+
}
187
+
}
188
+
}
189
+
CommitOperation::Delete => {
190
+
println!("\n[Update] Site {} was deleted", state.rkey);
191
+
}
192
+
}
193
+
}
194
+
}
195
+
_ => {
196
+
// Ignore identity and account messages
197
+
}
198
+
}
199
+
200
+
Ok(())
201
+
}
202
+
+90
crates.nix
+90
crates.nix
···
1
+
{...}: {
2
+
perSystem = {
3
+
pkgs,
4
+
config,
5
+
lib,
6
+
inputs',
7
+
...
8
+
}: {
9
+
# declare projects
10
+
nci.projects."wisp-place-cli" = {
11
+
path = ./cli;
12
+
export = false;
13
+
};
14
+
nci.toolchains.mkBuild = _:
15
+
with inputs'.fenix.packages;
16
+
combine [
17
+
minimal.rustc
18
+
minimal.cargo
19
+
targets.x86_64-pc-windows-gnu.latest.rust-std
20
+
targets.x86_64-unknown-linux-gnu.latest.rust-std
21
+
targets.aarch64-apple-darwin.latest.rust-std
22
+
targets.aarch64-unknown-linux-gnu.latest.rust-std
23
+
];
24
+
# configure crates
25
+
nci.crates."wisp-cli" = {
26
+
profiles = {
27
+
dev.runTests = false;
28
+
release.runTests = false;
29
+
};
30
+
targets."x86_64-unknown-linux-gnu" = let
31
+
targetPkgs = pkgs.pkgsCross.gnu64;
32
+
targetCC = targetPkgs.stdenv.cc;
33
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
34
+
in rec {
35
+
default = true;
36
+
depsDrvConfig.mkDerivation = {
37
+
nativeBuildInputs = [targetCC];
38
+
};
39
+
depsDrvConfig.env = rec {
40
+
TARGET_CC = "${targetCC.targetPrefix}cc";
41
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
42
+
};
43
+
drvConfig = depsDrvConfig;
44
+
};
45
+
targets."x86_64-pc-windows-gnu" = let
46
+
targetPkgs = pkgs.pkgsCross.mingwW64;
47
+
targetCC = targetPkgs.stdenv.cc;
48
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
49
+
in rec {
50
+
depsDrvConfig.mkDerivation = {
51
+
nativeBuildInputs = [targetCC];
52
+
buildInputs = with targetPkgs; [windows.pthreads];
53
+
};
54
+
depsDrvConfig.env = rec {
55
+
TARGET_CC = "${targetCC.targetPrefix}cc";
56
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
57
+
};
58
+
drvConfig = depsDrvConfig;
59
+
};
60
+
targets."aarch64-apple-darwin" = let
61
+
targetPkgs = pkgs.pkgsCross.aarch64-darwin;
62
+
targetCC = targetPkgs.stdenv.cc;
63
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
64
+
in rec {
65
+
depsDrvConfig.mkDerivation = {
66
+
nativeBuildInputs = [targetCC];
67
+
};
68
+
depsDrvConfig.env = rec {
69
+
TARGET_CC = "${targetCC.targetPrefix}cc";
70
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
71
+
};
72
+
drvConfig = depsDrvConfig;
73
+
};
74
+
targets."aarch64-unknown-linux-gnu" = let
75
+
targetPkgs = pkgs.pkgsCross.aarch64-multiplatform;
76
+
targetCC = targetPkgs.stdenv.cc;
77
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
78
+
in rec {
79
+
depsDrvConfig.mkDerivation = {
80
+
nativeBuildInputs = [targetCC];
81
+
};
82
+
depsDrvConfig.env = rec {
83
+
TARGET_CC = "${targetCC.targetPrefix}cc";
84
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
85
+
};
86
+
drvConfig = depsDrvConfig;
87
+
};
88
+
};
89
+
};
90
+
}
+318
flake.lock
+318
flake.lock
···
1
+
{
2
+
"nodes": {
3
+
"crane": {
4
+
"flake": false,
5
+
"locked": {
6
+
"lastModified": 1758758545,
7
+
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
8
+
"owner": "ipetkov",
9
+
"repo": "crane",
10
+
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
11
+
"type": "github"
12
+
},
13
+
"original": {
14
+
"owner": "ipetkov",
15
+
"ref": "v0.21.1",
16
+
"repo": "crane",
17
+
"type": "github"
18
+
}
19
+
},
20
+
"dream2nix": {
21
+
"inputs": {
22
+
"nixpkgs": [
23
+
"nci",
24
+
"nixpkgs"
25
+
],
26
+
"purescript-overlay": "purescript-overlay",
27
+
"pyproject-nix": "pyproject-nix"
28
+
},
29
+
"locked": {
30
+
"lastModified": 1754978539,
31
+
"narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=",
32
+
"owner": "nix-community",
33
+
"repo": "dream2nix",
34
+
"rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214",
35
+
"type": "github"
36
+
},
37
+
"original": {
38
+
"owner": "nix-community",
39
+
"repo": "dream2nix",
40
+
"type": "github"
41
+
}
42
+
},
43
+
"fenix": {
44
+
"inputs": {
45
+
"nixpkgs": [
46
+
"nixpkgs"
47
+
],
48
+
"rust-analyzer-src": "rust-analyzer-src"
49
+
},
50
+
"locked": {
51
+
"lastModified": 1762584108,
52
+
"narHash": "sha256-wZUW7dlXMXaRdvNbaADqhF8gg9bAfFiMV+iyFQiDv+Y=",
53
+
"owner": "nix-community",
54
+
"repo": "fenix",
55
+
"rev": "32f3ad3b6c690061173e1ac16708874975ec6056",
56
+
"type": "github"
57
+
},
58
+
"original": {
59
+
"owner": "nix-community",
60
+
"repo": "fenix",
61
+
"type": "github"
62
+
}
63
+
},
64
+
"flake-compat": {
65
+
"flake": false,
66
+
"locked": {
67
+
"lastModified": 1696426674,
68
+
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
69
+
"owner": "edolstra",
70
+
"repo": "flake-compat",
71
+
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
72
+
"type": "github"
73
+
},
74
+
"original": {
75
+
"owner": "edolstra",
76
+
"repo": "flake-compat",
77
+
"type": "github"
78
+
}
79
+
},
80
+
"mk-naked-shell": {
81
+
"flake": false,
82
+
"locked": {
83
+
"lastModified": 1681286841,
84
+
"narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=",
85
+
"owner": "90-008",
86
+
"repo": "mk-naked-shell",
87
+
"rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd",
88
+
"type": "github"
89
+
},
90
+
"original": {
91
+
"owner": "90-008",
92
+
"repo": "mk-naked-shell",
93
+
"type": "github"
94
+
}
95
+
},
96
+
"nci": {
97
+
"inputs": {
98
+
"crane": "crane",
99
+
"dream2nix": "dream2nix",
100
+
"mk-naked-shell": "mk-naked-shell",
101
+
"nixpkgs": [
102
+
"nixpkgs"
103
+
],
104
+
"parts": "parts",
105
+
"rust-overlay": "rust-overlay",
106
+
"treefmt": "treefmt"
107
+
},
108
+
"locked": {
109
+
"lastModified": 1762582646,
110
+
"narHash": "sha256-MMzE4xccG+8qbLhdaZoeFDUKWUOn3B4lhp5dZmgukmM=",
111
+
"owner": "90-008",
112
+
"repo": "nix-cargo-integration",
113
+
"rev": "0993c449377049fa8868a664e8290ac6658e0b9a",
114
+
"type": "github"
115
+
},
116
+
"original": {
117
+
"owner": "90-008",
118
+
"repo": "nix-cargo-integration",
119
+
"type": "github"
120
+
}
121
+
},
122
+
"nixpkgs": {
123
+
"locked": {
124
+
"lastModified": 1762361079,
125
+
"narHash": "sha256-lz718rr1BDpZBYk7+G8cE6wee3PiBUpn8aomG/vLLiY=",
126
+
"owner": "nixos",
127
+
"repo": "nixpkgs",
128
+
"rev": "ffcdcf99d65c61956d882df249a9be53e5902ea5",
129
+
"type": "github"
130
+
},
131
+
"original": {
132
+
"owner": "nixos",
133
+
"ref": "nixpkgs-unstable",
134
+
"repo": "nixpkgs",
135
+
"type": "github"
136
+
}
137
+
},
138
+
"parts": {
139
+
"inputs": {
140
+
"nixpkgs-lib": [
141
+
"nci",
142
+
"nixpkgs"
143
+
]
144
+
},
145
+
"locked": {
146
+
"lastModified": 1762440070,
147
+
"narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=",
148
+
"owner": "hercules-ci",
149
+
"repo": "flake-parts",
150
+
"rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8",
151
+
"type": "github"
152
+
},
153
+
"original": {
154
+
"owner": "hercules-ci",
155
+
"repo": "flake-parts",
156
+
"type": "github"
157
+
}
158
+
},
159
+
"parts_2": {
160
+
"inputs": {
161
+
"nixpkgs-lib": [
162
+
"nixpkgs"
163
+
]
164
+
},
165
+
"locked": {
166
+
"lastModified": 1762440070,
167
+
"narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=",
168
+
"owner": "hercules-ci",
169
+
"repo": "flake-parts",
170
+
"rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8",
171
+
"type": "github"
172
+
},
173
+
"original": {
174
+
"owner": "hercules-ci",
175
+
"repo": "flake-parts",
176
+
"type": "github"
177
+
}
178
+
},
179
+
"purescript-overlay": {
180
+
"inputs": {
181
+
"flake-compat": "flake-compat",
182
+
"nixpkgs": [
183
+
"nci",
184
+
"dream2nix",
185
+
"nixpkgs"
186
+
],
187
+
"slimlock": "slimlock"
188
+
},
189
+
"locked": {
190
+
"lastModified": 1728546539,
191
+
"narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=",
192
+
"owner": "thomashoneyman",
193
+
"repo": "purescript-overlay",
194
+
"rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4",
195
+
"type": "github"
196
+
},
197
+
"original": {
198
+
"owner": "thomashoneyman",
199
+
"repo": "purescript-overlay",
200
+
"type": "github"
201
+
}
202
+
},
203
+
"pyproject-nix": {
204
+
"inputs": {
205
+
"nixpkgs": [
206
+
"nci",
207
+
"dream2nix",
208
+
"nixpkgs"
209
+
]
210
+
},
211
+
"locked": {
212
+
"lastModified": 1752481895,
213
+
"narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=",
214
+
"owner": "pyproject-nix",
215
+
"repo": "pyproject.nix",
216
+
"rev": "16ee295c25107a94e59a7fc7f2e5322851781162",
217
+
"type": "github"
218
+
},
219
+
"original": {
220
+
"owner": "pyproject-nix",
221
+
"repo": "pyproject.nix",
222
+
"type": "github"
223
+
}
224
+
},
225
+
"root": {
226
+
"inputs": {
227
+
"fenix": "fenix",
228
+
"nci": "nci",
229
+
"nixpkgs": "nixpkgs",
230
+
"parts": "parts_2"
231
+
}
232
+
},
233
+
"rust-analyzer-src": {
234
+
"flake": false,
235
+
"locked": {
236
+
"lastModified": 1762438844,
237
+
"narHash": "sha256-ApIKJf6CcMsV2nYBXhGF95BmZMO/QXPhgfSnkA/rVUo=",
238
+
"owner": "rust-lang",
239
+
"repo": "rust-analyzer",
240
+
"rev": "4bf516ee5a960c1e2eee9fedd9b1c9e976a19c86",
241
+
"type": "github"
242
+
},
243
+
"original": {
244
+
"owner": "rust-lang",
245
+
"ref": "nightly",
246
+
"repo": "rust-analyzer",
247
+
"type": "github"
248
+
}
249
+
},
250
+
"rust-overlay": {
251
+
"inputs": {
252
+
"nixpkgs": [
253
+
"nci",
254
+
"nixpkgs"
255
+
]
256
+
},
257
+
"locked": {
258
+
"lastModified": 1762569282,
259
+
"narHash": "sha256-vINZAJpXQTZd5cfh06Rcw7hesH7sGSvi+Tn+HUieJn8=",
260
+
"owner": "oxalica",
261
+
"repo": "rust-overlay",
262
+
"rev": "a35a6144b976f70827c2fe2f5c89d16d8f9179d8",
263
+
"type": "github"
264
+
},
265
+
"original": {
266
+
"owner": "oxalica",
267
+
"repo": "rust-overlay",
268
+
"type": "github"
269
+
}
270
+
},
271
+
"slimlock": {
272
+
"inputs": {
273
+
"nixpkgs": [
274
+
"nci",
275
+
"dream2nix",
276
+
"purescript-overlay",
277
+
"nixpkgs"
278
+
]
279
+
},
280
+
"locked": {
281
+
"lastModified": 1688756706,
282
+
"narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=",
283
+
"owner": "thomashoneyman",
284
+
"repo": "slimlock",
285
+
"rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c",
286
+
"type": "github"
287
+
},
288
+
"original": {
289
+
"owner": "thomashoneyman",
290
+
"repo": "slimlock",
291
+
"type": "github"
292
+
}
293
+
},
294
+
"treefmt": {
295
+
"inputs": {
296
+
"nixpkgs": [
297
+
"nci",
298
+
"nixpkgs"
299
+
]
300
+
},
301
+
"locked": {
302
+
"lastModified": 1762410071,
303
+
"narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=",
304
+
"owner": "numtide",
305
+
"repo": "treefmt-nix",
306
+
"rev": "97a30861b13c3731a84e09405414398fbf3e109f",
307
+
"type": "github"
308
+
},
309
+
"original": {
310
+
"owner": "numtide",
311
+
"repo": "treefmt-nix",
312
+
"type": "github"
313
+
}
314
+
}
315
+
},
316
+
"root": "root",
317
+
"version": 7
318
+
}
+59
flake.nix
+59
flake.nix
···
1
+
{
2
+
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
3
+
inputs.nci.url = "github:90-008/nix-cargo-integration";
4
+
inputs.nci.inputs.nixpkgs.follows = "nixpkgs";
5
+
inputs.parts.url = "github:hercules-ci/flake-parts";
6
+
inputs.parts.inputs.nixpkgs-lib.follows = "nixpkgs";
7
+
inputs.fenix = {
8
+
url = "github:nix-community/fenix";
9
+
inputs.nixpkgs.follows = "nixpkgs";
10
+
};
11
+
12
+
outputs = inputs @ {
13
+
parts,
14
+
nci,
15
+
...
16
+
}:
17
+
parts.lib.mkFlake {inherit inputs;} {
18
+
systems = ["x86_64-linux" "aarch64-darwin"];
19
+
imports = [
20
+
nci.flakeModule
21
+
./crates.nix
22
+
];
23
+
perSystem = {
24
+
pkgs,
25
+
config,
26
+
...
27
+
}: let
28
+
crateOutputs = config.nci.outputs."wisp-cli";
29
+
mkRenamedPackage = name: pkg: isWindows: pkgs.runCommand name {} ''
30
+
mkdir -p $out/bin
31
+
if [ -f ${pkg}/bin/wisp-cli.exe ]; then
32
+
cp ${pkg}/bin/wisp-cli.exe $out/bin/${name}
33
+
elif [ -f ${pkg}/bin/wisp-cli ]; then
34
+
cp ${pkg}/bin/wisp-cli $out/bin/${name}
35
+
else
36
+
echo "Error: Could not find wisp-cli binary in ${pkg}/bin/"
37
+
ls -la ${pkg}/bin/ || true
38
+
exit 1
39
+
fi
40
+
'';
41
+
in {
42
+
devShells.default = crateOutputs.devShell;
43
+
packages.default = crateOutputs.packages.release;
44
+
packages.wisp-cli-x86_64-linux = mkRenamedPackage "wisp-cli-x86_64-linux" crateOutputs.packages.release false;
45
+
packages.wisp-cli-aarch64-linux = mkRenamedPackage "wisp-cli-aarch64-linux" crateOutputs.allTargets."aarch64-unknown-linux-gnu".packages.release false;
46
+
packages.wisp-cli-x86_64-windows = mkRenamedPackage "wisp-cli-x86_64-windows.exe" crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release true;
47
+
packages.wisp-cli-aarch64-darwin = mkRenamedPackage "wisp-cli-aarch64-darwin" crateOutputs.allTargets."aarch64-apple-darwin".packages.release false;
48
+
packages.all = pkgs.symlinkJoin {
49
+
name = "wisp-cli-all";
50
+
paths = [
51
+
config.packages.wisp-cli-x86_64-linux
52
+
config.packages.wisp-cli-aarch64-linux
53
+
config.packages.wisp-cli-x86_64-windows
54
+
config.packages.wisp-cli-aarch64-darwin
55
+
];
56
+
};
57
+
};
58
+
};
59
+
}
+7
-7
hosting-service/Dockerfile
+7
-7
hosting-service/Dockerfile
···
1
-
# Use official Bun image
2
-
FROM oven/bun:1.3 AS base
1
+
# Use official Node.js Alpine image
2
+
FROM node:alpine AS base
3
3
4
4
# Set working directory
5
5
WORKDIR /app
6
6
7
7
# Copy package files
8
-
COPY package.json bun.lock ./
8
+
COPY package.json ./
9
9
10
10
# Install dependencies
11
-
RUN bun install --frozen-lockfile --production
11
+
RUN npm install
12
12
13
13
# Copy source code
14
14
COPY src ./src
···
25
25
26
26
# Health check
27
27
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
28
-
CMD bun -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
28
+
CMD node -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
29
29
30
-
# Start the application
31
-
CMD ["bun", "src/index.ts"]
30
+
# Start the application (can override with 'npm run backfill' in compose)
31
+
CMD ["npm", "run", "start"]
-123
hosting-service/EXAMPLE.md
-123
hosting-service/EXAMPLE.md
···
1
-
# HTML Path Rewriting Example
2
-
3
-
This document demonstrates how HTML path rewriting works when serving sites via the `/s/:identifier/:site/*` route.
4
-
5
-
## Problem
6
-
7
-
When you create a static site with absolute paths like `/style.css` or `/images/logo.png`, these paths work fine when served from the root domain. However, when served from a subdirectory like `/s/alice.bsky.social/mysite/`, these absolute paths break because they resolve to the server root instead of the site root.
8
-
9
-
## Solution
10
-
11
-
The hosting service automatically rewrites absolute paths in HTML files to work correctly in the subdirectory context.
12
-
13
-
## Example
14
-
15
-
**Original HTML file (index.html):**
16
-
```html
17
-
<!DOCTYPE html>
18
-
<html>
19
-
<head>
20
-
<meta charset="UTF-8">
21
-
<title>My Site</title>
22
-
<link rel="stylesheet" href="/style.css">
23
-
<link rel="icon" href="/favicon.ico">
24
-
<script src="/app.js"></script>
25
-
</head>
26
-
<body>
27
-
<header>
28
-
<img src="/images/logo.png" alt="Logo">
29
-
<nav>
30
-
<a href="/">Home</a>
31
-
<a href="/about">About</a>
32
-
<a href="/contact">Contact</a>
33
-
</nav>
34
-
</header>
35
-
36
-
<main>
37
-
<h1>Welcome</h1>
38
-
<img src="/images/hero.jpg"
39
-
srcset="/images/hero.jpg 1x, /images/hero@2x.jpg 2x"
40
-
alt="Hero">
41
-
42
-
<form action="/submit" method="post">
43
-
<input type="text" name="email">
44
-
<button>Submit</button>
45
-
</form>
46
-
</main>
47
-
48
-
<footer>
49
-
<a href="https://example.com">External Link</a>
50
-
<a href="#top">Back to Top</a>
51
-
</footer>
52
-
</body>
53
-
</html>
54
-
```
55
-
56
-
**When accessed via `/s/alice.bsky.social/mysite/`, the HTML is rewritten to:**
57
-
```html
58
-
<!DOCTYPE html>
59
-
<html>
60
-
<head>
61
-
<meta charset="UTF-8">
62
-
<title>My Site</title>
63
-
<link rel="stylesheet" href="/s/alice.bsky.social/mysite/style.css">
64
-
<link rel="icon" href="/s/alice.bsky.social/mysite/favicon.ico">
65
-
<script src="/s/alice.bsky.social/mysite/app.js"></script>
66
-
</head>
67
-
<body>
68
-
<header>
69
-
<img src="/s/alice.bsky.social/mysite/images/logo.png" alt="Logo">
70
-
<nav>
71
-
<a href="/s/alice.bsky.social/mysite/">Home</a>
72
-
<a href="/s/alice.bsky.social/mysite/about">About</a>
73
-
<a href="/s/alice.bsky.social/mysite/contact">Contact</a>
74
-
</nav>
75
-
</header>
76
-
77
-
<main>
78
-
<h1>Welcome</h1>
79
-
<img src="/s/alice.bsky.social/mysite/images/hero.jpg"
80
-
srcset="/s/alice.bsky.social/mysite/images/hero.jpg 1x, /s/alice.bsky.social/mysite/images/hero@2x.jpg 2x"
81
-
alt="Hero">
82
-
83
-
<form action="/s/alice.bsky.social/mysite/submit" method="post">
84
-
<input type="text" name="email">
85
-
<button>Submit</button>
86
-
</form>
87
-
</main>
88
-
89
-
<footer>
90
-
<a href="https://example.com">External Link</a>
91
-
<a href="#top">Back to Top</a>
92
-
</footer>
93
-
</body>
94
-
</html>
95
-
```
96
-
97
-
## What's Preserved
98
-
99
-
Notice that:
100
-
- โ
Absolute paths are rewritten: `/style.css` โ `/s/alice.bsky.social/mysite/style.css`
101
-
- โ
External URLs are preserved: `https://example.com` stays the same
102
-
- โ
Anchors are preserved: `#top` stays the same
103
-
- โ
The rewriting is safe and won't break your site
104
-
105
-
## Supported Attributes
106
-
107
-
The rewriter handles these HTML attributes:
108
-
- `src` - images, scripts, iframes, videos, audio
109
-
- `href` - links, stylesheets
110
-
- `action` - forms
111
-
- `data` - objects
112
-
- `poster` - video posters
113
-
- `srcset` - responsive images
114
-
115
-
## Testing Your Site
116
-
117
-
To test if your site works with path rewriting:
118
-
119
-
1. Upload your site to your PDS as a `place.wisp.fs` record
120
-
2. Access it via: `https://hosting.wisp.place/s/YOUR_HANDLE/SITE_NAME/`
121
-
3. Check that all resources load correctly
122
-
123
-
If you're using relative paths already (like `./style.css` or `../images/logo.png`), they'll work without any rewriting.
+32
hosting-service/docker-entrypoint.sh
+32
hosting-service/docker-entrypoint.sh
···
1
+
#!/bin/sh
2
+
set -e
3
+
4
+
# Run different modes based on MODE environment variable
5
+
# Modes:
6
+
# - server (default): Start the hosting service
7
+
# - backfill: Run cache backfill and exit
8
+
# - backfill-server: Run cache backfill, then start the server
9
+
10
+
MODE="${MODE:-server}"
11
+
12
+
case "$MODE" in
13
+
backfill)
14
+
echo "๐ Running in backfill-only mode..."
15
+
exec npm run backfill
16
+
;;
17
+
backfill-server)
18
+
echo "๐ Running backfill, then starting server..."
19
+
npm run backfill
20
+
echo "โ
Backfill complete, starting server..."
21
+
exec npm run start
22
+
;;
23
+
server)
24
+
echo "๐ Starting server..."
25
+
exec npm run start
26
+
;;
27
+
*)
28
+
echo "โ Unknown MODE: $MODE"
29
+
echo "Valid modes: server, backfill, backfill-server"
30
+
exit 1
31
+
;;
32
+
esac
+134
hosting-service/example-_redirects
+134
hosting-service/example-_redirects
···
1
+
# Example _redirects file for Wisp hosting
2
+
# Place this file in the root directory of your site as "_redirects"
3
+
# Lines starting with # are comments
4
+
5
+
# ===================================
6
+
# SIMPLE REDIRECTS
7
+
# ===================================
8
+
9
+
# Redirect home page
10
+
# /home /
11
+
12
+
# Redirect old URLs to new ones
13
+
# /old-blog /blog
14
+
# /about-us /about
15
+
16
+
# ===================================
17
+
# SPLAT REDIRECTS (WILDCARDS)
18
+
# ===================================
19
+
20
+
# Redirect entire directories
21
+
# /news/* /blog/:splat
22
+
# /old-site/* /new-site/:splat
23
+
24
+
# ===================================
25
+
# PLACEHOLDER REDIRECTS
26
+
# ===================================
27
+
28
+
# Restructure blog URLs
29
+
# /blog/:year/:month/:day/:slug /posts/:year-:month-:day/:slug
30
+
31
+
# Capture multiple parameters
32
+
# /products/:category/:id /shop/:category/item/:id
33
+
34
+
# ===================================
35
+
# STATUS CODES
36
+
# ===================================
37
+
38
+
# Permanent redirect (301) - default if not specified
39
+
# /permanent-move /new-location 301
40
+
41
+
# Temporary redirect (302)
42
+
# /temp-redirect /temp-location 302
43
+
44
+
# Rewrite (200) - serves different content, URL stays the same
45
+
# /api/* /functions/:splat 200
46
+
47
+
# Custom 404 page
48
+
# /shop/* /shop-closed.html 404
49
+
50
+
# ===================================
51
+
# FORCE REDIRECTS
52
+
# ===================================
53
+
54
+
# Force redirect even if file exists (note the ! after status code)
55
+
# /override-file /other-file.html 200!
56
+
57
+
# ===================================
58
+
# CONDITIONAL REDIRECTS
59
+
# ===================================
60
+
61
+
# Country-based redirects (ISO 3166-1 alpha-2 codes)
62
+
# / /us/ 302 Country=us
63
+
# / /uk/ 302 Country=gb
64
+
# / /anz/ 302 Country=au,nz
65
+
66
+
# Language-based redirects
67
+
# /products /en/products 301 Language=en
68
+
# /products /de/products 301 Language=de
69
+
# /products /fr/products 301 Language=fr
70
+
71
+
# Cookie-based redirects (checks if cookie exists)
72
+
# /* /legacy/:splat 200 Cookie=is_legacy
73
+
74
+
# ===================================
75
+
# QUERY PARAMETERS
76
+
# ===================================
77
+
78
+
# Match specific query parameters
79
+
# /store id=:id /blog/:id 301
80
+
81
+
# Multiple parameters
82
+
# /search q=:query category=:cat /find/:cat/:query 301
83
+
84
+
# ===================================
85
+
# DOMAIN-LEVEL REDIRECTS
86
+
# ===================================
87
+
88
+
# Redirect to different domain (must include protocol)
89
+
# /external https://example.com/path
90
+
91
+
# Redirect entire subdomain
92
+
# http://blog.example.com/* https://example.com/blog/:splat 301!
93
+
# https://blog.example.com/* https://example.com/blog/:splat 301!
94
+
95
+
# ===================================
96
+
# COMMON PATTERNS
97
+
# ===================================
98
+
99
+
# Remove .html extensions
100
+
# /page.html /page
101
+
102
+
# Add trailing slash
103
+
# /about /about/
104
+
105
+
# Single-page app fallback (serve index.html for all paths)
106
+
# /* /index.html 200
107
+
108
+
# API proxy
109
+
# /api/* https://api.example.com/:splat 200
110
+
111
+
# ===================================
112
+
# CUSTOM ERROR PAGES
113
+
# ===================================
114
+
115
+
# Language-specific 404 pages
116
+
# /en/* /en/404.html 404
117
+
# /de/* /de/404.html 404
118
+
119
+
# Section-specific 404 pages
120
+
# /shop/* /shop/not-found.html 404
121
+
# /blog/* /blog/404.html 404
122
+
123
+
# ===================================
124
+
# NOTES
125
+
# ===================================
126
+
#
127
+
# - Rules are processed in order (first match wins)
128
+
# - More specific rules should come before general ones
129
+
# - Splats (*) can only be used at the end of a path
130
+
# - Query parameters are automatically preserved for 200, 301, 302
131
+
# - Trailing slashes are normalized (/ and no / are treated the same)
132
+
# - Default status code is 301 if not specified
133
+
#
134
+
+2
-2
hosting-service/package.json
+2
-2
hosting-service/package.json
···
5
5
"scripts": {
6
6
"dev": "tsx --env-file=.env watch src/index.ts",
7
7
"build": "tsc",
8
-
"start": "tsx --env-file=.env src/index.ts",
9
-
"backfill": "tsx --env-file=.env src/index.ts --backfill"
8
+
"start": "tsx src/index.ts",
9
+
"backfill": "tsx src/index.ts --backfill"
10
10
},
11
11
"dependencies": {
12
12
"@atproto/api": "^0.17.4",
+16
hosting-service/src/index.ts
+16
hosting-service/src/index.ts
···
4
4
import { logger } from './lib/observability';
5
5
import { mkdirSync, existsSync } from 'fs';
6
6
import { backfillCache } from './lib/backfill';
7
+
import { startDomainCacheCleanup, stopDomainCacheCleanup, setCacheOnlyMode } from './lib/db';
7
8
8
9
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
9
10
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
···
13
14
const hasBackfillFlag = args.includes('--backfill');
14
15
const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true';
15
16
17
+
// Cache-only mode: service will only cache files locally, no DB writes
18
+
const hasCacheOnlyFlag = args.includes('--cache-only');
19
+
export const CACHE_ONLY_MODE = hasCacheOnlyFlag || process.env.CACHE_ONLY_MODE === 'true';
20
+
21
+
// Configure cache-only mode in database module
22
+
if (CACHE_ONLY_MODE) {
23
+
setCacheOnlyMode(true);
24
+
}
25
+
16
26
// Ensure cache directory exists
17
27
if (!existsSync(CACHE_DIR)) {
18
28
mkdirSync(CACHE_DIR, { recursive: true });
19
29
console.log('Created cache directory:', CACHE_DIR);
20
30
}
31
+
32
+
// Start domain cache cleanup
33
+
startDomainCacheCleanup();
21
34
22
35
// Start firehose worker with observability logger
23
36
const firehose = new FirehoseWorker((msg, data) => {
···
61
74
Health: http://localhost:${PORT}/health
62
75
Cache: ${CACHE_DIR}
63
76
Firehose: Connected to Firehose
77
+
Cache-Only: ${CACHE_ONLY_MODE ? 'ENABLED (no DB writes)' : 'DISABLED'}
64
78
`);
65
79
66
80
// Graceful shutdown
67
81
process.on('SIGINT', async () => {
68
82
console.log('\n๐ Shutting down...');
69
83
firehose.stop();
84
+
stopDomainCacheCleanup();
70
85
server.close();
71
86
process.exit(0);
72
87
});
···
74
89
process.on('SIGTERM', async () => {
75
90
console.log('\n๐ Shutting down...');
76
91
firehose.stop();
92
+
stopDomainCacheCleanup();
77
93
server.close();
78
94
process.exit(0);
79
95
});
+177
hosting-service/src/lib/cache.ts
+177
hosting-service/src/lib/cache.ts
···
1
+
// In-memory LRU cache for file contents and metadata
2
+
3
+
interface CacheEntry<T> {
4
+
value: T;
5
+
size: number;
6
+
timestamp: number;
7
+
}
8
+
9
+
interface CacheStats {
10
+
hits: number;
11
+
misses: number;
12
+
evictions: number;
13
+
currentSize: number;
14
+
currentCount: number;
15
+
}
16
+
17
+
export class LRUCache<T> {
18
+
private cache: Map<string, CacheEntry<T>>;
19
+
private maxSize: number;
20
+
private maxCount: number;
21
+
private currentSize: number;
22
+
private stats: CacheStats;
23
+
24
+
constructor(maxSize: number, maxCount: number) {
25
+
this.cache = new Map();
26
+
this.maxSize = maxSize;
27
+
this.maxCount = maxCount;
28
+
this.currentSize = 0;
29
+
this.stats = {
30
+
hits: 0,
31
+
misses: 0,
32
+
evictions: 0,
33
+
currentSize: 0,
34
+
currentCount: 0,
35
+
};
36
+
}
37
+
38
+
get(key: string): T | null {
39
+
const entry = this.cache.get(key);
40
+
if (!entry) {
41
+
this.stats.misses++;
42
+
return null;
43
+
}
44
+
45
+
// Move to end (most recently used)
46
+
this.cache.delete(key);
47
+
this.cache.set(key, entry);
48
+
49
+
this.stats.hits++;
50
+
return entry.value;
51
+
}
52
+
53
+
set(key: string, value: T, size: number): void {
54
+
// Remove existing entry if present
55
+
if (this.cache.has(key)) {
56
+
const existing = this.cache.get(key)!;
57
+
this.currentSize -= existing.size;
58
+
this.cache.delete(key);
59
+
}
60
+
61
+
// Evict entries if needed
62
+
while (
63
+
(this.cache.size >= this.maxCount || this.currentSize + size > this.maxSize) &&
64
+
this.cache.size > 0
65
+
) {
66
+
const firstKey = this.cache.keys().next().value;
67
+
if (!firstKey) break; // Should never happen, but satisfy TypeScript
68
+
const firstEntry = this.cache.get(firstKey);
69
+
if (!firstEntry) break; // Should never happen, but satisfy TypeScript
70
+
this.cache.delete(firstKey);
71
+
this.currentSize -= firstEntry.size;
72
+
this.stats.evictions++;
73
+
}
74
+
75
+
// Add new entry
76
+
this.cache.set(key, {
77
+
value,
78
+
size,
79
+
timestamp: Date.now(),
80
+
});
81
+
this.currentSize += size;
82
+
83
+
// Update stats
84
+
this.stats.currentSize = this.currentSize;
85
+
this.stats.currentCount = this.cache.size;
86
+
}
87
+
88
+
delete(key: string): boolean {
89
+
const entry = this.cache.get(key);
90
+
if (!entry) return false;
91
+
92
+
this.cache.delete(key);
93
+
this.currentSize -= entry.size;
94
+
this.stats.currentSize = this.currentSize;
95
+
this.stats.currentCount = this.cache.size;
96
+
return true;
97
+
}
98
+
99
+
// Invalidate all entries for a specific site
100
+
invalidateSite(did: string, rkey: string): number {
101
+
const prefix = `${did}:${rkey}:`;
102
+
let count = 0;
103
+
104
+
for (const key of Array.from(this.cache.keys())) {
105
+
if (key.startsWith(prefix)) {
106
+
this.delete(key);
107
+
count++;
108
+
}
109
+
}
110
+
111
+
return count;
112
+
}
113
+
114
+
// Get cache size
115
+
size(): number {
116
+
return this.cache.size;
117
+
}
118
+
119
+
clear(): void {
120
+
this.cache.clear();
121
+
this.currentSize = 0;
122
+
this.stats.currentSize = 0;
123
+
this.stats.currentCount = 0;
124
+
}
125
+
126
+
getStats(): CacheStats {
127
+
return { ...this.stats };
128
+
}
129
+
130
+
// Get cache hit rate
131
+
getHitRate(): number {
132
+
const total = this.stats.hits + this.stats.misses;
133
+
return total === 0 ? 0 : (this.stats.hits / total) * 100;
134
+
}
135
+
}
136
+
137
+
// File metadata cache entry
138
+
export interface FileMetadata {
139
+
encoding?: 'gzip';
140
+
mimeType: string;
141
+
}
142
+
143
+
// Global cache instances
144
+
const FILE_CACHE_SIZE = 100 * 1024 * 1024; // 100MB
145
+
const FILE_CACHE_COUNT = 500;
146
+
const METADATA_CACHE_COUNT = 2000;
147
+
148
+
export const fileCache = new LRUCache<Buffer>(FILE_CACHE_SIZE, FILE_CACHE_COUNT);
149
+
export const metadataCache = new LRUCache<FileMetadata>(1024 * 1024, METADATA_CACHE_COUNT); // 1MB for metadata
150
+
export const rewrittenHtmlCache = new LRUCache<Buffer>(50 * 1024 * 1024, 200); // 50MB for rewritten HTML
151
+
152
+
// Helper to generate cache keys
153
+
export function getCacheKey(did: string, rkey: string, filePath: string, suffix?: string): string {
154
+
const base = `${did}:${rkey}:${filePath}`;
155
+
return suffix ? `${base}:${suffix}` : base;
156
+
}
157
+
158
+
// Invalidate all caches for a site
159
+
export function invalidateSiteCache(did: string, rkey: string): void {
160
+
const fileCount = fileCache.invalidateSite(did, rkey);
161
+
const metaCount = metadataCache.invalidateSite(did, rkey);
162
+
const htmlCount = rewrittenHtmlCache.invalidateSite(did, rkey);
163
+
164
+
console.log(`[Cache] Invalidated site ${did}:${rkey} - ${fileCount} files, ${metaCount} metadata, ${htmlCount} HTML`);
165
+
}
166
+
167
+
// Get overall cache statistics
168
+
export function getCacheStats() {
169
+
return {
170
+
files: fileCache.getStats(),
171
+
fileHitRate: fileCache.getHitRate(),
172
+
metadata: metadataCache.getStats(),
173
+
metadataHitRate: metadataCache.getHitRate(),
174
+
rewrittenHtml: rewrittenHtmlCache.getStats(),
175
+
rewrittenHtmlHitRate: rewrittenHtmlCache.getHitRate(),
176
+
};
177
+
}
+85
hosting-service/src/lib/db.ts
+85
hosting-service/src/lib/db.ts
···
1
1
import postgres from 'postgres';
2
2
import { createHash } from 'crypto';
3
3
4
+
// Global cache-only mode flag (set by index.ts)
5
+
let cacheOnlyMode = false;
6
+
7
+
export function setCacheOnlyMode(enabled: boolean) {
8
+
cacheOnlyMode = enabled;
9
+
if (enabled) {
10
+
console.log('[DB] Cache-only mode enabled - database writes will be skipped');
11
+
}
12
+
}
13
+
4
14
const sql = postgres(
5
15
process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp',
6
16
{
···
9
19
}
10
20
);
11
21
22
+
// Domain lookup cache with TTL
23
+
const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
24
+
25
+
interface CachedDomain<T> {
26
+
value: T;
27
+
timestamp: number;
28
+
}
29
+
30
+
const domainCache = new Map<string, CachedDomain<DomainLookup | null>>();
31
+
const customDomainCache = new Map<string, CachedDomain<CustomDomainLookup | null>>();
32
+
33
+
let cleanupInterval: NodeJS.Timeout | null = null;
34
+
35
+
export function startDomainCacheCleanup() {
36
+
if (cleanupInterval) return;
37
+
38
+
cleanupInterval = setInterval(() => {
39
+
const now = Date.now();
40
+
41
+
for (const [key, entry] of domainCache.entries()) {
42
+
if (now - entry.timestamp > DOMAIN_CACHE_TTL) {
43
+
domainCache.delete(key);
44
+
}
45
+
}
46
+
47
+
for (const [key, entry] of customDomainCache.entries()) {
48
+
if (now - entry.timestamp > DOMAIN_CACHE_TTL) {
49
+
customDomainCache.delete(key);
50
+
}
51
+
}
52
+
}, 30 * 60 * 1000); // Run every 30 minutes
53
+
}
54
+
55
+
export function stopDomainCacheCleanup() {
56
+
if (cleanupInterval) {
57
+
clearInterval(cleanupInterval);
58
+
cleanupInterval = null;
59
+
}
60
+
}
61
+
12
62
export interface DomainLookup {
13
63
did: string;
14
64
rkey: string | null;
···
27
77
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
28
78
const key = domain.toLowerCase();
29
79
80
+
// Check cache first
81
+
const cached = domainCache.get(key);
82
+
if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
83
+
return cached.value;
84
+
}
85
+
30
86
// Query database
31
87
const result = await sql<DomainLookup[]>`
32
88
SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1
33
89
`;
34
90
const data = result[0] || null;
91
+
92
+
// Cache the result
93
+
domainCache.set(key, { value: data, timestamp: Date.now() });
35
94
36
95
return data;
37
96
}
···
39
98
export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> {
40
99
const key = domain.toLowerCase();
41
100
101
+
// Check cache first
102
+
const cached = customDomainCache.get(key);
103
+
if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
104
+
return cached.value;
105
+
}
106
+
42
107
// Query database
43
108
const result = await sql<CustomDomainLookup[]>`
44
109
SELECT id, domain, did, rkey, verified FROM custom_domains
···
46
111
`;
47
112
const data = result[0] || null;
48
113
114
+
// Cache the result
115
+
customDomainCache.set(key, { value: data, timestamp: Date.now() });
116
+
49
117
return data;
50
118
}
51
119
52
120
export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
121
+
const key = `hash:${hash}`;
122
+
123
+
// Check cache first
124
+
const cached = customDomainCache.get(key);
125
+
if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
126
+
return cached.value;
127
+
}
128
+
53
129
// Query database
54
130
const result = await sql<CustomDomainLookup[]>`
55
131
SELECT id, domain, did, rkey, verified FROM custom_domains
···
57
133
`;
58
134
const data = result[0] || null;
59
135
136
+
// Cache the result
137
+
customDomainCache.set(key, { value: data, timestamp: Date.now() });
138
+
60
139
return data;
61
140
}
62
141
63
142
export async function upsertSite(did: string, rkey: string, displayName?: string) {
143
+
// Skip database writes in cache-only mode
144
+
if (cacheOnlyMode) {
145
+
console.log('[DB] Skipping upsertSite (cache-only mode)', { did, rkey });
146
+
return;
147
+
}
148
+
64
149
try {
65
150
// Only set display_name if provided (not undefined/null/empty)
66
151
const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null;
+10
-1
hosting-service/src/lib/firehose.ts
+10
-1
hosting-service/src/lib/firehose.ts
···
10
10
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'
11
11
import { Firehose } from '@atproto/sync'
12
12
import { IdResolver } from '@atproto/identity'
13
+
import { invalidateSiteCache } from './cache'
13
14
14
15
const CACHE_DIR = './cache/sites'
15
16
···
182
183
return
183
184
}
184
185
186
+
// Invalidate in-memory caches before updating
187
+
invalidateSiteCache(did, site)
188
+
185
189
// Cache the record with verified CID (uses atomic swap internally)
186
190
// All instances cache locally for edge serving
187
191
await downloadAndCacheSite(
···
193
197
)
194
198
195
199
// Acquire distributed lock only for database write to prevent duplicate writes
200
+
// Note: upsertSite will check cache-only mode internally and skip if needed
196
201
const lockKey = `db:upsert:${did}:${site}`
197
202
const lockAcquired = await tryAcquireLock(lockKey)
198
203
···
210
215
211
216
try {
212
217
// Upsert site to database (only one instance does this)
218
+
// In cache-only mode, this will be a no-op
213
219
await upsertSite(did, site, fsRecord.site)
214
220
this.log(
215
221
'Successfully processed create/update (cached + DB updated)',
···
257
263
})
258
264
}
259
265
260
-
// Delete cache
266
+
// Invalidate in-memory caches
267
+
invalidateSiteCache(did, site)
268
+
269
+
// Delete disk cache
261
270
this.deleteCache(did, site)
262
271
263
272
this.log('Successfully processed delete', { did, site })
+215
hosting-service/src/lib/redirects.test.ts
+215
hosting-service/src/lib/redirects.test.ts
···
1
+
import { describe, it, expect } from 'bun:test'
2
+
import { parseRedirectsFile, matchRedirectRule } from './redirects';
3
+
4
+
describe('parseRedirectsFile', () => {
5
+
it('should parse simple redirects', () => {
6
+
const content = `
7
+
# Comment line
8
+
/old-path /new-path
9
+
/home / 301
10
+
`;
11
+
const rules = parseRedirectsFile(content);
12
+
expect(rules).toHaveLength(2);
13
+
expect(rules[0]).toMatchObject({
14
+
from: '/old-path',
15
+
to: '/new-path',
16
+
status: 301,
17
+
force: false,
18
+
});
19
+
expect(rules[1]).toMatchObject({
20
+
from: '/home',
21
+
to: '/',
22
+
status: 301,
23
+
force: false,
24
+
});
25
+
});
26
+
27
+
it('should parse redirects with different status codes', () => {
28
+
const content = `
29
+
/temp-redirect /target 302
30
+
/rewrite /content 200
31
+
/not-found /404 404
32
+
`;
33
+
const rules = parseRedirectsFile(content);
34
+
expect(rules).toHaveLength(3);
35
+
expect(rules[0]?.status).toBe(302);
36
+
expect(rules[1]?.status).toBe(200);
37
+
expect(rules[2]?.status).toBe(404);
38
+
});
39
+
40
+
it('should parse force redirects', () => {
41
+
const content = `/force-path /target 301!`;
42
+
const rules = parseRedirectsFile(content);
43
+
expect(rules[0]?.force).toBe(true);
44
+
expect(rules[0]?.status).toBe(301);
45
+
});
46
+
47
+
it('should parse splat redirects', () => {
48
+
const content = `/news/* /blog/:splat`;
49
+
const rules = parseRedirectsFile(content);
50
+
expect(rules[0]?.from).toBe('/news/*');
51
+
expect(rules[0]?.to).toBe('/blog/:splat');
52
+
});
53
+
54
+
it('should parse placeholder redirects', () => {
55
+
const content = `/blog/:year/:month/:day /posts/:year-:month-:day`;
56
+
const rules = parseRedirectsFile(content);
57
+
expect(rules[0]?.from).toBe('/blog/:year/:month/:day');
58
+
expect(rules[0]?.to).toBe('/posts/:year-:month-:day');
59
+
});
60
+
61
+
it('should parse country-based redirects', () => {
62
+
const content = `/ /anz 302 Country=au,nz`;
63
+
const rules = parseRedirectsFile(content);
64
+
expect(rules[0]?.conditions?.country).toEqual(['au', 'nz']);
65
+
});
66
+
67
+
it('should parse language-based redirects', () => {
68
+
const content = `/products /en/products 301 Language=en`;
69
+
const rules = parseRedirectsFile(content);
70
+
expect(rules[0]?.conditions?.language).toEqual(['en']);
71
+
});
72
+
73
+
it('should parse cookie-based redirects', () => {
74
+
const content = `/* /legacy/:splat 200 Cookie=is_legacy,my_cookie`;
75
+
const rules = parseRedirectsFile(content);
76
+
expect(rules[0]?.conditions?.cookie).toEqual(['is_legacy', 'my_cookie']);
77
+
});
78
+
});
79
+
80
+
describe('matchRedirectRule', () => {
81
+
it('should match exact paths', () => {
82
+
const rules = parseRedirectsFile('/old-path /new-path');
83
+
const match = matchRedirectRule('/old-path', rules);
84
+
expect(match).toBeTruthy();
85
+
expect(match?.targetPath).toBe('/new-path');
86
+
expect(match?.status).toBe(301);
87
+
});
88
+
89
+
it('should match paths with trailing slash', () => {
90
+
const rules = parseRedirectsFile('/old-path /new-path');
91
+
const match = matchRedirectRule('/old-path/', rules);
92
+
expect(match).toBeTruthy();
93
+
expect(match?.targetPath).toBe('/new-path');
94
+
});
95
+
96
+
it('should match splat patterns', () => {
97
+
const rules = parseRedirectsFile('/news/* /blog/:splat');
98
+
const match = matchRedirectRule('/news/2024/01/15/my-post', rules);
99
+
expect(match).toBeTruthy();
100
+
expect(match?.targetPath).toBe('/blog/2024/01/15/my-post');
101
+
});
102
+
103
+
it('should match placeholder patterns', () => {
104
+
const rules = parseRedirectsFile('/blog/:year/:month/:day /posts/:year-:month-:day');
105
+
const match = matchRedirectRule('/blog/2024/01/15', rules);
106
+
expect(match).toBeTruthy();
107
+
expect(match?.targetPath).toBe('/posts/2024-01-15');
108
+
});
109
+
110
+
it('should preserve query strings for 301/302 redirects', () => {
111
+
const rules = parseRedirectsFile('/old /new 301');
112
+
const match = matchRedirectRule('/old', rules, {
113
+
queryParams: { foo: 'bar', baz: 'qux' },
114
+
});
115
+
expect(match?.targetPath).toContain('?');
116
+
expect(match?.targetPath).toContain('foo=bar');
117
+
expect(match?.targetPath).toContain('baz=qux');
118
+
});
119
+
120
+
it('should match based on query parameters', () => {
121
+
const rules = parseRedirectsFile('/store id=:id /blog/:id 301');
122
+
const match = matchRedirectRule('/store', rules, {
123
+
queryParams: { id: 'my-post' },
124
+
});
125
+
expect(match).toBeTruthy();
126
+
expect(match?.targetPath).toContain('/blog/my-post');
127
+
});
128
+
129
+
it('should not match when query params are missing', () => {
130
+
const rules = parseRedirectsFile('/store id=:id /blog/:id 301');
131
+
const match = matchRedirectRule('/store', rules, {
132
+
queryParams: {},
133
+
});
134
+
expect(match).toBeNull();
135
+
});
136
+
137
+
it('should match based on country header', () => {
138
+
const rules = parseRedirectsFile('/ /aus 302 Country=au');
139
+
const match = matchRedirectRule('/', rules, {
140
+
headers: { 'cf-ipcountry': 'AU' },
141
+
});
142
+
expect(match).toBeTruthy();
143
+
expect(match?.targetPath).toBe('/aus');
144
+
});
145
+
146
+
it('should not match wrong country', () => {
147
+
const rules = parseRedirectsFile('/ /aus 302 Country=au');
148
+
const match = matchRedirectRule('/', rules, {
149
+
headers: { 'cf-ipcountry': 'US' },
150
+
});
151
+
expect(match).toBeNull();
152
+
});
153
+
154
+
it('should match based on language header', () => {
155
+
const rules = parseRedirectsFile('/products /en/products 301 Language=en');
156
+
const match = matchRedirectRule('/products', rules, {
157
+
headers: { 'accept-language': 'en-US,en;q=0.9' },
158
+
});
159
+
expect(match).toBeTruthy();
160
+
expect(match?.targetPath).toBe('/en/products');
161
+
});
162
+
163
+
it('should match based on cookie presence', () => {
164
+
const rules = parseRedirectsFile('/* /legacy/:splat 200 Cookie=is_legacy');
165
+
const match = matchRedirectRule('/some-path', rules, {
166
+
cookies: { is_legacy: 'true' },
167
+
});
168
+
expect(match).toBeTruthy();
169
+
expect(match?.targetPath).toBe('/legacy/some-path');
170
+
});
171
+
172
+
it('should return first matching rule', () => {
173
+
const content = `
174
+
/path /first
175
+
/path /second
176
+
`;
177
+
const rules = parseRedirectsFile(content);
178
+
const match = matchRedirectRule('/path', rules);
179
+
expect(match?.targetPath).toBe('/first');
180
+
});
181
+
182
+
it('should match more specific rules before general ones', () => {
183
+
const content = `
184
+
/jobs/customer-ninja /careers/support
185
+
/jobs/* /careers/:splat
186
+
`;
187
+
const rules = parseRedirectsFile(content);
188
+
189
+
const match1 = matchRedirectRule('/jobs/customer-ninja', rules);
190
+
expect(match1?.targetPath).toBe('/careers/support');
191
+
192
+
const match2 = matchRedirectRule('/jobs/developer', rules);
193
+
expect(match2?.targetPath).toBe('/careers/developer');
194
+
});
195
+
196
+
it('should handle SPA routing pattern', () => {
197
+
const rules = parseRedirectsFile('/* /index.html 200');
198
+
199
+
// Should match any path
200
+
const match1 = matchRedirectRule('/about', rules);
201
+
expect(match1).toBeTruthy();
202
+
expect(match1?.targetPath).toBe('/index.html');
203
+
expect(match1?.status).toBe(200);
204
+
205
+
const match2 = matchRedirectRule('/users/123/profile', rules);
206
+
expect(match2).toBeTruthy();
207
+
expect(match2?.targetPath).toBe('/index.html');
208
+
expect(match2?.status).toBe(200);
209
+
210
+
const match3 = matchRedirectRule('/', rules);
211
+
expect(match3).toBeTruthy();
212
+
expect(match3?.targetPath).toBe('/index.html');
213
+
});
214
+
});
215
+
+413
hosting-service/src/lib/redirects.ts
+413
hosting-service/src/lib/redirects.ts
···
1
+
import { readFile } from 'fs/promises';
2
+
import { existsSync } from 'fs';
3
+
4
+
export interface RedirectRule {
5
+
from: string;
6
+
to: string;
7
+
status: number;
8
+
force: boolean;
9
+
conditions?: {
10
+
country?: string[];
11
+
language?: string[];
12
+
role?: string[];
13
+
cookie?: string[];
14
+
};
15
+
// For pattern matching
16
+
fromPattern?: RegExp;
17
+
fromParams?: string[]; // Named parameters from the pattern
18
+
queryParams?: Record<string, string>; // Expected query parameters
19
+
}
20
+
21
+
export interface RedirectMatch {
22
+
rule: RedirectRule;
23
+
targetPath: string;
24
+
status: number;
25
+
}
26
+
27
+
/**
28
+
* Parse a _redirects file into an array of redirect rules
29
+
*/
30
+
export function parseRedirectsFile(content: string): RedirectRule[] {
31
+
const lines = content.split('\n');
32
+
const rules: RedirectRule[] = [];
33
+
34
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
35
+
const lineRaw = lines[lineNum];
36
+
if (!lineRaw) continue;
37
+
38
+
const line = lineRaw.trim();
39
+
40
+
// Skip empty lines and comments
41
+
if (!line || line.startsWith('#')) {
42
+
continue;
43
+
}
44
+
45
+
try {
46
+
const rule = parseRedirectLine(line);
47
+
if (rule && rule.fromPattern) {
48
+
rules.push(rule);
49
+
}
50
+
} catch (err) {
51
+
console.warn(`Failed to parse redirect rule on line ${lineNum + 1}: ${line}`, err);
52
+
}
53
+
}
54
+
55
+
return rules;
56
+
}
57
+
58
+
/**
59
+
* Parse a single redirect rule line
60
+
* Format: /from [query_params] /to [status] [conditions]
61
+
*/
62
+
function parseRedirectLine(line: string): RedirectRule | null {
63
+
// Split by whitespace, but respect quoted strings (though not commonly used)
64
+
const parts = line.split(/\s+/);
65
+
66
+
if (parts.length < 2) {
67
+
return null;
68
+
}
69
+
70
+
let idx = 0;
71
+
const from = parts[idx++];
72
+
73
+
if (!from) {
74
+
return null;
75
+
}
76
+
77
+
let status = 301; // Default status
78
+
let force = false;
79
+
const conditions: NonNullable<RedirectRule['conditions']> = {};
80
+
const queryParams: Record<string, string> = {};
81
+
82
+
// Parse query parameters that come before the destination path
83
+
// They look like: key=:value (and don't start with /)
84
+
while (idx < parts.length) {
85
+
const part = parts[idx];
86
+
if (!part) {
87
+
idx++;
88
+
continue;
89
+
}
90
+
91
+
// If it starts with / or http, it's the destination path
92
+
if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) {
93
+
break;
94
+
}
95
+
96
+
// If it contains = and comes before the destination, it's a query param
97
+
if (part.includes('=')) {
98
+
const splitIndex = part.indexOf('=');
99
+
const key = part.slice(0, splitIndex);
100
+
const value = part.slice(splitIndex + 1);
101
+
102
+
if (key && value) {
103
+
queryParams[key] = value;
104
+
}
105
+
idx++;
106
+
} else {
107
+
// Not a query param, must be destination or something else
108
+
break;
109
+
}
110
+
}
111
+
112
+
// Next part should be the destination
113
+
if (idx >= parts.length) {
114
+
return null;
115
+
}
116
+
117
+
const to = parts[idx++];
118
+
if (!to) {
119
+
return null;
120
+
}
121
+
122
+
// Parse remaining parts for status code and conditions
123
+
for (let i = idx; i < parts.length; i++) {
124
+
const part = parts[i];
125
+
126
+
if (!part) continue;
127
+
128
+
// Check for status code (with optional ! for force)
129
+
if (/^\d+!?$/.test(part)) {
130
+
if (part.endsWith('!')) {
131
+
force = true;
132
+
status = parseInt(part.slice(0, -1));
133
+
} else {
134
+
status = parseInt(part);
135
+
}
136
+
continue;
137
+
}
138
+
139
+
// Check for condition parameters (Country=, Language=, Role=, Cookie=)
140
+
if (part.includes('=')) {
141
+
const splitIndex = part.indexOf('=');
142
+
const key = part.slice(0, splitIndex);
143
+
const value = part.slice(splitIndex + 1);
144
+
145
+
if (!key || !value) continue;
146
+
147
+
const keyLower = key.toLowerCase();
148
+
149
+
if (keyLower === 'country') {
150
+
conditions.country = value.split(',').map(v => v.trim().toLowerCase());
151
+
} else if (keyLower === 'language') {
152
+
conditions.language = value.split(',').map(v => v.trim().toLowerCase());
153
+
} else if (keyLower === 'role') {
154
+
conditions.role = value.split(',').map(v => v.trim());
155
+
} else if (keyLower === 'cookie') {
156
+
conditions.cookie = value.split(',').map(v => v.trim().toLowerCase());
157
+
}
158
+
}
159
+
}
160
+
161
+
// Parse the 'from' pattern
162
+
const { pattern, params } = convertPathToRegex(from);
163
+
164
+
return {
165
+
from,
166
+
to,
167
+
status,
168
+
force,
169
+
conditions: Object.keys(conditions).length > 0 ? conditions : undefined,
170
+
queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined,
171
+
fromPattern: pattern,
172
+
fromParams: params,
173
+
};
174
+
}
175
+
176
+
/**
177
+
* Convert a path pattern with placeholders and splats to a regex
178
+
* Examples:
179
+
* /blog/:year/:month/:day -> captures year, month, day
180
+
* /news/* -> captures splat
181
+
*/
182
+
function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[] } {
183
+
const params: string[] = [];
184
+
let regexStr = '^';
185
+
186
+
// Split by query string if present
187
+
const pathPart = pattern.split('?')[0] || pattern;
188
+
189
+
// Escape special regex characters except * and :
190
+
let escaped = pathPart.replace(/[.+^${}()|[\]\\]/g, '\\$&');
191
+
192
+
// Replace :param with named capture groups
193
+
escaped = escaped.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, paramName) => {
194
+
params.push(paramName);
195
+
// Match path segment (everything except / and ?)
196
+
return '([^/?]+)';
197
+
});
198
+
199
+
// Replace * with splat capture (matches everything including /)
200
+
if (escaped.includes('*')) {
201
+
escaped = escaped.replace(/\*/g, '(.*)');
202
+
params.push('splat');
203
+
}
204
+
205
+
regexStr += escaped;
206
+
207
+
// Make trailing slash optional
208
+
if (!regexStr.endsWith('.*')) {
209
+
regexStr += '/?';
210
+
}
211
+
212
+
regexStr += '$';
213
+
214
+
return {
215
+
pattern: new RegExp(regexStr),
216
+
params,
217
+
};
218
+
}
219
+
220
+
/**
221
+
* Match a request path against redirect rules
222
+
*/
223
+
export function matchRedirectRule(
224
+
requestPath: string,
225
+
rules: RedirectRule[],
226
+
context?: {
227
+
queryParams?: Record<string, string>;
228
+
headers?: Record<string, string>;
229
+
cookies?: Record<string, string>;
230
+
}
231
+
): RedirectMatch | null {
232
+
// Normalize path: ensure leading slash, remove trailing slash (except for root)
233
+
let normalizedPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
234
+
235
+
for (const rule of rules) {
236
+
// Check query parameter conditions first (if any)
237
+
if (rule.queryParams) {
238
+
// If rule requires query params but none provided, skip this rule
239
+
if (!context?.queryParams) {
240
+
continue;
241
+
}
242
+
243
+
const queryMatches = Object.entries(rule.queryParams).every(([key, value]) => {
244
+
const actualValue = context.queryParams?.[key];
245
+
return actualValue !== undefined;
246
+
});
247
+
248
+
if (!queryMatches) {
249
+
continue;
250
+
}
251
+
}
252
+
253
+
// Check conditional redirects (country, language, role, cookie)
254
+
if (rule.conditions) {
255
+
if (rule.conditions.country && context?.headers) {
256
+
const cfCountry = context.headers['cf-ipcountry'];
257
+
const xCountry = context.headers['x-country'];
258
+
const country = (cfCountry?.toLowerCase() || xCountry?.toLowerCase());
259
+
if (!country || !rule.conditions.country.includes(country)) {
260
+
continue;
261
+
}
262
+
}
263
+
264
+
if (rule.conditions.language && context?.headers) {
265
+
const acceptLang = context.headers['accept-language'];
266
+
if (!acceptLang) {
267
+
continue;
268
+
}
269
+
// Parse accept-language header (simplified)
270
+
const langs = acceptLang.split(',').map(l => {
271
+
const langPart = l.split(';')[0];
272
+
return langPart ? langPart.trim().toLowerCase() : '';
273
+
}).filter(l => l !== '');
274
+
const hasMatch = rule.conditions.language.some(lang =>
275
+
langs.some(l => l === lang || l.startsWith(lang + '-'))
276
+
);
277
+
if (!hasMatch) {
278
+
continue;
279
+
}
280
+
}
281
+
282
+
if (rule.conditions.cookie && context?.cookies) {
283
+
const hasCookie = rule.conditions.cookie.some(cookieName =>
284
+
context.cookies && cookieName in context.cookies
285
+
);
286
+
if (!hasCookie) {
287
+
continue;
288
+
}
289
+
}
290
+
291
+
// Role-based redirects would need JWT verification - skip for now
292
+
if (rule.conditions.role) {
293
+
continue;
294
+
}
295
+
}
296
+
297
+
// Match the path pattern
298
+
const match = rule.fromPattern?.exec(normalizedPath);
299
+
if (!match) {
300
+
continue;
301
+
}
302
+
303
+
// Build the target path by replacing placeholders
304
+
let targetPath = rule.to;
305
+
306
+
// Replace captured parameters
307
+
if (rule.fromParams && match.length > 1) {
308
+
for (let i = 0; i < rule.fromParams.length; i++) {
309
+
const paramName = rule.fromParams[i];
310
+
const paramValue = match[i + 1];
311
+
312
+
if (!paramName || !paramValue) continue;
313
+
314
+
if (paramName === 'splat') {
315
+
targetPath = targetPath.replace(':splat', paramValue);
316
+
} else {
317
+
targetPath = targetPath.replace(`:${paramName}`, paramValue);
318
+
}
319
+
}
320
+
}
321
+
322
+
// Handle query parameter replacements
323
+
if (rule.queryParams && context?.queryParams) {
324
+
for (const [key, placeholder] of Object.entries(rule.queryParams)) {
325
+
const actualValue = context.queryParams[key];
326
+
if (actualValue && placeholder && placeholder.startsWith(':')) {
327
+
const paramName = placeholder.slice(1);
328
+
if (paramName) {
329
+
targetPath = targetPath.replace(`:${paramName}`, actualValue);
330
+
}
331
+
}
332
+
}
333
+
}
334
+
335
+
// Preserve query string for 200, 301, 302 redirects (unless target already has one)
336
+
if ([200, 301, 302].includes(rule.status) && context?.queryParams && !targetPath.includes('?')) {
337
+
const queryString = Object.entries(context.queryParams)
338
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
339
+
.join('&');
340
+
if (queryString) {
341
+
targetPath += `?${queryString}`;
342
+
}
343
+
}
344
+
345
+
return {
346
+
rule,
347
+
targetPath,
348
+
status: rule.status,
349
+
};
350
+
}
351
+
352
+
return null;
353
+
}
354
+
355
+
/**
356
+
* Load redirect rules from a cached site
357
+
*/
358
+
export async function loadRedirectRules(did: string, rkey: string): Promise<RedirectRule[]> {
359
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
360
+
const redirectsPath = `${CACHE_DIR}/${did}/${rkey}/_redirects`;
361
+
362
+
if (!existsSync(redirectsPath)) {
363
+
return [];
364
+
}
365
+
366
+
try {
367
+
const content = await readFile(redirectsPath, 'utf-8');
368
+
return parseRedirectsFile(content);
369
+
} catch (err) {
370
+
console.error('Failed to load _redirects file', err);
371
+
return [];
372
+
}
373
+
}
374
+
375
+
/**
376
+
* Parse cookies from Cookie header
377
+
*/
378
+
export function parseCookies(cookieHeader?: string): Record<string, string> {
379
+
if (!cookieHeader) return {};
380
+
381
+
const cookies: Record<string, string> = {};
382
+
const parts = cookieHeader.split(';');
383
+
384
+
for (const part of parts) {
385
+
const [key, ...valueParts] = part.split('=');
386
+
if (key && valueParts.length > 0) {
387
+
cookies[key.trim()] = valueParts.join('=').trim();
388
+
}
389
+
}
390
+
391
+
return cookies;
392
+
}
393
+
394
+
/**
395
+
* Parse query string into object
396
+
*/
397
+
export function parseQueryString(url: string): Record<string, string> {
398
+
const queryStart = url.indexOf('?');
399
+
if (queryStart === -1) return {};
400
+
401
+
const queryString = url.slice(queryStart + 1);
402
+
const params: Record<string, string> = {};
403
+
404
+
for (const pair of queryString.split('&')) {
405
+
const [key, value] = pair.split('=');
406
+
if (key) {
407
+
params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
408
+
}
409
+
}
410
+
411
+
return params;
412
+
}
413
+
+1
-1
hosting-service/src/lib/safe-fetch.ts
+1
-1
hosting-service/src/lib/safe-fetch.ts
···
25
25
const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads
26
26
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
27
27
const MAX_JSON_SIZE = 1024 * 1024; // 1MB
28
-
const MAX_BLOB_SIZE = 100 * 1024 * 1024; // 100MB
28
+
const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB
29
29
const MAX_REDIRECTS = 10;
30
30
31
31
function isBlockedHost(hostname: string): boolean {
+122
-25
hosting-service/src/lib/utils.ts
+122
-25
hosting-service/src/lib/utils.ts
···
13
13
cachedAt: number;
14
14
did: string;
15
15
rkey: string;
16
+
// Map of file path to blob CID for incremental updates
17
+
fileCids?: Record<string, string>;
16
18
}
17
19
18
20
/**
···
200
202
throw new Error('Invalid record structure: root missing entries array');
201
203
}
202
204
205
+
// Get existing cache metadata to check for incremental updates
206
+
const existingMetadata = await getCacheMetadata(did, rkey);
207
+
const existingFileCids = existingMetadata?.fileCids || {};
208
+
203
209
// Use a temporary directory with timestamp to avoid collisions
204
210
const tempSuffix = `.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
205
211
const tempDir = `${CACHE_DIR}/${did}/${rkey}${tempSuffix}`;
206
212
const finalDir = `${CACHE_DIR}/${did}/${rkey}`;
207
213
208
214
try {
209
-
// Download to temporary directory
210
-
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix);
211
-
await saveCacheMetadata(did, rkey, recordCid, tempSuffix);
215
+
// Collect file CIDs from the new record
216
+
const newFileCids: Record<string, string> = {};
217
+
collectFileCidsFromEntries(record.root.entries, '', newFileCids);
218
+
219
+
// Download/copy files to temporary directory (with incremental logic)
220
+
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix, existingFileCids, finalDir);
221
+
await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids);
212
222
213
223
// Atomically replace old cache with new cache
214
224
// On POSIX systems (Linux/macOS), rename is atomic
···
245
255
}
246
256
}
247
257
258
+
/**
259
+
* Recursively collect file CIDs from entries for incremental update tracking
260
+
*/
261
+
function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void {
262
+
for (const entry of entries) {
263
+
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
264
+
const node = entry.node;
265
+
266
+
if ('type' in node && node.type === 'directory' && 'entries' in node) {
267
+
collectFileCidsFromEntries(node.entries, currentPath, fileCids);
268
+
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
269
+
const fileNode = node as File;
270
+
const cid = extractBlobCid(fileNode.blob);
271
+
if (cid) {
272
+
fileCids[currentPath] = cid;
273
+
}
274
+
}
275
+
}
276
+
}
277
+
248
278
async function cacheFiles(
249
279
did: string,
250
280
site: string,
251
281
entries: Entry[],
252
282
pdsEndpoint: string,
253
283
pathPrefix: string,
254
-
dirSuffix: string = ''
284
+
dirSuffix: string = '',
285
+
existingFileCids: Record<string, string> = {},
286
+
existingCacheDir?: string
255
287
): Promise<void> {
256
-
// Collect all file blob download tasks first
288
+
// Collect file tasks, separating unchanged files from new/changed files
257
289
const downloadTasks: Array<() => Promise<void>> = [];
258
-
290
+
const copyTasks: Array<() => Promise<void>> = [];
291
+
259
292
function collectFileTasks(
260
293
entries: Entry[],
261
294
currentPathPrefix: string
···
268
301
collectFileTasks(node.entries, currentPath);
269
302
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
270
303
const fileNode = node as File;
271
-
downloadTasks.push(() => cacheFileBlob(
272
-
did,
273
-
site,
274
-
currentPath,
275
-
fileNode.blob,
276
-
pdsEndpoint,
277
-
fileNode.encoding,
278
-
fileNode.mimeType,
279
-
fileNode.base64,
280
-
dirSuffix
281
-
));
304
+
const cid = extractBlobCid(fileNode.blob);
305
+
306
+
// Check if file is unchanged (same CID as existing cache)
307
+
if (cid && existingFileCids[currentPath] === cid && existingCacheDir) {
308
+
// File unchanged - copy from existing cache instead of downloading
309
+
copyTasks.push(() => copyExistingFile(
310
+
did,
311
+
site,
312
+
currentPath,
313
+
dirSuffix,
314
+
existingCacheDir
315
+
));
316
+
} else {
317
+
// File new or changed - download it
318
+
downloadTasks.push(() => cacheFileBlob(
319
+
did,
320
+
site,
321
+
currentPath,
322
+
fileNode.blob,
323
+
pdsEndpoint,
324
+
fileNode.encoding,
325
+
fileNode.mimeType,
326
+
fileNode.base64,
327
+
dirSuffix
328
+
));
329
+
}
282
330
}
283
331
}
284
332
}
285
333
286
334
collectFileTasks(entries, pathPrefix);
287
335
288
-
// Execute downloads concurrently with a limit of 3 at a time
289
-
const concurrencyLimit = 3;
290
-
for (let i = 0; i < downloadTasks.length; i += concurrencyLimit) {
291
-
const batch = downloadTasks.slice(i, i + concurrencyLimit);
336
+
console.log(`[Incremental Update] Files to copy: ${copyTasks.length}, Files to download: ${downloadTasks.length}`);
337
+
338
+
// Copy unchanged files in parallel (fast local operations)
339
+
const copyLimit = 10;
340
+
for (let i = 0; i < copyTasks.length; i += copyLimit) {
341
+
const batch = copyTasks.slice(i, i + copyLimit);
342
+
await Promise.all(batch.map(task => task()));
343
+
}
344
+
345
+
// Download new/changed files concurrently with a limit of 3 at a time
346
+
const downloadLimit = 3;
347
+
for (let i = 0; i < downloadTasks.length; i += downloadLimit) {
348
+
const batch = downloadTasks.slice(i, i + downloadLimit);
292
349
await Promise.all(batch.map(task => task()));
293
350
}
294
351
}
295
352
353
+
/**
354
+
* Copy an unchanged file from existing cache to new cache location
355
+
*/
356
+
async function copyExistingFile(
357
+
did: string,
358
+
site: string,
359
+
filePath: string,
360
+
dirSuffix: string,
361
+
existingCacheDir: string
362
+
): Promise<void> {
363
+
const { copyFile } = await import('fs/promises');
364
+
365
+
const sourceFile = `${existingCacheDir}/${filePath}`;
366
+
const destFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`;
367
+
const destDir = destFile.substring(0, destFile.lastIndexOf('/'));
368
+
369
+
// Create destination directory if needed
370
+
if (destDir && !existsSync(destDir)) {
371
+
mkdirSync(destDir, { recursive: true });
372
+
}
373
+
374
+
try {
375
+
// Copy the file
376
+
await copyFile(sourceFile, destFile);
377
+
378
+
// Copy metadata file if it exists
379
+
const sourceMetaFile = `${sourceFile}.meta`;
380
+
const destMetaFile = `${destFile}.meta`;
381
+
if (existsSync(sourceMetaFile)) {
382
+
await copyFile(sourceMetaFile, destMetaFile);
383
+
}
384
+
385
+
console.log(`[Incremental] Copied unchanged file: ${filePath}`);
386
+
} catch (err) {
387
+
console.error(`[Incremental] Failed to copy file ${filePath}, will attempt download:`, err);
388
+
throw err;
389
+
}
390
+
}
391
+
296
392
async function cacheFileBlob(
297
393
did: string,
298
394
site: string,
···
312
408
313
409
const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
314
410
315
-
// Allow up to 100MB per file blob, with 2 minute timeout
316
-
let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024, timeout: 120000 });
411
+
// Allow up to 500MB per file blob, with 5 minute timeout
412
+
let content = await safeFetchBlob(blobUrl, { maxSize: 500 * 1024 * 1024, timeout: 300000 });
317
413
318
414
console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`);
319
415
···
404
500
return existsSync(`${CACHE_DIR}/${did}/${site}`);
405
501
}
406
502
407
-
async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = ''): Promise<void> {
503
+
async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = '', fileCids?: Record<string, string>): Promise<void> {
408
504
const metadata: CacheMetadata = {
409
505
recordCid,
410
506
cachedAt: Date.now(),
411
507
did,
412
-
rkey
508
+
rkey,
509
+
fileCids
413
510
};
414
511
415
512
const metadataPath = `${CACHE_DIR}/${did}/${rkey}${dirSuffix}/.metadata.json`;
+353
-132
hosting-service/src/server.ts
+353
-132
hosting-service/src/server.ts
···
2
2
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
3
3
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils';
4
4
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
5
-
import { existsSync, readFileSync } from 'fs';
5
+
import { existsSync } from 'fs';
6
+
import { readFile, access } from 'fs/promises';
6
7
import { lookup } from 'mime-types';
7
8
import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability';
9
+
import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata } from './lib/cache';
10
+
import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects';
8
11
9
12
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
10
13
···
21
24
return validRkeyPattern.test(rkey);
22
25
}
23
26
27
+
/**
28
+
* Async file existence check
29
+
*/
30
+
async function fileExists(path: string): Promise<boolean> {
31
+
try {
32
+
await access(path);
33
+
return true;
34
+
} catch {
35
+
return false;
36
+
}
37
+
}
38
+
39
+
// Cache for redirect rules (per site)
40
+
const redirectRulesCache = new Map<string, RedirectRule[]>();
41
+
42
+
/**
43
+
* Clear redirect rules cache for a specific site
44
+
* Should be called when a site is updated/recached
45
+
*/
46
+
export function clearRedirectRulesCache(did: string, rkey: string) {
47
+
const cacheKey = `${did}:${rkey}`;
48
+
redirectRulesCache.delete(cacheKey);
49
+
}
50
+
24
51
// Helper to serve files from cache
25
-
async function serveFromCache(did: string, rkey: string, filePath: string) {
52
+
async function serveFromCache(
53
+
did: string,
54
+
rkey: string,
55
+
filePath: string,
56
+
fullUrl?: string,
57
+
headers?: Record<string, string>
58
+
) {
59
+
// Check for redirect rules first
60
+
const redirectCacheKey = `${did}:${rkey}`;
61
+
let redirectRules = redirectRulesCache.get(redirectCacheKey);
62
+
63
+
if (redirectRules === undefined) {
64
+
// Load rules for the first time
65
+
redirectRules = await loadRedirectRules(did, rkey);
66
+
redirectRulesCache.set(redirectCacheKey, redirectRules);
67
+
}
68
+
69
+
// Apply redirect rules if any exist
70
+
if (redirectRules.length > 0) {
71
+
const requestPath = '/' + (filePath || '');
72
+
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
73
+
const cookies = parseCookies(headers?.['cookie']);
74
+
75
+
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
76
+
queryParams,
77
+
headers,
78
+
cookies,
79
+
});
80
+
81
+
if (redirectMatch) {
82
+
const { targetPath, status } = redirectMatch;
83
+
84
+
// Handle different status codes
85
+
if (status === 200) {
86
+
// Rewrite: serve different content but keep URL the same
87
+
// Remove leading slash for internal path resolution
88
+
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
89
+
return serveFileInternal(did, rkey, rewritePath);
90
+
} else if (status === 301 || status === 302) {
91
+
// External redirect: change the URL
92
+
return new Response(null, {
93
+
status,
94
+
headers: {
95
+
'Location': targetPath,
96
+
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
97
+
},
98
+
});
99
+
} else if (status === 404) {
100
+
// Custom 404 page
101
+
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
102
+
const response = await serveFileInternal(did, rkey, custom404Path);
103
+
// Override status to 404
104
+
return new Response(response.body, {
105
+
status: 404,
106
+
headers: response.headers,
107
+
});
108
+
}
109
+
}
110
+
}
111
+
112
+
// No redirect matched, serve normally
113
+
return serveFileInternal(did, rkey, filePath);
114
+
}
115
+
116
+
// Internal function to serve a file (used by both normal serving and rewrites)
117
+
async function serveFileInternal(did: string, rkey: string, filePath: string) {
26
118
// Default to index.html if path is empty or ends with /
27
119
let requestPath = filePath || 'index.html';
28
120
if (requestPath.endsWith('/')) {
29
121
requestPath += 'index.html';
30
122
}
31
123
124
+
const cacheKey = getCacheKey(did, rkey, requestPath);
32
125
const cachedFile = getCachedFilePath(did, rkey, requestPath);
33
126
34
-
if (existsSync(cachedFile)) {
35
-
const content = readFileSync(cachedFile);
127
+
// Check in-memory cache first
128
+
let content = fileCache.get(cacheKey);
129
+
let meta = metadataCache.get(cacheKey);
130
+
131
+
if (!content && await fileExists(cachedFile)) {
132
+
// Read from disk and cache
133
+
content = await readFile(cachedFile);
134
+
fileCache.set(cacheKey, content, content.length);
135
+
36
136
const metaFile = `${cachedFile}.meta`;
137
+
if (await fileExists(metaFile)) {
138
+
const metaJson = await readFile(metaFile, 'utf-8');
139
+
meta = JSON.parse(metaJson);
140
+
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
141
+
}
142
+
}
37
143
38
-
console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`);
144
+
if (content) {
145
+
// Build headers with caching
146
+
const headers: Record<string, string> = {};
147
+
148
+
if (meta && meta.encoding === 'gzip' && meta.mimeType) {
149
+
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
39
150
40
-
// Check if file has compression metadata
41
-
if (existsSync(metaFile)) {
42
-
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
43
-
console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`);
44
-
45
-
// Check actual content for gzip magic bytes
46
-
if (content.length >= 2) {
47
-
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
48
-
console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic}`);
151
+
if (!shouldServeCompressed) {
152
+
const { gunzipSync } = await import('zlib');
153
+
const decompressed = gunzipSync(content);
154
+
headers['Content-Type'] = meta.mimeType;
155
+
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
156
+
return new Response(decompressed, { headers });
49
157
}
50
-
51
-
if (meta.encoding === 'gzip' && meta.mimeType) {
52
-
// Use shared function to determine if this should be served compressed
53
-
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
54
-
55
-
if (!shouldServeCompressed) {
56
-
// This shouldn't happen if caching is working correctly, but handle it gracefully
57
-
console.log(`[DEBUG SERVE] ${requestPath}: decompressing file that shouldn't be compressed (${meta.mimeType})`);
58
-
const { gunzipSync } = await import('zlib');
59
-
const decompressed = gunzipSync(content);
60
-
console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
61
-
return new Response(decompressed, {
62
-
headers: {
63
-
'Content-Type': meta.mimeType,
64
-
},
65
-
});
66
-
}
67
-
68
-
// Serve gzipped content with proper headers (for HTML, CSS, JS, etc.)
69
-
console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`);
70
-
return new Response(content, {
71
-
headers: {
72
-
'Content-Type': meta.mimeType,
73
-
'Content-Encoding': 'gzip',
74
-
},
75
-
});
76
-
}
158
+
159
+
headers['Content-Type'] = meta.mimeType;
160
+
headers['Content-Encoding'] = 'gzip';
161
+
headers['Cache-Control'] = meta.mimeType.startsWith('text/html')
162
+
? 'public, max-age=300'
163
+
: 'public, max-age=31536000, immutable';
164
+
return new Response(content, { headers });
77
165
}
78
166
79
-
// Serve non-compressed files normally
167
+
// Non-compressed files
80
168
const mimeType = lookup(cachedFile) || 'application/octet-stream';
81
-
return new Response(content, {
82
-
headers: {
83
-
'Content-Type': mimeType,
84
-
},
85
-
});
169
+
headers['Content-Type'] = mimeType;
170
+
headers['Cache-Control'] = mimeType.startsWith('text/html')
171
+
? 'public, max-age=300'
172
+
: 'public, max-age=31536000, immutable';
173
+
return new Response(content, { headers });
86
174
}
87
175
88
176
// Try index.html for directory-like paths
89
177
if (!requestPath.includes('.')) {
90
-
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
91
-
if (existsSync(indexFile)) {
92
-
const content = readFileSync(indexFile);
93
-
const metaFile = `${indexFile}.meta`;
178
+
const indexPath = `${requestPath}/index.html`;
179
+
const indexCacheKey = getCacheKey(did, rkey, indexPath);
180
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
94
181
95
-
// Check if file has compression metadata
96
-
if (existsSync(metaFile)) {
97
-
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
98
-
if (meta.encoding === 'gzip' && meta.mimeType) {
99
-
return new Response(content, {
100
-
headers: {
101
-
'Content-Type': meta.mimeType,
102
-
'Content-Encoding': 'gzip',
103
-
},
104
-
});
105
-
}
182
+
let indexContent = fileCache.get(indexCacheKey);
183
+
let indexMeta = metadataCache.get(indexCacheKey);
184
+
185
+
if (!indexContent && await fileExists(indexFile)) {
186
+
indexContent = await readFile(indexFile);
187
+
fileCache.set(indexCacheKey, indexContent, indexContent.length);
188
+
189
+
const indexMetaFile = `${indexFile}.meta`;
190
+
if (await fileExists(indexMetaFile)) {
191
+
const metaJson = await readFile(indexMetaFile, 'utf-8');
192
+
indexMeta = JSON.parse(metaJson);
193
+
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
106
194
}
195
+
}
107
196
108
-
return new Response(content, {
109
-
headers: {
110
-
'Content-Type': 'text/html; charset=utf-8',
111
-
},
112
-
});
197
+
if (indexContent) {
198
+
const headers: Record<string, string> = {
199
+
'Content-Type': 'text/html; charset=utf-8',
200
+
'Cache-Control': 'public, max-age=300',
201
+
};
202
+
203
+
if (indexMeta && indexMeta.encoding === 'gzip') {
204
+
headers['Content-Encoding'] = 'gzip';
205
+
}
206
+
207
+
return new Response(indexContent, { headers });
113
208
}
114
209
}
115
210
···
121
216
did: string,
122
217
rkey: string,
123
218
filePath: string,
124
-
basePath: string
219
+
basePath: string,
220
+
fullUrl?: string,
221
+
headers?: Record<string, string>
125
222
) {
223
+
// Check for redirect rules first
224
+
const redirectCacheKey = `${did}:${rkey}`;
225
+
let redirectRules = redirectRulesCache.get(redirectCacheKey);
226
+
227
+
if (redirectRules === undefined) {
228
+
// Load rules for the first time
229
+
redirectRules = await loadRedirectRules(did, rkey);
230
+
redirectRulesCache.set(redirectCacheKey, redirectRules);
231
+
}
232
+
233
+
// Apply redirect rules if any exist
234
+
if (redirectRules.length > 0) {
235
+
const requestPath = '/' + (filePath || '');
236
+
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
237
+
const cookies = parseCookies(headers?.['cookie']);
238
+
239
+
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
240
+
queryParams,
241
+
headers,
242
+
cookies,
243
+
});
244
+
245
+
if (redirectMatch) {
246
+
const { targetPath, status } = redirectMatch;
247
+
248
+
// Handle different status codes
249
+
if (status === 200) {
250
+
// Rewrite: serve different content but keep URL the same
251
+
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
252
+
return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath);
253
+
} else if (status === 301 || status === 302) {
254
+
// External redirect: change the URL
255
+
// For sites.wisp.place, we need to adjust the target path to include the base path
256
+
// unless it's an absolute URL
257
+
let redirectTarget = targetPath;
258
+
if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) {
259
+
redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath);
260
+
}
261
+
return new Response(null, {
262
+
status,
263
+
headers: {
264
+
'Location': redirectTarget,
265
+
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
266
+
},
267
+
});
268
+
} else if (status === 404) {
269
+
// Custom 404 page
270
+
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
271
+
const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath);
272
+
// Override status to 404
273
+
return new Response(response.body, {
274
+
status: 404,
275
+
headers: response.headers,
276
+
});
277
+
}
278
+
}
279
+
}
280
+
281
+
// No redirect matched, serve normally
282
+
return serveFileInternalWithRewrite(did, rkey, filePath, basePath);
283
+
}
284
+
285
+
// Internal function to serve a file with rewriting
286
+
async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) {
126
287
// Default to index.html if path is empty or ends with /
127
288
let requestPath = filePath || 'index.html';
128
289
if (requestPath.endsWith('/')) {
129
290
requestPath += 'index.html';
130
291
}
131
292
293
+
const cacheKey = getCacheKey(did, rkey, requestPath);
132
294
const cachedFile = getCachedFilePath(did, rkey, requestPath);
133
295
134
-
if (existsSync(cachedFile)) {
135
-
const metaFile = `${cachedFile}.meta`;
136
-
let mimeType = lookup(cachedFile) || 'application/octet-stream';
137
-
let isGzipped = false;
296
+
// Check for rewritten HTML in cache first (if it's HTML)
297
+
const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream';
298
+
if (isHtmlContent(requestPath, mimeTypeGuess)) {
299
+
const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`);
300
+
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
301
+
if (rewrittenContent) {
302
+
return new Response(rewrittenContent, {
303
+
headers: {
304
+
'Content-Type': 'text/html; charset=utf-8',
305
+
'Content-Encoding': 'gzip',
306
+
'Cache-Control': 'public, max-age=300',
307
+
},
308
+
});
309
+
}
310
+
}
138
311
139
-
// Check if file has compression metadata
140
-
if (existsSync(metaFile)) {
141
-
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
142
-
if (meta.encoding === 'gzip' && meta.mimeType) {
143
-
mimeType = meta.mimeType;
144
-
isGzipped = true;
145
-
}
312
+
// Check in-memory file cache
313
+
let content = fileCache.get(cacheKey);
314
+
let meta = metadataCache.get(cacheKey);
315
+
316
+
if (!content && await fileExists(cachedFile)) {
317
+
// Read from disk and cache
318
+
content = await readFile(cachedFile);
319
+
fileCache.set(cacheKey, content, content.length);
320
+
321
+
const metaFile = `${cachedFile}.meta`;
322
+
if (await fileExists(metaFile)) {
323
+
const metaJson = await readFile(metaFile, 'utf-8');
324
+
meta = JSON.parse(metaJson);
325
+
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
146
326
}
327
+
}
328
+
329
+
if (content) {
330
+
const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream';
331
+
const isGzipped = meta?.encoding === 'gzip';
147
332
148
333
// Check if this is HTML content that needs rewriting
149
-
// We decompress, rewrite paths, then recompress for efficient delivery
150
334
if (isHtmlContent(requestPath, mimeType)) {
151
-
let content: string;
335
+
let htmlContent: string;
152
336
if (isGzipped) {
153
337
const { gunzipSync } = await import('zlib');
154
-
const compressed = readFileSync(cachedFile);
155
-
content = gunzipSync(compressed).toString('utf-8');
338
+
htmlContent = gunzipSync(content).toString('utf-8');
156
339
} else {
157
-
content = readFileSync(cachedFile, 'utf-8');
340
+
htmlContent = content.toString('utf-8');
158
341
}
159
-
const rewritten = rewriteHtmlPaths(content, basePath, requestPath);
160
-
161
-
// Recompress the HTML for efficient delivery
342
+
const rewritten = rewriteHtmlPaths(htmlContent, basePath, requestPath);
343
+
344
+
// Recompress and cache the rewritten HTML
162
345
const { gzipSync } = await import('zlib');
163
346
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
164
-
347
+
348
+
const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`);
349
+
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
350
+
165
351
return new Response(recompressed, {
166
352
headers: {
167
353
'Content-Type': 'text/html; charset=utf-8',
168
354
'Content-Encoding': 'gzip',
355
+
'Cache-Control': 'public, max-age=300',
169
356
},
170
357
});
171
358
}
172
359
173
-
// Non-HTML files: serve gzipped content as-is with proper headers
174
-
const content = readFileSync(cachedFile);
360
+
// Non-HTML files: serve as-is
361
+
const headers: Record<string, string> = {
362
+
'Content-Type': mimeType,
363
+
'Cache-Control': 'public, max-age=31536000, immutable',
364
+
};
365
+
175
366
if (isGzipped) {
176
-
// Use shared function to determine if this should be served compressed
177
367
const shouldServeCompressed = shouldCompressMimeType(mimeType);
178
-
179
368
if (!shouldServeCompressed) {
180
-
// This shouldn't happen if caching is working correctly, but handle it gracefully
181
369
const { gunzipSync } = await import('zlib');
182
370
const decompressed = gunzipSync(content);
183
-
return new Response(decompressed, {
184
-
headers: {
185
-
'Content-Type': mimeType,
186
-
},
187
-
});
371
+
return new Response(decompressed, { headers });
188
372
}
189
-
190
-
return new Response(content, {
373
+
headers['Content-Encoding'] = 'gzip';
374
+
}
375
+
376
+
return new Response(content, { headers });
377
+
}
378
+
379
+
// Try index.html for directory-like paths
380
+
if (!requestPath.includes('.')) {
381
+
const indexPath = `${requestPath}/index.html`;
382
+
const indexCacheKey = getCacheKey(did, rkey, indexPath);
383
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
384
+
385
+
// Check for rewritten index.html in cache
386
+
const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
387
+
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
388
+
if (rewrittenContent) {
389
+
return new Response(rewrittenContent, {
191
390
headers: {
192
-
'Content-Type': mimeType,
391
+
'Content-Type': 'text/html; charset=utf-8',
193
392
'Content-Encoding': 'gzip',
393
+
'Cache-Control': 'public, max-age=300',
194
394
},
195
395
});
196
396
}
197
-
return new Response(content, {
198
-
headers: {
199
-
'Content-Type': mimeType,
200
-
},
201
-
});
202
-
}
203
397
204
-
// Try index.html for directory-like paths
205
-
if (!requestPath.includes('.')) {
206
-
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
207
-
if (existsSync(indexFile)) {
208
-
const metaFile = `${indexFile}.meta`;
209
-
let isGzipped = false;
398
+
let indexContent = fileCache.get(indexCacheKey);
399
+
let indexMeta = metadataCache.get(indexCacheKey);
400
+
401
+
if (!indexContent && await fileExists(indexFile)) {
402
+
indexContent = await readFile(indexFile);
403
+
fileCache.set(indexCacheKey, indexContent, indexContent.length);
210
404
211
-
if (existsSync(metaFile)) {
212
-
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
213
-
if (meta.encoding === 'gzip') {
214
-
isGzipped = true;
215
-
}
405
+
const indexMetaFile = `${indexFile}.meta`;
406
+
if (await fileExists(indexMetaFile)) {
407
+
const metaJson = await readFile(indexMetaFile, 'utf-8');
408
+
indexMeta = JSON.parse(metaJson);
409
+
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
216
410
}
411
+
}
217
412
218
-
// HTML needs path rewriting, decompress, rewrite, then recompress
219
-
let content: string;
413
+
if (indexContent) {
414
+
const isGzipped = indexMeta?.encoding === 'gzip';
415
+
416
+
let htmlContent: string;
220
417
if (isGzipped) {
221
418
const { gunzipSync } = await import('zlib');
222
-
const compressed = readFileSync(indexFile);
223
-
content = gunzipSync(compressed).toString('utf-8');
419
+
htmlContent = gunzipSync(indexContent).toString('utf-8');
224
420
} else {
225
-
content = readFileSync(indexFile, 'utf-8');
421
+
htmlContent = indexContent.toString('utf-8');
226
422
}
227
-
const indexPath = `${requestPath}/index.html`;
228
-
const rewritten = rewriteHtmlPaths(content, basePath, indexPath);
229
-
230
-
// Recompress the HTML for efficient delivery
423
+
const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath);
424
+
231
425
const { gzipSync } = await import('zlib');
232
426
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
233
-
427
+
428
+
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
429
+
234
430
return new Response(recompressed, {
235
431
headers: {
236
432
'Content-Type': 'text/html; charset=utf-8',
237
433
'Content-Encoding': 'gzip',
434
+
'Cache-Control': 'public, max-age=300',
238
435
},
239
436
});
240
437
}
···
264
461
265
462
try {
266
463
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
464
+
// Clear redirect rules cache since the site was updated
465
+
clearRedirectRulesCache(did, rkey);
267
466
logger.info('Site cached successfully', { did, rkey });
268
467
return true;
269
468
} catch (err) {
···
331
530
332
531
// Serve with HTML path rewriting to handle absolute paths
333
532
const basePath = `/${identifier}/${site}/`;
334
-
return serveFromCacheWithRewrite(did, site, filePath, basePath);
533
+
const headers: Record<string, string> = {};
534
+
c.req.raw.headers.forEach((value, key) => {
535
+
headers[key.toLowerCase()] = value;
536
+
});
537
+
return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers);
335
538
}
336
539
337
540
// Check if this is a DNS hash subdomain
···
367
570
return c.text('Site not found', 404);
368
571
}
369
572
370
-
return serveFromCache(customDomain.did, rkey, path);
573
+
const headers: Record<string, string> = {};
574
+
c.req.raw.headers.forEach((value, key) => {
575
+
headers[key.toLowerCase()] = value;
576
+
});
577
+
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
371
578
}
372
579
373
580
// Route 2: Registered subdomains - /*.wisp.place/*
···
391
598
return c.text('Site not found', 404);
392
599
}
393
600
394
-
return serveFromCache(domainInfo.did, rkey, path);
601
+
const headers: Record<string, string> = {};
602
+
c.req.raw.headers.forEach((value, key) => {
603
+
headers[key.toLowerCase()] = value;
604
+
});
605
+
return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers);
395
606
}
396
607
397
608
// Route 1: Custom domains - /*
···
414
625
return c.text('Site not found', 404);
415
626
}
416
627
417
-
return serveFromCache(customDomain.did, rkey, path);
628
+
const headers: Record<string, string> = {};
629
+
c.req.raw.headers.forEach((value, key) => {
630
+
headers[key.toLowerCase()] = value;
631
+
});
632
+
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
418
633
});
419
634
420
635
// Internal observability endpoints (for admin panel)
···
442
657
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
443
658
const stats = metricsCollector.getStats('hosting-service', timeWindow);
444
659
return c.json({ stats, timeWindow });
660
+
});
661
+
662
+
app.get('/__internal__/observability/cache', async (c) => {
663
+
const { getCacheStats } = await import('./lib/cache');
664
+
const stats = getCacheStats();
665
+
return c.json({ cache: stats });
445
666
});
446
667
447
668
export default app;
+3
-1
hosting-service/tsconfig.json
+3
-1
hosting-service/tsconfig.json
+9
-2
package.json
+9
-2
package.json
···
17
17
"@elysiajs/openapi": "^1.4.11",
18
18
"@elysiajs/opentelemetry": "^1.4.6",
19
19
"@elysiajs/static": "^1.4.2",
20
+
"@radix-ui/react-checkbox": "^1.3.3",
20
21
"@radix-ui/react-dialog": "^1.1.15",
21
22
"@radix-ui/react-label": "^2.1.7",
22
23
"@radix-ui/react-radio-group": "^1.3.8",
23
24
"@radix-ui/react-slot": "^1.2.3",
24
25
"@radix-ui/react-tabs": "^1.1.13",
25
26
"@tanstack/react-query": "^5.90.2",
27
+
"actor-typeahead": "^0.1.1",
28
+
"atproto-ui": "^0.11.3",
26
29
"class-variance-authority": "^0.7.1",
27
30
"clsx": "^2.1.1",
28
31
"elysia": "latest",
29
32
"iron-session": "^8.0.4",
30
33
"lucide-react": "^0.546.0",
34
+
"multiformats": "^13.4.1",
35
+
"prismjs": "^1.30.0",
31
36
"react": "^19.2.0",
32
37
"react-dom": "^19.2.0",
33
-
"react-shiki": "^0.9.0",
34
38
"tailwind-merge": "^3.3.1",
35
39
"tailwindcss": "4",
36
40
"tw-animate-css": "^1.4.0",
···
41
45
"@types/react": "^19.2.2",
42
46
"@types/react-dom": "^19.2.1",
43
47
"bun-plugin-tailwind": "^0.1.2",
44
-
"bun-types": "latest"
48
+
"bun-types": "latest",
49
+
"esbuild": "0.26.0"
45
50
},
46
51
"module": "src/index.js",
47
52
"trustedDependencies": [
53
+
"bun",
54
+
"cbor-extract",
48
55
"core-js",
49
56
"protobufjs"
50
57
]
+379
public/acceptable-use/acceptable-use.tsx
+379
public/acceptable-use/acceptable-use.tsx
···
1
+
import { createRoot } from 'react-dom/client'
2
+
import Layout from '@public/layouts'
3
+
import { Button } from '@public/components/ui/button'
4
+
import { Card } from '@public/components/ui/card'
5
+
import { ArrowLeft, Shield, AlertCircle, CheckCircle, Scale } from 'lucide-react'
6
+
7
+
function AcceptableUsePage() {
8
+
return (
9
+
<div className="min-h-screen bg-background">
10
+
{/* Header */}
11
+
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
12
+
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
13
+
<div className="flex items-center gap-2">
14
+
<img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" />
15
+
<span className="text-xl font-semibold text-foreground">
16
+
wisp.place
17
+
</span>
18
+
</div>
19
+
<Button
20
+
variant="ghost"
21
+
size="sm"
22
+
onClick={() => window.location.href = '/'}
23
+
>
24
+
<ArrowLeft className="w-4 h-4 mr-2" />
25
+
Back to Home
26
+
</Button>
27
+
</div>
28
+
</header>
29
+
30
+
{/* Hero Section */}
31
+
<div className="bg-gradient-to-b from-accent/10 to-background border-b border-border/40">
32
+
<div className="container mx-auto px-4 py-16 max-w-4xl text-center">
33
+
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent/20 mb-6">
34
+
<Shield className="w-8 h-8 text-accent" />
35
+
</div>
36
+
<h1 className="text-4xl md:text-5xl font-bold mb-4">Acceptable Use Policy</h1>
37
+
<div className="flex items-center justify-center gap-6 text-sm text-muted-foreground">
38
+
<div className="flex items-center gap-2">
39
+
<span className="font-medium">Effective:</span>
40
+
<span>November 10, 2025</span>
41
+
</div>
42
+
<div className="h-4 w-px bg-border"></div>
43
+
<div className="flex items-center gap-2">
44
+
<span className="font-medium">Last Updated:</span>
45
+
<span>November 10, 2025</span>
46
+
</div>
47
+
</div>
48
+
</div>
49
+
</div>
50
+
51
+
{/* Content */}
52
+
<div className="container mx-auto px-4 py-12 max-w-4xl">
53
+
<article className="space-y-12">
54
+
{/* Our Philosophy */}
55
+
<section>
56
+
<h2 className="text-3xl font-bold mb-6 text-foreground">Our Philosophy</h2>
57
+
<div className="space-y-4 text-lg leading-relaxed text-muted-foreground">
58
+
<p>
59
+
wisp.place exists to give you a corner of the internet that's truly yoursโa place to create, experiment, and express yourself freely. We believe in the open web and the fundamental importance of free expression. We're not here to police your thoughts, moderate your aesthetics, or judge your taste.
60
+
</p>
61
+
<p>
62
+
That said, we're also real people running real servers in real jurisdictions (the United States and the Netherlands), and there are legal and practical limits to what we can host. This policy aims to be as permissive as possible while keeping the lights on and staying on the right side of the law.
63
+
</p>
64
+
</div>
65
+
</section>
66
+
67
+
{/* What You Can Do */}
68
+
<Card className="bg-green-500/5 border-green-500/20 p-8">
69
+
<div className="flex items-start gap-4">
70
+
<div className="flex-shrink-0">
71
+
<CheckCircle className="w-8 h-8 text-green-500" />
72
+
</div>
73
+
<div className="space-y-4">
74
+
<h2 className="text-3xl font-bold text-foreground">What You Can Do</h2>
75
+
<div className="space-y-4 text-lg leading-relaxed text-muted-foreground">
76
+
<p>
77
+
<strong className="text-green-600 dark:text-green-400">Almost anything.</strong> Seriously. Build weird art projects. Write controversial essays. Create spaces that would make corporate platforms nervous. Express unpopular opinions. Make things that are strange, provocative, uncomfortable, or just plain yours.
78
+
</p>
79
+
<p>
80
+
We support creative and personal expression in all its forms, including adult content, political speech, counter-cultural work, and experimental projects.
81
+
</p>
82
+
</div>
83
+
</div>
84
+
</div>
85
+
</Card>
86
+
87
+
{/* What You Can't Do */}
88
+
<section>
89
+
<div className="flex items-center gap-3 mb-6">
90
+
<AlertCircle className="w-8 h-8 text-red-500" />
91
+
<h2 className="text-3xl font-bold text-foreground">What You Can't Do</h2>
92
+
</div>
93
+
94
+
<div className="space-y-8">
95
+
<Card className="p-6 border-2">
96
+
<h3 className="text-2xl font-semibold mb-4 text-foreground">Illegal Content</h3>
97
+
<p className="text-muted-foreground mb-4">
98
+
Don't host content that's illegal in the United States or the Netherlands. This includes but isn't limited to:
99
+
</p>
100
+
<ul className="space-y-3 text-muted-foreground">
101
+
<li className="flex items-start gap-3">
102
+
<span className="text-red-500 mt-1">โข</span>
103
+
<span><strong>Child sexual abuse material (CSAM)</strong> involving real minors in any form</span>
104
+
</li>
105
+
<li className="flex items-start gap-3">
106
+
<span className="text-red-500 mt-1">โข</span>
107
+
<span><strong>Realistic or AI-generated depictions</strong> of minors in sexual contexts, including photorealistic renders, deepfakes, or AI-generated imagery</span>
108
+
</li>
109
+
<li className="flex items-start gap-3">
110
+
<span className="text-red-500 mt-1">โข</span>
111
+
<span><strong>Non-consensual intimate imagery</strong> (revenge porn, deepfakes, hidden camera footage, etc.)</span>
112
+
</li>
113
+
<li className="flex items-start gap-3">
114
+
<span className="text-red-500 mt-1">โข</span>
115
+
<span>Content depicting or facilitating human trafficking, sexual exploitation, or sexual violence</span>
116
+
</li>
117
+
<li className="flex items-start gap-3">
118
+
<span className="text-red-500 mt-1">โข</span>
119
+
<span>Instructions for manufacturing explosives, biological weapons, or other instruments designed for mass harm</span>
120
+
</li>
121
+
<li className="flex items-start gap-3">
122
+
<span className="text-red-500 mt-1">โข</span>
123
+
<span>Content that facilitates imminent violence or terrorism</span>
124
+
</li>
125
+
<li className="flex items-start gap-3">
126
+
<span className="text-red-500 mt-1">โข</span>
127
+
<span>Stolen financial information, credentials, or personal data used for fraud</span>
128
+
</li>
129
+
</ul>
130
+
</Card>
131
+
132
+
<Card className="p-6 border-2">
133
+
<h3 className="text-2xl font-semibold mb-4 text-foreground">Intellectual Property Violations</h3>
134
+
<div className="space-y-4 text-muted-foreground">
135
+
<p>
136
+
Don't host content that clearly violates someone else's copyright, trademark, or other intellectual property rights. We're required to respond to valid DMCA takedown notices.
137
+
</p>
138
+
<p>
139
+
We understand that copyright law is complicated and sometimes ridiculous. We're not going to proactively scan your site or nitpick over fair use. But if we receive a legitimate legal complaint, we'll have to act on it.
140
+
</p>
141
+
</div>
142
+
</Card>
143
+
144
+
<Card className="p-6 border-2 border-red-500/30 bg-red-500/5">
145
+
<h3 className="text-2xl font-semibold mb-4 text-foreground">Hate Content</h3>
146
+
<div className="space-y-4 text-muted-foreground">
147
+
<p>
148
+
You can express controversial ideas. You can be offensive. You can make people uncomfortable. But pure hateโcontent that exists solely to dehumanize, threaten, or incite violence against people based on race, ethnicity, religion, gender, sexual orientation, disability, or similar characteristicsโisn't welcome here.
149
+
</p>
150
+
<p>
151
+
There's a difference between "I have deeply unpopular opinions about X" and "People like X should be eliminated." The former is protected expression. The latter isn't.
152
+
</p>
153
+
<div className="bg-background/50 border-l-4 border-red-500 p-4 rounded">
154
+
<p className="font-medium text-foreground">
155
+
<strong>A note on enforcement:</strong> While we're generally permissive and believe in giving people the benefit of the doubt, hate content is where we draw a hard line. I will be significantly more aggressive in moderating this type of content than anything else on this list. If your site exists primarily to spread hate or recruit people into hateful ideologies, you will be removed swiftly and without extensive appeals. This is non-negotiable.
156
+
</p>
157
+
</div>
158
+
</div>
159
+
</Card>
160
+
161
+
<Card className="p-6 border-2">
162
+
<h3 className="text-2xl font-semibold mb-4 text-foreground">Adult Content Guidelines</h3>
163
+
<div className="space-y-4 text-muted-foreground">
164
+
<p>
165
+
Adult content is allowed. This includes sexually explicit material, erotica, adult artwork, and NSFW creative expression.
166
+
</p>
167
+
<p className="font-medium">However:</p>
168
+
<ul className="space-y-2">
169
+
<li className="flex items-start gap-3">
170
+
<span className="text-red-500 mt-1">โข</span>
171
+
<span>No content involving real minors in any sexual context whatsoever</span>
172
+
</li>
173
+
<li className="flex items-start gap-3">
174
+
<span className="text-red-500 mt-1">โข</span>
175
+
<span>No photorealistic, AI-generated, or otherwise realistic depictions of minors in sexual contexts</span>
176
+
</li>
177
+
<li className="flex items-start gap-3">
178
+
<span className="text-green-500 mt-1">โข</span>
179
+
<span>Clearly stylized drawings and written fiction are permitted, provided they remain obviously non-photographic in nature</span>
180
+
</li>
181
+
<li className="flex items-start gap-3">
182
+
<span className="text-red-500 mt-1">โข</span>
183
+
<span>No non-consensual content (revenge porn, voyeurism, etc.)</span>
184
+
</li>
185
+
<li className="flex items-start gap-3">
186
+
<span className="text-red-500 mt-1">โข</span>
187
+
<span>No content depicting illegal sexual acts (bestiality, necrophilia, etc.)</span>
188
+
</li>
189
+
<li className="flex items-start gap-3">
190
+
<span className="text-yellow-500 mt-1">โข</span>
191
+
<span>Adult content should be clearly marked as such if discoverable through public directories or search</span>
192
+
</li>
193
+
</ul>
194
+
</div>
195
+
</Card>
196
+
197
+
<Card className="p-6 border-2">
198
+
<h3 className="text-2xl font-semibold mb-4 text-foreground">Malicious Technical Activity</h3>
199
+
<p className="text-muted-foreground mb-4">Don't use your site to:</p>
200
+
<ul className="space-y-2 text-muted-foreground">
201
+
<li className="flex items-start gap-3">
202
+
<span className="text-red-500 mt-1">โข</span>
203
+
<span>Distribute malware, viruses, or exploits</span>
204
+
</li>
205
+
<li className="flex items-start gap-3">
206
+
<span className="text-red-500 mt-1">โข</span>
207
+
<span>Conduct phishing or social engineering attacks</span>
208
+
</li>
209
+
<li className="flex items-start gap-3">
210
+
<span className="text-red-500 mt-1">โข</span>
211
+
<span>Launch DDoS attacks or network abuse</span>
212
+
</li>
213
+
<li className="flex items-start gap-3">
214
+
<span className="text-red-500 mt-1">โข</span>
215
+
<span>Mine cryptocurrency without explicit user consent</span>
216
+
</li>
217
+
<li className="flex items-start gap-3">
218
+
<span className="text-red-500 mt-1">โข</span>
219
+
<span>Scrape, spam, or abuse other services</span>
220
+
</li>
221
+
</ul>
222
+
</Card>
223
+
</div>
224
+
</section>
225
+
226
+
{/* Our Approach to Enforcement */}
227
+
<section>
228
+
<div className="flex items-center gap-3 mb-6">
229
+
<Scale className="w-8 h-8 text-accent" />
230
+
<h2 className="text-3xl font-bold text-foreground">Our Approach to Enforcement</h2>
231
+
</div>
232
+
<div className="space-y-6">
233
+
<div className="space-y-4 text-lg leading-relaxed text-muted-foreground">
234
+
<p>
235
+
<strong>We actively monitor for obvious violations.</strong> Not to censor your creativity or police your opinions, but to catch the clear-cut stuff that threatens the service's existence and makes this a worse place for everyone. We're looking for the blatantly illegal, the obviously harmfulโthe stuff that would get servers seized and communities destroyed.
236
+
</p>
237
+
<p>
238
+
We're not reading your blog posts looking for wrongthink. We're making sure this platform doesn't become a haven for the kind of content that ruins good things.
239
+
</p>
240
+
</div>
241
+
242
+
<Card className="p-6 bg-muted/30">
243
+
<p className="font-semibold mb-3 text-foreground">We take action when:</p>
244
+
<ol className="space-y-2 text-muted-foreground">
245
+
<li className="flex items-start gap-3">
246
+
<span className="font-bold text-accent">1.</span>
247
+
<span>We identify content that clearly violates this policy during routine monitoring</span>
248
+
</li>
249
+
<li className="flex items-start gap-3">
250
+
<span className="font-bold text-accent">2.</span>
251
+
<span>We receive a valid legal complaint (DMCA, court order, etc.)</span>
252
+
</li>
253
+
<li className="flex items-start gap-3">
254
+
<span className="font-bold text-accent">3.</span>
255
+
<span>Someone reports content that violates this policy and we can verify the violation</span>
256
+
</li>
257
+
<li className="flex items-start gap-3">
258
+
<span className="font-bold text-accent">4.</span>
259
+
<span>Your site is causing technical problems for the service or other users</span>
260
+
</li>
261
+
</ol>
262
+
</Card>
263
+
264
+
<Card className="p-6 bg-muted/30">
265
+
<p className="font-semibold mb-3 text-foreground">When we do need to take action, we'll try to:</p>
266
+
<ul className="space-y-2 text-muted-foreground">
267
+
<li className="flex items-start gap-3">
268
+
<span className="text-accent">โข</span>
269
+
<span>Contact you first when legally and practically possible</span>
270
+
</li>
271
+
<li className="flex items-start gap-3">
272
+
<span className="text-accent">โข</span>
273
+
<span>Be transparent about what's happening and why</span>
274
+
</li>
275
+
<li className="flex items-start gap-3">
276
+
<span className="text-accent">โข</span>
277
+
<span>Give you an opportunity to address the issue if appropriate</span>
278
+
</li>
279
+
</ul>
280
+
</Card>
281
+
282
+
<p className="text-muted-foreground">
283
+
For serious or repeated violations, we may suspend or terminate your account.
284
+
</p>
285
+
</div>
286
+
</section>
287
+
288
+
{/* Regional Compliance */}
289
+
<Card className="p-6 bg-blue-500/5 border-blue-500/20">
290
+
<h2 className="text-2xl font-bold mb-4 text-foreground">Regional Compliance</h2>
291
+
<p className="text-muted-foreground">
292
+
Our servers are located in the United States and the Netherlands. Content hosted on wisp.place must comply with the laws of both jurisdictions. While we aim to provide broad creative freedom, these legal requirements are non-negotiable.
293
+
</p>
294
+
</Card>
295
+
296
+
{/* Changes to This Policy */}
297
+
<section>
298
+
<h2 className="text-2xl font-bold mb-4 text-foreground">Changes to This Policy</h2>
299
+
<p className="text-muted-foreground">
300
+
We may update this policy as legal requirements or service realities change. If we make significant changes, we'll notify active users.
301
+
</p>
302
+
</section>
303
+
304
+
{/* Questions or Reports */}
305
+
<section>
306
+
<h2 className="text-2xl font-bold mb-4 text-foreground">Questions or Reports</h2>
307
+
<p className="text-muted-foreground">
308
+
If you have questions about this policy or need to report a violation, contact us at{' '}
309
+
<a
310
+
href="mailto:contact@wisp.place"
311
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
312
+
>
313
+
contact@wisp.place
314
+
</a>
315
+
.
316
+
</p>
317
+
</section>
318
+
319
+
{/* Final Message */}
320
+
<Card className="p-8 bg-accent/10 border-accent/30 border-2">
321
+
<p className="text-lg leading-relaxed text-foreground">
322
+
<strong>Remember:</strong> This policy exists to keep the service running and this community healthy, not to limit your creativity. When in doubt, ask yourself: "Is this likely to get real-world authorities knocking on doors or make this place worse for everyone?" If the answer is yes, it probably doesn't belong here. Everything else? Go wild.
323
+
</p>
324
+
</Card>
325
+
</article>
326
+
</div>
327
+
328
+
{/* Footer */}
329
+
<footer className="border-t border-border/40 bg-muted/20 mt-12">
330
+
<div className="container mx-auto px-4 py-8">
331
+
<div className="text-center text-sm text-muted-foreground">
332
+
<p>
333
+
Built by{' '}
334
+
<a
335
+
href="https://bsky.app/profile/nekomimi.pet"
336
+
target="_blank"
337
+
rel="noopener noreferrer"
338
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
339
+
>
340
+
@nekomimi.pet
341
+
</a>
342
+
{' โข '}
343
+
Contact:{' '}
344
+
<a
345
+
href="mailto:contact@wisp.place"
346
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
347
+
>
348
+
contact@wisp.place
349
+
</a>
350
+
{' โข '}
351
+
Legal/DMCA:{' '}
352
+
<a
353
+
href="mailto:legal@wisp.place"
354
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
355
+
>
356
+
legal@wisp.place
357
+
</a>
358
+
</p>
359
+
<p className="mt-2">
360
+
<a
361
+
href="/acceptable-use"
362
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
363
+
>
364
+
Acceptable Use Policy
365
+
</a>
366
+
</p>
367
+
</div>
368
+
</div>
369
+
</footer>
370
+
</div>
371
+
)
372
+
}
373
+
374
+
const root = createRoot(document.getElementById('elysia')!)
375
+
root.render(
376
+
<Layout className="gap-6">
377
+
<AcceptableUsePage />
378
+
</Layout>
379
+
)
+35
public/acceptable-use/index.html
+35
public/acceptable-use/index.html
···
1
+
<!doctype html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="UTF-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
<title>Acceptable Use Policy - wisp.place</title>
7
+
<meta name="description" content="Acceptable Use Policy for wisp.place - Guidelines for hosting content on our decentralized static site hosting platform." />
8
+
9
+
<!-- Open Graph / Facebook -->
10
+
<meta property="og:type" content="website" />
11
+
<meta property="og:url" content="https://wisp.place/acceptable-use" />
12
+
<meta property="og:title" content="Acceptable Use Policy - wisp.place" />
13
+
<meta property="og:description" content="Acceptable Use Policy for wisp.place - Guidelines for hosting content on our decentralized static site hosting platform." />
14
+
<meta property="og:site_name" content="wisp.place" />
15
+
16
+
<!-- Twitter -->
17
+
<meta name="twitter:card" content="summary_large_image" />
18
+
<meta name="twitter:url" content="https://wisp.place/acceptable-use" />
19
+
<meta name="twitter:title" content="Acceptable Use Policy - wisp.place" />
20
+
<meta name="twitter:description" content="Acceptable Use Policy for wisp.place - Guidelines for hosting content on our decentralized static site hosting platform." />
21
+
22
+
<!-- Theme -->
23
+
<meta name="theme-color" content="#7c3aed" />
24
+
25
+
<link rel="icon" type="image/x-icon" href="../favicon.ico">
26
+
<link rel="icon" type="image/png" sizes="32x32" href="../favicon-32x32.png">
27
+
<link rel="icon" type="image/png" sizes="16x16" href="../favicon-16x16.png">
28
+
<link rel="apple-touch-icon" sizes="180x180" href="../apple-touch-icon.png">
29
+
<link rel="manifest" href="../site.webmanifest">
30
+
</head>
31
+
<body>
32
+
<div id="elysia"></div>
33
+
<script type="module" src="./acceptable-use.tsx"></script>
34
+
</body>
35
+
</html>
+7
-1
public/admin/index.html
+7
-1
public/admin/index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Admin Dashboard - Wisp.place</title>
6
+
<title>wisp.place</title>
7
+
<meta name="description" content="Admin dashboard for wisp.place decentralized static site hosting." />
8
+
<meta name="robots" content="noindex, nofollow" />
9
+
10
+
<!-- Theme -->
11
+
<meta name="theme-color" content="#7c3aed" />
12
+
7
13
<link rel="stylesheet" href="./styles.css" />
8
14
</head>
9
15
<body>
public/android-chrome-192x192.png
public/android-chrome-192x192.png
This is a binary file and will not be displayed.
public/android-chrome-512x512.png
public/android-chrome-512x512.png
This is a binary file and will not be displayed.
public/apple-touch-icon.png
public/apple-touch-icon.png
This is a binary file and will not be displayed.
+30
public/components/ui/checkbox.tsx
+30
public/components/ui/checkbox.tsx
···
1
+
import * as React from "react"
2
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3
+
import { CheckIcon } from "lucide-react"
4
+
5
+
import { cn } from "@public/lib/utils"
6
+
7
+
function Checkbox({
8
+
className,
9
+
...props
10
+
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
11
+
return (
12
+
<CheckboxPrimitive.Root
13
+
data-slot="checkbox"
14
+
className={cn(
15
+
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
16
+
className
17
+
)}
18
+
{...props}
19
+
>
20
+
<CheckboxPrimitive.Indicator
21
+
data-slot="checkbox-indicator"
22
+
className="grid place-content-center text-current transition-none"
23
+
>
24
+
<CheckIcon className="size-3.5" />
25
+
</CheckboxPrimitive.Indicator>
26
+
</CheckboxPrimitive.Root>
27
+
)
28
+
}
29
+
30
+
export { Checkbox }
+94
-13
public/components/ui/code-block.tsx
+94
-13
public/components/ui/code-block.tsx
···
1
-
import ShikiHighlighter from 'react-shiki'
1
+
import { useEffect, useRef, useState } from 'react'
2
+
3
+
declare global {
4
+
interface Window {
5
+
Prism: {
6
+
languages: Record<string, any>
7
+
highlightElement: (element: HTMLElement) => void
8
+
highlightAll: () => void
9
+
}
10
+
}
11
+
}
2
12
3
13
interface CodeBlockProps {
4
14
code: string
5
-
language?: string
15
+
language?: 'bash' | 'yaml'
6
16
className?: string
7
17
}
8
18
9
19
export function CodeBlock({ code, language = 'bash', className = '' }: CodeBlockProps) {
20
+
const [isThemeLoaded, setIsThemeLoaded] = useState(false)
21
+
const codeRef = useRef<HTMLElement>(null)
22
+
23
+
useEffect(() => {
24
+
// Load Catppuccin theme CSS
25
+
const loadTheme = async () => {
26
+
// Detect if user prefers dark mode
27
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
28
+
const theme = prefersDark ? 'mocha' : 'latte'
29
+
30
+
// Remove any existing theme CSS
31
+
const existingTheme = document.querySelector('link[data-prism-theme]')
32
+
if (existingTheme) {
33
+
existingTheme.remove()
34
+
}
35
+
36
+
// Load the appropriate Catppuccin theme
37
+
const link = document.createElement('link')
38
+
link.rel = 'stylesheet'
39
+
link.href = `https://prismjs.catppuccin.com/${theme}.css`
40
+
link.setAttribute('data-prism-theme', theme)
41
+
document.head.appendChild(link)
42
+
43
+
// Load PrismJS if not already loaded
44
+
if (!window.Prism) {
45
+
const script = document.createElement('script')
46
+
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js'
47
+
script.onload = () => {
48
+
// Load language support if needed
49
+
if (language === 'yaml' && !window.Prism.languages.yaml) {
50
+
const yamlScript = document.createElement('script')
51
+
yamlScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-yaml.min.js'
52
+
yamlScript.onload = () => setIsThemeLoaded(true)
53
+
document.head.appendChild(yamlScript)
54
+
} else {
55
+
setIsThemeLoaded(true)
56
+
}
57
+
}
58
+
document.head.appendChild(script)
59
+
} else {
60
+
setIsThemeLoaded(true)
61
+
}
62
+
}
63
+
64
+
loadTheme()
65
+
66
+
// Listen for theme changes
67
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
68
+
const handleThemeChange = () => loadTheme()
69
+
mediaQuery.addEventListener('change', handleThemeChange)
70
+
71
+
return () => {
72
+
mediaQuery.removeEventListener('change', handleThemeChange)
73
+
}
74
+
}, [language])
75
+
76
+
// Highlight code when Prism is loaded and component is mounted
77
+
useEffect(() => {
78
+
if (isThemeLoaded && codeRef.current && window.Prism) {
79
+
window.Prism.highlightElement(codeRef.current)
80
+
}
81
+
}, [isThemeLoaded, code])
82
+
83
+
if (!isThemeLoaded) {
84
+
return (
85
+
<pre className={`p-4 bg-muted rounded-lg overflow-x-auto ${className}`}>
86
+
<code>{code.trim()}</code>
87
+
</pre>
88
+
)
89
+
}
90
+
91
+
// Map language to Prism language class
92
+
const languageMap = {
93
+
'bash': 'language-bash',
94
+
'yaml': 'language-yaml'
95
+
}
96
+
97
+
const prismLanguage = languageMap[language] || 'language-bash'
98
+
10
99
return (
11
-
<ShikiHighlighter
12
-
language={language}
13
-
theme={{
14
-
light: 'catppuccin-latte',
15
-
dark: 'catppuccin-mocha',
16
-
}}
17
-
defaultColor="light-dark()"
18
-
className={className}
19
-
>
20
-
{code.trim()}
21
-
</ShikiHighlighter>
100
+
<pre className={`p-4 rounded-lg overflow-x-auto ${className}`}>
101
+
<code ref={codeRef} className={prismLanguage}>{code.trim()}</code>
102
+
</pre>
22
103
)
23
104
}
+65
public/editor/components/TabSkeleton.tsx
+65
public/editor/components/TabSkeleton.tsx
···
1
+
import {
2
+
Card,
3
+
CardContent,
4
+
CardDescription,
5
+
CardHeader,
6
+
CardTitle
7
+
} from '@public/components/ui/card'
8
+
9
+
// Shimmer animation for skeleton loading
10
+
const Shimmer = () => (
11
+
<div className="animate-pulse">
12
+
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
13
+
<div className="h-4 bg-muted rounded w-1/2"></div>
14
+
</div>
15
+
)
16
+
17
+
const SkeletonLine = ({ className = '' }: { className?: string }) => (
18
+
<div className={`animate-pulse bg-muted rounded ${className}`}></div>
19
+
)
20
+
21
+
export function TabSkeleton() {
22
+
return (
23
+
<div className="space-y-4 min-h-[400px]">
24
+
<Card>
25
+
<CardHeader>
26
+
<div className="space-y-2">
27
+
<SkeletonLine className="h-6 w-1/3" />
28
+
<SkeletonLine className="h-4 w-2/3" />
29
+
</div>
30
+
</CardHeader>
31
+
<CardContent className="space-y-4">
32
+
{/* Skeleton content items */}
33
+
<div className="p-4 border border-border rounded-lg">
34
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
35
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
36
+
<SkeletonLine className="h-4 w-2/3" />
37
+
</div>
38
+
<div className="p-4 border border-border rounded-lg">
39
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
40
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
41
+
<SkeletonLine className="h-4 w-2/3" />
42
+
</div>
43
+
<div className="p-4 border border-border rounded-lg">
44
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
45
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
46
+
<SkeletonLine className="h-4 w-2/3" />
47
+
</div>
48
+
</CardContent>
49
+
</Card>
50
+
51
+
<Card>
52
+
<CardHeader>
53
+
<div className="space-y-2">
54
+
<SkeletonLine className="h-6 w-1/4" />
55
+
<SkeletonLine className="h-4 w-1/2" />
56
+
</div>
57
+
</CardHeader>
58
+
<CardContent className="space-y-3">
59
+
<SkeletonLine className="h-10 w-full" />
60
+
<SkeletonLine className="h-4 w-3/4" />
61
+
</CardContent>
62
+
</Card>
63
+
</div>
64
+
)
65
+
}
+245
-1389
public/editor/editor.tsx
+245
-1389
public/editor/editor.tsx
···
2
2
import { createRoot } from 'react-dom/client'
3
3
import { Button } from '@public/components/ui/button'
4
4
import {
5
-
Card,
6
-
CardContent,
7
-
CardDescription,
8
-
CardHeader,
9
-
CardTitle
10
-
} from '@public/components/ui/card'
11
-
import { Input } from '@public/components/ui/input'
12
-
import { Label } from '@public/components/ui/label'
13
-
import {
14
5
Tabs,
15
6
TabsContent,
16
7
TabsList,
17
8
TabsTrigger
18
9
} from '@public/components/ui/tabs'
19
-
import { Badge } from '@public/components/ui/badge'
20
10
import {
21
11
Dialog,
22
12
DialogContent,
···
25
15
DialogTitle,
26
16
DialogFooter
27
17
} from '@public/components/ui/dialog'
18
+
import { Checkbox } from '@public/components/ui/checkbox'
19
+
import { Label } from '@public/components/ui/label'
20
+
import { Badge } from '@public/components/ui/badge'
28
21
import {
29
-
Globe,
30
-
Upload,
31
-
ExternalLink,
32
-
CheckCircle2,
33
-
XCircle,
34
-
AlertCircle,
35
22
Loader2,
36
23
Trash2,
37
-
RefreshCw,
38
-
Settings
24
+
LogOut
39
25
} from 'lucide-react'
40
-
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
41
-
import { CodeBlock } from '@public/components/ui/code-block'
42
-
43
26
import Layout from '@public/layouts'
44
-
45
-
interface UserInfo {
46
-
did: string
47
-
handle: string
48
-
}
49
-
50
-
interface Site {
51
-
did: string
52
-
rkey: string
53
-
display_name: string | null
54
-
created_at: number
55
-
updated_at: number
56
-
}
57
-
58
-
interface CustomDomain {
59
-
id: string
60
-
domain: string
61
-
did: string
62
-
rkey: string
63
-
verified: boolean
64
-
last_verified_at: number | null
65
-
created_at: number
66
-
}
67
-
68
-
interface WispDomain {
69
-
domain: string
70
-
rkey: string | null
71
-
}
27
+
import { useUserInfo } from './hooks/useUserInfo'
28
+
import { useSiteData, type SiteWithDomains } from './hooks/useSiteData'
29
+
import { useDomainData } from './hooks/useDomainData'
30
+
import { SitesTab } from './tabs/SitesTab'
31
+
import { DomainsTab } from './tabs/DomainsTab'
32
+
import { UploadTab } from './tabs/UploadTab'
33
+
import { CLITab } from './tabs/CLITab'
72
34
73
35
function Dashboard() {
74
-
// User state
75
-
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
76
-
const [loading, setLoading] = useState(true)
36
+
// Use custom hooks
37
+
const { userInfo, loading, fetchUserInfo } = useUserInfo()
38
+
const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData()
39
+
const {
40
+
wispDomains,
41
+
customDomains,
42
+
domainsLoading,
43
+
verificationStatus,
44
+
fetchDomains,
45
+
addCustomDomain,
46
+
verifyDomain,
47
+
deleteCustomDomain,
48
+
mapWispDomain,
49
+
deleteWispDomain,
50
+
mapCustomDomain,
51
+
claimWispDomain,
52
+
checkWispAvailability
53
+
} = useDomainData()
77
54
78
-
// Sites state
79
-
const [sites, setSites] = useState<Site[]>([])
80
-
const [sitesLoading, setSitesLoading] = useState(true)
81
-
const [isSyncing, setIsSyncing] = useState(false)
82
-
83
-
// Domains state
84
-
const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
85
-
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
86
-
const [domainsLoading, setDomainsLoading] = useState(true)
87
-
88
-
// Site configuration state
89
-
const [configuringSite, setConfiguringSite] = useState<Site | null>(null)
90
-
const [selectedDomain, setSelectedDomain] = useState<string>('')
55
+
// Site configuration modal state (shared across components)
56
+
const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null)
57
+
const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set())
91
58
const [isSavingConfig, setIsSavingConfig] = useState(false)
92
59
const [isDeletingSite, setIsDeletingSite] = useState(false)
93
60
94
-
// Upload state
95
-
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
96
-
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
97
-
const [newSiteName, setNewSiteName] = useState('')
98
-
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
99
-
const [isUploading, setIsUploading] = useState(false)
100
-
const [uploadProgress, setUploadProgress] = useState('')
101
-
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
102
-
const [uploadedCount, setUploadedCount] = useState(0)
103
-
104
-
// Custom domain modal state
105
-
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
106
-
const [customDomain, setCustomDomain] = useState('')
107
-
const [isAddingDomain, setIsAddingDomain] = useState(false)
108
-
const [verificationStatus, setVerificationStatus] = useState<{
109
-
[id: string]: 'idle' | 'verifying' | 'success' | 'error'
110
-
}>({})
111
-
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
112
-
113
-
// Wisp domain claim state
114
-
const [wispHandle, setWispHandle] = useState('')
115
-
const [isClaimingWisp, setIsClaimingWisp] = useState(false)
116
-
const [wispAvailability, setWispAvailability] = useState<{
117
-
available: boolean | null
118
-
checking: boolean
119
-
}>({ available: null, checking: false })
120
-
121
-
// Fetch user info on mount
61
+
// Fetch initial data on mount
122
62
useEffect(() => {
123
63
fetchUserInfo()
124
64
fetchSites()
125
65
fetchDomains()
126
66
}, [])
127
67
128
-
// Auto-switch to 'new' mode if no sites exist
129
-
useEffect(() => {
130
-
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
131
-
setSiteMode('new')
132
-
}
133
-
}, [sites, sitesLoading, siteMode])
68
+
// Handle site configuration modal
69
+
const handleConfigureSite = (site: SiteWithDomains) => {
70
+
setConfiguringSite(site)
134
71
135
-
const fetchUserInfo = async () => {
136
-
try {
137
-
const response = await fetch('/api/user/info')
138
-
const data = await response.json()
139
-
setUserInfo(data)
140
-
} catch (err) {
141
-
console.error('Failed to fetch user info:', err)
142
-
} finally {
143
-
setLoading(false)
144
-
}
145
-
}
146
-
147
-
const fetchSites = async () => {
148
-
try {
149
-
const response = await fetch('/api/user/sites')
150
-
const data = await response.json()
151
-
setSites(data.sites || [])
152
-
} catch (err) {
153
-
console.error('Failed to fetch sites:', err)
154
-
} finally {
155
-
setSitesLoading(false)
156
-
}
157
-
}
72
+
// Build set of currently mapped domains
73
+
const mappedDomains = new Set<string>()
158
74
159
-
const syncSites = async () => {
160
-
setIsSyncing(true)
161
-
try {
162
-
const response = await fetch('/api/user/sync', {
163
-
method: 'POST'
75
+
if (site.domains) {
76
+
site.domains.forEach(domainInfo => {
77
+
if (domainInfo.type === 'wisp') {
78
+
// For wisp domains, use the domain itself as the identifier
79
+
mappedDomains.add(`wisp:${domainInfo.domain}`)
80
+
} else if (domainInfo.id) {
81
+
mappedDomains.add(domainInfo.id)
82
+
}
164
83
})
165
-
const data = await response.json()
166
-
if (data.success) {
167
-
console.log(`Synced ${data.synced} sites from PDS`)
168
-
// Refresh sites list
169
-
await fetchSites()
170
-
}
171
-
} catch (err) {
172
-
console.error('Failed to sync sites:', err)
173
-
alert('Failed to sync sites from PDS')
174
-
} finally {
175
-
setIsSyncing(false)
176
84
}
177
-
}
178
85
179
-
const fetchDomains = async () => {
180
-
try {
181
-
const response = await fetch('/api/user/domains')
182
-
const data = await response.json()
183
-
setWispDomain(data.wispDomain)
184
-
setCustomDomains(data.customDomains || [])
185
-
} catch (err) {
186
-
console.error('Failed to fetch domains:', err)
187
-
} finally {
188
-
setDomainsLoading(false)
189
-
}
86
+
setSelectedDomains(mappedDomains)
190
87
}
191
88
192
-
const getSiteUrl = (site: Site) => {
193
-
// Check if this site is mapped to the wisp.place domain
194
-
if (wispDomain && wispDomain.rkey === site.rkey) {
195
-
return `https://${wispDomain.domain}`
196
-
}
89
+
const handleSaveSiteConfig = async () => {
90
+
if (!configuringSite) return
197
91
198
-
// Check if this site is mapped to any custom domain
199
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
200
-
if (customDomain) {
201
-
return `https://${customDomain.domain}`
202
-
}
203
-
204
-
// Default fallback URL
205
-
if (!userInfo) return '#'
206
-
return `https://sites.wisp.place/${site.did}/${site.rkey}`
207
-
}
208
-
209
-
const getSiteDomainName = (site: Site) => {
210
-
if (wispDomain && wispDomain.rkey === site.rkey) {
211
-
return wispDomain.domain
212
-
}
213
-
214
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
215
-
if (customDomain) {
216
-
return customDomain.domain
217
-
}
218
-
219
-
return `sites.wisp.place/${site.did}/${site.rkey}`
220
-
}
221
-
222
-
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
223
-
if (e.target.files && e.target.files.length > 0) {
224
-
setSelectedFiles(e.target.files)
225
-
}
226
-
}
227
-
228
-
const handleUpload = async () => {
229
-
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
230
-
231
-
if (!siteName) {
232
-
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
233
-
return
234
-
}
235
-
236
-
setIsUploading(true)
237
-
setUploadProgress('Preparing files...')
238
-
92
+
setIsSavingConfig(true)
239
93
try {
240
-
const formData = new FormData()
241
-
formData.append('siteName', siteName)
242
-
243
-
if (selectedFiles) {
244
-
for (let i = 0; i < selectedFiles.length; i++) {
245
-
formData.append('files', selectedFiles[i])
246
-
}
247
-
}
248
-
249
-
setUploadProgress('Uploading to AT Protocol...')
250
-
const response = await fetch('/wisp/upload-files', {
251
-
method: 'POST',
252
-
body: formData
253
-
})
254
-
255
-
const data = await response.json()
256
-
if (data.success) {
257
-
setUploadProgress('Upload complete!')
258
-
setSkippedFiles(data.skippedFiles || [])
259
-
setUploadedCount(data.uploadedCount || data.fileCount || 0)
260
-
setSelectedSiteRkey('')
261
-
setNewSiteName('')
262
-
setSelectedFiles(null)
94
+
// Handle wisp domain mappings
95
+
const selectedWispDomainIds = Array.from(selectedDomains).filter(id => id.startsWith('wisp:'))
96
+
const selectedWispDomains = selectedWispDomainIds.map(id => id.replace('wisp:', ''))
263
97
264
-
// Refresh sites list
265
-
await fetchSites()
266
-
267
-
// Reset form - give more time if there are skipped files
268
-
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
269
-
setTimeout(() => {
270
-
setUploadProgress('')
271
-
setSkippedFiles([])
272
-
setUploadedCount(0)
273
-
setIsUploading(false)
274
-
}, resetDelay)
275
-
} else {
276
-
throw new Error(data.error || 'Upload failed')
277
-
}
278
-
} catch (err) {
279
-
console.error('Upload error:', err)
280
-
alert(
281
-
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
98
+
// Get currently mapped wisp domains
99
+
const currentlyMappedWispDomains = wispDomains.filter(
100
+
d => d.rkey === configuringSite.rkey
282
101
)
283
-
setIsUploading(false)
284
-
setUploadProgress('')
285
-
}
286
-
}
287
102
288
-
const handleAddCustomDomain = async () => {
289
-
if (!customDomain) {
290
-
alert('Please enter a domain')
291
-
return
292
-
}
293
-
294
-
setIsAddingDomain(true)
295
-
try {
296
-
const response = await fetch('/api/domain/custom/add', {
297
-
method: 'POST',
298
-
headers: { 'Content-Type': 'application/json' },
299
-
body: JSON.stringify({ domain: customDomain })
300
-
})
301
-
302
-
const data = await response.json()
303
-
if (data.success) {
304
-
setCustomDomain('')
305
-
setAddDomainModalOpen(false)
306
-
await fetchDomains()
307
-
308
-
// Automatically show DNS configuration for the newly added domain
309
-
setViewDomainDNS(data.id)
310
-
} else {
311
-
throw new Error(data.error || 'Failed to add domain')
103
+
// Unmap wisp domains that are no longer selected
104
+
for (const domain of currentlyMappedWispDomains) {
105
+
if (!selectedWispDomains.includes(domain.domain)) {
106
+
await mapWispDomain(domain.domain, null)
107
+
}
312
108
}
313
-
} catch (err) {
314
-
console.error('Add domain error:', err)
315
-
alert(
316
-
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
317
-
)
318
-
} finally {
319
-
setIsAddingDomain(false)
320
-
}
321
-
}
322
109
323
-
const handleVerifyDomain = async (id: string) => {
324
-
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
325
-
326
-
try {
327
-
const response = await fetch('/api/domain/custom/verify', {
328
-
method: 'POST',
329
-
headers: { 'Content-Type': 'application/json' },
330
-
body: JSON.stringify({ id })
331
-
})
332
-
333
-
const data = await response.json()
334
-
if (data.success && data.verified) {
335
-
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
336
-
await fetchDomains()
337
-
} else {
338
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
339
-
if (data.error) {
340
-
alert(`Verification failed: ${data.error}`)
110
+
// Map newly selected wisp domains
111
+
for (const domainName of selectedWispDomains) {
112
+
const isAlreadyMapped = currentlyMappedWispDomains.some(d => d.domain === domainName)
113
+
if (!isAlreadyMapped) {
114
+
await mapWispDomain(domainName, configuringSite.rkey)
341
115
}
342
116
}
343
-
} catch (err) {
344
-
console.error('Verify domain error:', err)
345
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
346
-
alert(
347
-
`Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
348
-
)
349
-
}
350
-
}
351
-
352
-
const handleDeleteCustomDomain = async (id: string) => {
353
-
if (!confirm('Are you sure you want to remove this custom domain?')) {
354
-
return
355
-
}
356
117
357
-
try {
358
-
const response = await fetch(`/api/domain/custom/${id}`, {
359
-
method: 'DELETE'
360
-
})
361
-
362
-
const data = await response.json()
363
-
if (data.success) {
364
-
await fetchDomains()
365
-
} else {
366
-
throw new Error('Failed to delete domain')
367
-
}
368
-
} catch (err) {
369
-
console.error('Delete domain error:', err)
370
-
alert(
371
-
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
118
+
// Handle custom domain mappings
119
+
const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => !id.startsWith('wisp:'))
120
+
const currentlyMappedCustomDomains = customDomains.filter(
121
+
d => d.rkey === configuringSite.rkey
372
122
)
373
-
}
374
-
}
375
123
376
-
const handleConfigureSite = (site: Site) => {
377
-
setConfiguringSite(site)
378
-
379
-
// Determine current domain mapping
380
-
if (wispDomain && wispDomain.rkey === site.rkey) {
381
-
setSelectedDomain('wisp')
382
-
} else {
383
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
384
-
if (customDomain) {
385
-
setSelectedDomain(customDomain.id)
386
-
} else {
387
-
setSelectedDomain('none')
124
+
// Unmap domains that are no longer selected
125
+
for (const domain of currentlyMappedCustomDomains) {
126
+
if (!selectedCustomDomainIds.includes(domain.id)) {
127
+
await mapCustomDomain(domain.id, null)
128
+
}
388
129
}
389
-
}
390
-
}
391
130
392
-
const handleSaveSiteConfig = async () => {
393
-
if (!configuringSite) return
394
-
395
-
setIsSavingConfig(true)
396
-
try {
397
-
if (selectedDomain === 'wisp') {
398
-
// Map to wisp.place domain
399
-
const response = await fetch('/api/domain/wisp/map-site', {
400
-
method: 'POST',
401
-
headers: { 'Content-Type': 'application/json' },
402
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
403
-
})
404
-
const data = await response.json()
405
-
if (!data.success) throw new Error('Failed to map site')
406
-
} else if (selectedDomain === 'none') {
407
-
// Unmap from all domains
408
-
// Unmap wisp domain if this site was mapped to it
409
-
if (wispDomain && wispDomain.rkey === configuringSite.rkey) {
410
-
await fetch('/api/domain/wisp/map-site', {
411
-
method: 'POST',
412
-
headers: { 'Content-Type': 'application/json' },
413
-
body: JSON.stringify({ siteRkey: null })
414
-
})
131
+
// Map newly selected domains
132
+
for (const domainId of selectedCustomDomainIds) {
133
+
const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
134
+
if (!isAlreadyMapped) {
135
+
await mapCustomDomain(domainId, configuringSite.rkey)
415
136
}
416
-
417
-
// Unmap from custom domains
418
-
const mappedCustom = customDomains.find(
419
-
(d) => d.rkey === configuringSite.rkey
420
-
)
421
-
if (mappedCustom) {
422
-
await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, {
423
-
method: 'POST',
424
-
headers: { 'Content-Type': 'application/json' },
425
-
body: JSON.stringify({ siteRkey: null })
426
-
})
427
-
}
428
-
} else {
429
-
// Map to a custom domain
430
-
const response = await fetch(
431
-
`/api/domain/custom/${selectedDomain}/map-site`,
432
-
{
433
-
method: 'POST',
434
-
headers: { 'Content-Type': 'application/json' },
435
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
436
-
}
437
-
)
438
-
const data = await response.json()
439
-
if (!data.success) throw new Error('Failed to map site')
440
137
}
441
138
442
-
// Refresh domains to get updated mappings
139
+
// Refresh both domains and sites to get updated mappings
443
140
await fetchDomains()
141
+
await fetchSites()
444
142
setConfiguringSite(null)
445
143
} catch (err) {
446
144
console.error('Save config error:', err)
···
460
158
}
461
159
462
160
setIsDeletingSite(true)
463
-
try {
464
-
const response = await fetch(`/api/site/${configuringSite.rkey}`, {
465
-
method: 'DELETE'
466
-
})
467
-
468
-
const data = await response.json()
469
-
if (data.success) {
470
-
// Refresh sites list
471
-
await fetchSites()
472
-
// Refresh domains in case this site was mapped
473
-
await fetchDomains()
474
-
setConfiguringSite(null)
475
-
} else {
476
-
throw new Error(data.error || 'Failed to delete site')
477
-
}
478
-
} catch (err) {
479
-
console.error('Delete site error:', err)
480
-
alert(
481
-
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
482
-
)
483
-
} finally {
484
-
setIsDeletingSite(false)
161
+
const success = await deleteSite(configuringSite.rkey)
162
+
if (success) {
163
+
// Refresh domains in case this site was mapped
164
+
await fetchDomains()
165
+
setConfiguringSite(null)
485
166
}
167
+
setIsDeletingSite(false)
486
168
}
487
169
488
-
const checkWispAvailability = async (handle: string) => {
489
-
const trimmedHandle = handle.trim().toLowerCase()
490
-
if (!trimmedHandle) {
491
-
setWispAvailability({ available: null, checking: false })
492
-
return
493
-
}
494
-
495
-
setWispAvailability({ available: null, checking: true })
496
-
try {
497
-
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
498
-
const data = await response.json()
499
-
setWispAvailability({ available: data.available, checking: false })
500
-
} catch (err) {
501
-
console.error('Check availability error:', err)
502
-
setWispAvailability({ available: false, checking: false })
503
-
}
170
+
const handleUploadComplete = async () => {
171
+
await fetchSites()
504
172
}
505
173
506
-
const handleClaimWispDomain = async () => {
507
-
const trimmedHandle = wispHandle.trim().toLowerCase()
508
-
if (!trimmedHandle) {
509
-
alert('Please enter a handle')
510
-
return
511
-
}
512
-
513
-
setIsClaimingWisp(true)
174
+
const handleLogout = async () => {
514
175
try {
515
-
const response = await fetch('/api/domain/claim', {
176
+
const response = await fetch('/api/auth/logout', {
516
177
method: 'POST',
517
-
headers: { 'Content-Type': 'application/json' },
518
-
body: JSON.stringify({ handle: trimmedHandle })
178
+
credentials: 'include'
519
179
})
520
-
521
-
const data = await response.json()
522
-
if (data.success) {
523
-
setWispHandle('')
524
-
setWispAvailability({ available: null, checking: false })
525
-
await fetchDomains()
180
+
const result = await response.json()
181
+
if (result.success) {
182
+
// Redirect to home page after successful logout
183
+
window.location.href = '/'
526
184
} else {
527
-
throw new Error(data.error || 'Failed to claim domain')
185
+
alert('Logout failed: ' + (result.error || 'Unknown error'))
528
186
}
529
187
} catch (err) {
530
-
console.error('Claim domain error:', err)
531
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
532
-
533
-
// Handle "Already claimed" error more gracefully
534
-
if (errorMessage.includes('Already claimed')) {
535
-
alert('You have already claimed a wisp.place subdomain. Please refresh the page.')
536
-
await fetchDomains()
537
-
} else {
538
-
alert(`Failed to claim domain: ${errorMessage}`)
539
-
}
540
-
} finally {
541
-
setIsClaimingWisp(false)
188
+
alert('Logout failed: ' + (err instanceof Error ? err.message : 'Unknown error'))
542
189
}
543
190
}
544
191
···
556
203
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
557
204
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
558
205
<div className="flex items-center gap-2">
559
-
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
560
-
<Globe className="w-5 h-5 text-primary-foreground" />
561
-
</div>
206
+
<img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" />
562
207
<span className="text-xl font-semibold text-foreground">
563
208
wisp.place
564
209
</span>
···
567
212
<span className="text-sm text-muted-foreground">
568
213
{userInfo?.handle || 'Loading...'}
569
214
</span>
215
+
<Button
216
+
variant="ghost"
217
+
size="sm"
218
+
onClick={handleLogout}
219
+
className="h-8 px-2"
220
+
>
221
+
<LogOut className="w-4 h-4" />
222
+
</Button>
570
223
</div>
571
224
</div>
572
225
</header>
···
588
241
</TabsList>
589
242
590
243
{/* Sites Tab */}
591
-
<TabsContent value="sites" className="space-y-4 min-h-[400px]">
592
-
<Card>
593
-
<CardHeader>
594
-
<div className="flex items-center justify-between">
595
-
<div>
596
-
<CardTitle>Your Sites</CardTitle>
597
-
<CardDescription>
598
-
View and manage all your deployed sites
599
-
</CardDescription>
600
-
</div>
601
-
<Button
602
-
variant="outline"
603
-
size="sm"
604
-
onClick={syncSites}
605
-
disabled={isSyncing || sitesLoading}
606
-
>
607
-
<RefreshCw
608
-
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
609
-
/>
610
-
Sync from PDS
611
-
</Button>
612
-
</div>
613
-
</CardHeader>
614
-
<CardContent className="space-y-4">
615
-
{sitesLoading ? (
616
-
<div className="flex items-center justify-center py-8">
617
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
618
-
</div>
619
-
) : sites.length === 0 ? (
620
-
<div className="text-center py-8 text-muted-foreground">
621
-
<p>No sites yet. Upload your first site!</p>
622
-
</div>
623
-
) : (
624
-
sites.map((site) => (
625
-
<div
626
-
key={`${site.did}-${site.rkey}`}
627
-
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
628
-
>
629
-
<div className="flex-1">
630
-
<div className="flex items-center gap-3 mb-2">
631
-
<h3 className="font-semibold text-lg">
632
-
{site.display_name || site.rkey}
633
-
</h3>
634
-
<Badge
635
-
variant="secondary"
636
-
className="text-xs"
637
-
>
638
-
active
639
-
</Badge>
640
-
</div>
641
-
<a
642
-
href={getSiteUrl(site)}
643
-
target="_blank"
644
-
rel="noopener noreferrer"
645
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
646
-
>
647
-
{getSiteDomainName(site)}
648
-
<ExternalLink className="w-3 h-3" />
649
-
</a>
650
-
</div>
651
-
<Button
652
-
variant="outline"
653
-
size="sm"
654
-
onClick={() => handleConfigureSite(site)}
655
-
>
656
-
<Settings className="w-4 h-4 mr-2" />
657
-
Configure
658
-
</Button>
659
-
</div>
660
-
))
661
-
)}
662
-
</CardContent>
663
-
</Card>
244
+
<TabsContent value="sites">
245
+
<SitesTab
246
+
sites={sites}
247
+
sitesLoading={sitesLoading}
248
+
isSyncing={isSyncing}
249
+
userInfo={userInfo}
250
+
onSyncSites={syncSites}
251
+
onConfigureSite={handleConfigureSite}
252
+
/>
664
253
</TabsContent>
665
254
666
255
{/* Domains Tab */}
667
-
<TabsContent value="domains" className="space-y-4 min-h-[400px]">
668
-
<Card>
669
-
<CardHeader>
670
-
<CardTitle>wisp.place Subdomain</CardTitle>
671
-
<CardDescription>
672
-
Your free subdomain on the wisp.place network
673
-
</CardDescription>
674
-
</CardHeader>
675
-
<CardContent>
676
-
{domainsLoading ? (
677
-
<div className="flex items-center justify-center py-4">
678
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
679
-
</div>
680
-
) : wispDomain ? (
681
-
<>
682
-
<div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
683
-
<div className="flex items-center gap-2">
684
-
<CheckCircle2 className="w-5 h-5 text-green-500" />
685
-
<span className="font-mono text-lg">
686
-
{wispDomain.domain}
687
-
</span>
688
-
</div>
689
-
{wispDomain.rkey && (
690
-
<p className="text-xs text-muted-foreground ml-7">
691
-
โ Mapped to site: {wispDomain.rkey}
692
-
</p>
693
-
)}
694
-
</div>
695
-
<p className="text-sm text-muted-foreground mt-3">
696
-
{wispDomain.rkey
697
-
? 'This domain is mapped to a specific site'
698
-
: 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}
699
-
</p>
700
-
</>
701
-
) : (
702
-
<div className="space-y-4">
703
-
<div className="p-4 bg-muted/30 rounded-lg">
704
-
<p className="text-sm text-muted-foreground mb-4">
705
-
Claim your free wisp.place subdomain
706
-
</p>
707
-
<div className="space-y-3">
708
-
<div className="space-y-2">
709
-
<Label htmlFor="wisp-handle">Choose your handle</Label>
710
-
<div className="flex gap-2">
711
-
<div className="flex-1 relative">
712
-
<Input
713
-
id="wisp-handle"
714
-
placeholder="mysite"
715
-
value={wispHandle}
716
-
onChange={(e) => {
717
-
setWispHandle(e.target.value)
718
-
if (e.target.value.trim()) {
719
-
checkWispAvailability(e.target.value)
720
-
} else {
721
-
setWispAvailability({ available: null, checking: false })
722
-
}
723
-
}}
724
-
disabled={isClaimingWisp}
725
-
className="pr-24"
726
-
/>
727
-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
728
-
.wisp.place
729
-
</span>
730
-
</div>
731
-
</div>
732
-
{wispAvailability.checking && (
733
-
<p className="text-xs text-muted-foreground flex items-center gap-1">
734
-
<Loader2 className="w-3 h-3 animate-spin" />
735
-
Checking availability...
736
-
</p>
737
-
)}
738
-
{!wispAvailability.checking && wispAvailability.available === true && (
739
-
<p className="text-xs text-green-600 flex items-center gap-1">
740
-
<CheckCircle2 className="w-3 h-3" />
741
-
Available
742
-
</p>
743
-
)}
744
-
{!wispAvailability.checking && wispAvailability.available === false && (
745
-
<p className="text-xs text-red-600 flex items-center gap-1">
746
-
<XCircle className="w-3 h-3" />
747
-
Not available
748
-
</p>
749
-
)}
750
-
</div>
751
-
<Button
752
-
onClick={handleClaimWispDomain}
753
-
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
754
-
className="w-full"
755
-
>
756
-
{isClaimingWisp ? (
757
-
<>
758
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
759
-
Claiming...
760
-
</>
761
-
) : (
762
-
'Claim Subdomain'
763
-
)}
764
-
</Button>
765
-
</div>
766
-
</div>
767
-
</div>
768
-
)}
769
-
</CardContent>
770
-
</Card>
771
-
772
-
<Card>
773
-
<CardHeader>
774
-
<CardTitle>Custom Domains</CardTitle>
775
-
<CardDescription>
776
-
Bring your own domain with DNS verification
777
-
</CardDescription>
778
-
</CardHeader>
779
-
<CardContent className="space-y-4">
780
-
<Button
781
-
onClick={() => setAddDomainModalOpen(true)}
782
-
className="w-full"
783
-
>
784
-
Add Custom Domain
785
-
</Button>
786
-
787
-
{domainsLoading ? (
788
-
<div className="flex items-center justify-center py-4">
789
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
790
-
</div>
791
-
) : customDomains.length === 0 ? (
792
-
<div className="text-center py-4 text-muted-foreground text-sm">
793
-
No custom domains added yet
794
-
</div>
795
-
) : (
796
-
<div className="space-y-2">
797
-
{customDomains.map((domain) => (
798
-
<div
799
-
key={domain.id}
800
-
className="flex items-center justify-between p-3 border border-border rounded-lg"
801
-
>
802
-
<div className="flex flex-col gap-1 flex-1">
803
-
<div className="flex items-center gap-2">
804
-
{domain.verified ? (
805
-
<CheckCircle2 className="w-4 h-4 text-green-500" />
806
-
) : (
807
-
<XCircle className="w-4 h-4 text-red-500" />
808
-
)}
809
-
<span className="font-mono">
810
-
{domain.domain}
811
-
</span>
812
-
</div>
813
-
{domain.rkey && domain.rkey !== 'self' && (
814
-
<p className="text-xs text-muted-foreground ml-6">
815
-
โ Mapped to site: {domain.rkey}
816
-
</p>
817
-
)}
818
-
</div>
819
-
<div className="flex items-center gap-2">
820
-
<Button
821
-
variant="outline"
822
-
size="sm"
823
-
onClick={() =>
824
-
setViewDomainDNS(domain.id)
825
-
}
826
-
>
827
-
View DNS
828
-
</Button>
829
-
{domain.verified ? (
830
-
<Badge variant="secondary">
831
-
Verified
832
-
</Badge>
833
-
) : (
834
-
<Button
835
-
variant="outline"
836
-
size="sm"
837
-
onClick={() =>
838
-
handleVerifyDomain(domain.id)
839
-
}
840
-
disabled={
841
-
verificationStatus[
842
-
domain.id
843
-
] === 'verifying'
844
-
}
845
-
>
846
-
{verificationStatus[
847
-
domain.id
848
-
] === 'verifying' ? (
849
-
<>
850
-
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
851
-
Verifying...
852
-
</>
853
-
) : (
854
-
'Verify DNS'
855
-
)}
856
-
</Button>
857
-
)}
858
-
<Button
859
-
variant="ghost"
860
-
size="sm"
861
-
onClick={() =>
862
-
handleDeleteCustomDomain(
863
-
domain.id
864
-
)
865
-
}
866
-
>
867
-
<Trash2 className="w-4 h-4" />
868
-
</Button>
869
-
</div>
870
-
</div>
871
-
))}
872
-
</div>
873
-
)}
874
-
</CardContent>
875
-
</Card>
256
+
<TabsContent value="domains">
257
+
<DomainsTab
258
+
wispDomains={wispDomains}
259
+
customDomains={customDomains}
260
+
domainsLoading={domainsLoading}
261
+
verificationStatus={verificationStatus}
262
+
userInfo={userInfo}
263
+
onAddCustomDomain={addCustomDomain}
264
+
onVerifyDomain={verifyDomain}
265
+
onDeleteCustomDomain={deleteCustomDomain}
266
+
onDeleteWispDomain={deleteWispDomain}
267
+
onClaimWispDomain={claimWispDomain}
268
+
onCheckWispAvailability={checkWispAvailability}
269
+
/>
876
270
</TabsContent>
877
271
878
272
{/* Upload Tab */}
879
-
<TabsContent value="upload" className="space-y-4 min-h-[400px]">
880
-
<Card>
881
-
<CardHeader>
882
-
<CardTitle>Upload Site</CardTitle>
883
-
<CardDescription>
884
-
Deploy a new site from a folder or Git repository
885
-
</CardDescription>
886
-
</CardHeader>
887
-
<CardContent className="space-y-6">
888
-
<div className="space-y-4">
889
-
<div className="p-4 bg-muted/50 rounded-lg">
890
-
<RadioGroup
891
-
value={siteMode}
892
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
893
-
disabled={isUploading}
894
-
>
895
-
<div className="flex items-center space-x-2">
896
-
<RadioGroupItem value="existing" id="existing" />
897
-
<Label htmlFor="existing" className="cursor-pointer">
898
-
Update existing site
899
-
</Label>
900
-
</div>
901
-
<div className="flex items-center space-x-2">
902
-
<RadioGroupItem value="new" id="new" />
903
-
<Label htmlFor="new" className="cursor-pointer">
904
-
Create new site
905
-
</Label>
906
-
</div>
907
-
</RadioGroup>
908
-
</div>
909
-
910
-
{siteMode === 'existing' ? (
911
-
<div className="space-y-2">
912
-
<Label htmlFor="site-select">Select Site</Label>
913
-
{sitesLoading ? (
914
-
<div className="flex items-center justify-center py-4">
915
-
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
916
-
</div>
917
-
) : sites.length === 0 ? (
918
-
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
919
-
No sites available. Create a new site instead.
920
-
</div>
921
-
) : (
922
-
<select
923
-
id="site-select"
924
-
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
925
-
value={selectedSiteRkey}
926
-
onChange={(e) => setSelectedSiteRkey(e.target.value)}
927
-
disabled={isUploading}
928
-
>
929
-
<option value="">Select a site...</option>
930
-
{sites.map((site) => (
931
-
<option key={site.rkey} value={site.rkey}>
932
-
{site.display_name || site.rkey}
933
-
</option>
934
-
))}
935
-
</select>
936
-
)}
937
-
</div>
938
-
) : (
939
-
<div className="space-y-2">
940
-
<Label htmlFor="new-site-name">New Site Name</Label>
941
-
<Input
942
-
id="new-site-name"
943
-
placeholder="my-awesome-site"
944
-
value={newSiteName}
945
-
onChange={(e) => setNewSiteName(e.target.value)}
946
-
disabled={isUploading}
947
-
/>
948
-
</div>
949
-
)}
950
-
951
-
<p className="text-xs text-muted-foreground">
952
-
File limits: 100MB per file, 300MB total
953
-
</p>
954
-
</div>
955
-
956
-
<div className="grid md:grid-cols-2 gap-4">
957
-
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
958
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
959
-
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
960
-
<h3 className="font-semibold mb-2">
961
-
Upload Folder
962
-
</h3>
963
-
<p className="text-sm text-muted-foreground mb-4">
964
-
Drag and drop or click to upload your
965
-
static site files
966
-
</p>
967
-
<input
968
-
type="file"
969
-
id="file-upload"
970
-
multiple
971
-
onChange={handleFileSelect}
972
-
className="hidden"
973
-
{...(({ webkitdirectory: '', directory: '' } as any))}
974
-
disabled={isUploading}
975
-
/>
976
-
<label htmlFor="file-upload">
977
-
<Button
978
-
variant="outline"
979
-
type="button"
980
-
onClick={() =>
981
-
document
982
-
.getElementById('file-upload')
983
-
?.click()
984
-
}
985
-
disabled={isUploading}
986
-
>
987
-
Choose Folder
988
-
</Button>
989
-
</label>
990
-
{selectedFiles && selectedFiles.length > 0 && (
991
-
<p className="text-sm text-muted-foreground mt-3">
992
-
{selectedFiles.length} files selected
993
-
</p>
994
-
)}
995
-
</CardContent>
996
-
</Card>
997
-
998
-
<Card className="border-2 border-dashed opacity-50">
999
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
1000
-
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
1001
-
<h3 className="font-semibold mb-2">
1002
-
Connect Git Repository
1003
-
</h3>
1004
-
<p className="text-sm text-muted-foreground mb-4">
1005
-
Link your GitHub, GitLab, or any Git
1006
-
repository
1007
-
</p>
1008
-
<Badge variant="secondary">Coming soon!</Badge>
1009
-
</CardContent>
1010
-
</Card>
1011
-
</div>
1012
-
1013
-
{uploadProgress && (
1014
-
<div className="space-y-3">
1015
-
<div className="p-4 bg-muted rounded-lg">
1016
-
<div className="flex items-center gap-2">
1017
-
<Loader2 className="w-4 h-4 animate-spin" />
1018
-
<span className="text-sm">{uploadProgress}</span>
1019
-
</div>
1020
-
</div>
1021
-
1022
-
{skippedFiles.length > 0 && (
1023
-
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
1024
-
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
1025
-
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
1026
-
<div className="flex-1">
1027
-
<span className="font-medium">
1028
-
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
1029
-
</span>
1030
-
{uploadedCount > 0 && (
1031
-
<span className="text-sm ml-2">
1032
-
({uploadedCount} uploaded successfully)
1033
-
</span>
1034
-
)}
1035
-
</div>
1036
-
</div>
1037
-
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
1038
-
{skippedFiles.slice(0, 5).map((file, idx) => (
1039
-
<div key={idx} className="text-xs">
1040
-
<span className="font-mono">{file.name}</span>
1041
-
<span className="text-muted-foreground"> - {file.reason}</span>
1042
-
</div>
1043
-
))}
1044
-
{skippedFiles.length > 5 && (
1045
-
<div className="text-xs text-muted-foreground">
1046
-
...and {skippedFiles.length - 5} more
1047
-
</div>
1048
-
)}
1049
-
</div>
1050
-
</div>
1051
-
)}
1052
-
</div>
1053
-
)}
1054
-
1055
-
<Button
1056
-
onClick={handleUpload}
1057
-
className="w-full"
1058
-
disabled={
1059
-
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
1060
-
isUploading ||
1061
-
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
1062
-
}
1063
-
>
1064
-
{isUploading ? (
1065
-
<>
1066
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
1067
-
Uploading...
1068
-
</>
1069
-
) : (
1070
-
<>
1071
-
{siteMode === 'existing' ? (
1072
-
'Update Site'
1073
-
) : (
1074
-
selectedFiles && selectedFiles.length > 0
1075
-
? 'Upload & Deploy'
1076
-
: 'Create Empty Site'
1077
-
)}
1078
-
</>
1079
-
)}
1080
-
</Button>
1081
-
</CardContent>
1082
-
</Card>
273
+
<TabsContent value="upload">
274
+
<UploadTab
275
+
sites={sites}
276
+
sitesLoading={sitesLoading}
277
+
onUploadComplete={handleUploadComplete}
278
+
/>
1083
279
</TabsContent>
1084
280
1085
281
{/* CLI Tab */}
1086
-
<TabsContent value="cli" className="space-y-4 min-h-[400px]">
1087
-
<Card>
1088
-
<CardHeader>
1089
-
<div className="flex items-center gap-2 mb-2">
1090
-
<CardTitle>Wisp CLI Tool</CardTitle>
1091
-
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
1092
-
<Badge variant="outline" className="text-xs">Alpha</Badge>
1093
-
</div>
1094
-
<CardDescription>
1095
-
Deploy static sites directly from your terminal
1096
-
</CardDescription>
1097
-
</CardHeader>
1098
-
<CardContent className="space-y-6">
1099
-
<div className="prose prose-sm max-w-none dark:prose-invert">
1100
-
<p className="text-sm text-muted-foreground">
1101
-
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
1102
-
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
1103
-
</p>
1104
-
</div>
1105
-
1106
-
<div className="space-y-3">
1107
-
<h3 className="text-sm font-semibold">Download CLI</h3>
1108
-
<div className="grid gap-2">
1109
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1110
-
<a
1111
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
1112
-
target="_blank"
1113
-
rel="noopener noreferrer"
1114
-
className="flex items-center justify-between mb-2"
1115
-
>
1116
-
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
1117
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1118
-
</a>
1119
-
<div className="text-xs text-muted-foreground">
1120
-
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
1121
-
</div>
1122
-
</div>
1123
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1124
-
<a
1125
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
1126
-
target="_blank"
1127
-
rel="noopener noreferrer"
1128
-
className="flex items-center justify-between mb-2"
1129
-
>
1130
-
<span className="font-mono text-sm">Linux (ARM64)</span>
1131
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1132
-
</a>
1133
-
<div className="text-xs text-muted-foreground">
1134
-
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
1135
-
</div>
1136
-
</div>
1137
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1138
-
<a
1139
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
1140
-
target="_blank"
1141
-
rel="noopener noreferrer"
1142
-
className="flex items-center justify-between mb-2"
1143
-
>
1144
-
<span className="font-mono text-sm">Linux (x86_64)</span>
1145
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1146
-
</a>
1147
-
<div className="text-xs text-muted-foreground">
1148
-
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
1149
-
</div>
1150
-
</div>
1151
-
</div>
1152
-
</div>
1153
-
1154
-
<div className="space-y-3">
1155
-
<h3 className="text-sm font-semibold">Basic Usage</h3>
1156
-
<CodeBlock
1157
-
code={`# Download and make executable
1158
-
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
1159
-
chmod +x wisp-cli-macos-arm64
1160
-
1161
-
# Deploy your site (will use OAuth)
1162
-
./wisp-cli-macos-arm64 your-handle.bsky.social \\
1163
-
--path ./dist \\
1164
-
--site my-site
1165
-
1166
-
# Your site will be available at:
1167
-
# https://sites.wisp.place/your-handle/my-site`}
1168
-
language="bash"
1169
-
/>
1170
-
</div>
1171
-
1172
-
<div className="space-y-3">
1173
-
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
1174
-
<p className="text-xs text-muted-foreground">
1175
-
Deploy automatically on every push using{' '}
1176
-
<a
1177
-
href="https://blog.tangled.org/ci"
1178
-
target="_blank"
1179
-
rel="noopener noreferrer"
1180
-
className="text-accent hover:underline"
1181
-
>
1182
-
Tangled Spindle
1183
-
</a>
1184
-
</p>
1185
-
1186
-
<div className="space-y-4">
1187
-
<div>
1188
-
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1189
-
<span>Example 1: Simple Asset Publishing</span>
1190
-
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
1191
-
</h4>
1192
-
<CodeBlock
1193
-
code={`when:
1194
-
- event: ['push']
1195
-
branch: ['main']
1196
-
- event: ['manual']
1197
-
1198
-
engine: 'nixery'
1199
-
1200
-
clone:
1201
-
skip: false
1202
-
depth: 1
1203
-
1204
-
dependencies:
1205
-
nixpkgs:
1206
-
- coreutils
1207
-
- curl
1208
-
1209
-
environment:
1210
-
SITE_PATH: '.' # Copy entire repo
1211
-
SITE_NAME: 'myWebbedSite'
1212
-
WISP_HANDLE: 'your-handle.bsky.social'
1213
-
1214
-
steps:
1215
-
- name: deploy assets to wisp
1216
-
command: |
1217
-
# Download Wisp CLI
1218
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1219
-
chmod +x wisp-cli
1220
-
1221
-
# Deploy to Wisp
1222
-
./wisp-cli \\
1223
-
"$WISP_HANDLE" \\
1224
-
--path "$SITE_PATH" \\
1225
-
--site "$SITE_NAME" \\
1226
-
--password "$WISP_APP_PASSWORD"
1227
-
1228
-
# Output
1229
-
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
1230
-
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
1231
-
`}
1232
-
language="yaml"
1233
-
/>
1234
-
</div>
1235
-
1236
-
<div>
1237
-
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1238
-
<span>Example 2: React/Vite Build & Deploy</span>
1239
-
<Badge variant="secondary" className="text-xs">Full Build</Badge>
1240
-
</h4>
1241
-
<CodeBlock
1242
-
code={`when:
1243
-
- event: ['push']
1244
-
branch: ['main']
1245
-
- event: ['manual']
1246
-
1247
-
engine: 'nixery'
1248
-
1249
-
clone:
1250
-
skip: false
1251
-
depth: 1
1252
-
submodules: false
1253
-
1254
-
dependencies:
1255
-
nixpkgs:
1256
-
- nodejs
1257
-
- coreutils
1258
-
- curl
1259
-
github:NixOS/nixpkgs/nixpkgs-unstable:
1260
-
- bun
1261
-
1262
-
environment:
1263
-
SITE_PATH: 'dist'
1264
-
SITE_NAME: 'my-react-site'
1265
-
WISP_HANDLE: 'your-handle.bsky.social'
1266
-
1267
-
steps:
1268
-
- name: build site
1269
-
command: |
1270
-
# necessary to ensure bun is in PATH
1271
-
export PATH="$HOME/.nix-profile/bin:$PATH"
1272
-
1273
-
bun install --frozen-lockfile
1274
-
1275
-
# build with vite, run directly to get around env issues
1276
-
bun node_modules/.bin/vite build
1277
-
1278
-
- name: deploy to wisp
1279
-
command: |
1280
-
# Download Wisp CLI
1281
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1282
-
chmod +x wisp-cli
1283
-
1284
-
# Deploy to Wisp
1285
-
./wisp-cli \\
1286
-
"$WISP_HANDLE" \\
1287
-
--path "$SITE_PATH" \\
1288
-
--site "$SITE_NAME" \\
1289
-
--password "$WISP_APP_PASSWORD"`}
1290
-
language="yaml"
1291
-
/>
1292
-
</div>
1293
-
</div>
1294
-
1295
-
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
1296
-
<p className="text-xs text-muted-foreground">
1297
-
<strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings.
1298
-
Generate an app password from your AT Protocol account settings.
1299
-
</p>
1300
-
</div>
1301
-
</div>
1302
-
1303
-
<div className="space-y-3">
1304
-
<h3 className="text-sm font-semibold">Learn More</h3>
1305
-
<div className="grid gap-2">
1306
-
<a
1307
-
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
1308
-
target="_blank"
1309
-
rel="noopener noreferrer"
1310
-
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1311
-
>
1312
-
<span className="text-sm">Source Code</span>
1313
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1314
-
</a>
1315
-
<a
1316
-
href="https://blog.tangled.org/ci"
1317
-
target="_blank"
1318
-
rel="noopener noreferrer"
1319
-
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1320
-
>
1321
-
<span className="text-sm">Tangled Spindle CI/CD</span>
1322
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1323
-
</a>
1324
-
</div>
1325
-
</div>
1326
-
</CardContent>
1327
-
</Card>
282
+
<TabsContent value="cli">
283
+
<CLITab />
1328
284
</TabsContent>
1329
285
</Tabs>
1330
286
</div>
1331
287
1332
-
{/* Add Custom Domain Modal */}
1333
-
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
1334
-
<DialogContent className="sm:max-w-lg">
1335
-
<DialogHeader>
1336
-
<DialogTitle>Add Custom Domain</DialogTitle>
1337
-
<DialogDescription>
1338
-
Enter your domain name. After adding, you'll see the DNS
1339
-
records to configure.
1340
-
</DialogDescription>
1341
-
</DialogHeader>
1342
-
<div className="space-y-4 py-4">
1343
-
<div className="space-y-2">
1344
-
<Label htmlFor="new-domain">Domain Name</Label>
1345
-
<Input
1346
-
id="new-domain"
1347
-
placeholder="example.com"
1348
-
value={customDomain}
1349
-
onChange={(e) => setCustomDomain(e.target.value)}
1350
-
/>
1351
-
<p className="text-xs text-muted-foreground">
1352
-
After adding, click "View DNS" to see the records you
1353
-
need to configure.
1354
-
</p>
1355
-
</div>
288
+
{/* Footer */}
289
+
<footer className="border-t border-border/40 bg-muted/20 mt-12">
290
+
<div className="container mx-auto px-4 py-8">
291
+
<div className="text-center text-sm text-muted-foreground">
292
+
<p>
293
+
Built by{' '}
294
+
<a
295
+
href="https://bsky.app/profile/nekomimi.pet"
296
+
target="_blank"
297
+
rel="noopener noreferrer"
298
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
299
+
>
300
+
@nekomimi.pet
301
+
</a>
302
+
{' โข '}
303
+
Contact:{' '}
304
+
<a
305
+
href="mailto:contact@wisp.place"
306
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
307
+
>
308
+
contact@wisp.place
309
+
</a>
310
+
{' โข '}
311
+
Legal/DMCA:{' '}
312
+
<a
313
+
href="mailto:legal@wisp.place"
314
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
315
+
>
316
+
legal@wisp.place
317
+
</a>
318
+
</p>
319
+
<p className="mt-2">
320
+
<a
321
+
href="/acceptable-use"
322
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
323
+
>
324
+
Acceptable Use Policy
325
+
</a>
326
+
</p>
1356
327
</div>
1357
-
<DialogFooter className="flex-col sm:flex-row gap-2">
1358
-
<Button
1359
-
variant="outline"
1360
-
onClick={() => {
1361
-
setAddDomainModalOpen(false)
1362
-
setCustomDomain('')
1363
-
}}
1364
-
className="w-full sm:w-auto"
1365
-
disabled={isAddingDomain}
1366
-
>
1367
-
Cancel
1368
-
</Button>
1369
-
<Button
1370
-
onClick={handleAddCustomDomain}
1371
-
disabled={!customDomain || isAddingDomain}
1372
-
className="w-full sm:w-auto"
1373
-
>
1374
-
{isAddingDomain ? (
1375
-
<>
1376
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
1377
-
Adding...
1378
-
</>
1379
-
) : (
1380
-
'Add Domain'
1381
-
)}
1382
-
</Button>
1383
-
</DialogFooter>
1384
-
</DialogContent>
1385
-
</Dialog>
328
+
</div>
329
+
</footer>
1386
330
1387
331
{/* Site Configuration Modal */}
1388
332
<Dialog
···
1391
335
>
1392
336
<DialogContent className="sm:max-w-lg">
1393
337
<DialogHeader>
1394
-
<DialogTitle>Configure Site Domain</DialogTitle>
338
+
<DialogTitle>Configure Site Domains</DialogTitle>
1395
339
<DialogDescription>
1396
-
Choose which domain this site should use
340
+
Select which domains should be mapped to this site. You can select multiple domains.
1397
341
</DialogDescription>
1398
342
</DialogHeader>
1399
343
{configuringSite && (
···
1406
350
</p>
1407
351
</div>
1408
352
1409
-
<RadioGroup
1410
-
value={selectedDomain}
1411
-
onValueChange={setSelectedDomain}
1412
-
>
1413
-
{wispDomain && (
1414
-
<div className="flex items-center space-x-2">
1415
-
<RadioGroupItem value="wisp" id="wisp" />
1416
-
<Label
1417
-
htmlFor="wisp"
1418
-
className="flex-1 cursor-pointer"
1419
-
>
1420
-
<div className="flex items-center justify-between">
1421
-
<span className="font-mono text-sm">
1422
-
{wispDomain.domain}
1423
-
</span>
1424
-
<Badge variant="secondary" className="text-xs ml-2">
1425
-
Free
1426
-
</Badge>
1427
-
</div>
1428
-
</Label>
1429
-
</div>
1430
-
)}
353
+
<div className="space-y-3">
354
+
<p className="text-sm font-medium">Available Domains:</p>
355
+
356
+
{wispDomains.map((wispDomain) => {
357
+
const domainId = `wisp:${wispDomain.domain}`
358
+
return (
359
+
<div key={domainId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30">
360
+
<Checkbox
361
+
id={domainId}
362
+
checked={selectedDomains.has(domainId)}
363
+
onCheckedChange={(checked) => {
364
+
const newSelected = new Set(selectedDomains)
365
+
if (checked) {
366
+
newSelected.add(domainId)
367
+
} else {
368
+
newSelected.delete(domainId)
369
+
}
370
+
setSelectedDomains(newSelected)
371
+
}}
372
+
/>
373
+
<Label
374
+
htmlFor={domainId}
375
+
className="flex-1 cursor-pointer"
376
+
>
377
+
<div className="flex items-center justify-between">
378
+
<span className="font-mono text-sm">
379
+
{wispDomain.domain}
380
+
</span>
381
+
<Badge variant="secondary" className="text-xs ml-2">
382
+
Wisp
383
+
</Badge>
384
+
</div>
385
+
</Label>
386
+
</div>
387
+
)
388
+
})}
1431
389
1432
390
{customDomains
1433
391
.filter((d) => d.verified)
1434
392
.map((domain) => (
1435
393
<div
1436
394
key={domain.id}
1437
-
className="flex items-center space-x-2"
395
+
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"
1438
396
>
1439
-
<RadioGroupItem
1440
-
value={domain.id}
397
+
<Checkbox
1441
398
id={domain.id}
399
+
checked={selectedDomains.has(domain.id)}
400
+
onCheckedChange={(checked) => {
401
+
const newSelected = new Set(selectedDomains)
402
+
if (checked) {
403
+
newSelected.add(domain.id)
404
+
} else {
405
+
newSelected.delete(domain.id)
406
+
}
407
+
setSelectedDomains(newSelected)
408
+
}}
1442
409
/>
1443
410
<Label
1444
411
htmlFor={domain.id}
···
1459
426
</div>
1460
427
))}
1461
428
1462
-
<div className="flex items-center space-x-2">
1463
-
<RadioGroupItem value="none" id="none" />
1464
-
<Label htmlFor="none" className="flex-1 cursor-pointer">
1465
-
<div className="flex flex-col">
1466
-
<span className="text-sm">Default URL</span>
1467
-
<span className="text-xs text-muted-foreground font-mono break-all">
1468
-
sites.wisp.place/{configuringSite.did}/
1469
-
{configuringSite.rkey}
1470
-
</span>
1471
-
</div>
1472
-
</Label>
1473
-
</div>
1474
-
</RadioGroup>
429
+
{customDomains.filter(d => d.verified).length === 0 && wispDomains.length === 0 && (
430
+
<p className="text-sm text-muted-foreground py-4 text-center">
431
+
No domains available. Add a custom domain or claim a wisp.place subdomain.
432
+
</p>
433
+
)}
434
+
</div>
435
+
436
+
<div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50">
437
+
<p className="text-xs text-muted-foreground">
438
+
<strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '}
439
+
<span className="font-mono">
440
+
sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
441
+
</span>
442
+
</p>
443
+
</div>
1475
444
</div>
1476
445
)}
1477
446
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
···
1517
486
)}
1518
487
</Button>
1519
488
</div>
1520
-
</DialogFooter>
1521
-
</DialogContent>
1522
-
</Dialog>
1523
-
1524
-
{/* View DNS Records Modal */}
1525
-
<Dialog
1526
-
open={viewDomainDNS !== null}
1527
-
onOpenChange={(open) => !open && setViewDomainDNS(null)}
1528
-
>
1529
-
<DialogContent className="sm:max-w-lg">
1530
-
<DialogHeader>
1531
-
<DialogTitle>DNS Configuration</DialogTitle>
1532
-
<DialogDescription>
1533
-
Add these DNS records to your domain provider
1534
-
</DialogDescription>
1535
-
</DialogHeader>
1536
-
{viewDomainDNS && userInfo && (
1537
-
<>
1538
-
{(() => {
1539
-
const domain = customDomains.find(
1540
-
(d) => d.id === viewDomainDNS
1541
-
)
1542
-
if (!domain) return null
1543
-
1544
-
return (
1545
-
<div className="space-y-4 py-4">
1546
-
<div className="p-3 bg-muted/30 rounded-lg">
1547
-
<p className="text-sm font-medium mb-1">
1548
-
Domain:
1549
-
</p>
1550
-
<p className="font-mono text-sm">
1551
-
{domain.domain}
1552
-
</p>
1553
-
</div>
1554
-
1555
-
<div className="space-y-3">
1556
-
<div className="p-3 bg-background rounded border border-border">
1557
-
<div className="flex justify-between items-start mb-2">
1558
-
<span className="text-xs font-semibold text-muted-foreground">
1559
-
TXT Record (Verification)
1560
-
</span>
1561
-
</div>
1562
-
<div className="font-mono text-xs space-y-2">
1563
-
<div>
1564
-
<span className="text-muted-foreground">
1565
-
Name:
1566
-
</span>{' '}
1567
-
<span className="select-all">
1568
-
_wisp.{domain.domain}
1569
-
</span>
1570
-
</div>
1571
-
<div>
1572
-
<span className="text-muted-foreground">
1573
-
Value:
1574
-
</span>{' '}
1575
-
<span className="select-all break-all">
1576
-
{userInfo.did}
1577
-
</span>
1578
-
</div>
1579
-
</div>
1580
-
</div>
1581
-
1582
-
<div className="p-3 bg-background rounded border border-border">
1583
-
<div className="flex justify-between items-start mb-2">
1584
-
<span className="text-xs font-semibold text-muted-foreground">
1585
-
CNAME Record (Pointing)
1586
-
</span>
1587
-
</div>
1588
-
<div className="font-mono text-xs space-y-2">
1589
-
<div>
1590
-
<span className="text-muted-foreground">
1591
-
Name:
1592
-
</span>{' '}
1593
-
<span className="select-all">
1594
-
{domain.domain}
1595
-
</span>
1596
-
</div>
1597
-
<div>
1598
-
<span className="text-muted-foreground">
1599
-
Value:
1600
-
</span>{' '}
1601
-
<span className="select-all">
1602
-
{domain.id}.dns.wisp.place
1603
-
</span>
1604
-
</div>
1605
-
</div>
1606
-
<p className="text-xs text-muted-foreground mt-2">
1607
-
Some DNS providers may require you to use @ or leave it blank for the root domain
1608
-
</p>
1609
-
</div>
1610
-
</div>
1611
-
1612
-
<div className="p-3 bg-muted/30 rounded-lg">
1613
-
<p className="text-xs text-muted-foreground">
1614
-
๐ก After configuring DNS, click "Verify DNS"
1615
-
to check if everything is set up correctly.
1616
-
DNS changes can take a few minutes to
1617
-
propagate.
1618
-
</p>
1619
-
</div>
1620
-
</div>
1621
-
)
1622
-
})()}
1623
-
</>
1624
-
)}
1625
-
<DialogFooter>
1626
-
<Button
1627
-
variant="outline"
1628
-
onClick={() => setViewDomainDNS(null)}
1629
-
className="w-full sm:w-auto"
1630
-
>
1631
-
Close
1632
-
</Button>
1633
489
</DialogFooter>
1634
490
</DialogContent>
1635
491
</Dialog>
+239
public/editor/hooks/useDomainData.ts
+239
public/editor/hooks/useDomainData.ts
···
1
+
import { useState } from 'react'
2
+
3
+
export interface CustomDomain {
4
+
id: string
5
+
domain: string
6
+
did: string
7
+
rkey: string
8
+
verified: boolean
9
+
last_verified_at: number | null
10
+
created_at: number
11
+
}
12
+
13
+
export interface WispDomain {
14
+
domain: string
15
+
rkey: string | null
16
+
}
17
+
18
+
type VerificationStatus = 'idle' | 'verifying' | 'success' | 'error'
19
+
20
+
export function useDomainData() {
21
+
const [wispDomains, setWispDomains] = useState<WispDomain[]>([])
22
+
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
23
+
const [domainsLoading, setDomainsLoading] = useState(true)
24
+
const [verificationStatus, setVerificationStatus] = useState<{
25
+
[id: string]: VerificationStatus
26
+
}>({})
27
+
28
+
const fetchDomains = async () => {
29
+
try {
30
+
const response = await fetch('/api/user/domains')
31
+
const data = await response.json()
32
+
setWispDomains(data.wispDomains || [])
33
+
setCustomDomains(data.customDomains || [])
34
+
} catch (err) {
35
+
console.error('Failed to fetch domains:', err)
36
+
} finally {
37
+
setDomainsLoading(false)
38
+
}
39
+
}
40
+
41
+
const addCustomDomain = async (domain: string) => {
42
+
try {
43
+
const response = await fetch('/api/domain/custom/add', {
44
+
method: 'POST',
45
+
headers: { 'Content-Type': 'application/json' },
46
+
body: JSON.stringify({ domain })
47
+
})
48
+
49
+
const data = await response.json()
50
+
if (data.success) {
51
+
await fetchDomains()
52
+
return { success: true, id: data.id }
53
+
} else {
54
+
throw new Error(data.error || 'Failed to add domain')
55
+
}
56
+
} catch (err) {
57
+
console.error('Add domain error:', err)
58
+
alert(
59
+
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
60
+
)
61
+
return { success: false }
62
+
}
63
+
}
64
+
65
+
const verifyDomain = async (id: string) => {
66
+
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
67
+
68
+
try {
69
+
const response = await fetch('/api/domain/custom/verify', {
70
+
method: 'POST',
71
+
headers: { 'Content-Type': 'application/json' },
72
+
body: JSON.stringify({ id })
73
+
})
74
+
75
+
const data = await response.json()
76
+
if (data.success && data.verified) {
77
+
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
78
+
await fetchDomains()
79
+
} else {
80
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
81
+
if (data.error) {
82
+
alert(`Verification failed: ${data.error}`)
83
+
}
84
+
}
85
+
} catch (err) {
86
+
console.error('Verify domain error:', err)
87
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
88
+
alert(
89
+
`Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
90
+
)
91
+
}
92
+
}
93
+
94
+
const deleteCustomDomain = async (id: string) => {
95
+
if (!confirm('Are you sure you want to remove this custom domain?')) {
96
+
return false
97
+
}
98
+
99
+
try {
100
+
const response = await fetch(`/api/domain/custom/${id}`, {
101
+
method: 'DELETE'
102
+
})
103
+
104
+
const data = await response.json()
105
+
if (data.success) {
106
+
await fetchDomains()
107
+
return true
108
+
} else {
109
+
throw new Error('Failed to delete domain')
110
+
}
111
+
} catch (err) {
112
+
console.error('Delete domain error:', err)
113
+
alert(
114
+
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
115
+
)
116
+
return false
117
+
}
118
+
}
119
+
120
+
const mapWispDomain = async (domain: string, siteRkey: string | null) => {
121
+
try {
122
+
const response = await fetch('/api/domain/wisp/map-site', {
123
+
method: 'POST',
124
+
headers: { 'Content-Type': 'application/json' },
125
+
body: JSON.stringify({ domain, siteRkey })
126
+
})
127
+
const data = await response.json()
128
+
if (!data.success) throw new Error('Failed to map wisp domain')
129
+
return true
130
+
} catch (err) {
131
+
console.error('Map wisp domain error:', err)
132
+
throw err
133
+
}
134
+
}
135
+
136
+
const deleteWispDomain = async (domain: string) => {
137
+
if (!confirm('Are you sure you want to remove this wisp.place domain?')) {
138
+
return false
139
+
}
140
+
141
+
try {
142
+
const response = await fetch(`/api/domain/wisp/${encodeURIComponent(domain)}`, {
143
+
method: 'DELETE'
144
+
})
145
+
146
+
const data = await response.json()
147
+
if (data.success) {
148
+
await fetchDomains()
149
+
return true
150
+
} else {
151
+
throw new Error('Failed to delete domain')
152
+
}
153
+
} catch (err) {
154
+
console.error('Delete wisp domain error:', err)
155
+
alert(
156
+
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
157
+
)
158
+
return false
159
+
}
160
+
}
161
+
162
+
const mapCustomDomain = async (domainId: string, siteRkey: string | null) => {
163
+
try {
164
+
const response = await fetch(`/api/domain/custom/${domainId}/map-site`, {
165
+
method: 'POST',
166
+
headers: { 'Content-Type': 'application/json' },
167
+
body: JSON.stringify({ siteRkey })
168
+
})
169
+
const data = await response.json()
170
+
if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`)
171
+
return true
172
+
} catch (err) {
173
+
console.error('Map custom domain error:', err)
174
+
throw err
175
+
}
176
+
}
177
+
178
+
const claimWispDomain = async (handle: string) => {
179
+
try {
180
+
const response = await fetch('/api/domain/claim', {
181
+
method: 'POST',
182
+
headers: { 'Content-Type': 'application/json' },
183
+
body: JSON.stringify({ handle })
184
+
})
185
+
186
+
const data = await response.json()
187
+
if (data.success) {
188
+
await fetchDomains()
189
+
return { success: true }
190
+
} else {
191
+
throw new Error(data.error || 'Failed to claim domain')
192
+
}
193
+
} catch (err) {
194
+
console.error('Claim domain error:', err)
195
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
196
+
197
+
// Handle domain limit error more gracefully
198
+
if (errorMessage.includes('Domain limit reached')) {
199
+
alert('You have already claimed 3 wisp.place subdomains (maximum limit).')
200
+
await fetchDomains()
201
+
} else {
202
+
alert(`Failed to claim domain: ${errorMessage}`)
203
+
}
204
+
return { success: false, error: errorMessage }
205
+
}
206
+
}
207
+
208
+
const checkWispAvailability = async (handle: string) => {
209
+
const trimmedHandle = handle.trim().toLowerCase()
210
+
if (!trimmedHandle) {
211
+
return { available: null }
212
+
}
213
+
214
+
try {
215
+
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
216
+
const data = await response.json()
217
+
return { available: data.available }
218
+
} catch (err) {
219
+
console.error('Check availability error:', err)
220
+
return { available: false }
221
+
}
222
+
}
223
+
224
+
return {
225
+
wispDomains,
226
+
customDomains,
227
+
domainsLoading,
228
+
verificationStatus,
229
+
fetchDomains,
230
+
addCustomDomain,
231
+
verifyDomain,
232
+
deleteCustomDomain,
233
+
mapWispDomain,
234
+
deleteWispDomain,
235
+
mapCustomDomain,
236
+
claimWispDomain,
237
+
checkWispAvailability
238
+
}
239
+
}
+112
public/editor/hooks/useSiteData.ts
+112
public/editor/hooks/useSiteData.ts
···
1
+
import { useState } from 'react'
2
+
3
+
export interface Site {
4
+
did: string
5
+
rkey: string
6
+
display_name: string | null
7
+
created_at: number
8
+
updated_at: number
9
+
}
10
+
11
+
export interface DomainInfo {
12
+
type: 'wisp' | 'custom'
13
+
domain: string
14
+
verified?: boolean
15
+
id?: string
16
+
}
17
+
18
+
export interface SiteWithDomains extends Site {
19
+
domains?: DomainInfo[]
20
+
}
21
+
22
+
export function useSiteData() {
23
+
const [sites, setSites] = useState<SiteWithDomains[]>([])
24
+
const [sitesLoading, setSitesLoading] = useState(true)
25
+
const [isSyncing, setIsSyncing] = useState(false)
26
+
27
+
const fetchSites = async () => {
28
+
try {
29
+
const response = await fetch('/api/user/sites')
30
+
const data = await response.json()
31
+
const sitesData: Site[] = data.sites || []
32
+
33
+
// Fetch domain info for each site
34
+
const sitesWithDomains = await Promise.all(
35
+
sitesData.map(async (site) => {
36
+
try {
37
+
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
38
+
const domainsData = await domainsResponse.json()
39
+
return {
40
+
...site,
41
+
domains: domainsData.domains || []
42
+
}
43
+
} catch (err) {
44
+
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
45
+
return {
46
+
...site,
47
+
domains: []
48
+
}
49
+
}
50
+
})
51
+
)
52
+
53
+
setSites(sitesWithDomains)
54
+
} catch (err) {
55
+
console.error('Failed to fetch sites:', err)
56
+
} finally {
57
+
setSitesLoading(false)
58
+
}
59
+
}
60
+
61
+
const syncSites = async () => {
62
+
setIsSyncing(true)
63
+
try {
64
+
const response = await fetch('/api/user/sync', {
65
+
method: 'POST'
66
+
})
67
+
const data = await response.json()
68
+
if (data.success) {
69
+
console.log(`Synced ${data.synced} sites from PDS`)
70
+
// Refresh sites list
71
+
await fetchSites()
72
+
}
73
+
} catch (err) {
74
+
console.error('Failed to sync sites:', err)
75
+
alert('Failed to sync sites from PDS')
76
+
} finally {
77
+
setIsSyncing(false)
78
+
}
79
+
}
80
+
81
+
const deleteSite = async (rkey: string) => {
82
+
try {
83
+
const response = await fetch(`/api/site/${rkey}`, {
84
+
method: 'DELETE'
85
+
})
86
+
87
+
const data = await response.json()
88
+
if (data.success) {
89
+
// Refresh sites list
90
+
await fetchSites()
91
+
return true
92
+
} else {
93
+
throw new Error(data.error || 'Failed to delete site')
94
+
}
95
+
} catch (err) {
96
+
console.error('Delete site error:', err)
97
+
alert(
98
+
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
99
+
)
100
+
return false
101
+
}
102
+
}
103
+
104
+
return {
105
+
sites,
106
+
sitesLoading,
107
+
isSyncing,
108
+
fetchSites,
109
+
syncSites,
110
+
deleteSite
111
+
}
112
+
}
+29
public/editor/hooks/useUserInfo.ts
+29
public/editor/hooks/useUserInfo.ts
···
1
+
import { useState } from 'react'
2
+
3
+
export interface UserInfo {
4
+
did: string
5
+
handle: string
6
+
}
7
+
8
+
export function useUserInfo() {
9
+
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
10
+
const [loading, setLoading] = useState(true)
11
+
12
+
const fetchUserInfo = async () => {
13
+
try {
14
+
const response = await fetch('/api/user/info')
15
+
const data = await response.json()
16
+
setUserInfo(data)
17
+
} catch (err) {
18
+
console.error('Failed to fetch user info:', err)
19
+
} finally {
20
+
setLoading(false)
21
+
}
22
+
}
23
+
24
+
return {
25
+
userInfo,
26
+
loading,
27
+
fetchUserInfo
28
+
}
29
+
}
+41
-1
public/editor/index.html
+41
-1
public/editor/index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Elysia Static</title>
6
+
<title>wisp.place</title>
7
+
<meta name="description" content="Manage your decentralized static sites hosted on AT Protocol." />
8
+
9
+
<!-- Open Graph / Facebook -->
10
+
<meta property="og:type" content="website" />
11
+
<meta property="og:url" content="https://wisp.place/editor" />
12
+
<meta property="og:title" content="Editor - wisp.place" />
13
+
<meta property="og:description" content="Manage your decentralized static sites hosted on AT Protocol." />
14
+
<meta property="og:site_name" content="wisp.place" />
15
+
16
+
<!-- Twitter -->
17
+
<meta name="twitter:card" content="summary" />
18
+
<meta name="twitter:url" content="https://wisp.place/editor" />
19
+
<meta name="twitter:title" content="Editor - wisp.place" />
20
+
<meta name="twitter:description" content="Manage your decentralized static sites hosted on AT Protocol." />
21
+
22
+
<!-- Theme -->
23
+
<meta name="theme-color" content="#7c3aed" />
24
+
7
25
<link rel="icon" type="image/x-icon" href="../favicon.ico">
26
+
<link rel="icon" type="image/png" sizes="32x32" href="../favicon-32x32.png">
27
+
<link rel="icon" type="image/png" sizes="16x16" href="../favicon-16x16.png">
28
+
<link rel="apple-touch-icon" sizes="180x180" href="../apple-touch-icon.png">
29
+
<link rel="manifest" href="../site.webmanifest">
30
+
<style>
31
+
/* Dark theme fallback styles for before JS loads */
32
+
@media (prefers-color-scheme: dark) {
33
+
body {
34
+
background-color: oklch(0.23 0.015 285);
35
+
color: oklch(0.90 0.005 285);
36
+
}
37
+
38
+
pre {
39
+
background-color: oklch(0.33 0.015 285) !important;
40
+
color: oklch(0.90 0.005 285) !important;
41
+
}
42
+
43
+
.bg-muted {
44
+
background-color: oklch(0.33 0.015 285) !important;
45
+
}
46
+
}
47
+
</style>
8
48
</head>
9
49
<body>
10
50
<div id="elysia"></div>
+322
public/editor/tabs/CLITab.tsx
+322
public/editor/tabs/CLITab.tsx
···
1
+
import {
2
+
Card,
3
+
CardContent,
4
+
CardDescription,
5
+
CardHeader,
6
+
CardTitle
7
+
} from '@public/components/ui/card'
8
+
import { Badge } from '@public/components/ui/badge'
9
+
import { ExternalLink } from 'lucide-react'
10
+
import { CodeBlock } from '@public/components/ui/code-block'
11
+
12
+
export function CLITab() {
13
+
return (
14
+
<div className="space-y-4 min-h-[400px]">
15
+
<Card>
16
+
<CardHeader>
17
+
<div className="flex items-center gap-2 mb-2">
18
+
<CardTitle>Wisp CLI Tool</CardTitle>
19
+
<Badge variant="secondary" className="text-xs">v0.2.0</Badge>
20
+
<Badge variant="outline" className="text-xs">Alpha</Badge>
21
+
</div>
22
+
<CardDescription>
23
+
Deploy static sites directly from your terminal
24
+
</CardDescription>
25
+
</CardHeader>
26
+
<CardContent className="space-y-6">
27
+
<div className="prose prose-sm max-w-none dark:prose-invert">
28
+
<p className="text-sm text-muted-foreground">
29
+
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
30
+
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
31
+
</p>
32
+
</div>
33
+
34
+
<div className="space-y-3">
35
+
<h3 className="text-sm font-semibold">Features</h3>
36
+
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
37
+
<li><strong>Deploy:</strong> Push static sites directly from your terminal</li>
38
+
<li><strong>Pull:</strong> Download sites from the PDS for development or backup</li>
39
+
<li><strong>Serve:</strong> Run a local server with real-time firehose updates</li>
40
+
</ul>
41
+
</div>
42
+
43
+
<div className="space-y-3">
44
+
<h3 className="text-sm font-semibold">Download v0.2.0</h3>
45
+
<div className="grid gap-2">
46
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
47
+
<a
48
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin"
49
+
target="_blank"
50
+
rel="noopener noreferrer"
51
+
className="flex items-center justify-between mb-2"
52
+
>
53
+
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
54
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
55
+
</a>
56
+
<div className="text-xs text-muted-foreground">
57
+
<span className="font-mono">SHA-1: a8c27ea41c5e2672bfecb3476ece1c801741d759</span>
58
+
</div>
59
+
</div>
60
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
61
+
<a
62
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
63
+
target="_blank"
64
+
rel="noopener noreferrer"
65
+
className="flex items-center justify-between mb-2"
66
+
>
67
+
<span className="font-mono text-sm">Linux (ARM64)</span>
68
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
69
+
</a>
70
+
<div className="text-xs text-muted-foreground">
71
+
<span className="font-mono">SHA-1: fd7ee689c7600fc953179ea755b0357c8481a622</span>
72
+
</div>
73
+
</div>
74
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
75
+
<a
76
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
77
+
target="_blank"
78
+
rel="noopener noreferrer"
79
+
className="flex items-center justify-between mb-2"
80
+
>
81
+
<span className="font-mono text-sm">Linux (x86_64)</span>
82
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
83
+
</a>
84
+
<div className="text-xs text-muted-foreground">
85
+
<span className="font-mono">SHA-1: 8bca6992559e19e1d29ab3d2fcc6d09b28e5a485</span>
86
+
</div>
87
+
</div>
88
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
89
+
<a
90
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-windows.exe"
91
+
target="_blank"
92
+
rel="noopener noreferrer"
93
+
className="flex items-center justify-between mb-2"
94
+
>
95
+
<span className="font-mono text-sm">Windows (x86_64)</span>
96
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
97
+
</a>
98
+
<div className="text-xs text-muted-foreground">
99
+
<span className="font-mono">SHA-1: 90ea3987a06597fa6c42e1df9009e9758e92dd54</span>
100
+
</div>
101
+
</div>
102
+
</div>
103
+
</div>
104
+
105
+
<div className="space-y-3">
106
+
<h3 className="text-sm font-semibold">Deploy a Site</h3>
107
+
<CodeBlock
108
+
code={`# Download and make executable
109
+
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin
110
+
chmod +x wisp-cli-aarch64-darwin
111
+
112
+
# Deploy your site
113
+
./wisp-cli-aarch64-darwin deploy your-handle.bsky.social \\
114
+
--path ./dist \\
115
+
--site my-site \\
116
+
--password your-app-password
117
+
118
+
# Your site will be available at:
119
+
# https://sites.wisp.place/your-handle/my-site`}
120
+
language="bash"
121
+
/>
122
+
</div>
123
+
124
+
<div className="space-y-3">
125
+
<h3 className="text-sm font-semibold">Pull a Site from PDS</h3>
126
+
<p className="text-xs text-muted-foreground">
127
+
Download a site from the PDS to your local machine (uses OAuth authentication):
128
+
</p>
129
+
<CodeBlock
130
+
code={`# Pull a site to a specific directory
131
+
wisp-cli pull your-handle.bsky.social \\
132
+
--site my-site \\
133
+
--output ./my-site
134
+
135
+
# Pull to current directory
136
+
wisp-cli pull your-handle.bsky.social \\
137
+
--site my-site
138
+
139
+
# Opens browser for OAuth authentication on first run`}
140
+
language="bash"
141
+
/>
142
+
</div>
143
+
144
+
<div className="space-y-3">
145
+
<h3 className="text-sm font-semibold">Serve a Site Locally with Real-Time Updates</h3>
146
+
<p className="text-xs text-muted-foreground">
147
+
Run a local server that monitors the firehose for real-time updates (uses OAuth authentication):
148
+
</p>
149
+
<CodeBlock
150
+
code={`# Serve on http://localhost:8080 (default)
151
+
wisp-cli serve your-handle.bsky.social \\
152
+
--site my-site
153
+
154
+
# Serve on a custom port
155
+
wisp-cli serve your-handle.bsky.social \\
156
+
--site my-site \\
157
+
--port 3000
158
+
159
+
# Downloads site, serves it, and watches firehose for live updates!`}
160
+
language="bash"
161
+
/>
162
+
</div>
163
+
164
+
<div className="space-y-3">
165
+
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
166
+
<p className="text-xs text-muted-foreground">
167
+
Deploy automatically on every push using{' '}
168
+
<a
169
+
href="https://blog.tangled.org/ci"
170
+
target="_blank"
171
+
rel="noopener noreferrer"
172
+
className="text-accent hover:underline"
173
+
>
174
+
Tangled Spindle
175
+
</a>
176
+
</p>
177
+
178
+
<div className="space-y-4">
179
+
<div>
180
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
181
+
<span>Example 1: Simple Asset Publishing</span>
182
+
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
183
+
</h4>
184
+
<CodeBlock
185
+
code={`when:
186
+
- event: ['push']
187
+
branch: ['main']
188
+
- event: ['manual']
189
+
190
+
engine: 'nixery'
191
+
192
+
clone:
193
+
skip: false
194
+
depth: 1
195
+
196
+
dependencies:
197
+
nixpkgs:
198
+
- coreutils
199
+
- curl
200
+
201
+
environment:
202
+
SITE_PATH: '.' # Copy entire repo
203
+
SITE_NAME: 'myWebbedSite'
204
+
WISP_HANDLE: 'your-handle.bsky.social'
205
+
206
+
steps:
207
+
- name: deploy assets to wisp
208
+
command: |
209
+
# Download Wisp CLI
210
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
211
+
chmod +x wisp-cli
212
+
213
+
# Deploy to Wisp
214
+
./wisp-cli deploy \\
215
+
"$WISP_HANDLE" \\
216
+
--path "$SITE_PATH" \\
217
+
--site "$SITE_NAME" \\
218
+
--password "$WISP_APP_PASSWORD"
219
+
220
+
# Output
221
+
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
222
+
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
223
+
`}
224
+
language="yaml"
225
+
/>
226
+
</div>
227
+
228
+
<div>
229
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
230
+
<span>Example 2: React/Vite Build & Deploy</span>
231
+
<Badge variant="secondary" className="text-xs">Full Build</Badge>
232
+
</h4>
233
+
<CodeBlock
234
+
code={`when:
235
+
- event: ['push']
236
+
branch: ['main']
237
+
- event: ['manual']
238
+
239
+
engine: 'nixery'
240
+
241
+
clone:
242
+
skip: false
243
+
depth: 1
244
+
submodules: false
245
+
246
+
dependencies:
247
+
nixpkgs:
248
+
- nodejs
249
+
- coreutils
250
+
- curl
251
+
github:NixOS/nixpkgs/nixpkgs-unstable:
252
+
- bun
253
+
254
+
environment:
255
+
SITE_PATH: 'dist'
256
+
SITE_NAME: 'my-react-site'
257
+
WISP_HANDLE: 'your-handle.bsky.social'
258
+
259
+
steps:
260
+
- name: build site
261
+
command: |
262
+
# necessary to ensure bun is in PATH
263
+
export PATH="$HOME/.nix-profile/bin:$PATH"
264
+
265
+
bun install --frozen-lockfile
266
+
267
+
# build with vite, run directly to get around env issues
268
+
bun node_modules/.bin/vite build
269
+
270
+
- name: deploy to wisp
271
+
command: |
272
+
# Download Wisp CLI
273
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
274
+
chmod +x wisp-cli
275
+
276
+
# Deploy to Wisp
277
+
./wisp-cli deploy \\
278
+
"$WISP_HANDLE" \\
279
+
--path "$SITE_PATH" \\
280
+
--site "$SITE_NAME" \\
281
+
--password "$WISP_APP_PASSWORD"`}
282
+
language="yaml"
283
+
/>
284
+
</div>
285
+
</div>
286
+
287
+
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
288
+
<p className="text-xs text-muted-foreground">
289
+
<strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings.
290
+
Generate an app password from your AT Protocol account settings.
291
+
</p>
292
+
</div>
293
+
</div>
294
+
295
+
<div className="space-y-3">
296
+
<h3 className="text-sm font-semibold">Learn More</h3>
297
+
<div className="grid gap-2">
298
+
<a
299
+
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
300
+
target="_blank"
301
+
rel="noopener noreferrer"
302
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
303
+
>
304
+
<span className="text-sm">Source Code</span>
305
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
306
+
</a>
307
+
<a
308
+
href="https://blog.tangled.org/ci"
309
+
target="_blank"
310
+
rel="noopener noreferrer"
311
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
312
+
>
313
+
<span className="text-sm">Tangled Spindle CI/CD</span>
314
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
315
+
</a>
316
+
</div>
317
+
</div>
318
+
</CardContent>
319
+
</Card>
320
+
</div>
321
+
)
322
+
}
+524
public/editor/tabs/DomainsTab.tsx
+524
public/editor/tabs/DomainsTab.tsx
···
1
+
import { useState } from 'react'
2
+
import {
3
+
Card,
4
+
CardContent,
5
+
CardDescription,
6
+
CardHeader,
7
+
CardTitle
8
+
} from '@public/components/ui/card'
9
+
import { Button } from '@public/components/ui/button'
10
+
import { Input } from '@public/components/ui/input'
11
+
import { Label } from '@public/components/ui/label'
12
+
import { Badge } from '@public/components/ui/badge'
13
+
import {
14
+
Dialog,
15
+
DialogContent,
16
+
DialogDescription,
17
+
DialogHeader,
18
+
DialogTitle,
19
+
DialogFooter
20
+
} from '@public/components/ui/dialog'
21
+
import {
22
+
CheckCircle2,
23
+
XCircle,
24
+
Loader2,
25
+
Trash2
26
+
} from 'lucide-react'
27
+
import type { WispDomain, CustomDomain } from '../hooks/useDomainData'
28
+
import type { UserInfo } from '../hooks/useUserInfo'
29
+
30
+
interface DomainsTabProps {
31
+
wispDomains: WispDomain[]
32
+
customDomains: CustomDomain[]
33
+
domainsLoading: boolean
34
+
verificationStatus: { [id: string]: 'idle' | 'verifying' | 'success' | 'error' }
35
+
userInfo: UserInfo | null
36
+
onAddCustomDomain: (domain: string) => Promise<{ success: boolean; id?: string }>
37
+
onVerifyDomain: (id: string) => Promise<void>
38
+
onDeleteCustomDomain: (id: string) => Promise<boolean>
39
+
onDeleteWispDomain: (domain: string) => Promise<boolean>
40
+
onClaimWispDomain: (handle: string) => Promise<{ success: boolean; error?: string }>
41
+
onCheckWispAvailability: (handle: string) => Promise<{ available: boolean | null }>
42
+
}
43
+
44
+
export function DomainsTab({
45
+
wispDomains,
46
+
customDomains,
47
+
domainsLoading,
48
+
verificationStatus,
49
+
userInfo,
50
+
onAddCustomDomain,
51
+
onVerifyDomain,
52
+
onDeleteCustomDomain,
53
+
onDeleteWispDomain,
54
+
onClaimWispDomain,
55
+
onCheckWispAvailability
56
+
}: DomainsTabProps) {
57
+
// Wisp domain claim state
58
+
const [wispHandle, setWispHandle] = useState('')
59
+
const [isClaimingWisp, setIsClaimingWisp] = useState(false)
60
+
const [wispAvailability, setWispAvailability] = useState<{
61
+
available: boolean | null
62
+
checking: boolean
63
+
}>({ available: null, checking: false })
64
+
65
+
// Custom domain modal state
66
+
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
67
+
const [customDomain, setCustomDomain] = useState('')
68
+
const [isAddingDomain, setIsAddingDomain] = useState(false)
69
+
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
70
+
71
+
const checkWispAvailability = async (handle: string) => {
72
+
const trimmedHandle = handle.trim().toLowerCase()
73
+
if (!trimmedHandle) {
74
+
setWispAvailability({ available: null, checking: false })
75
+
return
76
+
}
77
+
78
+
setWispAvailability({ available: null, checking: true })
79
+
const result = await onCheckWispAvailability(trimmedHandle)
80
+
setWispAvailability({ available: result.available, checking: false })
81
+
}
82
+
83
+
const handleClaimWispDomain = async () => {
84
+
const trimmedHandle = wispHandle.trim().toLowerCase()
85
+
if (!trimmedHandle) {
86
+
alert('Please enter a handle')
87
+
return
88
+
}
89
+
90
+
setIsClaimingWisp(true)
91
+
const result = await onClaimWispDomain(trimmedHandle)
92
+
if (result.success) {
93
+
setWispHandle('')
94
+
setWispAvailability({ available: null, checking: false })
95
+
}
96
+
setIsClaimingWisp(false)
97
+
}
98
+
99
+
const handleAddCustomDomain = async () => {
100
+
if (!customDomain) {
101
+
alert('Please enter a domain')
102
+
return
103
+
}
104
+
105
+
setIsAddingDomain(true)
106
+
const result = await onAddCustomDomain(customDomain)
107
+
setIsAddingDomain(false)
108
+
109
+
if (result.success) {
110
+
setCustomDomain('')
111
+
setAddDomainModalOpen(false)
112
+
// Automatically show DNS configuration for the newly added domain
113
+
if (result.id) {
114
+
setViewDomainDNS(result.id)
115
+
}
116
+
}
117
+
}
118
+
119
+
return (
120
+
<>
121
+
<div className="space-y-4 min-h-[400px]">
122
+
<Card>
123
+
<CardHeader>
124
+
<CardTitle>wisp.place Subdomains</CardTitle>
125
+
<CardDescription>
126
+
Your free subdomains on the wisp.place network (up to 3)
127
+
</CardDescription>
128
+
</CardHeader>
129
+
<CardContent>
130
+
{domainsLoading ? (
131
+
<div className="flex items-center justify-center py-4">
132
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
133
+
</div>
134
+
) : (
135
+
<div className="space-y-4">
136
+
{wispDomains.length > 0 && (
137
+
<div className="space-y-2">
138
+
{wispDomains.map((domain) => (
139
+
<div
140
+
key={domain.domain}
141
+
className="flex items-center justify-between p-3 border border-border rounded-lg"
142
+
>
143
+
<div className="flex flex-col gap-1 flex-1">
144
+
<div className="flex items-center gap-2">
145
+
<CheckCircle2 className="w-4 h-4 text-green-500" />
146
+
<span className="font-mono">
147
+
{domain.domain}
148
+
</span>
149
+
</div>
150
+
{domain.rkey && (
151
+
<p className="text-xs text-muted-foreground ml-6">
152
+
โ Mapped to site: {domain.rkey}
153
+
</p>
154
+
)}
155
+
</div>
156
+
<Button
157
+
variant="ghost"
158
+
size="sm"
159
+
onClick={() => onDeleteWispDomain(domain.domain)}
160
+
>
161
+
<Trash2 className="w-4 h-4" />
162
+
</Button>
163
+
</div>
164
+
))}
165
+
</div>
166
+
)}
167
+
168
+
{wispDomains.length < 3 && (
169
+
<div className="p-4 bg-muted/30 rounded-lg">
170
+
<p className="text-sm text-muted-foreground mb-4">
171
+
{wispDomains.length === 0
172
+
? 'Claim your free wisp.place subdomain'
173
+
: `Claim another wisp.place subdomain (${wispDomains.length}/3)`}
174
+
</p>
175
+
<div className="space-y-3">
176
+
<div className="space-y-2">
177
+
<Label htmlFor="wisp-handle">Choose your handle</Label>
178
+
<div className="flex gap-2">
179
+
<div className="flex-1 relative">
180
+
<Input
181
+
id="wisp-handle"
182
+
placeholder="mysite"
183
+
value={wispHandle}
184
+
onChange={(e) => {
185
+
setWispHandle(e.target.value)
186
+
if (e.target.value.trim()) {
187
+
checkWispAvailability(e.target.value)
188
+
} else {
189
+
setWispAvailability({ available: null, checking: false })
190
+
}
191
+
}}
192
+
disabled={isClaimingWisp}
193
+
className="pr-24"
194
+
/>
195
+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
196
+
.wisp.place
197
+
</span>
198
+
</div>
199
+
</div>
200
+
{wispAvailability.checking && (
201
+
<p className="text-xs text-muted-foreground flex items-center gap-1">
202
+
<Loader2 className="w-3 h-3 animate-spin" />
203
+
Checking availability...
204
+
</p>
205
+
)}
206
+
{!wispAvailability.checking && wispAvailability.available === true && (
207
+
<p className="text-xs text-green-600 flex items-center gap-1">
208
+
<CheckCircle2 className="w-3 h-3" />
209
+
Available
210
+
</p>
211
+
)}
212
+
{!wispAvailability.checking && wispAvailability.available === false && (
213
+
<p className="text-xs text-red-600 flex items-center gap-1">
214
+
<XCircle className="w-3 h-3" />
215
+
Not available
216
+
</p>
217
+
)}
218
+
</div>
219
+
<Button
220
+
onClick={handleClaimWispDomain}
221
+
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
222
+
className="w-full"
223
+
>
224
+
{isClaimingWisp ? (
225
+
<>
226
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
227
+
Claiming...
228
+
</>
229
+
) : (
230
+
'Claim Subdomain'
231
+
)}
232
+
</Button>
233
+
</div>
234
+
</div>
235
+
)}
236
+
237
+
{wispDomains.length === 3 && (
238
+
<div className="p-3 bg-muted/30 rounded-lg text-center">
239
+
<p className="text-sm text-muted-foreground">
240
+
You have claimed the maximum of 3 wisp.place subdomains
241
+
</p>
242
+
</div>
243
+
)}
244
+
</div>
245
+
)}
246
+
</CardContent>
247
+
</Card>
248
+
249
+
<Card>
250
+
<CardHeader>
251
+
<CardTitle>Custom Domains</CardTitle>
252
+
<CardDescription>
253
+
Bring your own domain with DNS verification
254
+
</CardDescription>
255
+
</CardHeader>
256
+
<CardContent className="space-y-4">
257
+
<Button
258
+
onClick={() => setAddDomainModalOpen(true)}
259
+
className="w-full"
260
+
>
261
+
Add Custom Domain
262
+
</Button>
263
+
264
+
{domainsLoading ? (
265
+
<div className="flex items-center justify-center py-4">
266
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
267
+
</div>
268
+
) : customDomains.length === 0 ? (
269
+
<div className="text-center py-4 text-muted-foreground text-sm">
270
+
No custom domains added yet
271
+
</div>
272
+
) : (
273
+
<div className="space-y-2">
274
+
{customDomains.map((domain) => (
275
+
<div
276
+
key={domain.id}
277
+
className="flex items-center justify-between p-3 border border-border rounded-lg"
278
+
>
279
+
<div className="flex flex-col gap-1 flex-1">
280
+
<div className="flex items-center gap-2">
281
+
{domain.verified ? (
282
+
<CheckCircle2 className="w-4 h-4 text-green-500" />
283
+
) : (
284
+
<XCircle className="w-4 h-4 text-red-500" />
285
+
)}
286
+
<span className="font-mono">
287
+
{domain.domain}
288
+
</span>
289
+
</div>
290
+
{domain.rkey && domain.rkey !== 'self' && (
291
+
<p className="text-xs text-muted-foreground ml-6">
292
+
โ Mapped to site: {domain.rkey}
293
+
</p>
294
+
)}
295
+
</div>
296
+
<div className="flex items-center gap-2">
297
+
<Button
298
+
variant="outline"
299
+
size="sm"
300
+
onClick={() =>
301
+
setViewDomainDNS(domain.id)
302
+
}
303
+
>
304
+
View DNS
305
+
</Button>
306
+
{domain.verified ? (
307
+
<Badge variant="secondary">
308
+
Verified
309
+
</Badge>
310
+
) : (
311
+
<Button
312
+
variant="outline"
313
+
size="sm"
314
+
onClick={() =>
315
+
onVerifyDomain(domain.id)
316
+
}
317
+
disabled={
318
+
verificationStatus[
319
+
domain.id
320
+
] === 'verifying'
321
+
}
322
+
>
323
+
{verificationStatus[
324
+
domain.id
325
+
] === 'verifying' ? (
326
+
<>
327
+
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
328
+
Verifying...
329
+
</>
330
+
) : (
331
+
'Verify DNS'
332
+
)}
333
+
</Button>
334
+
)}
335
+
<Button
336
+
variant="ghost"
337
+
size="sm"
338
+
onClick={() =>
339
+
onDeleteCustomDomain(
340
+
domain.id
341
+
)
342
+
}
343
+
>
344
+
<Trash2 className="w-4 h-4" />
345
+
</Button>
346
+
</div>
347
+
</div>
348
+
))}
349
+
</div>
350
+
)}
351
+
</CardContent>
352
+
</Card>
353
+
</div>
354
+
355
+
{/* Add Custom Domain Modal */}
356
+
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
357
+
<DialogContent className="sm:max-w-lg">
358
+
<DialogHeader>
359
+
<DialogTitle>Add Custom Domain</DialogTitle>
360
+
<DialogDescription>
361
+
Enter your domain name. After adding, you'll see the DNS
362
+
records to configure.
363
+
</DialogDescription>
364
+
</DialogHeader>
365
+
<div className="space-y-4 py-4">
366
+
<div className="space-y-2">
367
+
<Label htmlFor="new-domain">Domain Name</Label>
368
+
<Input
369
+
id="new-domain"
370
+
placeholder="example.com"
371
+
value={customDomain}
372
+
onChange={(e) => setCustomDomain(e.target.value)}
373
+
/>
374
+
<p className="text-xs text-muted-foreground">
375
+
After adding, click "View DNS" to see the records you
376
+
need to configure.
377
+
</p>
378
+
</div>
379
+
</div>
380
+
<DialogFooter className="flex-col sm:flex-row gap-2">
381
+
<Button
382
+
variant="outline"
383
+
onClick={() => {
384
+
setAddDomainModalOpen(false)
385
+
setCustomDomain('')
386
+
}}
387
+
className="w-full sm:w-auto"
388
+
disabled={isAddingDomain}
389
+
>
390
+
Cancel
391
+
</Button>
392
+
<Button
393
+
onClick={handleAddCustomDomain}
394
+
disabled={!customDomain || isAddingDomain}
395
+
className="w-full sm:w-auto"
396
+
>
397
+
{isAddingDomain ? (
398
+
<>
399
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
400
+
Adding...
401
+
</>
402
+
) : (
403
+
'Add Domain'
404
+
)}
405
+
</Button>
406
+
</DialogFooter>
407
+
</DialogContent>
408
+
</Dialog>
409
+
410
+
{/* View DNS Records Modal */}
411
+
<Dialog
412
+
open={viewDomainDNS !== null}
413
+
onOpenChange={(open) => !open && setViewDomainDNS(null)}
414
+
>
415
+
<DialogContent className="sm:max-w-lg">
416
+
<DialogHeader>
417
+
<DialogTitle>DNS Configuration</DialogTitle>
418
+
<DialogDescription>
419
+
Add these DNS records to your domain provider
420
+
</DialogDescription>
421
+
</DialogHeader>
422
+
{viewDomainDNS && userInfo && (
423
+
<>
424
+
{(() => {
425
+
const domain = customDomains.find(
426
+
(d) => d.id === viewDomainDNS
427
+
)
428
+
if (!domain) return null
429
+
430
+
return (
431
+
<div className="space-y-4 py-4">
432
+
<div className="p-3 bg-muted/30 rounded-lg">
433
+
<p className="text-sm font-medium mb-1">
434
+
Domain:
435
+
</p>
436
+
<p className="font-mono text-sm">
437
+
{domain.domain}
438
+
</p>
439
+
</div>
440
+
441
+
<div className="space-y-3">
442
+
<div className="p-3 bg-background rounded border border-border">
443
+
<div className="flex justify-between items-start mb-2">
444
+
<span className="text-xs font-semibold text-muted-foreground">
445
+
TXT Record (Verification)
446
+
</span>
447
+
</div>
448
+
<div className="font-mono text-xs space-y-2">
449
+
<div>
450
+
<span className="text-muted-foreground">
451
+
Name:
452
+
</span>{' '}
453
+
<span className="select-all">
454
+
_wisp.{domain.domain}
455
+
</span>
456
+
</div>
457
+
<div>
458
+
<span className="text-muted-foreground">
459
+
Value:
460
+
</span>{' '}
461
+
<span className="select-all break-all">
462
+
{userInfo.did}
463
+
</span>
464
+
</div>
465
+
</div>
466
+
</div>
467
+
468
+
<div className="p-3 bg-background rounded border border-border">
469
+
<div className="flex justify-between items-start mb-2">
470
+
<span className="text-xs font-semibold text-muted-foreground">
471
+
CNAME Record (Pointing)
472
+
</span>
473
+
</div>
474
+
<div className="font-mono text-xs space-y-2">
475
+
<div>
476
+
<span className="text-muted-foreground">
477
+
Name:
478
+
</span>{' '}
479
+
<span className="select-all">
480
+
{domain.domain}
481
+
</span>
482
+
</div>
483
+
<div>
484
+
<span className="text-muted-foreground">
485
+
Value:
486
+
</span>{' '}
487
+
<span className="select-all">
488
+
{domain.id}.dns.wisp.place
489
+
</span>
490
+
</div>
491
+
</div>
492
+
<p className="text-xs text-muted-foreground mt-2">
493
+
Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification.
494
+
</p>
495
+
</div>
496
+
</div>
497
+
498
+
<div className="p-3 bg-muted/30 rounded-lg">
499
+
<p className="text-xs text-muted-foreground">
500
+
๐ก After configuring DNS, click "Verify DNS"
501
+
to check if everything is set up correctly.
502
+
DNS changes can take a few minutes to
503
+
propagate.
504
+
</p>
505
+
</div>
506
+
</div>
507
+
)
508
+
})()}
509
+
</>
510
+
)}
511
+
<DialogFooter>
512
+
<Button
513
+
variant="outline"
514
+
onClick={() => setViewDomainDNS(null)}
515
+
className="w-full sm:w-auto"
516
+
>
517
+
Close
518
+
</Button>
519
+
</DialogFooter>
520
+
</DialogContent>
521
+
</Dialog>
522
+
</>
523
+
)
524
+
}
+196
public/editor/tabs/SitesTab.tsx
+196
public/editor/tabs/SitesTab.tsx
···
1
+
import {
2
+
Card,
3
+
CardContent,
4
+
CardDescription,
5
+
CardHeader,
6
+
CardTitle
7
+
} from '@public/components/ui/card'
8
+
import { Button } from '@public/components/ui/button'
9
+
import { Badge } from '@public/components/ui/badge'
10
+
import {
11
+
Globe,
12
+
ExternalLink,
13
+
CheckCircle2,
14
+
AlertCircle,
15
+
Loader2,
16
+
RefreshCw,
17
+
Settings
18
+
} from 'lucide-react'
19
+
import type { SiteWithDomains } from '../hooks/useSiteData'
20
+
import type { UserInfo } from '../hooks/useUserInfo'
21
+
22
+
interface SitesTabProps {
23
+
sites: SiteWithDomains[]
24
+
sitesLoading: boolean
25
+
isSyncing: boolean
26
+
userInfo: UserInfo | null
27
+
onSyncSites: () => Promise<void>
28
+
onConfigureSite: (site: SiteWithDomains) => void
29
+
}
30
+
31
+
export function SitesTab({
32
+
sites,
33
+
sitesLoading,
34
+
isSyncing,
35
+
userInfo,
36
+
onSyncSites,
37
+
onConfigureSite
38
+
}: SitesTabProps) {
39
+
const getSiteUrl = (site: SiteWithDomains) => {
40
+
// Use the first mapped domain if available
41
+
if (site.domains && site.domains.length > 0) {
42
+
return `https://${site.domains[0].domain}`
43
+
}
44
+
45
+
// Default fallback URL - use handle instead of DID
46
+
if (!userInfo) return '#'
47
+
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
48
+
}
49
+
50
+
const getSiteDomainName = (site: SiteWithDomains) => {
51
+
// Return the first domain if available
52
+
if (site.domains && site.domains.length > 0) {
53
+
return site.domains[0].domain
54
+
}
55
+
56
+
// Use handle instead of DID for display
57
+
if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
58
+
return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
59
+
}
60
+
61
+
return (
62
+
<div className="space-y-4 min-h-[400px]">
63
+
<Card>
64
+
<CardHeader>
65
+
<div className="flex items-center justify-between">
66
+
<div>
67
+
<CardTitle>Your Sites</CardTitle>
68
+
<CardDescription>
69
+
View and manage all your deployed sites
70
+
</CardDescription>
71
+
</div>
72
+
<Button
73
+
variant="outline"
74
+
size="sm"
75
+
onClick={onSyncSites}
76
+
disabled={isSyncing || sitesLoading}
77
+
>
78
+
<RefreshCw
79
+
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
80
+
/>
81
+
Sync from PDS
82
+
</Button>
83
+
</div>
84
+
</CardHeader>
85
+
<CardContent className="space-y-4">
86
+
{sitesLoading ? (
87
+
<div className="flex items-center justify-center py-8">
88
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
89
+
</div>
90
+
) : sites.length === 0 ? (
91
+
<div className="text-center py-8 text-muted-foreground">
92
+
<p>No sites yet. Upload your first site!</p>
93
+
</div>
94
+
) : (
95
+
sites.map((site) => (
96
+
<div
97
+
key={`${site.did}-${site.rkey}`}
98
+
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
99
+
>
100
+
<div className="flex-1">
101
+
<div className="flex items-center gap-3 mb-2">
102
+
<h3 className="font-semibold text-lg">
103
+
{site.display_name || site.rkey}
104
+
</h3>
105
+
<Badge
106
+
variant="secondary"
107
+
className="text-xs"
108
+
>
109
+
active
110
+
</Badge>
111
+
</div>
112
+
113
+
{/* Display all mapped domains */}
114
+
{site.domains && site.domains.length > 0 ? (
115
+
<div className="space-y-1">
116
+
{site.domains.map((domainInfo, idx) => (
117
+
<div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
118
+
<a
119
+
href={`https://${domainInfo.domain}`}
120
+
target="_blank"
121
+
rel="noopener noreferrer"
122
+
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
123
+
>
124
+
<Globe className="w-3 h-3" />
125
+
{domainInfo.domain}
126
+
<ExternalLink className="w-3 h-3" />
127
+
</a>
128
+
<Badge
129
+
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
130
+
className="text-xs"
131
+
>
132
+
{domainInfo.type}
133
+
</Badge>
134
+
{domainInfo.type === 'custom' && (
135
+
<Badge
136
+
variant={domainInfo.verified ? 'default' : 'secondary'}
137
+
className="text-xs"
138
+
>
139
+
{domainInfo.verified ? (
140
+
<>
141
+
<CheckCircle2 className="w-3 h-3 mr-1" />
142
+
verified
143
+
</>
144
+
) : (
145
+
<>
146
+
<AlertCircle className="w-3 h-3 mr-1" />
147
+
pending
148
+
</>
149
+
)}
150
+
</Badge>
151
+
)}
152
+
</div>
153
+
))}
154
+
</div>
155
+
) : (
156
+
<a
157
+
href={getSiteUrl(site)}
158
+
target="_blank"
159
+
rel="noopener noreferrer"
160
+
className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
161
+
>
162
+
{getSiteDomainName(site)}
163
+
<ExternalLink className="w-3 h-3" />
164
+
</a>
165
+
)}
166
+
</div>
167
+
<Button
168
+
variant="outline"
169
+
size="sm"
170
+
onClick={() => onConfigureSite(site)}
171
+
>
172
+
<Settings className="w-4 h-4 mr-2" />
173
+
Configure
174
+
</Button>
175
+
</div>
176
+
))
177
+
)}
178
+
</CardContent>
179
+
</Card>
180
+
181
+
<div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50">
182
+
<div className="flex items-start gap-2">
183
+
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
184
+
<div className="flex-1 space-y-1">
185
+
<p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400">
186
+
Note about sites.wisp.place URLs
187
+
</p>
188
+
<p className="text-xs text-muted-foreground">
189
+
Complex sites hosted on <code className="px-1 py-0.5 bg-background rounded text-xs">sites.wisp.place</code> may have broken assets if they use absolute paths (e.g., <code className="px-1 py-0.5 bg-background rounded text-xs">/folder/script.js</code>) in CSS or JavaScript files. While HTML paths are automatically rewritten, CSS and JS files are served as-is. For best results, use a wisp.place subdomain or custom domain, or ensure your site uses relative paths.
190
+
</p>
191
+
</div>
192
+
</div>
193
+
</div>
194
+
</div>
195
+
)
196
+
}
+323
public/editor/tabs/UploadTab.tsx
+323
public/editor/tabs/UploadTab.tsx
···
1
+
import { useState, useEffect } from 'react'
2
+
import {
3
+
Card,
4
+
CardContent,
5
+
CardDescription,
6
+
CardHeader,
7
+
CardTitle
8
+
} from '@public/components/ui/card'
9
+
import { Button } from '@public/components/ui/button'
10
+
import { Input } from '@public/components/ui/input'
11
+
import { Label } from '@public/components/ui/label'
12
+
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
13
+
import { Badge } from '@public/components/ui/badge'
14
+
import {
15
+
Globe,
16
+
Upload,
17
+
AlertCircle,
18
+
Loader2
19
+
} from 'lucide-react'
20
+
import type { SiteWithDomains } from '../hooks/useSiteData'
21
+
22
+
interface UploadTabProps {
23
+
sites: SiteWithDomains[]
24
+
sitesLoading: boolean
25
+
onUploadComplete: () => Promise<void>
26
+
}
27
+
28
+
export function UploadTab({
29
+
sites,
30
+
sitesLoading,
31
+
onUploadComplete
32
+
}: UploadTabProps) {
33
+
// Upload state
34
+
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
35
+
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
36
+
const [newSiteName, setNewSiteName] = useState('')
37
+
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
38
+
const [isUploading, setIsUploading] = useState(false)
39
+
const [uploadProgress, setUploadProgress] = useState('')
40
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
41
+
const [uploadedCount, setUploadedCount] = useState(0)
42
+
43
+
// Auto-switch to 'new' mode if no sites exist
44
+
useEffect(() => {
45
+
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
46
+
setSiteMode('new')
47
+
}
48
+
}, [sites, sitesLoading, siteMode])
49
+
50
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
51
+
if (e.target.files && e.target.files.length > 0) {
52
+
setSelectedFiles(e.target.files)
53
+
}
54
+
}
55
+
56
+
const handleUpload = async () => {
57
+
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
58
+
59
+
if (!siteName) {
60
+
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
61
+
return
62
+
}
63
+
64
+
setIsUploading(true)
65
+
setUploadProgress('Preparing files...')
66
+
67
+
try {
68
+
const formData = new FormData()
69
+
formData.append('siteName', siteName)
70
+
71
+
if (selectedFiles) {
72
+
for (let i = 0; i < selectedFiles.length; i++) {
73
+
formData.append('files', selectedFiles[i])
74
+
}
75
+
}
76
+
77
+
setUploadProgress('Uploading to AT Protocol...')
78
+
const response = await fetch('/wisp/upload-files', {
79
+
method: 'POST',
80
+
body: formData
81
+
})
82
+
83
+
const data = await response.json()
84
+
if (data.success) {
85
+
setUploadProgress('Upload complete!')
86
+
setSkippedFiles(data.skippedFiles || [])
87
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
88
+
setSelectedSiteRkey('')
89
+
setNewSiteName('')
90
+
setSelectedFiles(null)
91
+
92
+
// Refresh sites list
93
+
await onUploadComplete()
94
+
95
+
// Reset form - give more time if there are skipped files
96
+
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
97
+
setTimeout(() => {
98
+
setUploadProgress('')
99
+
setSkippedFiles([])
100
+
setUploadedCount(0)
101
+
setIsUploading(false)
102
+
}, resetDelay)
103
+
} else {
104
+
throw new Error(data.error || 'Upload failed')
105
+
}
106
+
} catch (err) {
107
+
console.error('Upload error:', err)
108
+
alert(
109
+
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
110
+
)
111
+
setIsUploading(false)
112
+
setUploadProgress('')
113
+
}
114
+
}
115
+
116
+
return (
117
+
<div className="space-y-4 min-h-[400px]">
118
+
<Card>
119
+
<CardHeader>
120
+
<CardTitle>Upload Site</CardTitle>
121
+
<CardDescription>
122
+
Deploy a new site from a folder or Git repository
123
+
</CardDescription>
124
+
</CardHeader>
125
+
<CardContent className="space-y-6">
126
+
<div className="space-y-4">
127
+
<div className="p-4 bg-muted/50 rounded-lg">
128
+
<RadioGroup
129
+
value={siteMode}
130
+
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
131
+
disabled={isUploading}
132
+
>
133
+
<div className="flex items-center space-x-2">
134
+
<RadioGroupItem value="existing" id="existing" />
135
+
<Label htmlFor="existing" className="cursor-pointer">
136
+
Update existing site
137
+
</Label>
138
+
</div>
139
+
<div className="flex items-center space-x-2">
140
+
<RadioGroupItem value="new" id="new" />
141
+
<Label htmlFor="new" className="cursor-pointer">
142
+
Create new site
143
+
</Label>
144
+
</div>
145
+
</RadioGroup>
146
+
</div>
147
+
148
+
{siteMode === 'existing' ? (
149
+
<div className="space-y-2">
150
+
<Label htmlFor="site-select">Select Site</Label>
151
+
{sitesLoading ? (
152
+
<div className="flex items-center justify-center py-4">
153
+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
154
+
</div>
155
+
) : sites.length === 0 ? (
156
+
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
157
+
No sites available. Create a new site instead.
158
+
</div>
159
+
) : (
160
+
<select
161
+
id="site-select"
162
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
163
+
value={selectedSiteRkey}
164
+
onChange={(e) => setSelectedSiteRkey(e.target.value)}
165
+
disabled={isUploading}
166
+
>
167
+
<option value="">Select a site...</option>
168
+
{sites.map((site) => (
169
+
<option key={site.rkey} value={site.rkey}>
170
+
{site.display_name || site.rkey}
171
+
</option>
172
+
))}
173
+
</select>
174
+
)}
175
+
</div>
176
+
) : (
177
+
<div className="space-y-2">
178
+
<Label htmlFor="new-site-name">New Site Name</Label>
179
+
<Input
180
+
id="new-site-name"
181
+
placeholder="my-awesome-site"
182
+
value={newSiteName}
183
+
onChange={(e) => setNewSiteName(e.target.value)}
184
+
disabled={isUploading}
185
+
/>
186
+
</div>
187
+
)}
188
+
189
+
<p className="text-xs text-muted-foreground">
190
+
File limits: 100MB per file, 300MB total
191
+
</p>
192
+
</div>
193
+
194
+
<div className="grid md:grid-cols-2 gap-4">
195
+
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
196
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
197
+
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
198
+
<h3 className="font-semibold mb-2">
199
+
Upload Folder
200
+
</h3>
201
+
<p className="text-sm text-muted-foreground mb-4">
202
+
Drag and drop or click to upload your
203
+
static site files
204
+
</p>
205
+
<input
206
+
type="file"
207
+
id="file-upload"
208
+
multiple
209
+
onChange={handleFileSelect}
210
+
className="hidden"
211
+
{...(({ webkitdirectory: '', directory: '' } as any))}
212
+
disabled={isUploading}
213
+
/>
214
+
<label htmlFor="file-upload">
215
+
<Button
216
+
variant="outline"
217
+
type="button"
218
+
onClick={() =>
219
+
document
220
+
.getElementById('file-upload')
221
+
?.click()
222
+
}
223
+
disabled={isUploading}
224
+
>
225
+
Choose Folder
226
+
</Button>
227
+
</label>
228
+
{selectedFiles && selectedFiles.length > 0 && (
229
+
<p className="text-sm text-muted-foreground mt-3">
230
+
{selectedFiles.length} files selected
231
+
</p>
232
+
)}
233
+
</CardContent>
234
+
</Card>
235
+
236
+
<Card className="border-2 border-dashed opacity-50">
237
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
238
+
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
239
+
<h3 className="font-semibold mb-2">
240
+
Connect Git Repository
241
+
</h3>
242
+
<p className="text-sm text-muted-foreground mb-4">
243
+
Link your GitHub, GitLab, or any Git
244
+
repository
245
+
</p>
246
+
<Badge variant="secondary">Coming soon!</Badge>
247
+
</CardContent>
248
+
</Card>
249
+
</div>
250
+
251
+
{uploadProgress && (
252
+
<div className="space-y-3">
253
+
<div className="p-4 bg-muted rounded-lg">
254
+
<div className="flex items-center gap-2">
255
+
<Loader2 className="w-4 h-4 animate-spin" />
256
+
<span className="text-sm">{uploadProgress}</span>
257
+
</div>
258
+
</div>
259
+
260
+
{skippedFiles.length > 0 && (
261
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
262
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
263
+
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
264
+
<div className="flex-1">
265
+
<span className="font-medium">
266
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
267
+
</span>
268
+
{uploadedCount > 0 && (
269
+
<span className="text-sm ml-2">
270
+
({uploadedCount} uploaded successfully)
271
+
</span>
272
+
)}
273
+
</div>
274
+
</div>
275
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
276
+
{skippedFiles.slice(0, 5).map((file, idx) => (
277
+
<div key={idx} className="text-xs">
278
+
<span className="font-mono">{file.name}</span>
279
+
<span className="text-muted-foreground"> - {file.reason}</span>
280
+
</div>
281
+
))}
282
+
{skippedFiles.length > 5 && (
283
+
<div className="text-xs text-muted-foreground">
284
+
...and {skippedFiles.length - 5} more
285
+
</div>
286
+
)}
287
+
</div>
288
+
</div>
289
+
)}
290
+
</div>
291
+
)}
292
+
293
+
<Button
294
+
onClick={handleUpload}
295
+
className="w-full"
296
+
disabled={
297
+
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
298
+
isUploading ||
299
+
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
300
+
}
301
+
>
302
+
{isUploading ? (
303
+
<>
304
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
305
+
Uploading...
306
+
</>
307
+
) : (
308
+
<>
309
+
{siteMode === 'existing' ? (
310
+
'Update Site'
311
+
) : (
312
+
selectedFiles && selectedFiles.length > 0
313
+
? 'Upload & Deploy'
314
+
: 'Create Empty Site'
315
+
)}
316
+
</>
317
+
)}
318
+
</Button>
319
+
</CardContent>
320
+
</Card>
321
+
</div>
322
+
)
323
+
}
public/favicon-16x16.png
public/favicon-16x16.png
This is a binary file and will not be displayed.
public/favicon-32x32.png
public/favicon-32x32.png
This is a binary file and will not be displayed.
public/favicon.ico
public/favicon.ico
This is a binary file and will not be displayed.
-14
public/favicon.svg
-14
public/favicon.svg
···
1
-
<!--?xml version="1.0" encoding="utf-8"?-->
2
-
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Centered large wisp on black background">
3
-
<!-- black background -->
4
-
<rect width="64" height="64" fill="#000000"></rect>
5
-
6
-
<!-- outer faint glow -->
7
-
<circle cx="32" cy="32" r="14" fill="none" stroke="#7CF5D8" stroke-opacity="0.35" stroke-width="2.6"></circle>
8
-
9
-
<!-- bright halo -->
10
-
<circle cx="32" cy="32" r="10" fill="none" stroke="#CFF8EE" stroke-opacity="0.95" stroke-width="2.4"></circle>
11
-
12
-
<!-- bright core -->
13
-
<circle cx="32" cy="32" r="4" fill="#FFFFFF"></circle>
14
-
</svg>
+23
-1
public/index.html
+23
-1
public/index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Elysia Static</title>
6
+
<title>wisp.place</title>
7
+
<meta name="description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution. Built on Bluesky's decentralized network." />
8
+
9
+
<!-- Open Graph / Facebook -->
10
+
<meta property="og:type" content="website" />
11
+
<meta property="og:url" content="https://wisp.place/" />
12
+
<meta property="og:title" content="wisp.place - Decentralized Static Site Hosting" />
13
+
<meta property="og:description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution." />
14
+
<meta property="og:site_name" content="wisp.place" />
15
+
16
+
<!-- Twitter -->
17
+
<meta name="twitter:card" content="summary_large_image" />
18
+
<meta name="twitter:url" content="https://wisp.place/" />
19
+
<meta name="twitter:title" content="wisp.place - Decentralized Static Site Hosting" />
20
+
<meta name="twitter:description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution." />
21
+
22
+
<!-- Theme -->
23
+
<meta name="theme-color" content="#7c3aed" />
24
+
7
25
<link rel="icon" type="image/x-icon" href="./favicon.ico">
26
+
<link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png">
27
+
<link rel="icon" type="image/png" sizes="16x16" href="./favicon-16x16.png">
28
+
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png">
29
+
<link rel="manifest" href="./site.webmanifest">
8
30
</head>
9
31
<body>
10
32
<div id="elysia"></div>
+428
-16
public/index.tsx
+428
-16
public/index.tsx
···
1
-
import { useState, useRef, useEffect } from 'react'
1
+
import React, { useState, useRef, useEffect } from 'react'
2
2
import { createRoot } from 'react-dom/client'
3
3
import {
4
4
ArrowRight,
···
9
9
Code,
10
10
Server
11
11
} from 'lucide-react'
12
-
13
12
import Layout from '@public/layouts'
14
13
import { Button } from '@public/components/ui/button'
15
14
import { Card } from '@public/components/ui/card'
15
+
import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui'
16
+
17
+
//Credit to https://tangled.org/@jakelazaroff.com/actor-typeahead
18
+
interface Actor {
19
+
handle: string
20
+
avatar?: string
21
+
displayName?: string
22
+
}
23
+
24
+
interface ActorTypeaheadProps {
25
+
children: React.ReactElement<React.InputHTMLAttributes<HTMLInputElement>>
26
+
host?: string
27
+
rows?: number
28
+
onSelect?: (handle: string) => void
29
+
autoSubmit?: boolean
30
+
}
31
+
32
+
const ActorTypeahead: React.FC<ActorTypeaheadProps> = ({
33
+
children,
34
+
host = 'https://public.api.bsky.app',
35
+
rows = 5,
36
+
onSelect,
37
+
autoSubmit = false
38
+
}) => {
39
+
const [actors, setActors] = useState<Actor[]>([])
40
+
const [index, setIndex] = useState(-1)
41
+
const [pressed, setPressed] = useState(false)
42
+
const [isOpen, setIsOpen] = useState(false)
43
+
const containerRef = useRef<HTMLDivElement>(null)
44
+
const inputRef = useRef<HTMLInputElement>(null)
45
+
const lastQueryRef = useRef<string>('')
46
+
const previousValueRef = useRef<string>('')
47
+
const preserveIndexRef = useRef(false)
48
+
49
+
const handleInput = async (e: React.FormEvent<HTMLInputElement>) => {
50
+
const query = e.currentTarget.value
51
+
52
+
// Check if the value actually changed (filter out arrow key events)
53
+
if (query === previousValueRef.current) {
54
+
return
55
+
}
56
+
previousValueRef.current = query
57
+
58
+
if (!query) {
59
+
setActors([])
60
+
setIndex(-1)
61
+
setIsOpen(false)
62
+
lastQueryRef.current = ''
63
+
return
64
+
}
65
+
66
+
// Store the query for this request
67
+
const currentQuery = query
68
+
lastQueryRef.current = currentQuery
69
+
70
+
try {
71
+
const url = new URL('xrpc/app.bsky.actor.searchActorsTypeahead', host)
72
+
url.searchParams.set('q', query)
73
+
url.searchParams.set('limit', `${rows}`)
74
+
75
+
const res = await fetch(url)
76
+
const json = await res.json()
77
+
78
+
// Only update if this is still the latest query
79
+
if (lastQueryRef.current === currentQuery) {
80
+
setActors(json.actors || [])
81
+
// Only reset index if we're not preserving it
82
+
if (!preserveIndexRef.current) {
83
+
setIndex(-1)
84
+
}
85
+
preserveIndexRef.current = false
86
+
setIsOpen(true)
87
+
}
88
+
} catch (error) {
89
+
console.error('Failed to fetch actors:', error)
90
+
if (lastQueryRef.current === currentQuery) {
91
+
setActors([])
92
+
setIsOpen(false)
93
+
}
94
+
}
95
+
}
96
+
97
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
98
+
const navigationKeys = ['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Enter', 'Escape']
99
+
100
+
// Mark that we should preserve the index for navigation keys
101
+
if (navigationKeys.includes(e.key)) {
102
+
preserveIndexRef.current = true
103
+
}
104
+
105
+
if (!isOpen || actors.length === 0) return
106
+
107
+
switch (e.key) {
108
+
case 'ArrowDown':
109
+
e.preventDefault()
110
+
setIndex((prev) => {
111
+
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, actors.length - 1)
112
+
return newIndex
113
+
})
114
+
break
115
+
case 'PageDown':
116
+
e.preventDefault()
117
+
setIndex(actors.length - 1)
118
+
break
119
+
case 'ArrowUp':
120
+
e.preventDefault()
121
+
setIndex((prev) => {
122
+
const newIndex = prev < 0 ? 0 : Math.max(prev - 1, 0)
123
+
return newIndex
124
+
})
125
+
break
126
+
case 'PageUp':
127
+
e.preventDefault()
128
+
setIndex(0)
129
+
break
130
+
case 'Escape':
131
+
e.preventDefault()
132
+
setActors([])
133
+
setIndex(-1)
134
+
setIsOpen(false)
135
+
break
136
+
case 'Enter':
137
+
if (index >= 0 && index < actors.length) {
138
+
e.preventDefault()
139
+
selectActor(actors[index].handle)
140
+
}
141
+
break
142
+
}
143
+
}
144
+
145
+
const selectActor = (handle: string) => {
146
+
if (inputRef.current) {
147
+
inputRef.current.value = handle
148
+
}
149
+
setActors([])
150
+
setIndex(-1)
151
+
setIsOpen(false)
152
+
onSelect?.(handle)
153
+
154
+
// Auto-submit the form if enabled
155
+
if (autoSubmit && inputRef.current) {
156
+
const form = inputRef.current.closest('form')
157
+
if (form) {
158
+
// Use setTimeout to ensure the value is set before submission
159
+
setTimeout(() => {
160
+
form.requestSubmit()
161
+
}, 0)
162
+
}
163
+
}
164
+
}
165
+
166
+
const handleFocusOut = (e: React.FocusEvent) => {
167
+
if (pressed) return
168
+
setActors([])
169
+
setIndex(-1)
170
+
setIsOpen(false)
171
+
}
172
+
173
+
// Clone the input element and add our event handlers
174
+
const input = React.cloneElement(children, {
175
+
ref: (el: HTMLInputElement) => {
176
+
inputRef.current = el
177
+
// Preserve the original ref if it exists
178
+
const originalRef = (children as any).ref
179
+
if (typeof originalRef === 'function') {
180
+
originalRef(el)
181
+
} else if (originalRef) {
182
+
originalRef.current = el
183
+
}
184
+
},
185
+
onInput: (e: React.FormEvent<HTMLInputElement>) => {
186
+
handleInput(e)
187
+
children.props.onInput?.(e)
188
+
},
189
+
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
190
+
handleKeyDown(e)
191
+
children.props.onKeyDown?.(e)
192
+
},
193
+
onBlur: (e: React.FocusEvent<HTMLInputElement>) => {
194
+
handleFocusOut(e)
195
+
children.props.onBlur?.(e)
196
+
},
197
+
autoComplete: 'off'
198
+
} as any)
199
+
200
+
return (
201
+
<div ref={containerRef} style={{ position: 'relative', display: 'block' }}>
202
+
{input}
203
+
{isOpen && actors.length > 0 && (
204
+
<ul
205
+
style={{
206
+
display: 'flex',
207
+
flexDirection: 'column',
208
+
position: 'absolute',
209
+
left: 0,
210
+
marginTop: '4px',
211
+
width: '100%',
212
+
listStyle: 'none',
213
+
overflow: 'hidden',
214
+
backgroundColor: 'rgba(255, 255, 255, 0.8)',
215
+
backgroundClip: 'padding-box',
216
+
backdropFilter: 'blur(12px)',
217
+
WebkitBackdropFilter: 'blur(12px)',
218
+
border: '1px solid rgba(0, 0, 0, 0.1)',
219
+
borderRadius: '8px',
220
+
boxShadow: '0 6px 6px -4px rgba(0, 0, 0, 0.2)',
221
+
padding: '4px',
222
+
margin: 0,
223
+
zIndex: 1000
224
+
}}
225
+
onMouseDown={() => setPressed(true)}
226
+
onMouseUp={() => {
227
+
setPressed(false)
228
+
inputRef.current?.focus()
229
+
}}
230
+
>
231
+
{actors.map((actor, i) => (
232
+
<li key={actor.handle}>
233
+
<button
234
+
type="button"
235
+
onClick={() => selectActor(actor.handle)}
236
+
style={{
237
+
all: 'unset',
238
+
boxSizing: 'border-box',
239
+
display: 'flex',
240
+
alignItems: 'center',
241
+
gap: '8px',
242
+
padding: '6px 8px',
243
+
width: '100%',
244
+
height: 'calc(1.5rem + 12px)',
245
+
borderRadius: '4px',
246
+
cursor: 'pointer',
247
+
backgroundColor: i === index ? 'hsl(var(--accent) / 0.5)' : 'transparent',
248
+
transition: 'background-color 0.1s'
249
+
}}
250
+
onMouseEnter={() => setIndex(i)}
251
+
>
252
+
<div
253
+
style={{
254
+
width: '1.5rem',
255
+
height: '1.5rem',
256
+
borderRadius: '50%',
257
+
backgroundColor: 'hsl(var(--muted))',
258
+
overflow: 'hidden',
259
+
flexShrink: 0
260
+
}}
261
+
>
262
+
{actor.avatar && (
263
+
<img
264
+
src={actor.avatar}
265
+
alt=""
266
+
style={{
267
+
display: 'block',
268
+
width: '100%',
269
+
height: '100%',
270
+
objectFit: 'cover'
271
+
}}
272
+
/>
273
+
)}
274
+
</div>
275
+
<span
276
+
style={{
277
+
whiteSpace: 'nowrap',
278
+
overflow: 'hidden',
279
+
textOverflow: 'ellipsis',
280
+
color: '#000000'
281
+
}}
282
+
>
283
+
{actor.handle}
284
+
</span>
285
+
</button>
286
+
</li>
287
+
))}
288
+
</ul>
289
+
)}
290
+
</div>
291
+
)
292
+
}
293
+
294
+
const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => {
295
+
const { record, rkey, loading } = useLatestRecord<FeedPostRecord>(
296
+
did,
297
+
'app.bsky.feed.post'
298
+
)
299
+
300
+
if (loading) return <span>Loadingโฆ</span>
301
+
if (!record || !rkey) return <span>No posts yet.</span>
302
+
303
+
return <BlueskyPost did={did} rkey={rkey} record={record} showParent={true} />
304
+
}
16
305
17
306
function App() {
18
307
const [showForm, setShowForm] = useState(false)
308
+
const [checkingAuth, setCheckingAuth] = useState(true)
19
309
const inputRef = useRef<HTMLInputElement>(null)
20
310
21
311
useEffect(() => {
312
+
// Check authentication status on mount
313
+
const checkAuth = async () => {
314
+
try {
315
+
const response = await fetch('/api/auth/status', {
316
+
credentials: 'include'
317
+
})
318
+
const data = await response.json()
319
+
if (data.authenticated) {
320
+
// User is already authenticated, redirect to editor
321
+
window.location.href = '/editor'
322
+
return
323
+
}
324
+
// If not authenticated, clear any stale cookies
325
+
document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'
326
+
} catch (error) {
327
+
console.error('Auth check failed:', error)
328
+
// Clear cookies on error as well
329
+
document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'
330
+
} finally {
331
+
setCheckingAuth(false)
332
+
}
333
+
}
334
+
335
+
checkAuth()
336
+
}, [])
337
+
338
+
useEffect(() => {
22
339
if (showForm) {
23
340
setTimeout(() => inputRef.current?.focus(), 500)
24
341
}
25
342
}, [showForm])
26
343
344
+
if (checkingAuth) {
345
+
return (
346
+
<div className="min-h-screen bg-background flex items-center justify-center">
347
+
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
348
+
</div>
349
+
)
350
+
}
351
+
27
352
return (
28
353
<>
29
354
<div className="min-h-screen">
···
31
356
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
32
357
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
33
358
<div className="flex items-center gap-2">
34
-
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
35
-
<Globe className="w-5 h-5 text-primary-foreground" />
36
-
</div>
359
+
<img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" />
37
360
<span className="text-xl font-semibold text-foreground">
38
361
wisp.place
39
362
</span>
···
49
372
<Button
50
373
size="sm"
51
374
className="bg-accent text-accent-foreground hover:bg-accent/90"
375
+
onClick={() => setShowForm(true)}
52
376
>
53
377
Get Started
54
378
</Button>
···
61
385
<div className="max-w-4xl mx-auto text-center">
62
386
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8">
63
387
<span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span>
64
-
<span className="text-sm text-accent-foreground">
388
+
<span className="text-sm text-foreground">
65
389
Built on AT Protocol
66
390
</span>
67
391
</div>
···
135
459
'Login failed:',
136
460
error
137
461
)
462
+
// Clear any invalid cookies
463
+
document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'
138
464
alert('Authentication failed')
139
465
}
140
466
}}
141
467
className="space-y-3"
142
468
>
143
-
<input
144
-
ref={inputRef}
145
-
type="text"
146
-
name="handle"
147
-
placeholder="Enter your handle (e.g., alice.bsky.social)"
148
-
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
149
-
/>
469
+
<ActorTypeahead
470
+
autoSubmit={true}
471
+
onSelect={(handle) => {
472
+
if (inputRef.current) {
473
+
inputRef.current.value = handle
474
+
}
475
+
}}
476
+
>
477
+
<input
478
+
ref={inputRef}
479
+
type="text"
480
+
name="handle"
481
+
placeholder="Enter your handle (e.g., alice.bsky.social)"
482
+
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
483
+
/>
484
+
</ActorTypeahead>
150
485
<button
151
486
type="submit"
152
487
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors"
···
286
621
287
622
{/* CTA Section */}
288
623
<section className="container mx-auto px-4 py-20">
624
+
<div className="max-w-6xl mx-auto">
625
+
<div className="text-center mb-12">
626
+
<h2 className="text-3xl md:text-4xl font-bold">
627
+
Follow on Bluesky for updates
628
+
</h2>
629
+
</div>
630
+
<div className="grid md:grid-cols-2 gap-8 items-center">
631
+
<Card
632
+
className="shadow-lg border-2 border-border overflow-hidden !py-3"
633
+
style={{
634
+
'--atproto-color-bg': 'var(--card)',
635
+
'--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)',
636
+
'--atproto-color-text': 'hsl(var(--foreground))',
637
+
'--atproto-color-text-secondary': 'hsl(var(--muted-foreground))',
638
+
'--atproto-color-link': 'hsl(var(--accent))',
639
+
'--atproto-color-link-hover': 'hsl(var(--accent))',
640
+
'--atproto-color-border': 'transparent',
641
+
} as AtProtoStyles}
642
+
>
643
+
<BlueskyPostList did="wisp.place" />
644
+
</Card>
645
+
<div className="space-y-6 w-full max-w-md mx-auto">
646
+
<Card
647
+
className="shadow-lg border-2 overflow-hidden relative !py-3"
648
+
style={{
649
+
'--atproto-color-bg': 'var(--card)',
650
+
'--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)',
651
+
'--atproto-color-text': 'hsl(var(--foreground))',
652
+
'--atproto-color-text-secondary': 'hsl(var(--muted-foreground))',
653
+
} as AtProtoStyles}
654
+
>
655
+
<BlueskyProfile did="wisp.place" />
656
+
</Card>
657
+
<Card
658
+
className="shadow-lg border-2 overflow-hidden relative !py-3"
659
+
style={{
660
+
'--atproto-color-bg': 'var(--card)',
661
+
'--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)',
662
+
'--atproto-color-text': 'hsl(var(--foreground))',
663
+
'--atproto-color-text-secondary': 'hsl(var(--muted-foreground))',
664
+
} as AtProtoStyles}
665
+
>
666
+
<LatestPostWithPrefetch did="wisp.place" />
667
+
</Card>
668
+
</div>
669
+
</div>
670
+
</div>
671
+
</section>
672
+
673
+
{/* Ready to Deploy CTA */}
674
+
<section className="container mx-auto px-4 py-20">
289
675
<div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12">
290
676
<h2 className="text-3xl md:text-4xl font-bold mb-4">
291
677
Ready to deploy?
···
319
705
>
320
706
@nekomimi.pet
321
707
</a>
708
+
{' โข '}
709
+
Contact:{' '}
710
+
<a
711
+
href="mailto:contact@wisp.place"
712
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
713
+
>
714
+
contact@wisp.place
715
+
</a>
716
+
{' โข '}
717
+
Legal/DMCA:{' '}
718
+
<a
719
+
href="mailto:legal@wisp.place"
720
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
721
+
>
722
+
legal@wisp.place
723
+
</a>
724
+
</p>
725
+
<p className="mt-2">
726
+
<a
727
+
href="/acceptable-use"
728
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
729
+
>
730
+
Acceptable Use Policy
731
+
</a>
322
732
</p>
323
733
</div>
324
734
</div>
···
330
740
331
741
const root = createRoot(document.getElementById('elysia')!)
332
742
root.render(
333
-
<Layout className="gap-6">
334
-
<App />
335
-
</Layout>
743
+
<AtProtoProvider>
744
+
<Layout className="gap-6">
745
+
<App />
746
+
</Layout>
747
+
</AtProtoProvider>
336
748
)
+18
-1
public/onboarding/index.html
+18
-1
public/onboarding/index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Get Started - wisp.place</title>
6
+
<title>wisp.place</title>
7
+
<meta name="description" content="Get started with wisp.place and host your first decentralized static site on AT Protocol." />
8
+
9
+
<!-- Open Graph / Facebook -->
10
+
<meta property="og:type" content="website" />
11
+
<meta property="og:url" content="https://wisp.place/onboarding" />
12
+
<meta property="og:title" content="Get Started - wisp.place" />
13
+
<meta property="og:description" content="Get started with wisp.place and host your first decentralized static site on AT Protocol." />
14
+
<meta property="og:site_name" content="wisp.place" />
15
+
16
+
<!-- Twitter -->
17
+
<meta name="twitter:card" content="summary" />
18
+
<meta name="twitter:url" content="https://wisp.place/onboarding" />
19
+
<meta name="twitter:title" content="Get Started - wisp.place" />
20
+
<meta name="twitter:description" content="Get started with wisp.place and host your first decentralized static site on AT Protocol." />
21
+
22
+
<!-- Theme -->
23
+
<meta name="theme-color" content="#7c3aed" />
7
24
</head>
8
25
<body>
9
26
<div id="elysia"></div>
+21
public/robots.txt
+21
public/robots.txt
···
1
+
# robots.txt for wisp.place
2
+
3
+
User-agent: *
4
+
5
+
# Allow indexing of landing page
6
+
Allow: /$
7
+
8
+
# Disallow application pages
9
+
Disallow: /editor
10
+
Disallow: /admin
11
+
Disallow: /onboarding
12
+
13
+
# Disallow API routes
14
+
Disallow: /api/
15
+
Disallow: /wisp/
16
+
17
+
# Allow static assets
18
+
Allow: /favicon.ico
19
+
Allow: /favicon-*.png
20
+
Allow: /apple-touch-icon.png
21
+
Allow: /site.webmanifest
+1
public/site.webmanifest
+1
public/site.webmanifest
···
1
+
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
public/transparent-full-size-ico.png
public/transparent-full-size-ico.png
This is a binary file and will not be displayed.
+2
-2
scripts/change-admin-password.ts
+2
-2
scripts/change-admin-password.ts
···
1
1
// Change admin password
2
-
import { adminAuth } from './src/lib/admin-auth'
3
-
import { db } from './src/lib/db'
2
+
import { adminAuth } from '../src/lib/admin-auth'
3
+
import { db } from '../src/lib/db'
4
4
import { randomBytes, createHash } from 'crypto'
5
5
6
6
// Get username and new password from command line
+21
-8
src/index.ts
+21
-8
src/index.ts
···
12
12
cleanupExpiredSessions,
13
13
rotateKeysIfNeeded
14
14
} from './lib/oauth-client'
15
+
import { getCookieSecret } from './lib/db'
15
16
import { authRoutes } from './routes/auth'
16
17
import { wispRoutes } from './routes/wisp'
17
18
import { domainRoutes } from './routes/domain'
···
30
31
31
32
// Initialize admin setup (prompt if no admin exists)
32
33
await promptAdminSetup()
34
+
35
+
// Get or generate cookie signing secret
36
+
const cookieSecret = await getCookieSecret()
33
37
34
38
const client = await getOAuthClient(config)
35
39
···
60
64
61
65
export const app = new Elysia({
62
66
serve: {
63
-
maxPayloadLength: 1024 * 1024 * 128 * 3,
67
+
maxRequestBodySize: 1024 * 1024 * 128 * 3,
64
68
development: Bun.env.NODE_ENV !== 'production' ? true : false,
65
69
id: Bun.env.NODE_ENV !== 'production' ? undefined : null,
70
+
},
71
+
cookie: {
72
+
secrets: cookieSecret,
73
+
sign: ['did']
66
74
}
67
75
})
68
76
// Observability middleware
···
96
104
})
97
105
.onError(observabilityMiddleware('main-app').onError)
98
106
.use(csrfProtection())
99
-
.use(authRoutes(client))
100
-
.use(wispRoutes(client))
101
-
.use(domainRoutes(client))
102
-
.use(userRoutes(client))
103
-
.use(siteRoutes(client))
104
-
.use(adminRoutes())
107
+
.use(authRoutes(client, cookieSecret))
108
+
.use(wispRoutes(client, cookieSecret))
109
+
.use(domainRoutes(client, cookieSecret))
110
+
.use(userRoutes(client, cookieSecret))
111
+
.use(siteRoutes(client, cookieSecret))
112
+
.use(adminRoutes(cookieSecret))
105
113
.use(
106
114
await staticPlugin({
107
115
prefix: '/'
···
110
118
.get('/client-metadata.json', () => {
111
119
return createClientMetadata(config)
112
120
})
113
-
.get('/jwks.json', async () => {
121
+
.get('/jwks.json', async ({ set }) => {
122
+
// Prevent caching to ensure clients always get fresh keys after rotation
123
+
set.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
124
+
set.headers['Pragma'] = 'no-cache'
125
+
set.headers['Expires'] = '0'
126
+
114
127
const keys = await getCurrentKeys()
115
128
if (!keys.length) return { keys: [] }
116
129
+182
-15
src/lib/db.ts
+182
-15
src/lib/db.ts
···
36
36
)
37
37
`;
38
38
39
-
// Domains table maps subdomain -> DID
39
+
// Cookie secrets table for signed cookies
40
+
await db`
41
+
CREATE TABLE IF NOT EXISTS cookie_secrets (
42
+
id TEXT PRIMARY KEY DEFAULT 'default',
43
+
secret TEXT NOT NULL,
44
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
45
+
)
46
+
`;
47
+
48
+
// Domains table maps subdomain -> DID (now supports up to 3 domains per user)
40
49
await db`
41
50
CREATE TABLE IF NOT EXISTS domains (
42
51
domain TEXT PRIMARY KEY,
43
-
did TEXT UNIQUE NOT NULL,
52
+
did TEXT NOT NULL,
44
53
rkey TEXT,
45
54
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
46
55
)
···
71
80
// Column might already exist, ignore
72
81
}
73
82
83
+
// Remove the unique constraint on domains.did to allow multiple domains per user
84
+
try {
85
+
await db`ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_did_key`;
86
+
} catch (err) {
87
+
// Constraint might already be removed, ignore
88
+
}
89
+
74
90
// Custom domains table for BYOD (bring your own domain)
75
91
await db`
76
92
CREATE TABLE IF NOT EXISTS custom_domains (
···
108
124
)
109
125
`;
110
126
127
+
// Create indexes for common query patterns
128
+
await Promise.all([
129
+
// oauth_states cleanup queries
130
+
db`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires_at ON oauth_states(expires_at)`.catch(err => {
131
+
if (!err.message?.includes('already exists')) {
132
+
console.error('Failed to create idx_oauth_states_expires_at:', err);
133
+
}
134
+
}),
135
+
136
+
// oauth_sessions cleanup queries
137
+
db`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires_at ON oauth_sessions(expires_at)`.catch(err => {
138
+
if (!err.message?.includes('already exists')) {
139
+
console.error('Failed to create idx_oauth_sessions_expires_at:', err);
140
+
}
141
+
}),
142
+
143
+
// oauth_keys key rotation queries
144
+
db`CREATE INDEX IF NOT EXISTS idx_oauth_keys_created_at ON oauth_keys(created_at)`.catch(err => {
145
+
if (!err.message?.includes('already exists')) {
146
+
console.error('Failed to create idx_oauth_keys_created_at:', err);
147
+
}
148
+
}),
149
+
150
+
// domains queries by (did, rkey)
151
+
db`CREATE INDEX IF NOT EXISTS idx_domains_did_rkey ON domains(did, rkey)`.catch(err => {
152
+
if (!err.message?.includes('already exists')) {
153
+
console.error('Failed to create idx_domains_did_rkey:', err);
154
+
}
155
+
}),
156
+
157
+
// custom_domains queries by did
158
+
db`CREATE INDEX IF NOT EXISTS idx_custom_domains_did ON custom_domains(did)`.catch(err => {
159
+
if (!err.message?.includes('already exists')) {
160
+
console.error('Failed to create idx_custom_domains_did:', err);
161
+
}
162
+
}),
163
+
164
+
// custom_domains queries by (did, rkey)
165
+
db`CREATE INDEX IF NOT EXISTS idx_custom_domains_did_rkey ON custom_domains(did, rkey)`.catch(err => {
166
+
if (!err.message?.includes('already exists')) {
167
+
console.error('Failed to create idx_custom_domains_did_rkey:', err);
168
+
}
169
+
}),
170
+
171
+
// custom_domains DNS verification worker queries
172
+
db`CREATE INDEX IF NOT EXISTS idx_custom_domains_verified ON custom_domains(verified)`.catch(err => {
173
+
if (!err.message?.includes('already exists')) {
174
+
console.error('Failed to create idx_custom_domains_verified:', err);
175
+
}
176
+
}),
177
+
178
+
// sites queries by did
179
+
db`CREATE INDEX IF NOT EXISTS idx_sites_did ON sites(did)`.catch(err => {
180
+
if (!err.message?.includes('already exists')) {
181
+
console.error('Failed to create idx_sites_did:', err);
182
+
}
183
+
})
184
+
]);
185
+
111
186
const RESERVED_HANDLES = new Set([
112
187
"www",
113
188
"api",
···
130
205
export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
131
206
132
207
export const getDomainByDid = async (did: string): Promise<string | null> => {
133
-
const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
208
+
const rows = await db`SELECT domain FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
134
209
return rows[0]?.domain ?? null;
135
210
};
136
211
137
212
export const getWispDomainInfo = async (did: string) => {
138
-
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`;
213
+
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
139
214
return rows[0] ?? null;
215
+
};
216
+
217
+
export const getAllWispDomains = async (did: string) => {
218
+
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC`;
219
+
return rows;
220
+
};
221
+
222
+
export const countWispDomains = async (did: string): Promise<number> => {
223
+
const rows = await db`SELECT COUNT(*) as count FROM domains WHERE did = ${did}`;
224
+
return Number(rows[0]?.count ?? 0);
140
225
};
141
226
142
227
export const getDidByDomain = async (domain: string): Promise<string | null> => {
···
192
277
export const claimDomain = async (did: string, handle: string): Promise<string> => {
193
278
const h = handle.trim().toLowerCase();
194
279
if (!isValidHandle(h)) throw new Error('invalid_handle');
280
+
281
+
// Check if user already has 3 domains
282
+
const existingCount = await countWispDomains(did);
283
+
if (existingCount >= 3) {
284
+
throw new Error('domain_limit_reached');
285
+
}
286
+
195
287
const domain = toDomain(h);
196
288
try {
197
289
await db`
···
199
291
VALUES (${domain}, ${did})
200
292
`;
201
293
} catch (err) {
202
-
// Unique constraint violations -> already taken or DID already claimed
294
+
// Unique constraint violations -> already taken
203
295
throw new Error('conflict');
204
296
}
205
297
return domain;
···
224
316
}
225
317
};
226
318
227
-
export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => {
319
+
export const updateWispDomainSite = async (domain: string, siteRkey: string | null): Promise<void> => {
228
320
await db`
229
321
UPDATE domains
230
322
SET rkey = ${siteRkey}
231
-
WHERE did = ${did}
323
+
WHERE domain = ${domain}
232
324
`;
233
325
};
234
326
235
327
export const getWispDomainSite = async (did: string): Promise<string | null> => {
236
-
const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`;
328
+
const rows = await db`SELECT rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
237
329
return rows[0]?.rkey ?? null;
238
330
};
239
331
332
+
export const deleteWispDomain = async (domain: string): Promise<void> => {
333
+
await db`DELETE FROM domains WHERE domain = ${domain}`;
334
+
};
335
+
240
336
// Session timeout configuration (30 days in seconds)
241
337
const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
242
338
// OAuth state timeout (1 hour in seconds)
···
244
340
245
341
const stateStore = {
246
342
async set(key: string, data: any) {
247
-
console.debug('[stateStore] set', key)
248
343
const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT;
249
344
await db`
250
345
INSERT INTO oauth_states (key, data, created_at, expires_at)
···
253
348
`;
254
349
},
255
350
async get(key: string) {
256
-
console.debug('[stateStore] get', key)
257
351
const now = Math.floor(Date.now() / 1000);
258
352
const result = await db`
259
353
SELECT data, expires_at
···
265
359
// Check if expired
266
360
const expiresAt = Number(result[0].expires_at);
267
361
if (expiresAt && now > expiresAt) {
268
-
console.debug('[stateStore] State expired, deleting', key);
269
362
await db`DELETE FROM oauth_states WHERE key = ${key}`;
270
363
return undefined;
271
364
}
···
273
366
return JSON.parse(result[0].data);
274
367
},
275
368
async del(key: string) {
276
-
console.debug('[stateStore] del', key)
277
369
await db`DELETE FROM oauth_states WHERE key = ${key}`;
278
370
}
279
371
};
280
372
281
373
const sessionStore = {
282
374
async set(sub: string, data: any) {
283
-
console.debug('[sessionStore] set', sub)
284
375
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT;
285
376
await db`
286
377
INSERT INTO oauth_sessions (sub, data, updated_at, expires_at)
···
292
383
`;
293
384
},
294
385
async get(sub: string) {
295
-
console.debug('[sessionStore] get', sub)
296
386
const now = Math.floor(Date.now() / 1000);
297
387
const result = await db`
298
388
SELECT data, expires_at
···
312
402
return JSON.parse(result[0].data);
313
403
},
314
404
async del(sub: string) {
315
-
console.debug('[sessionStore] del', sub)
316
405
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
317
406
}
318
407
};
···
578
667
return { success: false, error: err };
579
668
}
580
669
};
670
+
671
+
// Get all domains (wisp + custom) mapped to a specific site
672
+
export const getDomainsBySite = async (did: string, rkey: string) => {
673
+
const domains: Array<{
674
+
type: 'wisp' | 'custom';
675
+
domain: string;
676
+
verified?: boolean;
677
+
id?: string;
678
+
}> = [];
679
+
680
+
// Check wisp domain
681
+
const wispDomain = await db`
682
+
SELECT domain, rkey FROM domains
683
+
WHERE did = ${did} AND rkey = ${rkey}
684
+
`;
685
+
if (wispDomain.length > 0) {
686
+
domains.push({
687
+
type: 'wisp',
688
+
domain: wispDomain[0].domain,
689
+
});
690
+
}
691
+
692
+
// Check custom domains
693
+
const customDomains = await db`
694
+
SELECT id, domain, verified FROM custom_domains
695
+
WHERE did = ${did} AND rkey = ${rkey}
696
+
ORDER BY created_at DESC
697
+
`;
698
+
for (const cd of customDomains) {
699
+
domains.push({
700
+
type: 'custom',
701
+
domain: cd.domain,
702
+
verified: cd.verified,
703
+
id: cd.id,
704
+
});
705
+
}
706
+
707
+
return domains;
708
+
};
709
+
710
+
// Get count of domains mapped to a specific site
711
+
export const getDomainCountBySite = async (did: string, rkey: string) => {
712
+
const wispCount = await db`
713
+
SELECT COUNT(*) as count FROM domains
714
+
WHERE did = ${did} AND rkey = ${rkey}
715
+
`;
716
+
717
+
const customCount = await db`
718
+
SELECT COUNT(*) as count FROM custom_domains
719
+
WHERE did = ${did} AND rkey = ${rkey}
720
+
`;
721
+
722
+
return {
723
+
wisp: Number(wispCount[0]?.count || 0),
724
+
custom: Number(customCount[0]?.count || 0),
725
+
total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0),
726
+
};
727
+
};
728
+
729
+
// Cookie secret management - ensure we have a secret for signing cookies
730
+
export const getCookieSecret = async (): Promise<string> => {
731
+
// Check if secret already exists
732
+
const rows = await db`SELECT secret FROM cookie_secrets WHERE id = 'default' LIMIT 1`;
733
+
734
+
if (rows.length > 0) {
735
+
return rows[0].secret as string;
736
+
}
737
+
738
+
// Generate new secret if none exists
739
+
const secret = crypto.randomUUID() + crypto.randomUUID(); // 72 character random string
740
+
await db`
741
+
INSERT INTO cookie_secrets (id, secret, created_at)
742
+
VALUES ('default', ${secret}, EXTRACT(EPOCH FROM NOW()))
743
+
`;
744
+
745
+
console.log('[CookieSecret] Generated new cookie signing secret');
746
+
return secret;
747
+
};
+19
-3
src/lib/dns-verify.ts
+19
-3
src/lib/dns-verify.ts
···
135
135
}
136
136
137
137
/**
138
-
* Verify both TXT and CNAME records for a custom domain
138
+
* Verify custom domain using TXT record as authoritative proof
139
+
* CNAME check is optional/advisory - TXT record is sufficient for verification
140
+
*
141
+
* This approach works with CNAME flattening (e.g., Cloudflare) where the CNAME
142
+
* is resolved to A/AAAA records and won't be visible in DNS queries.
139
143
*/
140
144
export const verifyCustomDomain = async (
141
145
domain: string,
142
146
expectedDid: string,
143
147
expectedHash: string
144
148
): Promise<VerificationResult> => {
149
+
// TXT record is authoritative - it proves ownership
145
150
const txtResult = await verifyDomainOwnership(domain, expectedDid)
146
151
if (!txtResult.verified) {
147
152
return txtResult
148
153
}
149
154
155
+
// CNAME check is advisory only - we still check it for logging/debugging
156
+
// but don't fail verification if it's missing (could be flattened)
150
157
const cnameResult = await verifyCNAME(domain, expectedHash)
158
+
159
+
// Log CNAME status for debugging, but don't fail on it
151
160
if (!cnameResult.verified) {
152
-
return cnameResult
161
+
console.log(`[DNS Verify] โ ๏ธ CNAME verification failed (may be flattened):`, cnameResult.error)
153
162
}
154
163
155
-
return { verified: true }
164
+
// TXT verification is sufficient
165
+
return {
166
+
verified: true,
167
+
found: {
168
+
txt: txtResult.found?.txt,
169
+
cname: cnameResult.found?.cname
170
+
}
171
+
}
156
172
}
-1
src/lib/oauth-client.ts
-1
src/lib/oauth-client.ts
+360
src/lib/wisp-utils.test.ts
+360
src/lib/wisp-utils.test.ts
···
5
5
processUploadedFiles,
6
6
createManifest,
7
7
updateFileBlobs,
8
+
computeCID,
9
+
extractBlobMap,
8
10
type UploadedFile,
9
11
type FileUploadResult,
10
12
} from './wisp-utils'
···
637
639
}
638
640
})
639
641
})
642
+
643
+
describe('computeCID', () => {
644
+
test('should compute CID for gzipped+base64 encoded content', () => {
645
+
// This simulates the actual flow: gzip -> base64 -> compute CID
646
+
const originalContent = Buffer.from('Hello, World!')
647
+
const gzipped = compressFile(originalContent)
648
+
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
649
+
650
+
const cid = computeCID(base64Content)
651
+
652
+
// CID should be a valid CIDv1 string starting with 'bafkrei'
653
+
expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
654
+
expect(cid.length).toBeGreaterThan(10)
655
+
})
656
+
657
+
test('should compute deterministic CIDs for identical content', () => {
658
+
const content = Buffer.from('Test content for CID calculation')
659
+
const gzipped = compressFile(content)
660
+
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
661
+
662
+
const cid1 = computeCID(base64Content)
663
+
const cid2 = computeCID(base64Content)
664
+
665
+
expect(cid1).toBe(cid2)
666
+
})
667
+
668
+
test('should compute different CIDs for different content', () => {
669
+
const content1 = Buffer.from('Content A')
670
+
const content2 = Buffer.from('Content B')
671
+
672
+
const gzipped1 = compressFile(content1)
673
+
const gzipped2 = compressFile(content2)
674
+
675
+
const base64Content1 = Buffer.from(gzipped1.toString('base64'), 'binary')
676
+
const base64Content2 = Buffer.from(gzipped2.toString('base64'), 'binary')
677
+
678
+
const cid1 = computeCID(base64Content1)
679
+
const cid2 = computeCID(base64Content2)
680
+
681
+
expect(cid1).not.toBe(cid2)
682
+
})
683
+
684
+
test('should handle empty content', () => {
685
+
const emptyContent = Buffer.from('')
686
+
const gzipped = compressFile(emptyContent)
687
+
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
688
+
689
+
const cid = computeCID(base64Content)
690
+
691
+
expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
692
+
})
693
+
694
+
test('should compute same CID as PDS for base64-encoded content', () => {
695
+
// Test that binary encoding produces correct bytes for CID calculation
696
+
const testContent = Buffer.from('<!DOCTYPE html><html><body>Hello</body></html>')
697
+
const gzipped = compressFile(testContent)
698
+
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
699
+
700
+
// Compute CID twice to ensure consistency
701
+
const cid1 = computeCID(base64Content)
702
+
const cid2 = computeCID(base64Content)
703
+
704
+
expect(cid1).toBe(cid2)
705
+
expect(cid1).toMatch(/^bafkrei/)
706
+
})
707
+
708
+
test('should use binary encoding for base64 strings', () => {
709
+
// This test verifies we're using the correct encoding method
710
+
// For base64 strings, 'binary' encoding ensures each character becomes exactly one byte
711
+
const content = Buffer.from('Test content')
712
+
const gzipped = compressFile(content)
713
+
const base64String = gzipped.toString('base64')
714
+
715
+
// Using binary encoding (what we use in production)
716
+
const base64Content = Buffer.from(base64String, 'binary')
717
+
718
+
// Verify the length matches the base64 string length
719
+
expect(base64Content.length).toBe(base64String.length)
720
+
721
+
// Verify CID is computed correctly
722
+
const cid = computeCID(base64Content)
723
+
expect(cid).toMatch(/^bafkrei/)
724
+
})
725
+
})
726
+
727
+
describe('extractBlobMap', () => {
728
+
test('should extract blob map from flat directory structure', () => {
729
+
const mockCid = CID.parse(TEST_CID_STRING)
730
+
const mockBlob = new BlobRef(mockCid, 'text/html', 100)
731
+
732
+
const directory: Directory = {
733
+
$type: 'place.wisp.fs#directory',
734
+
type: 'directory',
735
+
entries: [
736
+
{
737
+
name: 'index.html',
738
+
node: {
739
+
$type: 'place.wisp.fs#file',
740
+
type: 'file',
741
+
blob: mockBlob,
742
+
},
743
+
},
744
+
],
745
+
}
746
+
747
+
const blobMap = extractBlobMap(directory)
748
+
749
+
expect(blobMap.size).toBe(1)
750
+
expect(blobMap.has('index.html')).toBe(true)
751
+
752
+
const entry = blobMap.get('index.html')
753
+
expect(entry?.cid).toBe(TEST_CID_STRING)
754
+
expect(entry?.blobRef).toBe(mockBlob)
755
+
})
756
+
757
+
test('should extract blob map from nested directory structure', () => {
758
+
const mockCid1 = CID.parse(TEST_CID_STRING)
759
+
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
760
+
761
+
const mockBlob1 = new BlobRef(mockCid1, 'text/html', 100)
762
+
const mockBlob2 = new BlobRef(mockCid2, 'text/css', 50)
763
+
764
+
const directory: Directory = {
765
+
$type: 'place.wisp.fs#directory',
766
+
type: 'directory',
767
+
entries: [
768
+
{
769
+
name: 'index.html',
770
+
node: {
771
+
$type: 'place.wisp.fs#file',
772
+
type: 'file',
773
+
blob: mockBlob1,
774
+
},
775
+
},
776
+
{
777
+
name: 'assets',
778
+
node: {
779
+
$type: 'place.wisp.fs#directory',
780
+
type: 'directory',
781
+
entries: [
782
+
{
783
+
name: 'styles.css',
784
+
node: {
785
+
$type: 'place.wisp.fs#file',
786
+
type: 'file',
787
+
blob: mockBlob2,
788
+
},
789
+
},
790
+
],
791
+
},
792
+
},
793
+
],
794
+
}
795
+
796
+
const blobMap = extractBlobMap(directory)
797
+
798
+
expect(blobMap.size).toBe(2)
799
+
expect(blobMap.has('index.html')).toBe(true)
800
+
expect(blobMap.has('assets/styles.css')).toBe(true)
801
+
802
+
expect(blobMap.get('index.html')?.cid).toBe(TEST_CID_STRING)
803
+
expect(blobMap.get('assets/styles.css')?.cid).toBe('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
804
+
})
805
+
806
+
test('should handle deeply nested directory structures', () => {
807
+
const mockCid = CID.parse(TEST_CID_STRING)
808
+
const mockBlob = new BlobRef(mockCid, 'text/javascript', 200)
809
+
810
+
const directory: Directory = {
811
+
$type: 'place.wisp.fs#directory',
812
+
type: 'directory',
813
+
entries: [
814
+
{
815
+
name: 'src',
816
+
node: {
817
+
$type: 'place.wisp.fs#directory',
818
+
type: 'directory',
819
+
entries: [
820
+
{
821
+
name: 'lib',
822
+
node: {
823
+
$type: 'place.wisp.fs#directory',
824
+
type: 'directory',
825
+
entries: [
826
+
{
827
+
name: 'utils.js',
828
+
node: {
829
+
$type: 'place.wisp.fs#file',
830
+
type: 'file',
831
+
blob: mockBlob,
832
+
},
833
+
},
834
+
],
835
+
},
836
+
},
837
+
],
838
+
},
839
+
},
840
+
],
841
+
}
842
+
843
+
const blobMap = extractBlobMap(directory)
844
+
845
+
expect(blobMap.size).toBe(1)
846
+
expect(blobMap.has('src/lib/utils.js')).toBe(true)
847
+
expect(blobMap.get('src/lib/utils.js')?.cid).toBe(TEST_CID_STRING)
848
+
})
849
+
850
+
test('should handle empty directory', () => {
851
+
const directory: Directory = {
852
+
$type: 'place.wisp.fs#directory',
853
+
type: 'directory',
854
+
entries: [],
855
+
}
856
+
857
+
const blobMap = extractBlobMap(directory)
858
+
859
+
expect(blobMap.size).toBe(0)
860
+
})
861
+
862
+
test('should correctly extract CID from BlobRef instances (not plain objects)', () => {
863
+
// This test verifies the fix: AT Protocol SDK returns BlobRef instances,
864
+
// not plain objects with $type and $link properties
865
+
const mockCid = CID.parse(TEST_CID_STRING)
866
+
const mockBlob = new BlobRef(mockCid, 'application/octet-stream', 500)
867
+
868
+
const directory: Directory = {
869
+
$type: 'place.wisp.fs#directory',
870
+
type: 'directory',
871
+
entries: [
872
+
{
873
+
name: 'test.bin',
874
+
node: {
875
+
$type: 'place.wisp.fs#file',
876
+
type: 'file',
877
+
blob: mockBlob,
878
+
},
879
+
},
880
+
],
881
+
}
882
+
883
+
const blobMap = extractBlobMap(directory)
884
+
885
+
// The fix: we call .toString() on the CID instance instead of accessing $link
886
+
expect(blobMap.get('test.bin')?.cid).toBe(TEST_CID_STRING)
887
+
expect(blobMap.get('test.bin')?.blobRef.ref.toString()).toBe(TEST_CID_STRING)
888
+
})
889
+
890
+
test('should handle multiple files in same directory', () => {
891
+
const mockCid1 = CID.parse(TEST_CID_STRING)
892
+
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
893
+
const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
894
+
895
+
const mockBlob1 = new BlobRef(mockCid1, 'image/png', 1000)
896
+
const mockBlob2 = new BlobRef(mockCid2, 'image/png', 2000)
897
+
const mockBlob3 = new BlobRef(mockCid3, 'image/png', 3000)
898
+
899
+
const directory: Directory = {
900
+
$type: 'place.wisp.fs#directory',
901
+
type: 'directory',
902
+
entries: [
903
+
{
904
+
name: 'images',
905
+
node: {
906
+
$type: 'place.wisp.fs#directory',
907
+
type: 'directory',
908
+
entries: [
909
+
{
910
+
name: 'logo.png',
911
+
node: {
912
+
$type: 'place.wisp.fs#file',
913
+
type: 'file',
914
+
blob: mockBlob1,
915
+
},
916
+
},
917
+
{
918
+
name: 'banner.png',
919
+
node: {
920
+
$type: 'place.wisp.fs#file',
921
+
type: 'file',
922
+
blob: mockBlob2,
923
+
},
924
+
},
925
+
{
926
+
name: 'icon.png',
927
+
node: {
928
+
$type: 'place.wisp.fs#file',
929
+
type: 'file',
930
+
blob: mockBlob3,
931
+
},
932
+
},
933
+
],
934
+
},
935
+
},
936
+
],
937
+
}
938
+
939
+
const blobMap = extractBlobMap(directory)
940
+
941
+
expect(blobMap.size).toBe(3)
942
+
expect(blobMap.has('images/logo.png')).toBe(true)
943
+
expect(blobMap.has('images/banner.png')).toBe(true)
944
+
expect(blobMap.has('images/icon.png')).toBe(true)
945
+
})
946
+
947
+
test('should handle mixed directory and file structure', () => {
948
+
const mockCid1 = CID.parse(TEST_CID_STRING)
949
+
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
950
+
const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
951
+
952
+
const directory: Directory = {
953
+
$type: 'place.wisp.fs#directory',
954
+
type: 'directory',
955
+
entries: [
956
+
{
957
+
name: 'index.html',
958
+
node: {
959
+
$type: 'place.wisp.fs#file',
960
+
type: 'file',
961
+
blob: new BlobRef(mockCid1, 'text/html', 100),
962
+
},
963
+
},
964
+
{
965
+
name: 'assets',
966
+
node: {
967
+
$type: 'place.wisp.fs#directory',
968
+
type: 'directory',
969
+
entries: [
970
+
{
971
+
name: 'styles.css',
972
+
node: {
973
+
$type: 'place.wisp.fs#file',
974
+
type: 'file',
975
+
blob: new BlobRef(mockCid2, 'text/css', 50),
976
+
},
977
+
},
978
+
],
979
+
},
980
+
},
981
+
{
982
+
name: 'README.md',
983
+
node: {
984
+
$type: 'place.wisp.fs#file',
985
+
type: 'file',
986
+
blob: new BlobRef(mockCid3, 'text/markdown', 200),
987
+
},
988
+
},
989
+
],
990
+
}
991
+
992
+
const blobMap = extractBlobMap(directory)
993
+
994
+
expect(blobMap.size).toBe(3)
995
+
expect(blobMap.has('index.html')).toBe(true)
996
+
expect(blobMap.has('assets/styles.css')).toBe(true)
997
+
expect(blobMap.has('README.md')).toBe(true)
998
+
})
999
+
})
+63
-2
src/lib/wisp-utils.ts
+63
-2
src/lib/wisp-utils.ts
···
2
2
import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs";
3
3
import { validateRecord } from "../lexicons/types/place/wisp/fs";
4
4
import { gzipSync } from 'zlib';
5
+
import { CID } from 'multiformats/cid';
6
+
import { sha256 } from 'multiformats/hashes/sha2';
7
+
import * as raw from 'multiformats/codecs/raw';
8
+
import { createHash } from 'crypto';
9
+
import * as mf from 'multiformats';
5
10
6
11
export interface UploadedFile {
7
12
name: string;
···
48
53
}
49
54
50
55
/**
51
-
* Compress a file using gzip
56
+
* Compress a file using gzip with deterministic output
52
57
*/
53
58
export function compressFile(content: Buffer): Buffer {
54
-
return gzipSync(content, { level: 9 });
59
+
return gzipSync(content, {
60
+
level: 9
61
+
});
55
62
}
56
63
57
64
/**
···
65
72
const directoryMap = new Map<string, UploadedFile[]>();
66
73
67
74
for (const file of files) {
75
+
// Skip undefined/null files (defensive)
76
+
if (!file || !file.name) {
77
+
console.error('Skipping undefined or invalid file in processUploadedFiles');
78
+
continue;
79
+
}
80
+
68
81
// Remove any base folder name from the path
69
82
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
70
83
const parts = normalizedPath.split('/');
···
239
252
240
253
return result;
241
254
}
255
+
256
+
/**
257
+
* Compute CID (Content Identifier) for blob content
258
+
* Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256
259
+
* Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
260
+
*/
261
+
export function computeCID(content: Buffer): string {
262
+
// Use node crypto to compute sha256 hash (same as AT Protocol)
263
+
const hash = createHash('sha256').update(content).digest();
264
+
// Create digest object from hash bytes
265
+
const digest = mf.digest.create(sha256.code, hash);
266
+
// Create CIDv1 with raw codec
267
+
const cid = CID.createV1(raw.code, digest);
268
+
return cid.toString();
269
+
}
270
+
271
+
/**
272
+
* Extract blob information from a directory tree
273
+
* Returns a map of file paths to their blob refs and CIDs
274
+
*/
275
+
export function extractBlobMap(
276
+
directory: Directory,
277
+
currentPath: string = ''
278
+
): Map<string, { blobRef: BlobRef; cid: string }> {
279
+
const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>();
280
+
281
+
for (const entry of directory.entries) {
282
+
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
283
+
284
+
if ('type' in entry.node && entry.node.type === 'file') {
285
+
const fileNode = entry.node as File;
286
+
// AT Protocol SDK returns BlobRef class instances, not plain objects
287
+
// The ref is a CID instance that can be converted to string
288
+
if (fileNode.blob && fileNode.blob.ref) {
289
+
const cidString = fileNode.blob.ref.toString();
290
+
blobMap.set(fullPath, {
291
+
blobRef: fileNode.blob,
292
+
cid: cidString
293
+
});
294
+
}
295
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
296
+
const subMap = extractBlobMap(entry.node as Directory, fullPath);
297
+
subMap.forEach((value, key) => blobMap.set(key, value));
298
+
}
299
+
}
300
+
301
+
return blobMap;
302
+
}
+106
-9
src/routes/admin.ts
+106
-9
src/routes/admin.ts
···
4
4
import { logCollector, errorTracker, metricsCollector } from '../lib/observability'
5
5
import { db } from '../lib/db'
6
6
7
-
export const adminRoutes = () =>
7
+
export const adminRoutes = (cookieSecret: string) =>
8
8
new Elysia({ prefix: '/api/admin' })
9
9
// Login
10
10
.post(
···
35
35
body: t.Object({
36
36
username: t.String(),
37
37
password: t.String()
38
+
}),
39
+
cookie: t.Cookie({
40
+
admin_session: t.Optional(t.String())
41
+
}, {
42
+
secrets: cookieSecret,
43
+
sign: ['admin_session']
38
44
})
39
45
}
40
46
)
···
47
53
}
48
54
cookie.admin_session.remove()
49
55
return { success: true }
56
+
}, {
57
+
cookie: t.Cookie({
58
+
admin_session: t.Optional(t.String())
59
+
}, {
60
+
secrets: cookieSecret,
61
+
sign: ['admin_session']
62
+
})
50
63
})
51
64
52
65
// Check auth status
···
65
78
authenticated: true,
66
79
username: session.username
67
80
}
81
+
}, {
82
+
cookie: t.Cookie({
83
+
admin_session: t.Optional(t.String())
84
+
}, {
85
+
secrets: cookieSecret,
86
+
sign: ['admin_session']
87
+
})
68
88
})
69
89
70
90
// Get logs (protected)
···
86
106
// Get logs from hosting service
87
107
let hostingLogs: any[] = []
88
108
try {
89
-
const hostingPort = process.env.HOSTING_PORT || '3001'
109
+
const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}`
90
110
const params = new URLSearchParams()
91
111
if (query.level) params.append('level', query.level as string)
92
112
if (query.service) params.append('service', query.service as string)
···
94
114
if (query.eventType) params.append('eventType', query.eventType as string)
95
115
params.append('limit', String(filter.limit || 100))
96
116
97
-
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/logs?${params}`)
117
+
const response = await fetch(`${hostingServiceUrl}/__internal__/observability/logs?${params}`)
98
118
if (response.ok) {
99
119
const data = await response.json()
100
120
hostingLogs = data.logs
···
109
129
)
110
130
111
131
return { logs: allLogs.slice(0, filter.limit || 100) }
132
+
}, {
133
+
cookie: t.Cookie({
134
+
admin_session: t.Optional(t.String())
135
+
}, {
136
+
secrets: cookieSecret,
137
+
sign: ['admin_session']
138
+
})
112
139
})
113
140
114
141
// Get errors (protected)
···
127
154
// Get errors from hosting service
128
155
let hostingErrors: any[] = []
129
156
try {
130
-
const hostingPort = process.env.HOSTING_PORT || '3001'
157
+
const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}`
131
158
const params = new URLSearchParams()
132
159
if (query.service) params.append('service', query.service as string)
133
160
params.append('limit', String(filter.limit || 100))
134
161
135
-
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/errors?${params}`)
162
+
const response = await fetch(`${hostingServiceUrl}/__internal__/observability/errors?${params}`)
136
163
if (response.ok) {
137
164
const data = await response.json()
138
165
hostingErrors = data.errors
···
147
174
)
148
175
149
176
return { errors: allErrors.slice(0, filter.limit || 100) }
177
+
}, {
178
+
cookie: t.Cookie({
179
+
admin_session: t.Optional(t.String())
180
+
}, {
181
+
secrets: cookieSecret,
182
+
sign: ['admin_session']
183
+
})
150
184
})
151
185
152
186
// Get metrics (protected)
···
173
207
}
174
208
175
209
try {
176
-
const hostingPort = process.env.HOSTING_PORT || '3001'
177
-
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/metrics?timeWindow=${timeWindow}`)
210
+
const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}`
211
+
const response = await fetch(`${hostingServiceUrl}/__internal__/observability/metrics?timeWindow=${timeWindow}`)
178
212
if (response.ok) {
179
213
const data = await response.json()
180
214
hostingServiceStats = data.stats
···
189
223
hostingService: hostingServiceStats,
190
224
timeWindow
191
225
}
226
+
}, {
227
+
cookie: t.Cookie({
228
+
admin_session: t.Optional(t.String())
229
+
}, {
230
+
secrets: cookieSecret,
231
+
sign: ['admin_session']
232
+
})
192
233
})
193
234
194
235
// Get database stats (protected)
···
204
245
205
246
// Get recent sites (including those without domains)
206
247
const recentSites = await db`
207
-
SELECT
248
+
SELECT
208
249
s.did,
209
250
s.rkey,
210
251
s.display_name,
···
235
276
message: error instanceof Error ? error.message : String(error)
236
277
}
237
278
}
279
+
}, {
280
+
cookie: t.Cookie({
281
+
admin_session: t.Optional(t.String())
282
+
}, {
283
+
secrets: cookieSecret,
284
+
sign: ['admin_session']
285
+
})
286
+
})
287
+
288
+
// Get cache stats (protected)
289
+
.get('/cache', async ({ cookie, set }) => {
290
+
const check = requireAdmin({ cookie, set })
291
+
if (check) return check
292
+
293
+
try {
294
+
const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}`
295
+
const response = await fetch(`${hostingServiceUrl}/__internal__/observability/cache`)
296
+
297
+
if (response.ok) {
298
+
const data = await response.json()
299
+
return data
300
+
} else {
301
+
set.status = 503
302
+
return {
303
+
error: 'Failed to fetch cache stats from hosting service',
304
+
message: 'Hosting service unavailable'
305
+
}
306
+
}
307
+
} catch (error) {
308
+
set.status = 500
309
+
return {
310
+
error: 'Failed to fetch cache stats',
311
+
message: error instanceof Error ? error.message : String(error)
312
+
}
313
+
}
314
+
}, {
315
+
cookie: t.Cookie({
316
+
admin_session: t.Optional(t.String())
317
+
}, {
318
+
secrets: cookieSecret,
319
+
sign: ['admin_session']
320
+
})
238
321
})
239
322
240
323
// Get sites listing (protected)
···
247
330
248
331
try {
249
332
const sites = await db`
250
-
SELECT
333
+
SELECT
251
334
s.did,
252
335
s.rkey,
253
336
s.display_name,
···
282
365
message: error instanceof Error ? error.message : String(error)
283
366
}
284
367
}
368
+
}, {
369
+
cookie: t.Cookie({
370
+
admin_session: t.Optional(t.String())
371
+
}, {
372
+
secrets: cookieSecret,
373
+
sign: ['admin_session']
374
+
})
285
375
})
286
376
287
377
// Get system health (protected)
···
301
391
},
302
392
timestamp: new Date().toISOString()
303
393
}
394
+
}, {
395
+
cookie: t.Cookie({
396
+
admin_session: t.Optional(t.String())
397
+
}, {
398
+
secrets: cookieSecret,
399
+
sign: ['admin_session']
400
+
})
304
401
})
305
402
+20
-6
src/routes/auth.ts
+20
-6
src/routes/auth.ts
···
1
-
import { Elysia } from 'elysia'
1
+
import { Elysia, t } from 'elysia'
2
2
import { NodeOAuthClient } from '@atproto/oauth-client-node'
3
-
import { getSitesByDid, getDomainByDid } from '../lib/db'
3
+
import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db'
4
4
import { syncSitesFromPDS } from '../lib/sync-sites'
5
5
import { authenticateRequest } from '../lib/wisp-auth'
6
6
import { logger } from '../lib/observability'
7
7
8
-
export const authRoutes = (client: NodeOAuthClient) => new Elysia()
8
+
export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({
9
+
cookie: {
10
+
secrets: cookieSecret,
11
+
sign: ['did']
12
+
}
13
+
})
9
14
.post('/api/auth/signin', async (c) => {
10
15
let handle = 'unknown'
11
16
try {
···
32
37
33
38
if (!session) {
34
39
logger.error('[Auth] OAuth callback failed: no session returned')
40
+
c.cookie.did.remove()
35
41
return c.redirect('/?error=auth_failed')
36
42
}
37
43
38
44
const cookieSession = c.cookie
39
-
cookieSession.did.value = session.did
45
+
cookieSession.did.set({
46
+
value: session.did,
47
+
httpOnly: true,
48
+
secure: process.env.NODE_ENV === 'production',
49
+
sameSite: 'lax',
50
+
maxAge: 30 * 24 * 60 * 60 // 30 days
51
+
})
40
52
41
53
// Sync sites from PDS to database cache
42
54
logger.debug('[Auth] Syncing sites from PDS for', session.did)
···
64
76
} catch (err) {
65
77
// This catches state validation failures and other OAuth errors
66
78
logger.error('[Auth] OAuth callback error', err)
79
+
c.cookie.did.remove()
67
80
return c.redirect('/?error=auth_failed')
68
81
}
69
82
})
···
73
86
const did = cookieSession.did?.value
74
87
75
88
// Clear the session cookie
76
-
cookieSession.did.value = ''
77
-
cookieSession.did.maxAge = 0
89
+
cookieSession.did.remove()
78
90
79
91
// If we have a DID, try to revoke the OAuth session
80
92
if (did && typeof did === 'string') {
···
98
110
const auth = await authenticateRequest(client, c.cookie)
99
111
100
112
if (!auth) {
113
+
c.cookie.did.remove()
101
114
return { authenticated: false }
102
115
}
103
116
···
107
120
}
108
121
} catch (err) {
109
122
logger.error('[Auth] Status check error', err)
123
+
c.cookie.did.remove()
110
124
return { authenticated: false }
111
125
}
112
126
})
+65
-14
src/routes/domain.ts
+65
-14
src/routes/domain.ts
···
10
10
isValidHandle,
11
11
toDomain,
12
12
updateDomain,
13
+
countWispDomains,
14
+
deleteWispDomain,
13
15
getCustomDomainInfo,
14
16
getCustomDomainById,
15
17
claimCustomDomain,
···
22
24
import { verifyCustomDomain } from '../lib/dns-verify'
23
25
import { logger } from '../lib/logger'
24
26
25
-
export const domainRoutes = (client: NodeOAuthClient) =>
26
-
new Elysia({ prefix: '/api/domain' })
27
+
export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
28
+
new Elysia({
29
+
prefix: '/api/domain',
30
+
cookie: {
31
+
secrets: cookieSecret,
32
+
sign: ['did']
33
+
}
34
+
})
27
35
// Public endpoints (no auth required)
28
36
.get('/check', async ({ query }) => {
29
37
try {
···
84
92
try {
85
93
const { handle } = body as { handle?: string };
86
94
const normalizedHandle = (handle || "").trim().toLowerCase();
87
-
95
+
88
96
if (!isValidHandle(normalizedHandle)) {
89
97
throw new Error("Invalid handle");
90
98
}
91
99
92
-
// ensure user hasn't already claimed
93
-
const existing = await getDomainByDid(auth.did);
94
-
if (existing) {
95
-
throw new Error("Already claimed");
96
-
}
97
-
100
+
// Check if user already has 3 domains (handled in claimDomain)
98
101
// claim in DB
99
102
let domain: string;
100
103
try {
101
104
domain = await claimDomain(auth.did, normalizedHandle);
102
105
} catch (err) {
103
-
throw new Error("Handle taken");
106
+
const message = err instanceof Error ? err.message : 'Unknown error';
107
+
if (message === 'domain_limit_reached') {
108
+
throw new Error("Domain limit reached: You can only claim up to 3 wisp.place domains");
109
+
}
110
+
throw new Error("Handle taken or error claiming domain");
104
111
}
105
112
106
-
// write place.wisp.domain record rkey = self
113
+
// write place.wisp.domain record with unique rkey
107
114
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init));
115
+
const rkey = normalizedHandle; // Use handle as rkey for uniqueness
108
116
await agent.com.atproto.repo.putRecord({
109
117
repo: auth.did,
110
118
collection: "place.wisp.domain",
111
-
rkey: "self",
119
+
rkey,
112
120
record: {
113
121
$type: "place.wisp.domain",
114
122
domain,
···
309
317
})
310
318
.post('/wisp/map-site', async ({ body, auth }) => {
311
319
try {
312
-
const { siteRkey } = body as { siteRkey: string | null };
320
+
const { domain, siteRkey } = body as { domain: string; siteRkey: string | null };
321
+
322
+
if (!domain) {
323
+
throw new Error('Domain parameter required');
324
+
}
313
325
314
326
// Update wisp.place domain to point to this site
315
-
await updateWispDomainSite(auth.did, siteRkey);
327
+
await updateWispDomainSite(domain, siteRkey);
316
328
317
329
return { success: true };
318
330
} catch (err) {
319
331
logger.error('[Domain] Wisp domain map error', err);
320
332
throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`);
333
+
}
334
+
})
335
+
.delete('/wisp/:domain', async ({ params, auth }) => {
336
+
try {
337
+
const { domain } = params;
338
+
339
+
// Verify domain belongs to user
340
+
const domainLower = domain.toLowerCase().trim();
341
+
const info = await isDomainRegistered(domainLower);
342
+
343
+
if (!info.registered || info.type !== 'wisp') {
344
+
throw new Error('Domain not found');
345
+
}
346
+
347
+
if (info.did !== auth.did) {
348
+
throw new Error('Unauthorized: You do not own this domain');
349
+
}
350
+
351
+
// Delete from database
352
+
await deleteWispDomain(domainLower);
353
+
354
+
// Delete from PDS
355
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init));
356
+
const handle = domainLower.replace(`.${process.env.BASE_DOMAIN || 'wisp.place'}`, '');
357
+
try {
358
+
await agent.com.atproto.repo.deleteRecord({
359
+
repo: auth.did,
360
+
collection: "place.wisp.domain",
361
+
rkey: handle,
362
+
});
363
+
} catch (err) {
364
+
// Record might not exist in PDS, continue anyway
365
+
logger.warn('[Domain] Could not delete wisp domain from PDS', err);
366
+
}
367
+
368
+
return { success: true };
369
+
} catch (err) {
370
+
logger.error('[Domain] Wisp domain delete error', err);
371
+
throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
321
372
}
322
373
})
323
374
.post('/custom/:id/map-site', async ({ params, body, auth }) => {
+8
-2
src/routes/site.ts
+8
-2
src/routes/site.ts
···
5
5
import { deleteSite } from '../lib/db'
6
6
import { logger } from '../lib/logger'
7
7
8
-
export const siteRoutes = (client: NodeOAuthClient) =>
9
-
new Elysia({ prefix: '/api/site' })
8
+
export const siteRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
9
+
new Elysia({
10
+
prefix: '/api/site',
11
+
cookie: {
12
+
secrets: cookieSecret,
13
+
sign: ['did']
14
+
}
15
+
})
10
16
.derive(async ({ cookie }) => {
11
17
const auth = await requireAuth(client, cookie)
12
18
return { auth }
+30
-10
src/routes/user.ts
+30
-10
src/routes/user.ts
···
1
-
import { Elysia } from 'elysia'
1
+
import { Elysia, t } from 'elysia'
2
2
import { requireAuth } from '../lib/wisp-auth'
3
3
import { NodeOAuthClient } from '@atproto/oauth-client-node'
4
4
import { Agent } from '@atproto/api'
5
-
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db'
5
+
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db'
6
6
import { syncSitesFromPDS } from '../lib/sync-sites'
7
7
import { logger } from '../lib/logger'
8
8
9
-
export const userRoutes = (client: NodeOAuthClient) =>
10
-
new Elysia({ prefix: '/api/user' })
9
+
export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
10
+
new Elysia({
11
+
prefix: '/api/user',
12
+
cookie: {
13
+
secrets: cookieSecret,
14
+
sign: ['did']
15
+
}
16
+
})
11
17
.derive(async ({ cookie }) => {
12
18
const auth = await requireAuth(client, cookie)
13
19
return { auth }
···
65
71
})
66
72
.get('/domains', async ({ auth }) => {
67
73
try {
68
-
// Get wisp.place subdomain with mapping
69
-
const wispDomainInfo = await getWispDomainInfo(auth.did)
74
+
// Get all wisp.place subdomains with mappings (up to 3)
75
+
const wispDomains = await getAllWispDomains(auth.did)
70
76
71
77
// Get custom domains
72
78
const customDomains = await getCustomDomainsByDid(auth.did)
73
79
74
80
return {
75
-
wispDomain: wispDomainInfo ? {
76
-
domain: wispDomainInfo.domain,
77
-
rkey: wispDomainInfo.rkey || null
78
-
} : null,
81
+
wispDomains: wispDomains.map(d => ({
82
+
domain: d.domain,
83
+
rkey: d.rkey || null
84
+
})),
79
85
customDomains
80
86
}
81
87
} catch (err) {
···
98
104
throw new Error('Failed to sync sites')
99
105
}
100
106
})
107
+
.get('/site/:rkey/domains', async ({ auth, params }) => {
108
+
try {
109
+
const { rkey } = params
110
+
const domains = await getDomainsBySite(auth.did, rkey)
111
+
112
+
return {
113
+
rkey,
114
+
domains
115
+
}
116
+
} catch (err) {
117
+
logger.error('[User] Site domains error', err)
118
+
throw new Error('Failed to get domains for site')
119
+
}
120
+
})
+138
-12
src/routes/wisp.ts
+138
-12
src/routes/wisp.ts
···
9
9
createManifest,
10
10
updateFileBlobs,
11
11
shouldCompressFile,
12
-
compressFile
12
+
compressFile,
13
+
computeCID,
14
+
extractBlobMap
13
15
} from '../lib/wisp-utils'
14
16
import { upsertSite } from '../lib/db'
15
17
import { logger } from '../lib/observability'
···
35
37
return true;
36
38
}
37
39
38
-
export const wispRoutes = (client: NodeOAuthClient) =>
39
-
new Elysia({ prefix: '/wisp' })
40
+
export const wispRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
41
+
new Elysia({
42
+
prefix: '/wisp',
43
+
cookie: {
44
+
secrets: cookieSecret,
45
+
sign: ['did']
46
+
}
47
+
})
40
48
.derive(async ({ cookie }) => {
41
49
const auth = await requireAuth(client, cookie)
42
50
return { auth }
···
48
56
siteName: string;
49
57
files: File | File[]
50
58
};
59
+
60
+
console.log('=== UPLOAD FILES START ===');
61
+
console.log('Site name:', siteName);
62
+
console.log('Files received:', Array.isArray(files) ? files.length : 'single file');
51
63
52
64
try {
53
65
if (!siteName) {
···
106
118
107
119
// Create agent with OAuth session
108
120
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
121
+
console.log('Agent created for DID:', auth.did);
122
+
123
+
// Try to fetch existing record to enable incremental updates
124
+
let existingBlobMap = new Map<string, { blobRef: any; cid: string }>();
125
+
console.log('Attempting to fetch existing record...');
126
+
try {
127
+
const rkey = siteName;
128
+
const existingRecord = await agent.com.atproto.repo.getRecord({
129
+
repo: auth.did,
130
+
collection: 'place.wisp.fs',
131
+
rkey: rkey
132
+
});
133
+
console.log('Existing record found!');
134
+
135
+
if (existingRecord.data.value && typeof existingRecord.data.value === 'object' && 'root' in existingRecord.data.value) {
136
+
const manifest = existingRecord.data.value as any;
137
+
existingBlobMap = extractBlobMap(manifest.root);
138
+
console.log(`Found existing manifest with ${existingBlobMap.size} files for incremental update`);
139
+
logger.info(`Found existing manifest with ${existingBlobMap.size} files for incremental update`);
140
+
}
141
+
} catch (error: any) {
142
+
console.log('No existing record found or error:', error?.message || error);
143
+
// Record doesn't exist yet, this is a new site
144
+
if (error?.status !== 400 && error?.error !== 'RecordNotFound') {
145
+
logger.warn('Failed to fetch existing record, proceeding with full upload', error);
146
+
}
147
+
}
109
148
110
149
// Convert File objects to UploadedFile format
111
150
// Elysia gives us File objects directly, handle both single file and array
···
113
152
const uploadedFiles: UploadedFile[] = [];
114
153
const skippedFiles: Array<{ name: string; reason: string }> = [];
115
154
116
-
155
+
console.log('Processing files, count:', fileArray.length);
117
156
118
157
for (let i = 0; i < fileArray.length; i++) {
119
158
const file = fileArray[i];
159
+
console.log(`Processing file ${i + 1}/${fileArray.length}:`, file.name, file.size, 'bytes');
120
160
121
161
// Skip files that are too large (limit to 100MB per file)
122
162
const maxSize = MAX_FILE_SIZE; // 100MB
···
135
175
// Compress and base64 encode ALL files
136
176
const compressedContent = compressFile(originalContent);
137
177
// Base64 encode the gzipped content to prevent PDS content sniffing
138
-
const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8');
178
+
// Convert base64 string to bytes using binary encoding (each char becomes exactly one byte)
179
+
// This is what PDS receives and computes CID on
180
+
const base64Content = Buffer.from(compressedContent.toString('base64'), 'binary');
139
181
const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1);
182
+
console.log(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`);
140
183
logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`);
141
184
142
185
uploadedFiles.push({
143
186
name: file.name,
144
-
content: base64Content,
187
+
content: base64Content, // This is the gzipped+base64 content that will be uploaded and CID-computed
145
188
mimeType: originalMimeType,
146
189
size: base64Content.length,
147
190
compressed: true,
···
206
249
}
207
250
208
251
// Process files into directory structure
209
-
const { directory, fileCount } = processUploadedFiles(uploadedFiles);
252
+
console.log('Processing uploaded files into directory structure...');
253
+
console.log('uploadedFiles array length:', uploadedFiles.length);
254
+
console.log('uploadedFiles contents:', uploadedFiles.map((f, i) => `${i}: ${f?.name || 'UNDEFINED'}`));
210
255
211
-
// Upload files as blobs in parallel
256
+
// Filter out any undefined/null/invalid entries (defensive)
257
+
const validUploadedFiles = uploadedFiles.filter((f, i) => {
258
+
if (!f) {
259
+
console.error(`Filtering out undefined/null file at index ${i}`);
260
+
return false;
261
+
}
262
+
if (!f.name) {
263
+
console.error(`Filtering out file with no name at index ${i}:`, f);
264
+
return false;
265
+
}
266
+
if (!f.content) {
267
+
console.error(`Filtering out file with no content at index ${i}:`, f.name);
268
+
return false;
269
+
}
270
+
return true;
271
+
});
272
+
if (validUploadedFiles.length !== uploadedFiles.length) {
273
+
console.warn(`Filtered out ${uploadedFiles.length - validUploadedFiles.length} invalid files`);
274
+
}
275
+
console.log('validUploadedFiles length:', validUploadedFiles.length);
276
+
277
+
const { directory, fileCount } = processUploadedFiles(validUploadedFiles);
278
+
console.log('Directory structure created, file count:', fileCount);
279
+
280
+
// Upload files as blobs in parallel (or reuse existing blobs with matching CIDs)
281
+
console.log('Starting blob upload/reuse phase...');
212
282
// For compressed files, we upload as octet-stream and store the original MIME type in metadata
213
283
// For text/html files, we also use octet-stream as a workaround for PDS image pipeline issues
214
-
const uploadPromises = uploadedFiles.map(async (file, i) => {
284
+
const uploadPromises = validUploadedFiles.map(async (file, i) => {
215
285
try {
286
+
// Skip undefined files (shouldn't happen after filter, but defensive)
287
+
if (!file || !file.name) {
288
+
console.error(`ERROR: Undefined file at index ${i} in validUploadedFiles!`);
289
+
throw new Error(`Undefined file at index ${i}`);
290
+
}
291
+
292
+
// Compute CID for this file to check if it already exists
293
+
// Note: file.content is already gzipped+base64 encoded
294
+
const fileCID = computeCID(file.content);
295
+
296
+
// Normalize the file path for comparison (remove base folder prefix like "cobblemon/")
297
+
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
298
+
299
+
// Check if we have an existing blob with the same CID
300
+
// Try both the normalized path and the full path
301
+
const existingBlob = existingBlobMap.get(normalizedPath) || existingBlobMap.get(file.name);
302
+
303
+
if (existingBlob && existingBlob.cid === fileCID) {
304
+
// Reuse existing blob - no need to upload
305
+
logger.info(`[File Upload] Reusing existing blob for: ${file.name} (CID: ${fileCID})`);
306
+
307
+
return {
308
+
result: {
309
+
hash: existingBlob.cid,
310
+
blobRef: existingBlob.blobRef,
311
+
...(file.compressed && {
312
+
encoding: 'gzip' as const,
313
+
mimeType: file.originalMimeType || file.mimeType,
314
+
base64: true
315
+
})
316
+
},
317
+
filePath: file.name,
318
+
sentMimeType: file.mimeType,
319
+
returnedMimeType: existingBlob.blobRef.mimeType,
320
+
reused: true
321
+
};
322
+
}
323
+
324
+
// File is new or changed - upload it
216
325
// If compressed, always upload as octet-stream
217
326
// Otherwise, workaround: PDS incorrectly processes text/html through image pipeline
218
327
const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html')
···
220
329
: file.mimeType;
221
330
222
331
const compressionInfo = file.compressed ? ' (gzipped)' : '';
223
-
logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`);
332
+
logger.info(`[File Upload] Uploading new/changed file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo}, CID: ${fileCID})`);
224
333
225
334
const uploadResult = await agent.com.atproto.repo.uploadBlob(
226
335
file.content,
···
244
353
},
245
354
filePath: file.name,
246
355
sentMimeType: file.mimeType,
247
-
returnedMimeType: returnedBlobRef.mimeType
356
+
returnedMimeType: returnedBlobRef.mimeType,
357
+
reused: false
248
358
};
249
359
} catch (uploadError) {
250
360
logger.error('Upload failed for file', uploadError);
···
255
365
// Wait for all uploads to complete
256
366
const uploadedBlobs = await Promise.all(uploadPromises);
257
367
368
+
// Count reused vs uploaded blobs
369
+
const reusedCount = uploadedBlobs.filter(b => (b as any).reused).length;
370
+
const uploadedCount = uploadedBlobs.filter(b => !(b as any).reused).length;
371
+
console.log(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`);
372
+
logger.info(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`);
373
+
258
374
// Extract results and file paths in correct order
259
375
const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result);
260
376
const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath);
261
377
262
378
// Update directory with file blobs
379
+
console.log('Updating directory with blob references...');
263
380
const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths);
264
381
265
382
// Create manifest
383
+
console.log('Creating manifest...');
266
384
const manifest = createManifest(siteName, updatedDirectory, fileCount);
385
+
console.log('Manifest created successfully');
267
386
268
387
// Use site name as rkey
269
388
const rkey = siteName;
270
389
271
390
let record;
272
391
try {
392
+
console.log('Putting record to PDS with rkey:', rkey);
273
393
record = await agent.com.atproto.repo.putRecord({
274
394
repo: auth.did,
275
395
collection: 'place.wisp.fs',
276
396
rkey: rkey,
277
397
record: manifest
278
398
});
399
+
console.log('Record successfully created on PDS:', record.data.uri);
279
400
} catch (putRecordError: any) {
401
+
console.error('FAILED to create record on PDS:', putRecordError);
280
402
logger.error('Failed to create record on PDS', putRecordError);
281
403
282
404
throw putRecordError;
···
292
414
fileCount,
293
415
siteName,
294
416
skippedFiles,
295
-
uploadedCount: uploadedFiles.length
417
+
uploadedCount: validUploadedFiles.length
296
418
};
297
419
420
+
console.log('=== UPLOAD FILES COMPLETE ===');
298
421
return result;
299
422
} catch (error) {
423
+
console.error('=== UPLOAD ERROR ===');
424
+
console.error('Error details:', error);
425
+
console.error('Stack trace:', error instanceof Error ? error.stack : 'N/A');
300
426
logger.error('Upload error', error, {
301
427
message: error instanceof Error ? error.message : 'Unknown error',
302
428
name: error instanceof Error ? error.name : undefined