+69
-5
README.md
+69
-5
README.md
···
1
1
# Wisp.place
2
-
A static site hosting service built on the AT Protocol. [https://wisp.place](https://wisp.place)
2
+
3
+
Decentralized static site hosting on the AT Protocol. [https://wisp.place](https://wisp.place)
4
+
5
+
## What is this?
6
+
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.
8
+
9
+
## Quick Start
10
+
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.
3
22
4
-
/src is the main backend
23
+
## Architecture
5
24
6
-
/hosting-service is the microservice that serves on-disk caches of sites pulled from the firehose and pdses
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
7
29
8
-
/cli is the wisp-cli, a way to upload sites directly to the pds
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
9
36
10
-
full readme soon
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
+
cargo run
47
+
48
+
# CLI
49
+
cd cli
50
+
cargo build
51
+
```
52
+
53
+
## Limits
54
+
55
+
- Max file size: 100MB (PDS limit)
56
+
- Max site size: 300MB
57
+
- Max files: 2000
58
+
59
+
## Tech Stack
60
+
61
+
- Backend: Bun + Elysia + PostgreSQL
62
+
- Frontend: React 19 + Tailwind 4 + Radix UI
63
+
- Hosting: Rust microservice
64
+
- CLI: Rust + Jacquard (AT Protocol library)
65
+
- Protocol: AT Protocol OAuth + custom lexicons
66
+
67
+
## License
68
+
69
+
MIT
70
+
71
+
## Links
72
+
73
+
- [AT Protocol](https://atproto.com)
74
+
- [Jacquard Library](https://tangled.org/@nonbinary.computer/jacquard)
+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
+6
-1
src/index.ts
+6
-1
src/index.ts
···
110
110
.get('/client-metadata.json', () => {
111
111
return createClientMetadata(config)
112
112
})
113
-
.get('/jwks.json', async () => {
113
+
.get('/jwks.json', async ({ set }) => {
114
+
// Prevent caching to ensure clients always get fresh keys after rotation
115
+
set.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
116
+
set.headers['Pragma'] = 'no-cache'
117
+
set.headers['Expires'] = '0'
118
+
114
119
const keys = await getCurrentKeys()
115
120
if (!keys.length) return { keys: [] }
116
121
+36
-8
src/lib/db.ts
+36
-8
src/lib/db.ts
···
36
36
)
37
37
`;
38
38
39
-
// Domains table maps subdomain -> DID
39
+
// Domains table maps subdomain -> DID (now supports up to 3 domains per user)
40
40
await db`
41
41
CREATE TABLE IF NOT EXISTS domains (
42
42
domain TEXT PRIMARY KEY,
43
-
did TEXT UNIQUE NOT NULL,
43
+
did TEXT NOT NULL,
44
44
rkey TEXT,
45
45
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
46
46
)
···
69
69
await db`ALTER TABLE oauth_states ADD COLUMN IF NOT EXISTS expires_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) + 3600`;
70
70
} catch (err) {
71
71
// Column might already exist, ignore
72
+
}
73
+
74
+
// Remove the unique constraint on domains.did to allow multiple domains per user
75
+
try {
76
+
await db`ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_did_key`;
77
+
} catch (err) {
78
+
// Constraint might already be removed, ignore
72
79
}
73
80
74
81
// Custom domains table for BYOD (bring your own domain)
···
189
196
export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
190
197
191
198
export const getDomainByDid = async (did: string): Promise<string | null> => {
192
-
const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
199
+
const rows = await db`SELECT domain FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
193
200
return rows[0]?.domain ?? null;
194
201
};
195
202
196
203
export const getWispDomainInfo = async (did: string) => {
197
-
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`;
204
+
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
198
205
return rows[0] ?? null;
199
206
};
200
207
208
+
export const getAllWispDomains = async (did: string) => {
209
+
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC`;
210
+
return rows;
211
+
};
212
+
213
+
export const countWispDomains = async (did: string): Promise<number> => {
214
+
const rows = await db`SELECT COUNT(*) as count FROM domains WHERE did = ${did}`;
215
+
return Number(rows[0]?.count ?? 0);
216
+
};
217
+
201
218
export const getDidByDomain = async (domain: string): Promise<string | null> => {
202
219
const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
203
220
return rows[0]?.did ?? null;
···
251
268
export const claimDomain = async (did: string, handle: string): Promise<string> => {
252
269
const h = handle.trim().toLowerCase();
253
270
if (!isValidHandle(h)) throw new Error('invalid_handle');
271
+
272
+
// Check if user already has 3 domains
273
+
const existingCount = await countWispDomains(did);
274
+
if (existingCount >= 3) {
275
+
throw new Error('domain_limit_reached');
276
+
}
277
+
254
278
const domain = toDomain(h);
255
279
try {
256
280
await db`
···
258
282
VALUES (${domain}, ${did})
259
283
`;
260
284
} catch (err) {
261
-
// Unique constraint violations -> already taken or DID already claimed
285
+
// Unique constraint violations -> already taken
262
286
throw new Error('conflict');
263
287
}
264
288
return domain;
···
283
307
}
284
308
};
285
309
286
-
export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => {
310
+
export const updateWispDomainSite = async (domain: string, siteRkey: string | null): Promise<void> => {
287
311
await db`
288
312
UPDATE domains
289
313
SET rkey = ${siteRkey}
290
-
WHERE did = ${did}
314
+
WHERE domain = ${domain}
291
315
`;
292
316
};
293
317
294
318
export const getWispDomainSite = async (did: string): Promise<string | null> => {
295
-
const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`;
319
+
const rows = await db`SELECT rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
296
320
return rows[0]?.rkey ?? null;
321
+
};
322
+
323
+
export const deleteWispDomain = async (domain: string): Promise<void> => {
324
+
await db`DELETE FROM domains WHERE domain = ${domain}`;
297
325
};
298
326
299
327
// Session timeout configuration (30 days in seconds)