priv sites to docs
25 unmodified lines26272829303132333416 unmodified lines25 unmodified lines }, { label: 'Guides', items: [ { label: 'Private Sites', slug: 'private-sites' }, { label: 'Local Development', slug: 'local-development' }, { label: 'Architecture', slug: 'architecture' }, { label: 'Self-Hosting', slug: 'deployment' }, { label: 'Monitoring & Metrics', slug: 'monitoring' },16 unmodified lines495 unmodified linesExpand all49649749849950050150250350450550650750850951051151251351451551651751851952052152252352452560 unmodified lines495 unmodified linesExpand all border-left-color: var(--sl-color-accent) !important; font-weight: 600 !important;}
/* Separate document sections without ruling every endpoint. */starlight-toc > nav > ul > li + li { border-top: 1px solid color-mix(in oklch, var(--sl-color-border) 70%, transparent) !important; margin-top: 0.65rem !important; padding-top: 0.65rem !important;}
starlight-toc > nav > ul > li > a { color: var(--sl-color-gray-2) !important; font-weight: 600 !important;}
starlight-toc > nav > ul > li > ul { margin-top: 0.15rem !important;}
starlight-toc > nav > ul > li > ul a { font-size: 0.72rem !important; line-height: 1.35 !important; padding-left: 1rem !important;}
/* ── Badges ────────────────────────────────────────────────────────────────── */
.sl-badge { font-family: "JetBrains Mono", ui-monospace, monospace !important;60 unmodified lines12 unmodified lines131415161718192021294 unmodified linesExpand all12 unmodified lines
- **Deploy**: Push static sites directly from your terminal- **Pull**: Download sites from the PDS for development or backup- **Serve**: Run a local server with real-time firehose updates- **Private sites**: Upload access-controlled sites and manage share links- **Authenticate** with app password or OAuth- **Incremental updates**: Only upload changed files
## Recommended Install294 unmodified linesExpand all6 unmodified lines7891011121314151626 unmodified lines43444546474849505149526 unmodified lines**Decentralized static site hosting on the AT Protocol.**
wisp.place enables you to host static websites directly in your AT Protocol repository. Your Personal Data Server (PDS) holds the cryptographically signed manifest and files as the authoritative source of truth, while hosting services index and serve them with CDN-like performance.
Private sites use wisp.place storage instead. They are access-controlled and never written to your PDS or the firehose.
## Quick Start
### Using the Web InterfaceVisit [https://wisp.place](https://wisp.place) and sign in with your AT Protocol account to deploy sites through the browser.26 unmodified lines
## Next steps
- [CLI reference](/cli) — deploy, pull, and serve sites from the command line- [Private sites](/private-sites) — upload and share access-controlled sites- [Architecture](/architecture) — how the firehose, hosting, and storage tiers fit together- [Self-hosting](/deployment) — run your own Wisp instance- [Redirects & rewrites](/redirects) — Netlify-style URL rules- [Lexicons](/lexicons) — the AT Protocol record types that power it all```No newline at end of file```123345676789910111213111415131415161716171819201920222321252223242825262730313233343536373839404142434445464748495051525354285657582930316132333463646535366737386970717239404142747543444577464779808182838485864849888950515291535455939495969756579958596061101102103626364105106656667681081091101111121136970717211511611773747576119120121777812312479808112612712812913013113213313413513613713813914014182838485---title: Private Sitesdescription: Owner-only and share-link sites that never touch the PDS, and how they migrate to permissioned datadescription: Access-controlled static sites stored by wisp.place---
Private sites are sites that only their owner, and anyone holding a share link, can open.They are stored entirely by wisp and are **never written to your PDS**.Private sites are static sites that are only available to their owner and people they areshared with. Their files and metadata are stored by wisp.place, not in the owner's PDS, anddo not appear in the firehose.
## Why they are not on the PDSPrivate sites are access-controlled, not end-to-end encrypted. The wisp.place operator canread their contents. Use application-layer encryption if the host must not be able to read asite.
The AT Protocol repository is a public broadcast medium:## Uploading
- `com.atproto.sync.getBlob` is unauthenticated, so uploading site files as blobs makes them publicly fetchable no matter what the app does- `com.atproto.sync.getRepo` returns a public CAR of the repository- `com.atproto.sync.subscribeRepos` broadcasts every commit, so writing a `place.wisp.fs` record would publish the site's existence, name, and structure to every relayIn the web app, open **Upload**, select **Upload privately**, choose a folder, and set anexpiry. The site appears alongside public sites in the **Sites** tab.
Hiding a URL is not access control. So until AT Protocol ships permissioned data, privatesites live only in wisp's own storage, and `firehose-service` is not involved at all.With the CLI:
## Creating a private site
```bashwisp private deploy --path ./my-site --name "draft"wispctl private deploy --path ./report --name "report"```
Options:The default expiry is seven days. Use `--expiry 0` for no expiry, or pass a number ofminutes up to one year:
| Flag | Meaning ||---|---|| `--path <dir>` | Directory to upload (default `.`) || `--name <name>` | Display name. Not an identifier || `--expiry <minutes>` | Minutes until expiry. **Omit** for the default (7 days), **`0`** for never |
Private sites are served from a dedicated host, `priv.wisp.place`, which never servespublic user content.
## Expiry
The same rule applies to sites and to share links:
- **omitted** — the configured default is applied (`DEFAULT_PRIVATE_SITE_EXPIRY_MINUTES`, 7 days)- **`0`** — never expires- **`n`** — expires `n` minutes from now
A share link is additionally clamped to its site's expiry, so a link can never outlive thecontent it points at.
An expired site stays visible **to its owner**, so it can be inspected or deleted. It isclosed to everyone else.
## Share links
```bashwisp private share <siteId> --label "for review"wisp private shares <siteId>wisp private revoke <siteId> <shareId>wispctl private deploy --path ./report --name "report" --expiry 1440```
The share URL carries its credential in a query parameter:Private sites are limited to 500 files and 100 MB in total. They cannot be updated inplace; upload a new private site and delete the old one when its contents change.
```https://priv.wisp.place/<siteId>/?k=wss_...```## Opening a site
Treat that URL as a secret:Each private site has its own `*.priv.wisp.place` address. Opening it requires one of:
- the token is generated from 32 random bytes and stored **only as a sha256 hash**- it is displayed exactly once, at creation, and cannot be retrieved afterwards- `wisp private shares` shows only a short non-secret prefix- revocation is immediate and permanent- the owner signed in with their AT Protocol account- an account that has been given access- an active unrestricted share link
Private responses are sent with `Cache-Control: no-store`, `Referrer-Policy: no-referrer`(so the token cannot leak through an outbound link), and `X-Robots-Tag: noindex`.Private sites cannot be mapped to a wisp subdomain or custom domain. Responses are notcached by the browser or indexed by search engines.
## Access rules## Sharing
| Requester | Result ||---|---|| The owner, signed in | allowed || A different signed-in account | denied || Anonymous, no link | denied || A valid share link | allowed || An expired or revoked link | denied || Any requester after the site expired | denied (except the owner) |Expand a private site in the **Sites** tab to create and revoke share links. A link can be:
Every denial returns an identical `404`, so probing cannot distinguish an existing privatesite from one that never existed, nor a revoked link from an expired one.- unrestricted, so anyone holding it can open the site- restricted to an AT Protocol account, so the recipient must sign in with that account
## Migration path to permissioned data (v2)An account-restricted recipient can later open the private-site address while signed in,without using the share link again. Revoking the share removes that access.
[Proposal 0016](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data)specifies **permissioned data** for AT Protocol: records with an access perimeter, held in apermissioned repo per `(user, space)` and gated by a space credential. It is a mergedproposal with a work-in-progress implementation, not shipped protocol, so v1 does not dependon it.With the CLI:
v1 was shaped so the migration is additive rather than a rewrite:```bash# Anyone with the link can open itwispctl private share <site-id> --label "review"
**Identifiers.** A private site id is TID-shaped and record-key valid, because 0016specifies a space key (`skey`) as "analogous to a record key". The existing `siteId` canbecome the `skey` unchanged.# Only this account can open itwispctl private share <site-id> --to did:plc:...
**Ownership.** `owner_did` is immutable and is the subject of every access decision, whichmatches 0016 keeping record authority on the user DID.wispctl private shares <site-id>wispctl private revoke <site-id> <share-id>```
**One decision point.** All authorization runs through `evaluateAccess` in`@wispplace/private-sites`. It is a pure function over `(site, shares, principal, now)`.Under 0016, a space may set `policy: managing-app`, in which case the space authority callsthe managing app's `com.atproto.simplespace.checkUserAccess` at credential-mint time todecide whether to authorize a user. **wisp.place becomes that managing app**, and the bodyof `evaluateAccess` becomes the body of `checkUserAccess`.A new share link is shown once and cannot be retrieved later. Treat unrestricted links ascredentials. The share list retains its label, status, expiry, last-used time, and a shortnon-secret token prefix.
**Principals, not transports.** `AccessPrincipal` is an abstract union — `owner`,`shareToken`, `anonymous` — deliberately not "a cookie" or "a query parameter". Adding a`spaceCredential` principal in v2 is a new variant, not a refactor.Share links use the same expiry rules as sites. An omitted expiry uses the seven-daydefault, `0` means no expiry, and a positive value is a number of minutes. A share cannever outlive its site.
**Grants as rows.** Share links are first-class records with stable ids, expiry, andrevocation, so they map onto either a simplespace member list or dynamic`checkUserAccess` policy input.## Expiry and deletion
**Content metadata.** Every file retains its path, size, MIME type, and sha256, so files canbe re-uploaded as permissioned blobs and reconciled by digest.After a site expires, its links stop working and the site is removed. Deleting a privatesite removes its files and all of its share links immediately.
### What migration will involve
1. Create a space per private site, with wisp.place as the `managingApp`.2. Re-upload each stored file as a permissioned blob, matching on the retained sha256.3. Write file records into the owner's permissioned repo, keyed by the existing `siteId`.4. Reimplement `evaluateAccess` behind `checkUserAccess`.5. Serve reads through space credentials, keeping share links working as a wisp-level grant that `checkUserAccess` honours.
### What will not change
Permissioned data provides **access control, not confidentiality** — the proposal isexplicit that it is not end-to-end encrypted and that servers can read the data they handle.That is also true of v1. Private sites are private from other *users*, not from the wispoperator. Anything needing protection from the operator requires application-layerencryption, which is out of scope for both v1 and 0016.```bashwispctl private listwispctl private delete <site-id>```238 unmodified linesExpand all23924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042181 unmodified lines238 unmodified linesExpand all**Errors:** `AuthenticationRequired`, `InvalidRequest`, `NotFound`
---
## Private Sites
Private-site methods are authenticated and only operate on sites owned by the calling DID.Private-site files are stored by wisp.place and are not written to the caller's PDS. See[Private Sites](/private-sites) for the feature's access and expiry rules.
### `place.wisp.v2.privateSite.create` — procedure 🔒
Creates a private site from a `multipart/form-data` upload.
**Input:**
| Field | Type | Required | Notes ||---|---|---|---|| `name` | `string` | ✅ | Display name, up to 128 characters || `files` | `file` | ✅ | Repeat for each file; the filename is used as its path || `expiryMinutes` | `integer` | | Omit for seven days, `0` for no expiry, maximum 525600 |
Uploads are limited to 500 files and 100 MB in total.
**Response:**
| Field | Type | Notes ||---|---|---|| `siteId` | `string` (record-key) | Stable private-site identifier || `name` | `string` | Display name || `fileCount` | `integer` | || `totalBytes` | `integer` | || `expiresAt` | `string` (datetime) | Omitted when the site does not expire || `createdAt` | `string` (datetime) | || `url` | `string` | Requires an account with access or a share link |
**Errors:** `AuthenticationRequired`, `InvalidRequest`, `PayloadTooLarge`
---
### `place.wisp.v2.privateSite.list` — query 🔒
Lists private sites owned by the authenticated DID.
**Response:**
```json{ "sites": [ { "siteId": "3mabc...", "name": "review", "fileCount": 12, "totalBytes": 48321, "expiresAt": "2026-08-22T12:00:00.000Z", "createdAt": "2026-08-15T12:00:00.000Z", "shareCount": 1, "expired": false } ]}```
`expiresAt` is omitted when the site does not expire. `shareCount` includes only activeshares.
**Errors:** `AuthenticationRequired`
---
### `place.wisp.v2.privateSite.delete` — procedure 🔒
Deletes an owned private site, its files, and all of its share links.
**Input:**
| Field | Type | Required ||---|---|---|| `siteId` | `string` (record-key) | ✅ |
**Response:**
```json{ "siteId": "3mabc...", "deleted": true }```
**Errors:** `AuthenticationRequired`, `InvalidRequest`, `NotFound`
---
### `place.wisp.v2.privateSite.createShare` — procedure 🔒
Creates a share link for an owned private site. The returned URL contains the credentialand is only returned once.
**Input:**
| Field | Type | Required | Notes ||---|---|---|---|| `siteId` | `string` (record-key) | ✅ | || `label` | `string` | | Human label, up to 128 characters || `expiryMinutes` | `integer` | | Omit for seven days; `0` for no independent expiry || `audienceDid` | `string` (DID) | | Restricts the link to this account; omit for an unrestricted link |
A share's expiry is clamped to the site's expiry.
**Response:**
| Field | Type | Notes ||---|---|---|| `shareId` | `string` | || `siteId` | `string` (record-key) | || `url` | `string` | Short share URL; returned once || `directUrl` | `string` (URI) | Equivalent URL on the private-site origin || `expiresAt` | `string` (datetime) | Omitted when the share does not expire || `createdAt` | `string` (datetime) | || `audienceDid` | `string` (DID) | Present for account-restricted links |
**Errors:** `AuthenticationRequired`, `InvalidRequest`, `NotFound`
---
### `place.wisp.v2.privateSite.listShares` — query 🔒
Lists share links for an owned private site. Share credentials are never returned.
**Params:**
| Field | Type | Required ||---|---|---|| `siteId` | `string` (record-key) | ✅ |
**Response:**
```json{ "shares": [ { "shareId": "3mdef...", "tokenPrefix": "wss_abcd", "label": "review", "audienceDid": "did:plc:...", "expiresAt": "2026-08-22T12:00:00.000Z", "createdAt": "2026-08-15T12:00:00.000Z", "lastUsedAt": "2026-08-16T09:00:00.000Z", "status": "active" } ]}```
`status` is `active`, `expired`, or `revoked`. `tokenPrefix` is for identification onlyand does not grant access.
**Errors:** `AuthenticationRequired`, `NotFound`
---
### `place.wisp.v2.privateSite.revokeShare` — procedure 🔒
Permanently revokes a share link.
**Input:**
| Field | Type | Required ||---|---|---|| `siteId` | `string` (record-key) | ✅ || `shareId` | `string` | ✅ |
**Response:**
```json{ "shareId": "3mdef...", "revoked": true }```
**Errors:** `AuthenticationRequired`, `InvalidRequest`, `NotFound`
---
## Signing Secrets
Server-managed HMAC signing secrets for webhooks. The token is returned **once** at creation time and never stored in plaintext — it cannot be retrieved again, only rotated.
81 unmodified lines