Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.

init

Changed files
+908
lexicons
src
+43
api.md
···
+
/**
+
* AUTHENTICATION ROUTES
+
*
+
* Handles OAuth authentication flow for Bluesky/ATProto accounts
+
* All routes are on the editor.wisp.place subdomain
+
*
+
* Routes:
+
* POST /api/auth/signin - Initiate OAuth sign-in flow
+
* GET /api/auth/callback - OAuth callback handler (redirect from PDS)
+
* GET /api/auth/status - Check current authentication status
+
* POST /api/auth/logout - Sign out and clear session
+
*/
+
+
/**
+
* CUSTOM DOMAIN ROUTES
+
*
+
* Handles custom domain (BYOD - Bring Your Own Domain) management
+
* Users can claim custom domains with DNS verification (TXT + CNAME)
+
* and map them to their sites
+
*
+
* Routes:
+
* GET /api/check-domain - Fast verification check for routing (public)
+
* GET /api/custom-domains - List user's custom domains
+
* POST /api/custom-domains/check - Check domain availability and DNS config
+
* POST /api/custom-domains/claim - Claim a custom domain
+
* PUT /api/custom-domains/:id/site - Update site mapping
+
* DELETE /api/custom-domains/:id - Remove a custom domain
+
* POST /api/custom-domains/:id/verify - Manually trigger verification
+
*/
+
+
/**
+
* WISP SITE MANAGEMENT ROUTES
+
*
+
* API endpoints for managing user's Wisp sites stored in ATProto repos
+
* Handles reading site metadata, fetching content, updating sites, and uploads
+
* All routes are on the editor.wisp.place subdomain
+
*
+
* Routes:
+
* GET /wisp/sites - List all sites for authenticated user
+
* GET /wisp/fs/:site - Get site record (metadata/manifest)
+
* GET /wisp/fs/:site/file/* - Get individual file content by path
+
* POST /wisp/upload-files - Upload and deploy files as a site
+
*/
+48
lexicons/fs.json
···
+
{
+
"lexicon": 1,
+
"id": "place.wisp.fs",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "Virtual filesystem manifest for a Wisp site",
+
"record": {
+
"type": "object",
+
"required": ["site", "root", "createdAt"],
+
"properties": {
+
"site": { "type": "string" },
+
"root": { "type": "ref", "ref": "#directory" },
+
"fileCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
+
"createdAt": { "type": "string", "format": "datetime" }
+
}
+
}
+
},
+
"file": {
+
"type": "object",
+
"required": ["type", "hash"],
+
"properties": {
+
"type": { "type": "string", "const": "file" },
+
"hash": { "type": "string", "description": "Content blob hash" }
+
}
+
},
+
"directory": {
+
"type": "object",
+
"required": ["type", "entries"],
+
"properties": {
+
"type": { "type": "string", "const": "directory" },
+
"entries": {
+
"type": "array",
+
"maxLength": 500,
+
"items": { "type": "ref", "ref": "#entry" }
+
}
+
}
+
},
+
"entry": {
+
"type": "object",
+
"required": ["name", "node"],
+
"properties": {
+
"name": { "type": "string", "maxLength": 255 },
+
"node": { "type": "union", "refs": ["#file", "#directory"] }
+
}
+
}
+
}
+
}
+44
src/lexicon/index.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import {
+
type Auth,
+
type Options as XrpcOptions,
+
Server as XrpcServer,
+
type StreamConfigOrHandler,
+
type MethodConfigOrHandler,
+
createServer as createXrpcServer,
+
} from '@atproto/xrpc-server'
+
import { schemas } from './lexicons.js'
+
+
export function createServer(options?: XrpcOptions): Server {
+
return new Server(options)
+
}
+
+
export class Server {
+
xrpc: XrpcServer
+
place: PlaceNS
+
+
constructor(options?: XrpcOptions) {
+
this.xrpc = createXrpcServer(schemas, options)
+
this.place = new PlaceNS(this)
+
}
+
}
+
+
export class PlaceNS {
+
_server: Server
+
wisp: PlaceWispNS
+
+
constructor(server: Server) {
+
this._server = server
+
this.wisp = new PlaceWispNS(server)
+
}
+
}
+
+
export class PlaceWispNS {
+
_server: Server
+
+
constructor(server: Server) {
+
this._server = server
+
}
+
}
+125
src/lexicon/lexicons.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import {
+
type LexiconDoc,
+
Lexicons,
+
ValidationError,
+
type ValidationResult,
+
} from '@atproto/lexicon'
+
import { type $Typed, is$typed, maybe$typed } from './util.js'
+
+
export const schemaDict = {
+
PlaceWispFs: {
+
lexicon: 1,
+
id: 'place.wisp.fs',
+
defs: {
+
main: {
+
type: 'record',
+
description: 'Virtual filesystem manifest for a Wisp site',
+
record: {
+
type: 'object',
+
required: ['site', 'root', 'createdAt'],
+
properties: {
+
site: {
+
type: 'string',
+
},
+
root: {
+
type: 'ref',
+
ref: 'lex:place.wisp.fs#directory',
+
},
+
fileCount: {
+
type: 'integer',
+
minimum: 0,
+
maximum: 1000,
+
},
+
createdAt: {
+
type: 'string',
+
format: 'datetime',
+
},
+
},
+
},
+
},
+
file: {
+
type: 'object',
+
required: ['type', 'hash'],
+
properties: {
+
type: {
+
type: 'string',
+
const: 'file',
+
},
+
hash: {
+
type: 'string',
+
description: 'Content blob hash',
+
},
+
},
+
},
+
directory: {
+
type: 'object',
+
required: ['type', 'entries'],
+
properties: {
+
type: {
+
type: 'string',
+
const: 'directory',
+
},
+
entries: {
+
type: 'array',
+
maxLength: 500,
+
items: {
+
type: 'ref',
+
ref: 'lex:place.wisp.fs#entry',
+
},
+
},
+
},
+
},
+
entry: {
+
type: 'object',
+
required: ['name', 'node'],
+
properties: {
+
name: {
+
type: 'string',
+
maxLength: 255,
+
},
+
node: {
+
type: 'union',
+
refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'],
+
},
+
},
+
},
+
},
+
},
+
} as const satisfies Record<string, LexiconDoc>
+
export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]
+
export const lexicons: Lexicons = new Lexicons(schemas)
+
+
export function validate<T extends { $type: string }>(
+
v: unknown,
+
id: string,
+
hash: string,
+
requiredType: true,
+
): ValidationResult<T>
+
export function validate<T extends { $type?: string }>(
+
v: unknown,
+
id: string,
+
hash: string,
+
requiredType?: false,
+
): ValidationResult<T>
+
export function validate(
+
v: unknown,
+
id: string,
+
hash: string,
+
requiredType?: boolean,
+
): ValidationResult {
+
return (requiredType ? is$typed : maybe$typed)(v, id, hash)
+
? lexicons.validate(`${id}#${hash}`, v)
+
: {
+
success: false,
+
error: new ValidationError(
+
`Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`,
+
),
+
}
+
}
+
+
export const ids = {
+
PlaceWispFs: 'place.wisp.fs',
+
} as const
+79
src/lexicon/types/place/wisp/fs.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
+
import { CID } from 'multiformats/cid'
+
import { validate as _validate } from '../../../lexicons'
+
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
+
+
const is$typed = _is$typed,
+
validate = _validate
+
const id = 'place.wisp.fs'
+
+
export interface Record {
+
$type: 'place.wisp.fs'
+
site: string
+
root: Directory
+
fileCount?: number
+
createdAt: string
+
[k: string]: unknown
+
}
+
+
const hashRecord = 'main'
+
+
export function isRecord<V>(v: V) {
+
return is$typed(v, id, hashRecord)
+
}
+
+
export function validateRecord<V>(v: V) {
+
return validate<Record & V>(v, id, hashRecord, true)
+
}
+
+
export interface File {
+
$type?: 'place.wisp.fs#file'
+
type: 'file'
+
/** Content blob hash */
+
hash: string
+
}
+
+
const hashFile = 'file'
+
+
export function isFile<V>(v: V) {
+
return is$typed(v, id, hashFile)
+
}
+
+
export function validateFile<V>(v: V) {
+
return validate<File & V>(v, id, hashFile)
+
}
+
+
export interface Directory {
+
$type?: 'place.wisp.fs#directory'
+
type: 'directory'
+
entries: Entry[]
+
}
+
+
const hashDirectory = 'directory'
+
+
export function isDirectory<V>(v: V) {
+
return is$typed(v, id, hashDirectory)
+
}
+
+
export function validateDirectory<V>(v: V) {
+
return validate<Directory & V>(v, id, hashDirectory)
+
}
+
+
export interface Entry {
+
$type?: 'place.wisp.fs#entry'
+
name: string
+
node: $Typed<File> | $Typed<Directory> | { $type: string }
+
}
+
+
const hashEntry = 'entry'
+
+
export function isEntry<V>(v: V) {
+
return is$typed(v, id, hashEntry)
+
}
+
+
export function validateEntry<V>(v: V) {
+
return validate<Entry & V>(v, id, hashEntry)
+
}
+82
src/lexicon/util.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
+
import { type ValidationResult } from '@atproto/lexicon'
+
+
export type OmitKey<T, K extends keyof T> = {
+
[K2 in keyof T as K2 extends K ? never : K2]: T[K2]
+
}
+
+
export type $Typed<V, T extends string = string> = V & { $type: T }
+
export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
+
+
export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
+
? Id
+
: `${Id}#${Hash}`
+
+
function isObject<V>(v: V): v is V & object {
+
return v != null && typeof v === 'object'
+
}
+
+
function is$type<Id extends string, Hash extends string>(
+
$type: unknown,
+
id: Id,
+
hash: Hash,
+
): $type is $Type<Id, Hash> {
+
return hash === 'main'
+
? $type === id
+
: // $type === `${id}#${hash}`
+
typeof $type === 'string' &&
+
$type.length === id.length + 1 + hash.length &&
+
$type.charCodeAt(id.length) === 35 /* '#' */ &&
+
$type.startsWith(id) &&
+
$type.endsWith(hash)
+
}
+
+
export type $TypedObject<
+
V,
+
Id extends string,
+
Hash extends string,
+
> = V extends {
+
$type: $Type<Id, Hash>
+
}
+
? V
+
: V extends { $type?: string }
+
? V extends { $type?: infer T extends $Type<Id, Hash> }
+
? V & { $type: T }
+
: never
+
: V & { $type: $Type<Id, Hash> }
+
+
export function is$typed<V, Id extends string, Hash extends string>(
+
v: V,
+
id: Id,
+
hash: Hash,
+
): v is $TypedObject<V, Id, Hash> {
+
return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
+
}
+
+
export function maybe$typed<V, Id extends string, Hash extends string>(
+
v: V,
+
id: Id,
+
hash: Hash,
+
): v is V & object & { $type?: $Type<Id, Hash> } {
+
return (
+
isObject(v) &&
+
('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true)
+
)
+
}
+
+
export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
+
export type ValidatorParam<V extends Validator> =
+
V extends Validator<infer R> ? R : never
+
+
/**
+
* Utility function that allows to convert a "validate*" utility function into a
+
* type predicate.
+
*/
+
export function asPredicate<V extends Validator>(validate: V) {
+
return function <T>(v: T): v is T & ValidatorParam<V> {
+
return validate(v).success
+
}
+
}
+4
src/lib/constants.ts
···
+
export const BASE_HOST = Bun.env.BASE_DOMAIN || "wisp.place";
+
export const MAX_SITE_SIZE = 300 * 1024 * 1024; //300MB
+
export const MAX_FILE_SIZE = 100 * 1024 * 1024; //100MB
+
export const MAX_FILE_COUNT = 2000;
+344
src/lib/db.ts
···
+
import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
+
import { SQL } from "bun";
+
import { JoseKey } from "@atproto/jwk-jose";
+
import { BASE_HOST } from "./constants";
+
+
export const db = new SQL(
+
process.env.NODE_ENV === 'production'
+
? process.env.DATABASE_URL || (() => {
+
throw new Error('DATABASE_URL environment variable is required in production');
+
})()
+
: process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/wisp"
+
);
+
+
await db`
+
CREATE TABLE IF NOT EXISTS oauth_states (
+
key TEXT PRIMARY KEY,
+
data TEXT NOT NULL,
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
+
)
+
`;
+
+
await db`
+
CREATE TABLE IF NOT EXISTS oauth_sessions (
+
sub TEXT PRIMARY KEY,
+
data TEXT NOT NULL,
+
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
+
)
+
`;
+
+
await db`
+
CREATE TABLE IF NOT EXISTS oauth_keys (
+
kid TEXT PRIMARY KEY,
+
jwk TEXT NOT NULL
+
)
+
`;
+
+
// Domains table maps subdomain -> DID
+
await db`
+
CREATE TABLE IF NOT EXISTS domains (
+
domain TEXT PRIMARY KEY,
+
did TEXT UNIQUE NOT NULL,
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
+
)
+
`;
+
+
// Custom domains table for BYOD (bring your own domain)
+
await db`
+
CREATE TABLE IF NOT EXISTS custom_domains (
+
id TEXT PRIMARY KEY,
+
domain TEXT UNIQUE NOT NULL,
+
did TEXT NOT NULL,
+
rkey TEXT NOT NULL DEFAULT 'self',
+
verified BOOLEAN DEFAULT false,
+
last_verified_at BIGINT,
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
+
)
+
`;
+
+
// Sites table - cache of place.wisp.fs records from PDS
+
await db`
+
CREATE TABLE IF NOT EXISTS sites (
+
did TEXT NOT NULL,
+
rkey TEXT NOT NULL,
+
display_name TEXT,
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
+
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
+
PRIMARY KEY (did, rkey)
+
)
+
`;
+
+
const RESERVED_HANDLES = new Set([
+
"www",
+
"api",
+
"admin",
+
"static",
+
"public",
+
"preview"
+
]);
+
+
export const isValidHandle = (handle: string): boolean => {
+
const h = handle.trim().toLowerCase();
+
if (h.length < 3 || h.length > 63) return false;
+
if (!/^[a-z0-9-]+$/.test(h)) return false;
+
if (h.startsWith('-') || h.endsWith('-')) return false;
+
if (h.includes('--')) return false;
+
if (RESERVED_HANDLES.has(h)) return false;
+
return true;
+
};
+
+
export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
+
+
export const getDomainByDid = async (did: string): Promise<string | null> => {
+
const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
+
return rows[0]?.domain ?? null;
+
};
+
+
export const getDidByDomain = async (domain: string): Promise<string | null> => {
+
const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
+
return rows[0]?.did ?? null;
+
};
+
+
export const isDomainAvailable = async (handle: string): Promise<boolean> => {
+
const h = handle.trim().toLowerCase();
+
if (!isValidHandle(h)) return false;
+
const domain = toDomain(h);
+
const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`;
+
return rows.length === 0;
+
};
+
+
export const claimDomain = async (did: string, handle: string): Promise<string> => {
+
const h = handle.trim().toLowerCase();
+
if (!isValidHandle(h)) throw new Error('invalid_handle');
+
const domain = toDomain(h);
+
try {
+
await db`
+
INSERT INTO domains (domain, did)
+
VALUES (${domain}, ${did})
+
`;
+
} catch (err) {
+
// Unique constraint violations -> already taken or DID already claimed
+
throw new Error('conflict');
+
}
+
return domain;
+
};
+
+
export const updateDomain = async (did: string, handle: string): Promise<string> => {
+
const h = handle.trim().toLowerCase();
+
if (!isValidHandle(h)) throw new Error('invalid_handle');
+
const domain = toDomain(h);
+
try {
+
const rows = await db`
+
UPDATE domains SET domain = ${domain}
+
WHERE did = ${did}
+
RETURNING domain
+
`;
+
if (rows.length > 0) return rows[0].domain as string;
+
// No existing row, behave like claim
+
return await claimDomain(did, handle);
+
} catch (err) {
+
// Unique constraint violations -> already taken by someone else
+
throw new Error('conflict');
+
}
+
};
+
+
const stateStore = {
+
async set(key: string, data: any) {
+
console.debug('[stateStore] set', key)
+
await db`
+
INSERT INTO oauth_states (key, data)
+
VALUES (${key}, ${JSON.stringify(data)})
+
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
+
`;
+
},
+
async get(key: string) {
+
console.debug('[stateStore] get', key)
+
const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
+
return result[0] ? JSON.parse(result[0].data) : undefined;
+
},
+
async del(key: string) {
+
console.debug('[stateStore] del', key)
+
await db`DELETE FROM oauth_states WHERE key = ${key}`;
+
}
+
};
+
+
const sessionStore = {
+
async set(sub: string, data: any) {
+
console.debug('[sessionStore] set', sub)
+
await db`
+
INSERT INTO oauth_sessions (sub, data)
+
VALUES (${sub}, ${JSON.stringify(data)})
+
ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
+
`;
+
},
+
async get(sub: string) {
+
console.debug('[sessionStore] get', sub)
+
const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
+
return result[0] ? JSON.parse(result[0].data) : undefined;
+
},
+
async del(sub: string) {
+
console.debug('[sessionStore] del', sub)
+
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
+
}
+
};
+
+
export { sessionStore };
+
+
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
+
client_id: `${config.domain}/client-metadata.json`,
+
client_name: config.clientName,
+
client_uri: config.domain,
+
logo_uri: `${config.domain}/logo.png`,
+
tos_uri: `${config.domain}/tos`,
+
policy_uri: `${config.domain}/policy`,
+
redirect_uris: [`${config.domain}/api/auth/callback`],
+
grant_types: ['authorization_code', 'refresh_token'],
+
response_types: ['code'],
+
application_type: 'web',
+
token_endpoint_auth_method: 'private_key_jwt',
+
token_endpoint_auth_signing_alg: "ES256",
+
scope: "atproto transition:generic",
+
dpop_bound_access_tokens: true,
+
jwks_uri: `${config.domain}/jwks.json`,
+
subject_type: 'public',
+
authorization_signed_response_alg: 'ES256'
+
});
+
+
const persistKey = async (key: JoseKey) => {
+
const priv = key.privateJwk;
+
if (!priv) return;
+
const kid = key.kid ?? crypto.randomUUID();
+
await db`
+
INSERT INTO oauth_keys (kid, jwk)
+
VALUES (${kid}, ${JSON.stringify(priv)})
+
ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
+
`;
+
};
+
+
const loadPersistedKeys = async (): Promise<JoseKey[]> => {
+
const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
+
const keys: JoseKey[] = [];
+
for (const row of rows) {
+
try {
+
const obj = JSON.parse(row.jwk);
+
const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
+
keys.push(key);
+
} catch (err) {
+
console.error('Could not parse stored JWK', err);
+
}
+
}
+
return keys;
+
};
+
+
const ensureKeys = async (): Promise<JoseKey[]> => {
+
let keys = await loadPersistedKeys();
+
const needed: string[] = [];
+
for (let i = 1; i <= 3; i++) {
+
const kid = `key${i}`;
+
if (!keys.some(k => k.kid === kid)) needed.push(kid);
+
}
+
for (const kid of needed) {
+
const newKey = await JoseKey.generate(['ES256'], kid);
+
await persistKey(newKey);
+
keys.push(newKey);
+
}
+
keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
+
return keys;
+
};
+
+
let currentKeys: JoseKey[] = [];
+
+
export const getCurrentKeys = () => currentKeys;
+
+
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
+
if (currentKeys.length === 0) {
+
currentKeys = await ensureKeys();
+
}
+
+
return new NodeOAuthClient({
+
clientMetadata: createClientMetadata(config),
+
keyset: currentKeys,
+
stateStore,
+
sessionStore
+
});
+
};
+
+
export const getCustomDomainsByDid = async (did: string) => {
+
const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`;
+
return rows;
+
};
+
+
export const getCustomDomainInfo = async (domain: string) => {
+
const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`;
+
return rows[0] ?? null;
+
};
+
+
export const getCustomDomainByHash = async (hash: string) => {
+
const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`;
+
return rows[0] ?? null;
+
};
+
+
export const getCustomDomainById = async (id: string) => {
+
const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`;
+
return rows[0] ?? null;
+
};
+
+
export const claimCustomDomain = async (did: string, domain: string, siteName: string, hash: string) => {
+
const domainLower = domain.toLowerCase();
+
try {
+
await db`
+
INSERT INTO custom_domains (id, domain, did, site_name, verified, created_at)
+
VALUES (${hash}, ${domainLower}, ${did}, ${siteName}, false, EXTRACT(EPOCH FROM NOW()))
+
`;
+
return { success: true, hash };
+
} catch (err) {
+
console.error('Failed to claim custom domain', err);
+
throw new Error('conflict');
+
}
+
};
+
+
export const updateCustomDomainSite = async (id: string, siteName: string) => {
+
const rows = await db`
+
UPDATE custom_domains
+
SET site_name = ${siteName}
+
WHERE id = ${id}
+
RETURNING *
+
`;
+
return rows[0] ?? null;
+
};
+
+
export const updateCustomDomainVerification = async (id: string, verified: boolean) => {
+
const rows = await db`
+
UPDATE custom_domains
+
SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW())
+
WHERE id = ${id}
+
RETURNING *
+
`;
+
return rows[0] ?? null;
+
};
+
+
export const deleteCustomDomain = async (id: string) => {
+
await db`DELETE FROM custom_domains WHERE id = ${id}`;
+
};
+
+
export const getSitesByDid = async (did: string) => {
+
const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`;
+
return rows;
+
};
+
+
export const upsertSite = async (did: string, siteName: string, displayName?: string) => {
+
try {
+
await db`
+
INSERT INTO sites (did, site_name, display_name, created_at, updated_at)
+
VALUES (${did}, ${siteName}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
+
ON CONFLICT (did, site_name)
+
DO UPDATE SET
+
display_name = COALESCE(EXCLUDED.display_name, sites.display_name),
+
updated_at = EXTRACT(EPOCH FROM NOW())
+
`;
+
return { success: true };
+
} catch (err) {
+
console.error('Failed to upsert site', err);
+
return { success: false, error: err };
+
}
+
};
+127
src/lib/oauth-client.ts
···
+
import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
+
import { JoseKey } from "@atproto/jwk-jose";
+
import { db } from "./db";
+
+
const stateStore = {
+
async set(key: string, data: any) {
+
console.debug('[stateStore] set', key)
+
await db`
+
INSERT INTO oauth_states (key, data)
+
VALUES (${key}, ${JSON.stringify(data)})
+
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
+
`;
+
},
+
async get(key: string) {
+
console.debug('[stateStore] get', key)
+
const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
+
return result[0] ? JSON.parse(result[0].data) : undefined;
+
},
+
async del(key: string) {
+
console.debug('[stateStore] del', key)
+
await db`DELETE FROM oauth_states WHERE key = ${key}`;
+
}
+
};
+
+
const sessionStore = {
+
async set(sub: string, data: any) {
+
console.debug('[sessionStore] set', sub)
+
await db`
+
INSERT INTO oauth_sessions (sub, data)
+
VALUES (${sub}, ${JSON.stringify(data)})
+
ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
+
`;
+
},
+
async get(sub: string) {
+
console.debug('[sessionStore] get', sub)
+
const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
+
return result[0] ? JSON.parse(result[0].data) : undefined;
+
},
+
async del(sub: string) {
+
console.debug('[sessionStore] del', sub)
+
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
+
}
+
};
+
+
export { sessionStore };
+
+
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => {
+
// Use editor.wisp.place for OAuth endpoints since that's where the API routes live
+
return {
+
client_id: `${config.domain}/client-metadata.json`,
+
client_name: config.clientName,
+
client_uri: `https://wisp.place`,
+
logo_uri: `${config.domain}/logo.png`,
+
tos_uri: `${config.domain}/tos`,
+
policy_uri: `${config.domain}/policy`,
+
redirect_uris: [`${config.domain}/api/auth/callback`],
+
grant_types: ['authorization_code', 'refresh_token'],
+
response_types: ['code'],
+
application_type: 'web',
+
token_endpoint_auth_method: 'private_key_jwt',
+
token_endpoint_auth_signing_alg: "ES256",
+
scope: "atproto transition:generic",
+
dpop_bound_access_tokens: true,
+
jwks_uri: `${config.domain}/jwks.json`,
+
subject_type: 'public',
+
authorization_signed_response_alg: 'ES256'
+
};
+
};
+
+
const persistKey = async (key: JoseKey) => {
+
const priv = key.privateJwk;
+
if (!priv) return;
+
const kid = key.kid ?? crypto.randomUUID();
+
await db`
+
INSERT INTO oauth_keys (kid, jwk)
+
VALUES (${kid}, ${JSON.stringify(priv)})
+
ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
+
`;
+
};
+
+
const loadPersistedKeys = async (): Promise<JoseKey[]> => {
+
const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
+
const keys: JoseKey[] = [];
+
for (const row of rows) {
+
try {
+
const obj = JSON.parse(row.jwk);
+
const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
+
keys.push(key);
+
} catch (err) {
+
console.error('Could not parse stored JWK', err);
+
}
+
}
+
return keys;
+
};
+
+
const ensureKeys = async (): Promise<JoseKey[]> => {
+
let keys = await loadPersistedKeys();
+
const needed: string[] = [];
+
for (let i = 1; i <= 3; i++) {
+
const kid = `key${i}`;
+
if (!keys.some(k => k.kid === kid)) needed.push(kid);
+
}
+
for (const kid of needed) {
+
const newKey = await JoseKey.generate(['ES256'], kid);
+
await persistKey(newKey);
+
keys.push(newKey);
+
}
+
keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
+
return keys;
+
};
+
+
let currentKeys: JoseKey[] = [];
+
+
export const getCurrentKeys = () => currentKeys;
+
+
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
+
if (currentKeys.length === 0) {
+
currentKeys = await ensureKeys();
+
}
+
+
return new NodeOAuthClient({
+
clientMetadata: createClientMetadata(config),
+
keyset: currentKeys,
+
stateStore,
+
sessionStore
+
});
+
};
+12
src/lib/types.ts
···
+
import type { BlobRef } from "@atproto/api";
+
+
/**
+
* Configuration for the Wisp client
+
* @typeParam Config
+
*/
+
export type Config = {
+
/** The base domain URL with HTTPS protocol */
+
domain: `https://${string}`,
+
/** Name of the client application */
+
clientName: string
+
};