···11-{
22- "lexicon": 1,
33- "id": "com.atproto.label.defs",
44- "defs": {
55- "label": {
66- "type": "object",
77- "required": [
88- "src",
99- "uri",
1010- "val",
1111- "cts"
1212- ],
1313- "properties": {
1414- "cid": {
1515- "type": "string",
1616- "format": "cid",
1717- "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."
1818- },
1919- "cts": {
2020- "type": "string",
2121- "format": "datetime",
2222- "description": "Timestamp when this label was created."
2323- },
2424- "exp": {
2525- "type": "string",
2626- "format": "datetime",
2727- "description": "Timestamp at which this label expires (no longer applies)."
2828- },
2929- "neg": {
3030- "type": "boolean",
3131- "description": "If true, this is a negation label, overwriting a previous label."
3232- },
3333- "sig": {
3434- "type": "bytes",
3535- "description": "Signature of dag-cbor encoded label."
3636- },
3737- "src": {
3838- "type": "string",
3939- "format": "did",
4040- "description": "DID of the actor who created this label."
4141- },
4242- "uri": {
4343- "type": "string",
4444- "format": "uri",
4545- "description": "AT URI of the record, repository (account), or other resource that this label applies to."
4646- },
4747- "val": {
4848- "type": "string",
4949- "maxLength": 128,
5050- "description": "The short string name of the value or type of this label."
5151- },
5252- "ver": {
5353- "type": "integer",
5454- "description": "The AT Protocol version of the label object."
5555- }
5656- },
5757- "description": "Metadata tag on an atproto resource (eg, repo or record)."
5858- },
5959- "selfLabel": {
6060- "type": "object",
6161- "required": [
6262- "val"
6363- ],
6464- "properties": {
6565- "val": {
6666- "type": "string",
6767- "maxLength": 128,
6868- "description": "The short string name of the value or type of this label."
6969- }
7070- },
7171- "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel."
7272- },
7373- "labelValue": {
7474- "type": "string",
7575- "knownValues": [
7676- "!hide",
7777- "!no-promote",
7878- "!warn",
7979- "!no-unauthenticated",
8080- "dmca-violation",
8181- "doxxing",
8282- "porn",
8383- "sexual",
8484- "nudity",
8585- "nsfl",
8686- "gore"
8787- ]
8888- },
8989- "selfLabels": {
9090- "type": "object",
9191- "required": [
9292- "values"
9393- ],
9494- "properties": {
9595- "values": {
9696- "type": "array",
9797- "items": {
9898- "ref": "#selfLabel",
9999- "type": "ref"
100100- },
101101- "maxLength": 10
102102- }
103103- },
104104- "description": "Metadata tags on an atproto record, published by the author within the record."
105105- },
106106- "labelValueDefinition": {
107107- "type": "object",
108108- "required": [
109109- "identifier",
110110- "severity",
111111- "blurs",
112112- "locales"
113113- ],
114114- "properties": {
115115- "blurs": {
116116- "type": "string",
117117- "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
118118- "knownValues": [
119119- "content",
120120- "media",
121121- "none"
122122- ]
123123- },
124124- "locales": {
125125- "type": "array",
126126- "items": {
127127- "ref": "#labelValueDefinitionStrings",
128128- "type": "ref"
129129- }
130130- },
131131- "severity": {
132132- "type": "string",
133133- "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
134134- "knownValues": [
135135- "inform",
136136- "alert",
137137- "none"
138138- ]
139139- },
140140- "adultOnly": {
141141- "type": "boolean",
142142- "description": "Does the user need to have adult content enabled in order to configure this label?"
143143- },
144144- "identifier": {
145145- "type": "string",
146146- "maxLength": 100,
147147- "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
148148- "maxGraphemes": 100
149149- },
150150- "defaultSetting": {
151151- "type": "string",
152152- "default": "warn",
153153- "description": "The default setting for this label.",
154154- "knownValues": [
155155- "ignore",
156156- "warn",
157157- "hide"
158158- ]
159159- }
160160- },
161161- "description": "Declares a label value and its expected interpretations and behaviors."
162162- },
163163- "labelValueDefinitionStrings": {
164164- "type": "object",
165165- "required": [
166166- "lang",
167167- "name",
168168- "description"
169169- ],
170170- "properties": {
171171- "lang": {
172172- "type": "string",
173173- "format": "language",
174174- "description": "The code of the language these strings are written in."
175175- },
176176- "name": {
177177- "type": "string",
178178- "maxLength": 640,
179179- "description": "A short human-readable name for the label.",
180180- "maxGraphemes": 64
181181- },
182182- "description": {
183183- "type": "string",
184184- "maxLength": 100000,
185185- "description": "A longer description of what the label means and why it might be applied.",
186186- "maxGraphemes": 10000
187187- }
188188- },
189189- "description": "Strings which describe the label in the UI, localized into a specific language."
190190- }
191191- }
192192-}
···11+export * as AppWafrnContentCreatePost from "./types/app/wafrn/content/createPost.js";
22+export * as AppWafrnContentPost from "./types/app/wafrn/content/post.js";
···11-import { Elysia } from 'elysia'
22-import { getSession, refreshSession, type SessionData } from './session'
33-44-/**
55- * Auth plugin with Macro for declarative authentication
66- *
77- * Usage:
88- * const app = new Elysia()
99- * .use(authMacro)
1010- * .get('/profile', ({ session }) => session.account, {
1111- * auth: true
1212- * })
1313- * .get('/admin', ({ session }) => 'admin area', {
1414- * auth: 'admin'
1515- * })
1616- */
1717-export const authMacro = new Elysia({ name: 'auth' }).macro({
1818- auth: (enabled: boolean | 'admin') => ({
1919- async resolve({ cookie, status }) {
2020- if (!enabled) {
2121- return
2222- }
2323-2424- const sessionId = cookie.session?.value as string
2525-2626- if (!sessionId) {
2727- return status(401, 'Unauthorized: No session cookie')
2828- }
2929-3030- const session = await getSession(sessionId)
3131-3232- if (!session) {
3333- return status(401, 'Unauthorized: Invalid or expired session')
3434- }
3535-3636- // Check if account is active
3737- if (!session.account.active) {
3838- return status(403, 'Forbidden: Account is inactive')
3939- }
4040-4141- if (enabled === 'admin' && session.account.role !== 'admin') {
4242- return status(403, 'Forbidden: Admin role required')
4343- }
4444-4545- // Refresh session if needed (sliding window)
4646- await refreshSession(sessionId)
4747-4848- return { session }
4949- }
5050- })
5151-})
-18
packages/server/src/lib/profile.ts
···11-import { Agent } from '@atproto/api'
22-import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'
33-44-export function parseProfile(profile: ProfileViewDetailed) {
55- return profile.handle !== 'handle.invalid' ? null : profile
66-}
77-88-export async function getProfile(agent: Agent, didOrHandle: string) {
99- // if no 'app.bsky.actor.profile' record is found on the user's PDS, this method will not return an error
1010- // instead it will return an empty skeleton of a profile record with "handle" set to "handle.invalid"
1111- const profile = await agent.getProfile({ actor: didOrHandle })
1212- if (!profile.success) {
1313- throw new Error('Failed to fetch profile')
1414- }
1515-1616- const data = profile.data.handle === 'handle.invalid' ? null : profile.data
1717- return data
1818-}
-63
packages/server/src/lib/redirect-validator.ts
···11-import env from '@api/lib/env'
22-33-/**
44- * Validates that a redirect URI is allowed
55- * Prevents open redirect vulnerabilities by checking against whitelist
66- *
77- * @param uri - The redirect URI to validate
88- * @returns true if the URI's origin is in the whitelist
99- */
1010-export function isAllowedRedirectUri(uri: string): boolean {
1111- try {
1212- const url = new URL(uri)
1313- const allowedOrigins = env.ALLOWED_REDIRECT_ORIGINS.split(',').map((o) =>
1414- o.trim()
1515- )
1616- return allowedOrigins.includes(url.origin)
1717- } catch {
1818- // Invalid URL format
1919- return false
2020- }
2121-}
2222-2323-const DEFAULT_LOCAL_REDIRECT_URI = 'http://127.0.0.1:5173'
2424-2525-/**
2626- * Gets the default redirect URI for the environment
2727- * Uses the first allowed origin in the whitelist
2828- *
2929- * @returns The default redirect URI
3030- */
3131-export function getDefaultRedirectUri(): string {
3232- const allowed = env.ALLOWED_REDIRECT_ORIGINS.split(',').map((o) => o.trim())
3333- return allowed[0] || DEFAULT_LOCAL_REDIRECT_URI
3434-}
3535-3636-export function parseRedirectState(state: string | null) {
3737- let defaultUri = getDefaultRedirectUri()
3838- if (!state) {
3939- return defaultUri
4040- }
4141-4242- try {
4343- const stateData = JSON.parse(state) as {
4444- returnTo: string
4545- timestamp: number
4646- }
4747- if (!stateData.returnTo) {
4848- return defaultUri
4949- }
5050-5151- // Re-validate even though we validated on login
5252- // (defense in depth - in case state was tampered with)
5353- if (!isAllowedRedirectUri(stateData.returnTo)) {
5454- console.warn('State contained invalid redirect_uri:', stateData.returnTo)
5555- return defaultUri
5656- }
5757-5858- return stateData.returnTo
5959- } catch (error) {
6060- console.error('Failed to parse OAuth state:', error)
6161- return defaultUri
6262- }
6363-}
-181
packages/server/src/lib/session.ts
···11-import { db } from '@api/db/db'
22-33-// Session configuration
44-const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds
55-const REFRESH_THRESHOLD = 0.5 // Refresh if >50% time elapsed
66-77-export interface SessionData {
88- id: string
99- account: {
1010- did: string
1111- handle: string
1212- role: string
1313- active: boolean
1414- }
1515- created_at: number
1616- expires_at: number
1717-}
1818-1919-/**
2020- * Create a new app session for a user
2121- * @param accountDid - The user's DID
2222- * @param data - Optional additional data to store (will be merged with account data)
2323- * @returns Session UUID
2424- */
2525-export async function createSession(
2626- accountDid: string,
2727- data?: object
2828-): Promise<string> {
2929- const sessionId = crypto.randomUUID()
3030- const now = Date.now()
3131- const expiresAt = now + SESSION_DURATION
3232-3333- // Get account data
3434- const account = await db
3535- .selectFrom('accounts')
3636- .select(['did', 'handle', 'role', 'active'])
3737- .where('did', '=', accountDid)
3838- .executeTakeFirst()
3939-4040- if (!account) {
4141- throw new Error('Account not found')
4242- }
4343-4444- // Store session data as JSON
4545- const sessionData = {
4646- did: account.did,
4747- handle: account.handle,
4848- role: account.role,
4949- active: Boolean(account.active),
5050- ...data
5151- }
5252-5353- await db
5454- .insertInto('app_sessions')
5555- .values({
5656- id: sessionId,
5757- account_did: accountDid,
5858- data: JSON.stringify(sessionData),
5959- created_at: now,
6060- expires_at: expiresAt
6161- })
6262- .execute()
6363-6464- return sessionId
6565-}
6666-6767-/**
6868- * Get session data with fresh account information
6969- * @param sessionId - Session UUID
7070- * @returns Session data with account info, or null if not found/expired
7171- */
7272-export async function getSession(
7373- sessionId: string
7474-): Promise<SessionData | null> {
7575- const now = Date.now()
7676-7777- // Get session joined with account to ensure fresh data
7878- const result = await db
7979- .selectFrom('app_sessions')
8080- .innerJoin('accounts', 'accounts.did', 'app_sessions.account_did')
8181- .select([
8282- 'app_sessions.id',
8383- 'app_sessions.created_at',
8484- 'app_sessions.expires_at',
8585- 'accounts.did',
8686- 'accounts.handle',
8787- 'accounts.role',
8888- 'accounts.active'
8989- ])
9090- .where('app_sessions.id', '=', sessionId)
9191- .where('app_sessions.expires_at', '>', now)
9292- .executeTakeFirst()
9393-9494- if (!result) {
9595- return null
9696- }
9797-9898- return {
9999- id: result.id,
100100- account: {
101101- did: result.did,
102102- handle: result.handle,
103103- role: result.role,
104104- active: Boolean(result.active)
105105- },
106106- created_at: result.created_at,
107107- expires_at: result.expires_at
108108- }
109109-}
110110-111111-/**
112112- * Refresh session expiration (sliding window)
113113- * Only refreshes if more than REFRESH_THRESHOLD of time has elapsed
114114- * @param sessionId - Session UUID
115115- */
116116-export async function refreshSession(sessionId: string): Promise<void> {
117117- const session = await db
118118- .selectFrom('app_sessions')
119119- .select(['created_at', 'expires_at'])
120120- .where('id', '=', sessionId)
121121- .executeTakeFirst()
122122-123123- if (!session) {
124124- return
125125- }
126126-127127- const now = Date.now()
128128- const elapsed = now - session.created_at
129129- const total = session.expires_at - session.created_at
130130-131131- // Only refresh if more than threshold has elapsed
132132- if (elapsed / total > REFRESH_THRESHOLD) {
133133- const newExpiresAt = now + SESSION_DURATION
134134-135135- await db
136136- .updateTable('app_sessions')
137137- .set({ expires_at: newExpiresAt })
138138- .where('id', '=', sessionId)
139139- .execute()
140140- }
141141-}
142142-143143-/**
144144- * Delete a single session (logout)
145145- * @param sessionId - Session UUID
146146- */
147147-export async function deleteSession(sessionId: string): Promise<void> {
148148- await db.deleteFrom('app_sessions').where('id', '=', sessionId).execute()
149149-}
150150-151151-/**
152152- * Delete all sessions for a user (revoke all)
153153- * @param accountDid - User's DID
154154- * @returns Number of sessions deleted
155155- */
156156-export async function deleteAllUserSessions(
157157- accountDid: string
158158-): Promise<number> {
159159- const result = await db
160160- .deleteFrom('app_sessions')
161161- .where('account_did', '=', accountDid)
162162- .executeTakeFirst()
163163-164164- return Number(result.numDeletedRows)
165165-}
166166-167167-/**
168168- * Clean up expired sessions
169169- * Should be run periodically (cron job or on startup)
170170- * @returns Number of sessions deleted
171171- */
172172-export async function cleanupExpiredSessions(): Promise<number> {
173173- const now = Date.now()
174174-175175- const result = await db
176176- .deleteFrom('app_sessions')
177177- .where('expires_at', '<', now)
178178- .executeTakeFirst()
179179-180180- return Number(result.numDeletedRows)
181181-}
···11+export * as AppWafrnContentCreatePost from "./types/app/wafrn/content/createPost.js";
22+export * as AppWafrnContentPost from "./types/app/wafrn/content/post.js";