+43
api.md
+43
api.md
···
1
+
/**
2
+
* AUTHENTICATION ROUTES
3
+
*
4
+
* Handles OAuth authentication flow for Bluesky/ATProto accounts
5
+
* All routes are on the editor.wisp.place subdomain
6
+
*
7
+
* Routes:
8
+
* POST /api/auth/signin - Initiate OAuth sign-in flow
9
+
* GET /api/auth/callback - OAuth callback handler (redirect from PDS)
10
+
* GET /api/auth/status - Check current authentication status
11
+
* POST /api/auth/logout - Sign out and clear session
12
+
*/
13
+
14
+
/**
15
+
* CUSTOM DOMAIN ROUTES
16
+
*
17
+
* Handles custom domain (BYOD - Bring Your Own Domain) management
18
+
* Users can claim custom domains with DNS verification (TXT + CNAME)
19
+
* and map them to their sites
20
+
*
21
+
* Routes:
22
+
* GET /api/check-domain - Fast verification check for routing (public)
23
+
* GET /api/custom-domains - List user's custom domains
24
+
* POST /api/custom-domains/check - Check domain availability and DNS config
25
+
* POST /api/custom-domains/claim - Claim a custom domain
26
+
* PUT /api/custom-domains/:id/site - Update site mapping
27
+
* DELETE /api/custom-domains/:id - Remove a custom domain
28
+
* POST /api/custom-domains/:id/verify - Manually trigger verification
29
+
*/
30
+
31
+
/**
32
+
* WISP SITE MANAGEMENT ROUTES
33
+
*
34
+
* API endpoints for managing user's Wisp sites stored in ATProto repos
35
+
* Handles reading site metadata, fetching content, updating sites, and uploads
36
+
* All routes are on the editor.wisp.place subdomain
37
+
*
38
+
* Routes:
39
+
* GET /wisp/sites - List all sites for authenticated user
40
+
* GET /wisp/fs/:site - Get site record (metadata/manifest)
41
+
* GET /wisp/fs/:site/file/* - Get individual file content by path
42
+
* POST /wisp/upload-files - Upload and deploy files as a site
43
+
*/
+48
lexicons/fs.json
+48
lexicons/fs.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "place.wisp.fs",
4
+
"defs": {
5
+
"main": {
6
+
"type": "record",
7
+
"description": "Virtual filesystem manifest for a Wisp site",
8
+
"record": {
9
+
"type": "object",
10
+
"required": ["site", "root", "createdAt"],
11
+
"properties": {
12
+
"site": { "type": "string" },
13
+
"root": { "type": "ref", "ref": "#directory" },
14
+
"fileCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
15
+
"createdAt": { "type": "string", "format": "datetime" }
16
+
}
17
+
}
18
+
},
19
+
"file": {
20
+
"type": "object",
21
+
"required": ["type", "hash"],
22
+
"properties": {
23
+
"type": { "type": "string", "const": "file" },
24
+
"hash": { "type": "string", "description": "Content blob hash" }
25
+
}
26
+
},
27
+
"directory": {
28
+
"type": "object",
29
+
"required": ["type", "entries"],
30
+
"properties": {
31
+
"type": { "type": "string", "const": "directory" },
32
+
"entries": {
33
+
"type": "array",
34
+
"maxLength": 500,
35
+
"items": { "type": "ref", "ref": "#entry" }
36
+
}
37
+
}
38
+
},
39
+
"entry": {
40
+
"type": "object",
41
+
"required": ["name", "node"],
42
+
"properties": {
43
+
"name": { "type": "string", "maxLength": 255 },
44
+
"node": { "type": "union", "refs": ["#file", "#directory"] }
45
+
}
46
+
}
47
+
}
48
+
}
+44
src/lexicon/index.ts
+44
src/lexicon/index.ts
···
1
+
/**
2
+
* GENERATED CODE - DO NOT MODIFY
3
+
*/
4
+
import {
5
+
type Auth,
6
+
type Options as XrpcOptions,
7
+
Server as XrpcServer,
8
+
type StreamConfigOrHandler,
9
+
type MethodConfigOrHandler,
10
+
createServer as createXrpcServer,
11
+
} from '@atproto/xrpc-server'
12
+
import { schemas } from './lexicons.js'
13
+
14
+
export function createServer(options?: XrpcOptions): Server {
15
+
return new Server(options)
16
+
}
17
+
18
+
export class Server {
19
+
xrpc: XrpcServer
20
+
place: PlaceNS
21
+
22
+
constructor(options?: XrpcOptions) {
23
+
this.xrpc = createXrpcServer(schemas, options)
24
+
this.place = new PlaceNS(this)
25
+
}
26
+
}
27
+
28
+
export class PlaceNS {
29
+
_server: Server
30
+
wisp: PlaceWispNS
31
+
32
+
constructor(server: Server) {
33
+
this._server = server
34
+
this.wisp = new PlaceWispNS(server)
35
+
}
36
+
}
37
+
38
+
export class PlaceWispNS {
39
+
_server: Server
40
+
41
+
constructor(server: Server) {
42
+
this._server = server
43
+
}
44
+
}
+125
src/lexicon/lexicons.ts
+125
src/lexicon/lexicons.ts
···
1
+
/**
2
+
* GENERATED CODE - DO NOT MODIFY
3
+
*/
4
+
import {
5
+
type LexiconDoc,
6
+
Lexicons,
7
+
ValidationError,
8
+
type ValidationResult,
9
+
} from '@atproto/lexicon'
10
+
import { type $Typed, is$typed, maybe$typed } from './util.js'
11
+
12
+
export const schemaDict = {
13
+
PlaceWispFs: {
14
+
lexicon: 1,
15
+
id: 'place.wisp.fs',
16
+
defs: {
17
+
main: {
18
+
type: 'record',
19
+
description: 'Virtual filesystem manifest for a Wisp site',
20
+
record: {
21
+
type: 'object',
22
+
required: ['site', 'root', 'createdAt'],
23
+
properties: {
24
+
site: {
25
+
type: 'string',
26
+
},
27
+
root: {
28
+
type: 'ref',
29
+
ref: 'lex:place.wisp.fs#directory',
30
+
},
31
+
fileCount: {
32
+
type: 'integer',
33
+
minimum: 0,
34
+
maximum: 1000,
35
+
},
36
+
createdAt: {
37
+
type: 'string',
38
+
format: 'datetime',
39
+
},
40
+
},
41
+
},
42
+
},
43
+
file: {
44
+
type: 'object',
45
+
required: ['type', 'hash'],
46
+
properties: {
47
+
type: {
48
+
type: 'string',
49
+
const: 'file',
50
+
},
51
+
hash: {
52
+
type: 'string',
53
+
description: 'Content blob hash',
54
+
},
55
+
},
56
+
},
57
+
directory: {
58
+
type: 'object',
59
+
required: ['type', 'entries'],
60
+
properties: {
61
+
type: {
62
+
type: 'string',
63
+
const: 'directory',
64
+
},
65
+
entries: {
66
+
type: 'array',
67
+
maxLength: 500,
68
+
items: {
69
+
type: 'ref',
70
+
ref: 'lex:place.wisp.fs#entry',
71
+
},
72
+
},
73
+
},
74
+
},
75
+
entry: {
76
+
type: 'object',
77
+
required: ['name', 'node'],
78
+
properties: {
79
+
name: {
80
+
type: 'string',
81
+
maxLength: 255,
82
+
},
83
+
node: {
84
+
type: 'union',
85
+
refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'],
86
+
},
87
+
},
88
+
},
89
+
},
90
+
},
91
+
} as const satisfies Record<string, LexiconDoc>
92
+
export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]
93
+
export const lexicons: Lexicons = new Lexicons(schemas)
94
+
95
+
export function validate<T extends { $type: string }>(
96
+
v: unknown,
97
+
id: string,
98
+
hash: string,
99
+
requiredType: true,
100
+
): ValidationResult<T>
101
+
export function validate<T extends { $type?: string }>(
102
+
v: unknown,
103
+
id: string,
104
+
hash: string,
105
+
requiredType?: false,
106
+
): ValidationResult<T>
107
+
export function validate(
108
+
v: unknown,
109
+
id: string,
110
+
hash: string,
111
+
requiredType?: boolean,
112
+
): ValidationResult {
113
+
return (requiredType ? is$typed : maybe$typed)(v, id, hash)
114
+
? lexicons.validate(`${id}#${hash}`, v)
115
+
: {
116
+
success: false,
117
+
error: new ValidationError(
118
+
`Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`,
119
+
),
120
+
}
121
+
}
122
+
123
+
export const ids = {
124
+
PlaceWispFs: 'place.wisp.fs',
125
+
} as const
+79
src/lexicon/types/place/wisp/fs.ts
+79
src/lexicon/types/place/wisp/fs.ts
···
1
+
/**
2
+
* GENERATED CODE - DO NOT MODIFY
3
+
*/
4
+
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
5
+
import { CID } from 'multiformats/cid'
6
+
import { validate as _validate } from '../../../lexicons'
7
+
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8
+
9
+
const is$typed = _is$typed,
10
+
validate = _validate
11
+
const id = 'place.wisp.fs'
12
+
13
+
export interface Record {
14
+
$type: 'place.wisp.fs'
15
+
site: string
16
+
root: Directory
17
+
fileCount?: number
18
+
createdAt: string
19
+
[k: string]: unknown
20
+
}
21
+
22
+
const hashRecord = 'main'
23
+
24
+
export function isRecord<V>(v: V) {
25
+
return is$typed(v, id, hashRecord)
26
+
}
27
+
28
+
export function validateRecord<V>(v: V) {
29
+
return validate<Record & V>(v, id, hashRecord, true)
30
+
}
31
+
32
+
export interface File {
33
+
$type?: 'place.wisp.fs#file'
34
+
type: 'file'
35
+
/** Content blob hash */
36
+
hash: string
37
+
}
38
+
39
+
const hashFile = 'file'
40
+
41
+
export function isFile<V>(v: V) {
42
+
return is$typed(v, id, hashFile)
43
+
}
44
+
45
+
export function validateFile<V>(v: V) {
46
+
return validate<File & V>(v, id, hashFile)
47
+
}
48
+
49
+
export interface Directory {
50
+
$type?: 'place.wisp.fs#directory'
51
+
type: 'directory'
52
+
entries: Entry[]
53
+
}
54
+
55
+
const hashDirectory = 'directory'
56
+
57
+
export function isDirectory<V>(v: V) {
58
+
return is$typed(v, id, hashDirectory)
59
+
}
60
+
61
+
export function validateDirectory<V>(v: V) {
62
+
return validate<Directory & V>(v, id, hashDirectory)
63
+
}
64
+
65
+
export interface Entry {
66
+
$type?: 'place.wisp.fs#entry'
67
+
name: string
68
+
node: $Typed<File> | $Typed<Directory> | { $type: string }
69
+
}
70
+
71
+
const hashEntry = 'entry'
72
+
73
+
export function isEntry<V>(v: V) {
74
+
return is$typed(v, id, hashEntry)
75
+
}
76
+
77
+
export function validateEntry<V>(v: V) {
78
+
return validate<Entry & V>(v, id, hashEntry)
79
+
}
+82
src/lexicon/util.ts
+82
src/lexicon/util.ts
···
1
+
/**
2
+
* GENERATED CODE - DO NOT MODIFY
3
+
*/
4
+
5
+
import { type ValidationResult } from '@atproto/lexicon'
6
+
7
+
export type OmitKey<T, K extends keyof T> = {
8
+
[K2 in keyof T as K2 extends K ? never : K2]: T[K2]
9
+
}
10
+
11
+
export type $Typed<V, T extends string = string> = V & { $type: T }
12
+
export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
13
+
14
+
export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
15
+
? Id
16
+
: `${Id}#${Hash}`
17
+
18
+
function isObject<V>(v: V): v is V & object {
19
+
return v != null && typeof v === 'object'
20
+
}
21
+
22
+
function is$type<Id extends string, Hash extends string>(
23
+
$type: unknown,
24
+
id: Id,
25
+
hash: Hash,
26
+
): $type is $Type<Id, Hash> {
27
+
return hash === 'main'
28
+
? $type === id
29
+
: // $type === `${id}#${hash}`
30
+
typeof $type === 'string' &&
31
+
$type.length === id.length + 1 + hash.length &&
32
+
$type.charCodeAt(id.length) === 35 /* '#' */ &&
33
+
$type.startsWith(id) &&
34
+
$type.endsWith(hash)
35
+
}
36
+
37
+
export type $TypedObject<
38
+
V,
39
+
Id extends string,
40
+
Hash extends string,
41
+
> = V extends {
42
+
$type: $Type<Id, Hash>
43
+
}
44
+
? V
45
+
: V extends { $type?: string }
46
+
? V extends { $type?: infer T extends $Type<Id, Hash> }
47
+
? V & { $type: T }
48
+
: never
49
+
: V & { $type: $Type<Id, Hash> }
50
+
51
+
export function is$typed<V, Id extends string, Hash extends string>(
52
+
v: V,
53
+
id: Id,
54
+
hash: Hash,
55
+
): v is $TypedObject<V, Id, Hash> {
56
+
return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
57
+
}
58
+
59
+
export function maybe$typed<V, Id extends string, Hash extends string>(
60
+
v: V,
61
+
id: Id,
62
+
hash: Hash,
63
+
): v is V & object & { $type?: $Type<Id, Hash> } {
64
+
return (
65
+
isObject(v) &&
66
+
('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true)
67
+
)
68
+
}
69
+
70
+
export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
71
+
export type ValidatorParam<V extends Validator> =
72
+
V extends Validator<infer R> ? R : never
73
+
74
+
/**
75
+
* Utility function that allows to convert a "validate*" utility function into a
76
+
* type predicate.
77
+
*/
78
+
export function asPredicate<V extends Validator>(validate: V) {
79
+
return function <T>(v: T): v is T & ValidatorParam<V> {
80
+
return validate(v).success
81
+
}
82
+
}
+4
src/lib/constants.ts
+4
src/lib/constants.ts
+344
src/lib/db.ts
+344
src/lib/db.ts
···
1
+
import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
2
+
import { SQL } from "bun";
3
+
import { JoseKey } from "@atproto/jwk-jose";
4
+
import { BASE_HOST } from "./constants";
5
+
6
+
export const db = new SQL(
7
+
process.env.NODE_ENV === 'production'
8
+
? process.env.DATABASE_URL || (() => {
9
+
throw new Error('DATABASE_URL environment variable is required in production');
10
+
})()
11
+
: process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/wisp"
12
+
);
13
+
14
+
await db`
15
+
CREATE TABLE IF NOT EXISTS oauth_states (
16
+
key TEXT PRIMARY KEY,
17
+
data TEXT NOT NULL,
18
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
19
+
)
20
+
`;
21
+
22
+
await db`
23
+
CREATE TABLE IF NOT EXISTS oauth_sessions (
24
+
sub TEXT PRIMARY KEY,
25
+
data TEXT NOT NULL,
26
+
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
27
+
)
28
+
`;
29
+
30
+
await db`
31
+
CREATE TABLE IF NOT EXISTS oauth_keys (
32
+
kid TEXT PRIMARY KEY,
33
+
jwk TEXT NOT NULL
34
+
)
35
+
`;
36
+
37
+
// Domains table maps subdomain -> DID
38
+
await db`
39
+
CREATE TABLE IF NOT EXISTS domains (
40
+
domain TEXT PRIMARY KEY,
41
+
did TEXT UNIQUE NOT NULL,
42
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
43
+
)
44
+
`;
45
+
46
+
// Custom domains table for BYOD (bring your own domain)
47
+
await db`
48
+
CREATE TABLE IF NOT EXISTS custom_domains (
49
+
id TEXT PRIMARY KEY,
50
+
domain TEXT UNIQUE NOT NULL,
51
+
did TEXT NOT NULL,
52
+
rkey TEXT NOT NULL DEFAULT 'self',
53
+
verified BOOLEAN DEFAULT false,
54
+
last_verified_at BIGINT,
55
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
56
+
)
57
+
`;
58
+
59
+
// Sites table - cache of place.wisp.fs records from PDS
60
+
await db`
61
+
CREATE TABLE IF NOT EXISTS sites (
62
+
did TEXT NOT NULL,
63
+
rkey TEXT NOT NULL,
64
+
display_name TEXT,
65
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
66
+
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
67
+
PRIMARY KEY (did, rkey)
68
+
)
69
+
`;
70
+
71
+
const RESERVED_HANDLES = new Set([
72
+
"www",
73
+
"api",
74
+
"admin",
75
+
"static",
76
+
"public",
77
+
"preview"
78
+
]);
79
+
80
+
export const isValidHandle = (handle: string): boolean => {
81
+
const h = handle.trim().toLowerCase();
82
+
if (h.length < 3 || h.length > 63) return false;
83
+
if (!/^[a-z0-9-]+$/.test(h)) return false;
84
+
if (h.startsWith('-') || h.endsWith('-')) return false;
85
+
if (h.includes('--')) return false;
86
+
if (RESERVED_HANDLES.has(h)) return false;
87
+
return true;
88
+
};
89
+
90
+
export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
91
+
92
+
export const getDomainByDid = async (did: string): Promise<string | null> => {
93
+
const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
94
+
return rows[0]?.domain ?? null;
95
+
};
96
+
97
+
export const getDidByDomain = async (domain: string): Promise<string | null> => {
98
+
const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
99
+
return rows[0]?.did ?? null;
100
+
};
101
+
102
+
export const isDomainAvailable = async (handle: string): Promise<boolean> => {
103
+
const h = handle.trim().toLowerCase();
104
+
if (!isValidHandle(h)) return false;
105
+
const domain = toDomain(h);
106
+
const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`;
107
+
return rows.length === 0;
108
+
};
109
+
110
+
export const claimDomain = async (did: string, handle: string): Promise<string> => {
111
+
const h = handle.trim().toLowerCase();
112
+
if (!isValidHandle(h)) throw new Error('invalid_handle');
113
+
const domain = toDomain(h);
114
+
try {
115
+
await db`
116
+
INSERT INTO domains (domain, did)
117
+
VALUES (${domain}, ${did})
118
+
`;
119
+
} catch (err) {
120
+
// Unique constraint violations -> already taken or DID already claimed
121
+
throw new Error('conflict');
122
+
}
123
+
return domain;
124
+
};
125
+
126
+
export const updateDomain = async (did: string, handle: string): Promise<string> => {
127
+
const h = handle.trim().toLowerCase();
128
+
if (!isValidHandle(h)) throw new Error('invalid_handle');
129
+
const domain = toDomain(h);
130
+
try {
131
+
const rows = await db`
132
+
UPDATE domains SET domain = ${domain}
133
+
WHERE did = ${did}
134
+
RETURNING domain
135
+
`;
136
+
if (rows.length > 0) return rows[0].domain as string;
137
+
// No existing row, behave like claim
138
+
return await claimDomain(did, handle);
139
+
} catch (err) {
140
+
// Unique constraint violations -> already taken by someone else
141
+
throw new Error('conflict');
142
+
}
143
+
};
144
+
145
+
const stateStore = {
146
+
async set(key: string, data: any) {
147
+
console.debug('[stateStore] set', key)
148
+
await db`
149
+
INSERT INTO oauth_states (key, data)
150
+
VALUES (${key}, ${JSON.stringify(data)})
151
+
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
152
+
`;
153
+
},
154
+
async get(key: string) {
155
+
console.debug('[stateStore] get', key)
156
+
const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
157
+
return result[0] ? JSON.parse(result[0].data) : undefined;
158
+
},
159
+
async del(key: string) {
160
+
console.debug('[stateStore] del', key)
161
+
await db`DELETE FROM oauth_states WHERE key = ${key}`;
162
+
}
163
+
};
164
+
165
+
const sessionStore = {
166
+
async set(sub: string, data: any) {
167
+
console.debug('[sessionStore] set', sub)
168
+
await db`
169
+
INSERT INTO oauth_sessions (sub, data)
170
+
VALUES (${sub}, ${JSON.stringify(data)})
171
+
ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
172
+
`;
173
+
},
174
+
async get(sub: string) {
175
+
console.debug('[sessionStore] get', sub)
176
+
const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
177
+
return result[0] ? JSON.parse(result[0].data) : undefined;
178
+
},
179
+
async del(sub: string) {
180
+
console.debug('[sessionStore] del', sub)
181
+
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
182
+
}
183
+
};
184
+
185
+
export { sessionStore };
186
+
187
+
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
188
+
client_id: `${config.domain}/client-metadata.json`,
189
+
client_name: config.clientName,
190
+
client_uri: config.domain,
191
+
logo_uri: `${config.domain}/logo.png`,
192
+
tos_uri: `${config.domain}/tos`,
193
+
policy_uri: `${config.domain}/policy`,
194
+
redirect_uris: [`${config.domain}/api/auth/callback`],
195
+
grant_types: ['authorization_code', 'refresh_token'],
196
+
response_types: ['code'],
197
+
application_type: 'web',
198
+
token_endpoint_auth_method: 'private_key_jwt',
199
+
token_endpoint_auth_signing_alg: "ES256",
200
+
scope: "atproto transition:generic",
201
+
dpop_bound_access_tokens: true,
202
+
jwks_uri: `${config.domain}/jwks.json`,
203
+
subject_type: 'public',
204
+
authorization_signed_response_alg: 'ES256'
205
+
});
206
+
207
+
const persistKey = async (key: JoseKey) => {
208
+
const priv = key.privateJwk;
209
+
if (!priv) return;
210
+
const kid = key.kid ?? crypto.randomUUID();
211
+
await db`
212
+
INSERT INTO oauth_keys (kid, jwk)
213
+
VALUES (${kid}, ${JSON.stringify(priv)})
214
+
ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
215
+
`;
216
+
};
217
+
218
+
const loadPersistedKeys = async (): Promise<JoseKey[]> => {
219
+
const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
220
+
const keys: JoseKey[] = [];
221
+
for (const row of rows) {
222
+
try {
223
+
const obj = JSON.parse(row.jwk);
224
+
const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
225
+
keys.push(key);
226
+
} catch (err) {
227
+
console.error('Could not parse stored JWK', err);
228
+
}
229
+
}
230
+
return keys;
231
+
};
232
+
233
+
const ensureKeys = async (): Promise<JoseKey[]> => {
234
+
let keys = await loadPersistedKeys();
235
+
const needed: string[] = [];
236
+
for (let i = 1; i <= 3; i++) {
237
+
const kid = `key${i}`;
238
+
if (!keys.some(k => k.kid === kid)) needed.push(kid);
239
+
}
240
+
for (const kid of needed) {
241
+
const newKey = await JoseKey.generate(['ES256'], kid);
242
+
await persistKey(newKey);
243
+
keys.push(newKey);
244
+
}
245
+
keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
246
+
return keys;
247
+
};
248
+
249
+
let currentKeys: JoseKey[] = [];
250
+
251
+
export const getCurrentKeys = () => currentKeys;
252
+
253
+
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
254
+
if (currentKeys.length === 0) {
255
+
currentKeys = await ensureKeys();
256
+
}
257
+
258
+
return new NodeOAuthClient({
259
+
clientMetadata: createClientMetadata(config),
260
+
keyset: currentKeys,
261
+
stateStore,
262
+
sessionStore
263
+
});
264
+
};
265
+
266
+
export const getCustomDomainsByDid = async (did: string) => {
267
+
const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`;
268
+
return rows;
269
+
};
270
+
271
+
export const getCustomDomainInfo = async (domain: string) => {
272
+
const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`;
273
+
return rows[0] ?? null;
274
+
};
275
+
276
+
export const getCustomDomainByHash = async (hash: string) => {
277
+
const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`;
278
+
return rows[0] ?? null;
279
+
};
280
+
281
+
export const getCustomDomainById = async (id: string) => {
282
+
const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`;
283
+
return rows[0] ?? null;
284
+
};
285
+
286
+
export const claimCustomDomain = async (did: string, domain: string, siteName: string, hash: string) => {
287
+
const domainLower = domain.toLowerCase();
288
+
try {
289
+
await db`
290
+
INSERT INTO custom_domains (id, domain, did, site_name, verified, created_at)
291
+
VALUES (${hash}, ${domainLower}, ${did}, ${siteName}, false, EXTRACT(EPOCH FROM NOW()))
292
+
`;
293
+
return { success: true, hash };
294
+
} catch (err) {
295
+
console.error('Failed to claim custom domain', err);
296
+
throw new Error('conflict');
297
+
}
298
+
};
299
+
300
+
export const updateCustomDomainSite = async (id: string, siteName: string) => {
301
+
const rows = await db`
302
+
UPDATE custom_domains
303
+
SET site_name = ${siteName}
304
+
WHERE id = ${id}
305
+
RETURNING *
306
+
`;
307
+
return rows[0] ?? null;
308
+
};
309
+
310
+
export const updateCustomDomainVerification = async (id: string, verified: boolean) => {
311
+
const rows = await db`
312
+
UPDATE custom_domains
313
+
SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW())
314
+
WHERE id = ${id}
315
+
RETURNING *
316
+
`;
317
+
return rows[0] ?? null;
318
+
};
319
+
320
+
export const deleteCustomDomain = async (id: string) => {
321
+
await db`DELETE FROM custom_domains WHERE id = ${id}`;
322
+
};
323
+
324
+
export const getSitesByDid = async (did: string) => {
325
+
const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`;
326
+
return rows;
327
+
};
328
+
329
+
export const upsertSite = async (did: string, siteName: string, displayName?: string) => {
330
+
try {
331
+
await db`
332
+
INSERT INTO sites (did, site_name, display_name, created_at, updated_at)
333
+
VALUES (${did}, ${siteName}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
334
+
ON CONFLICT (did, site_name)
335
+
DO UPDATE SET
336
+
display_name = COALESCE(EXCLUDED.display_name, sites.display_name),
337
+
updated_at = EXTRACT(EPOCH FROM NOW())
338
+
`;
339
+
return { success: true };
340
+
} catch (err) {
341
+
console.error('Failed to upsert site', err);
342
+
return { success: false, error: err };
343
+
}
344
+
};
+127
src/lib/oauth-client.ts
+127
src/lib/oauth-client.ts
···
1
+
import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
2
+
import { JoseKey } from "@atproto/jwk-jose";
3
+
import { db } from "./db";
4
+
5
+
const stateStore = {
6
+
async set(key: string, data: any) {
7
+
console.debug('[stateStore] set', key)
8
+
await db`
9
+
INSERT INTO oauth_states (key, data)
10
+
VALUES (${key}, ${JSON.stringify(data)})
11
+
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
12
+
`;
13
+
},
14
+
async get(key: string) {
15
+
console.debug('[stateStore] get', key)
16
+
const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
17
+
return result[0] ? JSON.parse(result[0].data) : undefined;
18
+
},
19
+
async del(key: string) {
20
+
console.debug('[stateStore] del', key)
21
+
await db`DELETE FROM oauth_states WHERE key = ${key}`;
22
+
}
23
+
};
24
+
25
+
const sessionStore = {
26
+
async set(sub: string, data: any) {
27
+
console.debug('[sessionStore] set', sub)
28
+
await db`
29
+
INSERT INTO oauth_sessions (sub, data)
30
+
VALUES (${sub}, ${JSON.stringify(data)})
31
+
ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
32
+
`;
33
+
},
34
+
async get(sub: string) {
35
+
console.debug('[sessionStore] get', sub)
36
+
const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
37
+
return result[0] ? JSON.parse(result[0].data) : undefined;
38
+
},
39
+
async del(sub: string) {
40
+
console.debug('[sessionStore] del', sub)
41
+
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
42
+
}
43
+
};
44
+
45
+
export { sessionStore };
46
+
47
+
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => {
48
+
// Use editor.wisp.place for OAuth endpoints since that's where the API routes live
49
+
return {
50
+
client_id: `${config.domain}/client-metadata.json`,
51
+
client_name: config.clientName,
52
+
client_uri: `https://wisp.place`,
53
+
logo_uri: `${config.domain}/logo.png`,
54
+
tos_uri: `${config.domain}/tos`,
55
+
policy_uri: `${config.domain}/policy`,
56
+
redirect_uris: [`${config.domain}/api/auth/callback`],
57
+
grant_types: ['authorization_code', 'refresh_token'],
58
+
response_types: ['code'],
59
+
application_type: 'web',
60
+
token_endpoint_auth_method: 'private_key_jwt',
61
+
token_endpoint_auth_signing_alg: "ES256",
62
+
scope: "atproto transition:generic",
63
+
dpop_bound_access_tokens: true,
64
+
jwks_uri: `${config.domain}/jwks.json`,
65
+
subject_type: 'public',
66
+
authorization_signed_response_alg: 'ES256'
67
+
};
68
+
};
69
+
70
+
const persistKey = async (key: JoseKey) => {
71
+
const priv = key.privateJwk;
72
+
if (!priv) return;
73
+
const kid = key.kid ?? crypto.randomUUID();
74
+
await db`
75
+
INSERT INTO oauth_keys (kid, jwk)
76
+
VALUES (${kid}, ${JSON.stringify(priv)})
77
+
ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
78
+
`;
79
+
};
80
+
81
+
const loadPersistedKeys = async (): Promise<JoseKey[]> => {
82
+
const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
83
+
const keys: JoseKey[] = [];
84
+
for (const row of rows) {
85
+
try {
86
+
const obj = JSON.parse(row.jwk);
87
+
const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
88
+
keys.push(key);
89
+
} catch (err) {
90
+
console.error('Could not parse stored JWK', err);
91
+
}
92
+
}
93
+
return keys;
94
+
};
95
+
96
+
const ensureKeys = async (): Promise<JoseKey[]> => {
97
+
let keys = await loadPersistedKeys();
98
+
const needed: string[] = [];
99
+
for (let i = 1; i <= 3; i++) {
100
+
const kid = `key${i}`;
101
+
if (!keys.some(k => k.kid === kid)) needed.push(kid);
102
+
}
103
+
for (const kid of needed) {
104
+
const newKey = await JoseKey.generate(['ES256'], kid);
105
+
await persistKey(newKey);
106
+
keys.push(newKey);
107
+
}
108
+
keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
109
+
return keys;
110
+
};
111
+
112
+
let currentKeys: JoseKey[] = [];
113
+
114
+
export const getCurrentKeys = () => currentKeys;
115
+
116
+
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
117
+
if (currentKeys.length === 0) {
118
+
currentKeys = await ensureKeys();
119
+
}
120
+
121
+
return new NodeOAuthClient({
122
+
clientMetadata: createClientMetadata(config),
123
+
keyset: currentKeys,
124
+
stateStore,
125
+
sessionStore
126
+
});
127
+
};
+12
src/lib/types.ts
+12
src/lib/types.ts
···
1
+
import type { BlobRef } from "@atproto/api";
2
+
3
+
/**
4
+
* Configuration for the Wisp client
5
+
* @typeParam Config
6
+
*/
7
+
export type Config = {
8
+
/** The base domain URL with HTTPS protocol */
9
+
domain: `https://${string}`,
10
+
/** Name of the client application */
11
+
clientName: string
12
+
};