work-in-progress atproto PDS
typescript atproto pds atcute

Compare changes

Choose any two refs to compare.

+1
.prettierrc
··· 15 15 "^@atcute/(.*)$", 16 16 "^@danaus/(.*)$", 17 17 "^@kelinci/(.*)$", 18 + "^@oomfware/(.*)$", 18 19 "", 19 20 "<THIRD_PARTY_MODULES>", 20 21 "",
+4 -4
CLAUDE.md
··· 11 11 - run tests via `bun test` (bun, in package) 12 12 - typecheck via `bun run tsc` (tsc, in package) 13 13 - check `pnpm view <package>` before adding a new dependency 14 + - pnpm doesn't hoist packages by default; check the package's own `node_modules/` directory when 15 + inspecting dependencies (e.g., `packages/danaus/node_modules/@atcute/crypto` not root 16 + `node_modules`) 14 17 15 18 ### code writing 16 19 ··· 40 43 - use `@throws` for exceptions when applicable 41 44 - keep descriptions concise but informative 42 45 43 - ### working style 46 + ### agentic coding 44 47 45 48 - `.research/` directory in the project root serves as a workspace for temporary experiments, 46 49 analysis, and planning materials. create if not present (it's gitignored). this directory may ··· 58 61 be sure 59 62 - Task tool (subagents for exploration, planning, etc.) may not always be accurate; verify subagent 60 63 findings when needed 61 - - pnpm doesn't hoist packages by default; check the package's own `node_modules/` directory when 62 - inspecting dependencies (e.g., `packages/danaus/node_modules/@atcute/crypto` not root 63 - `node_modules`) 64 64 65 65 ## Decision Graph Workflow 66 66
+1 -1
package.json
··· 11 11 "devDependencies": { 12 12 "@ianvs/prettier-plugin-sort-imports": "^4.7.0", 13 13 "@prettier/plugin-oxc": "^0.1.3", 14 - "oxlint": "^1.36.0", 14 + "oxlint": "^1.38.0", 15 15 "prettier": "^3.7.4", 16 16 "prettier-plugin-tailwindcss": "^0.7.2", 17 17 "typescript": "^5.9.3"
+14
packages/danaus/drizzle/identity/20260106131826_whole_micromax/migration.sql
··· 1 + CREATE TABLE `did_doc` ( 2 + `did` text PRIMARY KEY, 3 + `doc` text NOT NULL, 4 + `updated_at` integer NOT NULL 5 + ); 6 + --> statement-breakpoint 7 + CREATE TABLE `handle` ( 8 + `handle` text PRIMARY KEY, 9 + `did` text NOT NULL, 10 + `updated_at` integer NOT NULL 11 + ); 12 + --> statement-breakpoint 13 + CREATE INDEX `did_doc_updated_at_idx` ON `did_doc` (`updated_at`);--> statement-breakpoint 14 + CREATE INDEX `handle_updated_at_idx` ON `handle` (`updated_at`);
+125
packages/danaus/drizzle/identity/20260106131826_whole_micromax/snapshot.json
··· 1 + { 2 + "version": "7", 3 + "dialect": "sqlite", 4 + "id": "05a4b23c-3072-4afe-bb1d-7495fb7fcdad", 5 + "prevIds": [ 6 + "00000000-0000-0000-0000-000000000000" 7 + ], 8 + "ddl": [ 9 + { 10 + "name": "did_doc", 11 + "entityType": "tables" 12 + }, 13 + { 14 + "name": "handle", 15 + "entityType": "tables" 16 + }, 17 + { 18 + "type": "text", 19 + "notNull": false, 20 + "autoincrement": false, 21 + "default": null, 22 + "generated": null, 23 + "name": "did", 24 + "entityType": "columns", 25 + "table": "did_doc" 26 + }, 27 + { 28 + "type": "text", 29 + "notNull": true, 30 + "autoincrement": false, 31 + "default": null, 32 + "generated": null, 33 + "name": "doc", 34 + "entityType": "columns", 35 + "table": "did_doc" 36 + }, 37 + { 38 + "type": "integer", 39 + "notNull": true, 40 + "autoincrement": false, 41 + "default": null, 42 + "generated": null, 43 + "name": "updated_at", 44 + "entityType": "columns", 45 + "table": "did_doc" 46 + }, 47 + { 48 + "type": "text", 49 + "notNull": false, 50 + "autoincrement": false, 51 + "default": null, 52 + "generated": null, 53 + "name": "handle", 54 + "entityType": "columns", 55 + "table": "handle" 56 + }, 57 + { 58 + "type": "text", 59 + "notNull": true, 60 + "autoincrement": false, 61 + "default": null, 62 + "generated": null, 63 + "name": "did", 64 + "entityType": "columns", 65 + "table": "handle" 66 + }, 67 + { 68 + "type": "integer", 69 + "notNull": true, 70 + "autoincrement": false, 71 + "default": null, 72 + "generated": null, 73 + "name": "updated_at", 74 + "entityType": "columns", 75 + "table": "handle" 76 + }, 77 + { 78 + "columns": [ 79 + "did" 80 + ], 81 + "nameExplicit": false, 82 + "name": "did_doc_pk", 83 + "table": "did_doc", 84 + "entityType": "pks" 85 + }, 86 + { 87 + "columns": [ 88 + "handle" 89 + ], 90 + "nameExplicit": false, 91 + "name": "handle_pk", 92 + "table": "handle", 93 + "entityType": "pks" 94 + }, 95 + { 96 + "columns": [ 97 + { 98 + "value": "updated_at", 99 + "isExpression": false 100 + } 101 + ], 102 + "isUnique": false, 103 + "where": null, 104 + "origin": "manual", 105 + "name": "did_doc_updated_at_idx", 106 + "entityType": "indexes", 107 + "table": "did_doc" 108 + }, 109 + { 110 + "columns": [ 111 + { 112 + "value": "updated_at", 113 + "isExpression": false 114 + } 115 + ], 116 + "isUnique": false, 117 + "where": null, 118 + "origin": "manual", 119 + "name": "handle_updated_at_idx", 120 + "entityType": "indexes", 121 + "table": "handle" 122 + } 123 + ], 124 + "renames": [] 125 + }
+7 -3
packages/danaus/package.json
··· 17 17 "css:watch": "tailwindcss -i src/web/styles/main.css -o src/web/styles/main.out.css -w", 18 18 "db:generate:account": "drizzle-kit generate --dialect=sqlite --schema=src/accounts/db/schema.ts --out=drizzle/accounts", 19 19 "db:generate:actor": "drizzle-kit generate --dialect=sqlite --schema=src/actors/db/schema.ts --out=drizzle/actors", 20 + "db:generate:identity": "drizzle-kit generate --dialect=sqlite --schema=src/identity/db/schema.ts --out=drizzle/identity", 20 21 "db:generate:sequencer": "drizzle-kit generate --dialect=sqlite --schema=src/sequencer/db/schema.ts --out=drizzle/sequencer" 21 22 }, 22 23 "dependencies": { ··· 25 26 "@atcute/car": "^5.0.0", 26 27 "@atcute/cbor": "^2.2.8", 27 28 "@atcute/cid": "^2.3.0", 28 - "@atcute/client": "^4.2.0", 29 + "@atcute/client": "^4.2.1", 29 30 "@atcute/crypto": "^2.3.0", 30 31 "@atcute/did-plc": "^0.3.1", 31 32 "@atcute/identity": "^1.1.3", ··· 38 39 "@atcute/tid": "^1.1.1", 39 40 "@atcute/uint8array": "^1.0.6", 40 41 "@atcute/util-fetch": "^1.0.5", 41 - "@atcute/xrpc-server": "^0.1.7", 42 + "@atcute/xrpc-server": "^0.1.8", 42 43 "@atcute/xrpc-server-bun": "^0.1.1", 43 44 "@kelinci/danaus-lexicons": "workspace:*", 45 + "@oomfware/fetch-router": "^0.2.1", 46 + "@oomfware/forms": "^0.2.0", 47 + "@oomfware/jsx": "^0.1.4", 44 48 "cva": "1.0.0-beta.4", 45 49 "drizzle-orm": "1.0.0-beta.6-4414a19", 46 50 "get-port": "^7.1.0", 47 - "hono": "^4.11.3", 48 51 "jose": "^6.1.3", 49 52 "nanoid": "^5.1.6", 53 + "p-queue": "^9.1.0", 50 54 "valibot": "^1.2.0" 51 55 }, 52 56 "devDependencies": {
+12 -3
packages/danaus/src/actors/blob-store/disk.ts
··· 37 37 return path.join(this.directory, cid); 38 38 } 39 39 40 - async putTemp(data: Request): Promise<string> { 40 + async putTemp(stream: ReadableStream<Uint8Array>): Promise<string> { 41 41 const tempKey = nanoid(); 42 + const tempPath = this.getTempPath(tempKey); 43 + 44 + await mkdir(this.tempDirectory, { recursive: true }); 45 + 46 + const file = Bun.file(tempPath); 47 + const writer = file.writer(); 42 48 43 - const temp = Bun.file(this.getTempPath(tempKey)); 44 - await temp.write(data); 49 + for await (const chunk of stream) { 50 + writer.write(chunk); 51 + } 52 + 53 + await writer.end(); 45 54 46 55 return tempKey; 47 56 }
+8 -2
packages/danaus/src/actors/blob-store/s3.ts
··· 39 39 return `blocks/${this.did}/${cid}`; 40 40 } 41 41 42 - async putTemp(data: Request): Promise<string> { 42 + async putTemp(stream: ReadableStream<Uint8Array>): Promise<string> { 43 43 const tempKey = nanoid(); 44 44 45 45 const temp = this.client.file(this.getTempPath(tempKey)); 46 - await temp.write(data); 46 + const writer = temp.writer(); 47 + 48 + for await (const chunk of stream) { 49 + writer.write(chunk); 50 + } 51 + 52 + await writer.end(); 47 53 48 54 return tempKey; 49 55 }
+1 -1
packages/danaus/src/actors/blob-store/types.ts
··· 1 1 export interface BlobStore { 2 - putTemp(data: Request): Promise<string>; 2 + putTemp(stream: ReadableStream<Uint8Array>): Promise<string>; 3 3 putPermanent(cid: string, data: Request): Promise<void>; 4 4 5 5 makePermanent(tempKey: string, cid: string): Promise<void>;
+4 -2
packages/danaus/src/api/com.atproto/identity.resolveHandle.ts
··· 10 10 * @param context app context 11 11 */ 12 12 export const resolveHandle = (router: XRPCRouter, context: AppContext) => { 13 - const { accountManager, config, handleResolver } = context; 13 + const { accountManager, config, handleResolver, proxy } = context; 14 14 15 15 router.addQuery(ComAtprotoIdentityResolveHandle, { 16 - async handler({ params }) { 16 + async handler({ params, request }) { 17 + await proxy.passthrough(request); 18 + 17 19 const handle = params.handle.toLowerCase(); 18 20 19 21 if (!isHandle(handle)) {
+4 -2
packages/danaus/src/api/com.atproto/repo.getRecord.ts
··· 9 9 * @param context app context 10 10 */ 11 11 export const getRecord = (router: XRPCRouter, context: AppContext) => { 12 - const { accountManager, actorManager } = context; 12 + const { accountManager, actorManager, proxy } = context; 13 13 14 14 router.addQuery(ComAtprotoRepoGetRecord, { 15 - async handler({ params }) { 15 + async handler({ params, request }) { 16 + await proxy.passthrough(request); 17 + 16 18 const { repo, collection, rkey, cid } = params; 17 19 18 20 const did = accountManager.getAccountDid(repo);
+61 -18
packages/danaus/src/api/com.atproto/repo.uploadBlob.ts
··· 28 28 29 29 const blobStore = actorManager.resources.createBlobStore(auth.did); 30 30 31 - const [{ digest, size }, tempKey] = await Promise.all([ 32 - hashBlob(request.clone() as Request, config.service.blobs.maxUploadSize), 33 - blobStore.putTemp(request), 34 - ]); 31 + const { stream, result } = hashingStream(request.body!, config.service.blobs.maxUploadSize); 32 + 33 + const tempKey = await blobStore.putTemp(stream); 34 + 35 + const { digest, size: hashSize } = await result; 35 36 36 37 const cid = CID.toString(CID.fromDigest(CID.CODEC_RAW, digest)); 37 38 ··· 51 52 cid: cid, 52 53 created_at: new Date(), 53 54 mime_type: mimeType, 54 - size: size, 55 + size: hashSize, 55 56 temp_key: tempKey, 56 57 }) 57 58 .onConflictDoUpdate({ ··· 66 67 return { 67 68 cid: cid, 68 69 mimeType: mimeType, 69 - size: size, 70 + size: hashSize, 70 71 }; 71 72 }); 72 73 ··· 82 83 }); 83 84 }; 84 85 85 - const hashBlob = async (request: Request, maxSize: number): Promise<{ digest: Uint8Array; size: number }> => { 86 + interface HashingStreamResult { 87 + stream: ReadableStream<Uint8Array>; 88 + result: Promise<{ digest: Uint8Array; size: number }>; 89 + } 90 + 91 + /** 92 + * create a passthrough stream that hashes data as it flows through. 93 + * uses pull-based reading to work with bun's stream implementation. 94 + * @param input input stream 95 + * @param maxSize maximum allowed size 96 + * @returns passthrough stream and promise for digest and size 97 + */ 98 + const hashingStream = (input: ReadableStream<Uint8Array>, maxSize: number): HashingStreamResult => { 86 99 const hasher = createHash('sha256'); 87 100 let size = 0; 88 101 89 - for await (const chunk of request.body!) { 90 - size += chunk.length; 102 + const { promise: result, resolve, reject } = Promise.withResolvers<{ digest: Uint8Array; size: number }>(); 103 + 104 + let reader: ReadableStreamDefaultReader<Uint8Array>; 105 + 106 + const stream = new ReadableStream<Uint8Array>({ 107 + start() { 108 + reader = input.getReader() as any; 109 + }, 110 + async pull(controller) { 111 + try { 112 + const { done, value } = await reader.read(); 113 + 114 + if (done) { 115 + resolve({ digest: new Uint8Array(hasher.digest()), size }); 116 + controller.close(); 117 + return; 118 + } 119 + 120 + size += value.length; 91 121 92 - if (size > maxSize) { 93 - throw new InvalidRequestError({ 94 - error: 'BlobTooLarge', 95 - description: `blob exceeds upload size limit`, 96 - }); 97 - } 122 + if (size > maxSize) { 123 + const err = new InvalidRequestError({ 124 + error: 'BlobTooLarge', 125 + description: `blob exceeds upload size limit`, 126 + }); 127 + reject(err); 128 + controller.error(new Error('blob too large')); 129 + reader.cancel(); 130 + return; 131 + } 98 132 99 - hasher.update(chunk); 100 - } 133 + hasher.update(value); 134 + controller.enqueue(value); 135 + } catch (err) { 136 + reject(err); 137 + controller.error(err); 138 + } 139 + }, 140 + cancel() { 141 + reader.cancel(); 142 + }, 143 + }); 101 144 102 - return { digest: new Uint8Array(hasher.digest()), size }; 145 + return { stream, result }; 103 146 };
+60
packages/danaus/src/background.ts
··· 1 + import PQueue from 'p-queue'; 2 + 3 + export interface BackgroundQueueOptions { 4 + /** maximum concurrent tasks (default: 5) */ 5 + concurrency?: number; 6 + } 7 + 8 + /** 9 + * a simple queue for in-process, out-of-band background work. 10 + * tasks are fire-and-forget with error logging. 11 + */ 12 + export class BackgroundQueue implements Disposable { 13 + readonly #queue: PQueue; 14 + #destroyed = false; 15 + 16 + constructor(options: BackgroundQueueOptions = {}) { 17 + this.#queue = new PQueue({ concurrency: options.concurrency ?? 5 }); 18 + } 19 + 20 + /** 21 + * add a task to the background queue. 22 + * task errors are logged but not propagated. 23 + * @param task async function to execute 24 + */ 25 + add(task: () => Promise<void>): void { 26 + if (this.#destroyed) { 27 + return; 28 + } 29 + 30 + this.#queue 31 + .add(() => task()) 32 + .catch((err) => { 33 + console.error('background queue task failed:', err); 34 + }); 35 + } 36 + 37 + /** 38 + * wait for all pending tasks to complete. 39 + */ 40 + async onIdle(): Promise<void> { 41 + await this.#queue.onIdle(); 42 + } 43 + 44 + /** 45 + * stop accepting new tasks and wait for pending tasks to complete. 46 + */ 47 + async destroy(): Promise<void> { 48 + this.#destroyed = true; 49 + await this.#queue.onIdle(); 50 + } 51 + 52 + dispose(): void { 53 + this.#destroyed = true; 54 + this.#queue.clear(); 55 + } 56 + 57 + [Symbol.dispose](): void { 58 + this.dispose(); 59 + } 60 + }
+1 -8
packages/danaus/src/bin/pds.ts
··· 23 23 24 24 targets.set('did:web:api.bsky.app#bsky_appview', { 25 25 to: `did:web:localhost%3A${BSKY_PORT}#bsky_appview`, 26 - exclude: [ 27 - 'app.bsky.actor.getPreferences', 28 - 'app.bsky.actor.putPreferences', 29 - 'com.atproto.repo.applyWrites', 30 - 'com.atproto.repo.createRecord', 31 - 'com.atproto.repo.putRecord', 32 - 'com.atproto.server.getSession', 33 - ], 26 + exclude: ['app.bsky.actor.getPreferences', 'app.bsky.actor.putPreferences'], 34 27 }); 35 28 36 29 const pds = await TestPds.create({
+43 -2
packages/danaus/src/context.ts
··· 15 15 import { S3BlobStore } from './actors/blob-store/s3'; 16 16 import { ActorManager } from './actors/manager'; 17 17 import { AuthVerifier } from './auth/verifier'; 18 + import { BackgroundQueue } from './background'; 18 19 import type { AppConfig } from './config'; 19 20 import { Crawlers } from './crawlers'; 21 + import { CachedDidDocumentResolver } from './identity/cached-did-document-resolver'; 22 + import { CachedHandleResolver } from './identity/cached-handle-resolver'; 23 + import { IdentityCache } from './identity/manager'; 24 + import { createServiceProxy, type ServiceProxy } from './proxy/index'; 20 25 import { Sequencer } from './sequencer/sequencer'; 21 26 22 27 export interface AppContext { 23 28 config: AppConfig; 24 29 30 + backgroundQueue: BackgroundQueue; 31 + identityCache: IdentityCache; 32 + 25 33 handleResolver: HandleResolver; 26 34 didDocumentResolver: DidDocumentResolver<'plc' | 'web'>; 27 35 plcClient: PlcClient; ··· 31 39 authVerifier: AuthVerifier; 32 40 33 41 sequencer: Sequencer; 42 + 43 + /** service proxy for forwarding requests to atproto-proxy targets */ 44 + proxy: ServiceProxy; 34 45 } 35 46 36 47 export const createAppContext = (config: AppConfig): AppContext => { 37 - const handleResolver = new CompositeHandleResolver({ 48 + const backgroundQueue = new BackgroundQueue(); 49 + 50 + const identityCache = new IdentityCache({ 51 + location: config.database.identityCacheDbLocation, 52 + walAutoCheckpointDisabled: config.database.walAutoCheckpointDisabled, 53 + backgroundQueue: backgroundQueue, 54 + }); 55 + 56 + const baseHandleResolver = new CompositeHandleResolver({ 38 57 strategy: 'race', 39 58 methods: { 40 59 http: new WellKnownHandleResolver(), ··· 42 61 }, 43 62 }); 44 63 45 - const didDocumentResolver = new CompositeDidDocumentResolver({ 64 + const handleResolver = new CachedHandleResolver({ 65 + cache: identityCache, 66 + resolver: baseHandleResolver, 67 + }); 68 + 69 + const baseDidDocumentResolver = new CompositeDidDocumentResolver({ 46 70 methods: { 47 71 plc: new PlcDidDocumentResolver({ apiUrl: config.identity.plcDirectoryUrl }), 48 72 web: new WebDidDocumentResolver(), 49 73 }, 74 + }); 75 + 76 + const didDocumentResolver = new CachedDidDocumentResolver({ 77 + cache: identityCache, 78 + resolver: baseDidDocumentResolver, 50 79 }); 51 80 52 81 const plcClient = new PlcClient({ ··· 91 120 didDocumentResolver: didDocumentResolver, 92 121 }); 93 122 123 + const proxy = createServiceProxy({ 124 + targets: config.proxy.targets, 125 + authVerifier: authVerifier, 126 + actorManager: actorManager, 127 + didDocumentResolver: didDocumentResolver, 128 + }); 129 + 94 130 return { 95 131 config: config, 132 + 133 + backgroundQueue: backgroundQueue, 134 + identityCache: identityCache, 96 135 97 136 handleResolver: handleResolver, 98 137 didDocumentResolver: didDocumentResolver, ··· 103 142 authVerifier: authVerifier, 104 143 105 144 sequencer: sequencer, 145 + 146 + proxy: proxy, 106 147 }; 107 148 };
+61
packages/danaus/src/identity/cached-did-document-resolver.ts
··· 1 + import type { DidDocument } from '@atcute/identity'; 2 + import type { DidDocumentResolver, ResolveDidDocumentOptions } from '@atcute/identity-resolver'; 3 + import type { Did } from '@atcute/lexicons/syntax'; 4 + 5 + import type { IdentityCache } from './manager.ts'; 6 + 7 + type AtprotoDidMethod = 'plc' | 'web'; 8 + 9 + export interface CachedDidDocumentResolverOptions { 10 + cache: IdentityCache; 11 + resolver: DidDocumentResolver<AtprotoDidMethod>; 12 + } 13 + 14 + /** 15 + * DID document resolver wrapper that adds caching with stale-while-revalidate. 16 + */ 17 + export class CachedDidDocumentResolver implements DidDocumentResolver<AtprotoDidMethod> { 18 + readonly #cache: IdentityCache; 19 + readonly #resolver: DidDocumentResolver<AtprotoDidMethod>; 20 + 21 + constructor(options: CachedDidDocumentResolverOptions) { 22 + this.#cache = options.cache; 23 + this.#resolver = options.resolver; 24 + } 25 + 26 + async resolve(did: Did<AtprotoDidMethod>, options?: ResolveDidDocumentOptions): Promise<DidDocument> { 27 + // bypass cache if requested 28 + if (options?.noCache) { 29 + const doc = await this.#resolver.resolve(did, options); 30 + this.#cache.setDidDoc(did, doc); 31 + return doc; 32 + } 33 + 34 + // check cache 35 + const cached = this.#cache.getDidDoc(did); 36 + 37 + if (cached && !cached.expired) { 38 + // trigger background refresh if stale 39 + if (cached.stale) { 40 + this.#cache.refreshDidDoc(did, () => this.resolveNoThrow(did)); 41 + } 42 + return cached.value; 43 + } 44 + 45 + // cache miss or expired - fetch fresh 46 + const doc = await this.#resolver.resolve(did, options); 47 + this.#cache.setDidDoc(did, doc); 48 + return doc; 49 + } 50 + 51 + private async resolveNoThrow( 52 + did: Did<AtprotoDidMethod>, 53 + options?: ResolveDidDocumentOptions, 54 + ): Promise<DidDocument | null> { 55 + try { 56 + return await this.#resolver.resolve(did, options); 57 + } catch { 58 + return null; 59 + } 60 + } 61 + }
+55
packages/danaus/src/identity/cached-handle-resolver.ts
··· 1 + import type { HandleResolver, ResolveHandleOptions } from '@atcute/identity-resolver'; 2 + import type { AtprotoDid, Handle } from '@atcute/lexicons/syntax'; 3 + 4 + import type { IdentityCache } from './manager.ts'; 5 + 6 + export interface CachedHandleResolverOptions { 7 + cache: IdentityCache; 8 + resolver: HandleResolver; 9 + } 10 + 11 + /** 12 + * handle resolver wrapper that adds caching with stale-while-revalidate. 13 + */ 14 + export class CachedHandleResolver implements HandleResolver { 15 + readonly #cache: IdentityCache; 16 + readonly #resolver: HandleResolver; 17 + 18 + constructor(options: CachedHandleResolverOptions) { 19 + this.#cache = options.cache; 20 + this.#resolver = options.resolver; 21 + } 22 + 23 + async resolve(handle: Handle, options?: ResolveHandleOptions): Promise<AtprotoDid> { 24 + // bypass cache if requested 25 + if (options?.noCache) { 26 + const did = await this.#resolver.resolve(handle, options); 27 + this.#cache.setHandle(handle, did); 28 + return did; 29 + } 30 + 31 + // check cache 32 + const cached = this.#cache.getHandle(handle); 33 + 34 + if (cached && !cached.expired) { 35 + // trigger background refresh if stale 36 + if (cached.stale) { 37 + this.#cache.refreshHandle(handle, () => this.resolveNoThrow(handle)); 38 + } 39 + return cached.value; 40 + } 41 + 42 + // cache miss or expired - fetch fresh 43 + const did = await this.#resolver.resolve(handle, options); 44 + this.#cache.setHandle(handle, did); 45 + return did; 46 + } 47 + 48 + private async resolveNoThrow(handle: Handle, options?: ResolveHandleOptions): Promise<AtprotoDid | null> { 49 + try { 50 + return await this.#resolver.resolve(handle, options); 51 + } catch { 52 + return null; 53 + } 54 + } 55 + }
+30
packages/danaus/src/identity/db/index.ts
··· 1 + import path from 'node:path'; 2 + import { Database } from 'bun:sqlite'; 3 + 4 + import { drizzle } from 'drizzle-orm/bun-sqlite'; 5 + import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; 6 + 7 + import * as schema from './schema.ts'; 8 + 9 + const MIGRATIONS_DIR = path.resolve(import.meta.dir, '../../../drizzle/identity'); 10 + 11 + export const getIdentityCacheDb = (location: string, walAutoCheckpointDisabled: boolean) => { 12 + const sqliteDb = new Database(location); 13 + sqliteDb.run(`PRAGMA journal_mode = WAL;`); 14 + if (walAutoCheckpointDisabled) { 15 + sqliteDb.run(`PRAGMA wal_autocheckpoint = 0;`); 16 + } 17 + 18 + const db = drizzle({ 19 + client: sqliteDb, 20 + schema: schema, 21 + }); 22 + 23 + migrate(db, { migrationsFolder: MIGRATIONS_DIR }); 24 + 25 + return db; 26 + }; 27 + 28 + export type IdentityCacheDb = ReturnType<typeof getIdentityCacheDb>; 29 + 30 + export { schema as t };
+26
packages/danaus/src/identity/db/schema.ts
··· 1 + import type { DidDocument } from '@atcute/identity'; 2 + import type { AtprotoDid, Handle } from '@atcute/lexicons/syntax'; 3 + 4 + import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; 5 + 6 + /** cached handle resolutions */ 7 + export const handle = sqliteTable( 8 + 'handle', 9 + { 10 + handle: text().$type<Handle>().primaryKey(), 11 + did: text().$type<AtprotoDid>().notNull(), 12 + updated_at: integer().notNull(), 13 + }, 14 + (t) => [index('handle_updated_at_idx').on(t.updated_at)], 15 + ); 16 + 17 + /** cached DID documents */ 18 + export const didDoc = sqliteTable( 19 + 'did_doc', 20 + { 21 + did: text().$type<AtprotoDid>().primaryKey(), 22 + doc: text({ mode: 'json' }).$type<DidDocument>().notNull(), 23 + updated_at: integer().notNull(), 24 + }, 25 + (t) => [index('did_doc_updated_at_idx').on(t.updated_at)], 26 + );
+212
packages/danaus/src/identity/manager.ts
··· 1 + import type { DidDocument } from '@atcute/identity'; 2 + import type { AtprotoDid, Handle } from '@atcute/lexicons/syntax'; 3 + 4 + import { eq, lt } from 'drizzle-orm'; 5 + 6 + import type { BackgroundQueue } from '#app/background.ts'; 7 + import { HOUR } from '#app/utils/times.ts'; 8 + 9 + import { getIdentityCacheDb, t, type IdentityCacheDb } from './db/index.ts'; 10 + 11 + const DEFAULT_STALE_TTL = HOUR; 12 + const DEFAULT_MAX_TTL = 24 * HOUR; 13 + const DEFAULT_PRUNE_INTERVAL = HOUR; 14 + 15 + export interface IdentityCacheOptions { 16 + location: string; 17 + walAutoCheckpointDisabled: boolean; 18 + backgroundQueue: BackgroundQueue; 19 + /** time before an entry is considered stale (default: 1 hour) */ 20 + staleTtl?: number; 21 + /** time before an entry expires completely (default: 24 hours) */ 22 + maxTtl?: number; 23 + /** interval between pruning runs (default: 1 hour) */ 24 + pruneInterval?: number; 25 + } 26 + 27 + export interface CacheResult<T> { 28 + value: T; 29 + updatedAt: number; 30 + stale: boolean; 31 + expired: boolean; 32 + } 33 + 34 + /** 35 + * SQLite-backed identity cache for handles and DID documents. 36 + * supports stale-while-revalidate pattern with background refresh. 37 + */ 38 + export class IdentityCache implements Disposable { 39 + readonly #db: IdentityCacheDb; 40 + readonly #backgroundQueue: BackgroundQueue; 41 + readonly #staleTtl: number; 42 + readonly #maxTtl: number; 43 + readonly #pruneInterval: Timer; 44 + 45 + constructor(options: IdentityCacheOptions) { 46 + this.#db = getIdentityCacheDb(options.location, options.walAutoCheckpointDisabled); 47 + this.#backgroundQueue = options.backgroundQueue; 48 + this.#staleTtl = options.staleTtl ?? DEFAULT_STALE_TTL; 49 + this.#maxTtl = options.maxTtl ?? DEFAULT_MAX_TTL; 50 + 51 + const pruneIntervalMs = options.pruneInterval ?? DEFAULT_PRUNE_INTERVAL; 52 + this.#pruneInterval = setInterval(() => { 53 + this.#backgroundQueue.add(() => this.pruneExpired()); 54 + }, pruneIntervalMs); 55 + } 56 + 57 + // #region handles 58 + 59 + /** 60 + * get a cached handle resolution. 61 + * @param handle handle to look up 62 + * @returns cache result or null if not found 63 + */ 64 + getHandle(handle: Handle): CacheResult<AtprotoDid> | null { 65 + const row = this.#db.select().from(t.handle).where(eq(t.handle.handle, handle)).get(); 66 + 67 + if (!row) { 68 + return null; 69 + } 70 + 71 + const now = Date.now(); 72 + return { 73 + value: row.did, 74 + updatedAt: row.updated_at, 75 + stale: now > row.updated_at + this.#staleTtl, 76 + expired: now > row.updated_at + this.#maxTtl, 77 + }; 78 + } 79 + 80 + /** 81 + * cache a handle resolution. 82 + * @param handle handle 83 + * @param did resolved DID 84 + */ 85 + setHandle(handle: Handle, did: AtprotoDid): void { 86 + this.#db 87 + .insert(t.handle) 88 + .values({ handle, did, updated_at: Date.now() }) 89 + .onConflictDoUpdate({ 90 + target: t.handle.handle, 91 + set: { did, updated_at: Date.now() }, 92 + }) 93 + .run(); 94 + } 95 + 96 + /** 97 + * remove a handle from cache. 98 + * @param handle handle to remove 99 + */ 100 + clearHandle(handle: Handle): void { 101 + this.#db.delete(t.handle).where(eq(t.handle.handle, handle)).run(); 102 + } 103 + 104 + /** 105 + * queue a background refresh for a handle. 106 + * @param handle handle to refresh 107 + * @param resolve function to resolve the handle 108 + */ 109 + refreshHandle(handle: Handle, resolve: () => Promise<AtprotoDid | null>): void { 110 + this.#backgroundQueue.add(async () => { 111 + const did = await resolve(); 112 + if (did) { 113 + this.setHandle(handle, did); 114 + } else { 115 + this.clearHandle(handle); 116 + } 117 + }); 118 + } 119 + 120 + // #endregion 121 + 122 + // #region DID documents 123 + 124 + /** 125 + * get a cached DID document. 126 + * @param did DID to look up 127 + * @returns cache result or null if not found 128 + */ 129 + getDidDoc(did: AtprotoDid): CacheResult<DidDocument> | null { 130 + const row = this.#db.select().from(t.didDoc).where(eq(t.didDoc.did, did)).get(); 131 + 132 + if (!row) { 133 + return null; 134 + } 135 + 136 + const now = Date.now(); 137 + return { 138 + value: row.doc, 139 + updatedAt: row.updated_at, 140 + stale: now > row.updated_at + this.#staleTtl, 141 + expired: now > row.updated_at + this.#maxTtl, 142 + }; 143 + } 144 + 145 + /** 146 + * cache a DID document. 147 + * @param did DID 148 + * @param doc DID document 149 + */ 150 + setDidDoc(did: AtprotoDid, doc: DidDocument): void { 151 + this.#db 152 + .insert(t.didDoc) 153 + .values({ did, doc, updated_at: Date.now() }) 154 + .onConflictDoUpdate({ 155 + target: t.didDoc.did, 156 + set: { doc, updated_at: Date.now() }, 157 + }) 158 + .run(); 159 + } 160 + 161 + /** 162 + * remove a DID document from cache. 163 + * @param did DID to remove 164 + */ 165 + clearDidDoc(did: AtprotoDid): void { 166 + this.#db.delete(t.didDoc).where(eq(t.didDoc.did, did)).run(); 167 + } 168 + 169 + /** 170 + * queue a background refresh for a DID document. 171 + * @param did DID to refresh 172 + * @param resolve function to resolve the DID document 173 + */ 174 + refreshDidDoc(did: AtprotoDid, resolve: () => Promise<DidDocument | null>): void { 175 + this.#backgroundQueue.add(async () => { 176 + const doc = await resolve(); 177 + if (doc) { 178 + this.setDidDoc(did, doc); 179 + } else { 180 + this.clearDidDoc(did); 181 + } 182 + }); 183 + } 184 + 185 + // #endregion 186 + 187 + /** 188 + * remove all expired entries from the cache. 189 + */ 190 + async pruneExpired(): Promise<void> { 191 + const cutoff = Date.now() - this.#maxTtl; 192 + this.#db.delete(t.handle).where(lt(t.handle.updated_at, cutoff)).run(); 193 + this.#db.delete(t.didDoc).where(lt(t.didDoc.updated_at, cutoff)).run(); 194 + } 195 + 196 + /** 197 + * clear all cached entries. 198 + */ 199 + clear(): void { 200 + this.#db.delete(t.handle).run(); 201 + this.#db.delete(t.didDoc).run(); 202 + } 203 + 204 + dispose(): void { 205 + clearInterval(this.#pruneInterval); 206 + this.#db.$client.close(); 207 + } 208 + 209 + [Symbol.dispose](): void { 210 + this.dispose(); 211 + } 212 + }
+10 -5
packages/danaus/src/pds-server.ts
··· 8 8 import { localDanaus } from './api/local.danaus/index.ts'; 9 9 import type { AppConfig } from './config.ts'; 10 10 import { createAppContext, type AppContext } from './context.ts'; 11 - import { createProxyMiddleware } from './proxy/index.ts'; 12 - import { createWebApp } from './web/app.ts'; 11 + import { createWebRouter } from './web/router.ts'; 13 12 import styles from './web/styles/main.out.css' with { type: 'file' }; 14 13 15 14 export interface PdsServerOptions { ··· 44 43 await using disposables = new AsyncDisposableStack(); 45 44 46 45 const context = createAppContext(this.config); 46 + 47 + // register cleanup in reverse dependency order 48 + // NOTE: Bun/JSCore quirk - AsyncDisposableStack.use() requires AsyncDisposable, 49 + // doesn't accept Disposable like the spec allows, so we use defer() instead 50 + disposables.defer(() => context.backgroundQueue.dispose()); 51 + disposables.defer(() => context.identityCache.dispose()); 47 52 disposables.defer(() => context.accountManager.dispose()); 48 53 49 54 const { wrap, adapter } = createBunWebSocket(); ··· 55 60 allowedHeaders: ['x-bsky-topics'], 56 61 allowPrivateNetwork: true, 57 62 }), 58 - createProxyMiddleware(context), 59 63 ], 64 + handleNotFound: context.proxy.handleNotFound, 60 65 handleException(err, request) { 61 66 return defaultExceptionHandler(err, request); 62 67 }, ··· 68 73 comAtproto(router, context); 69 74 localDanaus(router, context); 70 75 71 - const web = createWebApp(context); 76 + const web = createWebRouter(context); 72 77 73 78 const corsHeaders = { 'access-control-allow-origin': '*' }; 74 79 ··· 113 118 '/xrpc/*': wrapped.fetch, 114 119 115 120 '/assets/style.css': new Response(Bun.file(styles), { headers: { 'cache-control': 'no-cache' } }), 116 - '/*': web.fetch, 121 + '/*': (request) => web.fetch(request), 117 122 }, 118 123 }); 119 124 disposables.defer(() => server.stop());
+103 -24
packages/danaus/src/proxy/index.ts
··· 1 - import { InvalidRequestError, type FetchMiddleware } from '@atcute/xrpc-server'; 1 + import type { DidDocumentResolver } from '@atcute/identity-resolver'; 2 + import { defaultNotFoundHandler, InvalidRequestError, type NotFoundHandler } from '@atcute/xrpc-server'; 2 3 import { createServiceJwt } from '@atcute/xrpc-server/auth'; 3 4 4 - import type { AppContext } from '#app/context.ts'; 5 + import type { ActorManager } from '#app/actors/manager.ts'; 6 + import type { AuthVerifier } from '#app/auth/verifier.ts'; 7 + import type { ProxyTargetConfig } from '#app/config.ts'; 5 8 6 9 import { 7 10 buildProxyRequestHeaders, 11 + buildProxyRequestHeadersWithInput, 8 12 filterResponseHeaders, 9 13 parseProxyHeader, 10 14 parseRequestNsid, 11 15 } from './utils.ts'; 16 + 17 + export type { ProxyTarget } from './utils.ts'; 18 + 19 + export type PassthroughFn = (request: Request, input?: unknown) => Promise<void>; 20 + 21 + export interface ServiceProxy { 22 + /** not-found handler for XRPCRouter that proxies unhandled requests */ 23 + handleNotFound: NotFoundHandler; 24 + /** 25 + * proxy the request to an external service and throw the response. 26 + * use this in local handlers that want to delegate to the atproto-proxy target. 27 + * @param request original request 28 + * @param input parsed input body for POST requests (if body was already consumed) 29 + * @throws Response from the proxied request, or returns if no atproto-proxy header 30 + */ 31 + passthrough: PassthroughFn; 32 + } 33 + 34 + export interface ServiceProxyOptions { 35 + targets: Map<string, ProxyTargetConfig>; 36 + authVerifier: AuthVerifier; 37 + actorManager: ActorManager; 38 + didDocumentResolver: DidDocumentResolver<string>; 39 + } 12 40 13 41 /** 14 - * create proxy middleware that forwards requests to external services. 15 - * the middleware activates when the `atproto-proxy` header is present. 16 - * @param ctx app context 17 - * @returns fetch middleware 42 + * create service proxy handlers. 43 + * @param options proxy dependencies 44 + * @returns handleNotFound for XRPCRouter and passthrough for local handlers 18 45 */ 19 - export const createProxyMiddleware = (ctx: AppContext): FetchMiddleware => { 20 - return async (request, next) => { 46 + export const createServiceProxy = (options: ServiceProxyOptions): ServiceProxy => { 47 + const { targets, authVerifier, actorManager, didDocumentResolver } = options; 48 + 49 + /** 50 + * core proxy logic - performs the actual proxying. 51 + */ 52 + const proxyRequest = async (request: Request, input?: unknown): Promise<Response> => { 21 53 const proxyHeader = request.headers.get('atproto-proxy'); 22 54 if (!proxyHeader) { 23 - return next(request); 55 + throw new InvalidRequestError({ description: `missing atproto-proxy header` }); 24 56 } 25 57 26 58 // only allow GET, HEAD, POST ··· 28 60 throw new InvalidRequestError({ description: `XRPC requests only support GET, HEAD, and POST` }); 29 61 } 30 62 63 + // dev-only check: input should not be provided for GET requests 64 + if (import.meta.env?.DEV && input !== undefined && request.method === 'GET') { 65 + throw new Error(`passthrough: input provided for GET request`); 66 + } 67 + 31 68 // parse NSID from request path 32 69 const lxm = parseRequestNsid(request); 33 70 34 71 // parse proxy header and resolve target 35 - const target = await parseProxyHeader(ctx, proxyHeader, lxm); 72 + const target = await parseProxyHeader(targets, didDocumentResolver, proxyHeader, lxm); 36 73 if (target === null) { 37 74 // NSID is excluded from proxying for this target 38 - return next(request); 75 + throw new InvalidRequestError({ description: `method not found` }); 39 76 } 40 77 41 78 // verify authorization and get user DID 42 - const auth = await ctx.authVerifier.authorization(request); 79 + const auth = await authVerifier.authorization(request); 43 80 44 81 // load user's signing keypair 45 - const keypair = await ctx.actorManager.importKeypair(auth.did); 82 + const keypair = await actorManager.importKeypair(auth.did); 46 83 47 84 // create service auth JWT signed with user's key 48 85 const serviceJwt = await createServiceJwt({ ··· 57 94 upstreamUrl.protocol = new URL(target.url).protocol; 58 95 upstreamUrl.host = new URL(target.url).host; 59 96 60 - const upstreamHeaders = buildProxyRequestHeaders(request, serviceJwt); 97 + let upstreamHeaders: Headers; 98 + let upstreamBody: Bun.BodyInit | null = null; 99 + 100 + if (input !== undefined) { 101 + // body was already consumed, reserialize from input 102 + upstreamHeaders = buildProxyRequestHeadersWithInput(request, serviceJwt); 103 + upstreamBody = JSON.stringify(input); 104 + } else if (request.method === 'POST') { 105 + // stream the original body 106 + upstreamHeaders = buildProxyRequestHeaders(request, serviceJwt); 107 + upstreamBody = request.body; 108 + } else { 109 + upstreamHeaders = buildProxyRequestHeaders(request, serviceJwt); 110 + } 61 111 62 112 const upstreamRequest = new Request(upstreamUrl.toString(), { 63 113 method: request.method, 64 114 headers: upstreamHeaders, 65 - body: request.method === 'POST' ? request.body : null, 66 - duplex: request.method === 'POST' ? 'half' : undefined, 115 + body: upstreamBody, 116 + duplex: upstreamBody !== null ? 'half' : undefined, 67 117 }); 68 118 69 119 // forward request 70 120 const upstreamResponse = await fetch(upstreamRequest); 71 121 72 - upstreamResponse 73 - .clone() 74 - .text() 75 - .then((text) => { 76 - console.log(`${auth.did} -> ${upstreamUrl.toString()}`); 77 - console.log(text); 78 - }); 79 - 80 122 // build response with filtered headers 81 123 const responseHeaders = filterResponseHeaders(upstreamResponse.headers); 82 124 ··· 86 128 headers: responseHeaders, 87 129 }); 88 130 }; 131 + 132 + const handleNotFound: NotFoundHandler = async (request) => { 133 + const proxyHeader = request.headers.get('atproto-proxy'); 134 + if (!proxyHeader) { 135 + return defaultNotFoundHandler(request); 136 + } 137 + 138 + return proxyRequest(request); 139 + }; 140 + 141 + const passthrough: PassthroughFn = async (request, input) => { 142 + const proxyHeader = request.headers.get('atproto-proxy'); 143 + if (!proxyHeader) { 144 + // no proxy header - continue with local handler logic 145 + return; 146 + } 147 + 148 + // dev-only check: input should not be provided for GET requests 149 + if (import.meta.env?.DEV && input !== undefined && request.method === 'GET') { 150 + throw new Error(`passthrough: input provided for GET request`); 151 + } 152 + 153 + // parse NSID from request path 154 + const lxm = parseRequestNsid(request); 155 + 156 + // check if NSID is excluded for this target 157 + const target = await parseProxyHeader(targets, didDocumentResolver, proxyHeader, lxm); 158 + if (target === null) { 159 + // NSID is excluded - continue with local handler logic 160 + return; 161 + } 162 + 163 + const response = await proxyRequest(request, input); 164 + throw response; 165 + }; 166 + 167 + return { handleNotFound, passthrough }; 89 168 };
+47 -5
packages/danaus/src/proxy/utils.ts
··· 1 1 import { getAtprotoServiceEndpoint, isAtprotoAudience } from '@atcute/identity'; 2 + import type { DidDocumentResolver } from '@atcute/identity-resolver'; 2 3 import type { Did, Nsid } from '@atcute/lexicons'; 3 4 import { isNsid, type AtprotoDid } from '@atcute/lexicons/syntax'; 4 5 import { InvalidRequestError } from '@atcute/xrpc-server'; 5 6 6 - import type { AppContext } from '#app/context.ts'; 7 + import type { ProxyTargetConfig } from '#app/config.ts'; 7 8 8 9 export interface ProxyTarget { 9 10 did: Did; ··· 12 13 13 14 /** 14 15 * parse atproto-proxy header and resolve service endpoint. 15 - * @param ctx app context 16 + * @param targets proxy target configurations 17 + * @param didDocumentResolver DID document resolver 16 18 * @param header proxy header value (format: `did#serviceId`) 17 19 * @param nsid request NSID to check exclusions 18 20 * @returns resolved proxy target with DID and URL, or null if NSID is excluded 19 21 */ 20 22 export const parseProxyHeader = async ( 21 - ctx: AppContext, 23 + targets: Map<string, ProxyTargetConfig>, 24 + didDocumentResolver: DidDocumentResolver<string>, 22 25 header: string, 23 26 nsid: Nsid, 24 27 ): Promise<ProxyTarget | null> => { ··· 26 29 throw new InvalidRequestError({ description: `invalid atproto-proxy header` }); 27 30 } 28 31 29 - const targetConfig = ctx.config.proxy.targets.get(header); 32 + const targetConfig = targets.get(header); 30 33 31 34 // check if NSID is excluded for this target 32 35 if (targetConfig?.exclude?.includes(nsid)) { ··· 40 43 const did = audience.slice(0, hashIndex) as AtprotoDid; 41 44 const serviceId = audience.slice(hashIndex) as `#${string}`; 42 45 43 - const didDoc = await ctx.didDocumentResolver.resolve(did); 46 + const didDoc = await didDocumentResolver.resolve(did); 44 47 if (!didDoc) { 45 48 throw new InvalidRequestError({ description: `could not resolve proxy did` }); 46 49 } ··· 97 100 } 98 101 } 99 102 } 103 + 104 + // set service auth 105 + headers.set('authorization', `Bearer ${serviceJwt}`); 106 + 107 + return headers; 108 + }; 109 + 110 + /** 111 + * build headers for upstream proxy request when input body was already consumed. 112 + * sets content-type to application/json since the body will be reserialized. 113 + * @param req original request 114 + * @param serviceJwt service auth JWT 115 + * @returns headers for upstream request 116 + */ 117 + export const buildProxyRequestHeadersWithInput = (req: Request, serviceJwt: string): Headers => { 118 + const headers = new Headers(); 119 + 120 + // forward standard headers 121 + for (const name of REQUEST_HEADERS_TO_FORWARD) { 122 + const value = req.headers.get(name); 123 + if (value) { 124 + headers.set(name, value); 125 + } 126 + } 127 + 128 + // ensure accept-encoding has a value 129 + if (!headers.has('accept-encoding')) { 130 + headers.set('accept-encoding', 'identity'); 131 + } 132 + 133 + // forward all x-* headers 134 + for (const [name, value] of req.headers) { 135 + if (name.startsWith('x-')) { 136 + headers.set(name, value); 137 + } 138 + } 139 + 140 + // set content-type for reserialized JSON body 141 + headers.set('content-type', 'application/json'); 100 142 101 143 // set service auth 102 144 headers.set('authorization', `Bearer ${serviceJwt}`);
+172 -195
packages/danaus/src/web/account/forms.ts
··· 1 - import { signOperation, type UnsignedOperation } from '@atcute/did-plc'; 1 + import { PlcClientError, signOperation, type UnsignedOperation } from '@atcute/did-plc'; 2 2 import type { Did, Handle } from '@atcute/lexicons'; 3 3 import { isHandle } from '@atcute/lexicons/syntax'; 4 4 import { XRPCError } from '@atcute/xrpc-server'; 5 + import { redirect } from '@oomfware/fetch-router'; 6 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 7 + import { form, invalid } from '@oomfware/forms'; 5 8 6 - import { HTTPException } from 'hono/http-exception'; 7 9 import * as v from 'valibot'; 8 10 9 11 import { parseAppPasswordPrivilege } from '#app/accounts/app-passwords.ts'; 10 12 import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '#app/accounts/passwords.ts'; 11 - import { readWebSessionToken, setWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts'; 13 + import { setWebSessionToken } from '#app/auth/web.ts'; 12 14 import type { AppContext } from '#app/context.ts'; 13 15 import { isHostnameSuffix } from '#app/utils/schema.ts'; 14 16 15 - import { form, getRequestContext, invalid, redirect } from '../forms/index.ts'; 17 + import { getAppContext } from '../middlewares/app-context.ts'; 18 + import { getSession } from '../middlewares/session.ts'; 16 19 17 - export const createAccountForms = (ctx: AppContext) => { 18 - const { accountManager } = ctx; 19 - 20 - const verifyCredentials = () => { 21 - const c = getRequestContext(); 22 - const token = readWebSessionToken(c.req.raw); 23 - if (!token) { 24 - throw new HTTPException(302, { 25 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 26 - }); 27 - } 20 + /** 21 + * validates credentials, creates session, sets cookie, and redirects. 22 + */ 23 + export const signInForm = form( 24 + v.object({ 25 + identifier: v.pipe(v.string(), v.minLength(1, `Enter your email or username`)), 26 + _password: v.pipe(v.string(), v.minLength(1, `Enter your password`)), 27 + remember: v.optional(v.boolean()), 28 + redirect: v.optional(v.string()), 29 + }), 30 + async (data, issue) => { 31 + const { accountManager } = getAppContext(); 32 + const { request } = getContext(); 28 33 29 - const sessionId = verifyWebSessionToken(ctx.config.secrets.jwtKey, token); 30 - if (!sessionId) { 31 - throw new HTTPException(302, { 32 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 33 - }); 34 + if (data._password.length < MIN_PASSWORD_LENGTH || data._password.length > MAX_PASSWORD_LENGTH) { 35 + invalid(issue.identifier(`Invalid account credentials`)); 34 36 } 35 37 36 - const session = accountManager.getWebSession(sessionId); 37 - if (!session) { 38 - throw new HTTPException(302, { 39 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 40 - }); 38 + const account = await accountManager.verifyAccountPassword(data.identifier, data._password); 39 + if (account === null) { 40 + invalid(issue.identifier(`Invalid account credentials`)); 41 41 } 42 42 43 - return session; 44 - }; 43 + const { session, token } = await accountManager.createWebSession({ 44 + did: account.did, 45 + remember: data.remember ?? false, 46 + userAgent: request.headers.get('user-agent') ?? undefined, 47 + }); 45 48 46 - /** 47 - * validates credentials, creates session, sets cookie, and redirects. 48 - */ 49 - const signInForm = form( 50 - v.object({ 51 - identifier: v.pipe(v.string(), v.minLength(1, `Enter your email or username`)), 52 - password: v.pipe(v.string(), v.minLength(1, `Enter your password`)), 53 - remember: v.optional(v.boolean()), 54 - redirect: v.optional(v.string()), 55 - }), 56 - async (data, issue) => { 57 - const c = getRequestContext(); 49 + setWebSessionToken(request, token, { 50 + expires: session.expires_at, 51 + httpOnly: true, 52 + sameSite: 'lax', 53 + path: '/', 54 + }); 58 55 59 - if (data.password.length < MIN_PASSWORD_LENGTH || data.password.length > MAX_PASSWORD_LENGTH) { 60 - invalid(issue.identifier(`Invalid account credentials`)); 61 - } 56 + redirect(data.redirect ?? '/account'); 57 + }, 58 + ); 62 59 63 - const account = await accountManager.verifyAccountPassword(data.identifier, data.password); 64 - if (account === null) { 65 - invalid(issue.identifier(`Invalid account credentials`)); 66 - } 60 + /** 61 + * creates an app password and returns the secret for display. 62 + */ 63 + export const createAppPasswordForm = form( 64 + v.object({ 65 + name: v.pipe(v.string(), v.minLength(1, `Name is required`), v.maxLength(32, `Name is too long`)), 66 + privilege: v.picklist(['limited', 'privileged', 'full'], `Invalid privilege`), 67 + }), 68 + async (data) => { 69 + const { accountManager } = getAppContext(); 70 + const session = getSession(); 67 71 68 - const { session, token } = await accountManager.createWebSession({ 69 - did: account.did, 70 - remember: data.remember ?? false, 71 - userAgent: c.req.header('user-agent'), 72 - }); 72 + const privilege = parseAppPasswordPrivilege(data.privilege); 73 73 74 - setWebSessionToken(c.req.raw, token, { 75 - expires: session.expires_at, 76 - httpOnly: true, 77 - sameSite: 'lax', 78 - path: '/', 74 + try { 75 + const { appPassword, secret } = await accountManager.createAppPassword({ 76 + did: session.did, 77 + name: data.name, 78 + privilege, 79 79 }); 80 80 81 - redirect(302, data.redirect ?? '/account'); 82 - }, 83 - ); 84 - 85 - /** 86 - * creates an app password and returns the secret for display. 87 - */ 88 - const createAppPasswordForm = form( 89 - v.object({ 90 - name: v.pipe(v.string(), v.minLength(1, `Name is required`), v.maxLength(32, `Name is too long`)), 91 - privilege: v.picklist(['limited', 'privileged', 'full'], `Invalid privilege`), 92 - }), 93 - async (data) => { 94 - const session = verifyCredentials(); 95 - const privilege = parseAppPasswordPrivilege(data.privilege); 96 - 97 - try { 98 - const { appPassword, secret } = await accountManager.createAppPassword({ 99 - did: session.did, 100 - name: data.name, 101 - privilege, 102 - }); 103 - 104 - return { name: appPassword.name, secret }; 105 - } catch (err) { 106 - if (err instanceof XRPCError && err.status === 400) { 107 - switch (err.error) { 108 - case 'DuplicateAppPassword': { 109 - invalid(`An app password with this name already exists`); 110 - } 111 - case 'TooManyAppPasswords': { 112 - invalid(`You've reached the maximum amount of app passwords allowed`); 113 - } 81 + return { name: appPassword.name, secret }; 82 + } catch (err) { 83 + if (err instanceof XRPCError && err.status === 400) { 84 + switch (err.error) { 85 + case 'DuplicateAppPassword': { 86 + invalid(`An app password with this name already exists`); 87 + } 88 + case 'TooManyAppPasswords': { 89 + invalid(`You've reached the maximum amount of app passwords allowed`); 114 90 } 115 91 } 116 - 117 - throw err; 118 92 } 119 - }, 120 - ); 121 93 122 - /** 123 - * deletes an app password and redirects back to the list. 124 - */ 125 - const deleteAppPasswordForm = form( 126 - v.object({ 127 - name: v.pipe(v.string(), v.minLength(1)), 128 - }), 129 - async (data) => { 130 - const session = verifyCredentials(); 94 + throw err; 95 + } 96 + }, 97 + ); 131 98 132 - accountManager.deleteAppPassword(session.did, data.name); 133 - }, 134 - ); 99 + /** 100 + * deletes an app password. 101 + */ 102 + export const deleteAppPasswordForm = form( 103 + v.object({ 104 + name: v.pipe(v.string(), v.minLength(1)), 105 + }), 106 + async (data) => { 107 + const { accountManager } = getAppContext(); 108 + const session = getSession(); 135 109 136 - /** 137 - * updates the account handle, including PLC document for did:plc accounts. 138 - */ 139 - const updateHandleForm = form( 140 - v.object({ 141 - domain: v.union([v.pipe(v.string(), v.check(isHostnameSuffix)), v.literal('custom')]), 142 - handle: v.pipe(v.string(), v.minLength(1, `Handle is required`)), 143 - }), 144 - async (data) => { 145 - const { did } = verifyCredentials(); 110 + accountManager.deleteAppPassword(session.did, data.name); 111 + }, 112 + ); 146 113 147 - let handle: Handle; 148 - if (data.domain === 'custom') { 149 - if (!isHandle(data.handle)) { 150 - invalid(`Invalid handle`); 151 - } 114 + /** 115 + * updates the account handle, including PLC document for did:plc accounts. 116 + */ 117 + export const updateHandleForm = form( 118 + v.object({ 119 + domain: v.union([v.pipe(v.string(), v.check(isHostnameSuffix)), v.literal('custom')]), 120 + handle: v.pipe(v.string(), v.minLength(1, `Handle is required`)), 121 + }), 122 + async (data) => { 123 + const ctx = getAppContext(); 124 + const { did } = getSession(); 152 125 153 - handle = data.handle; 154 - } else { 155 - const fullHandle = `${data.handle}${data.domain}`; 156 - if (!isHandle(fullHandle)) { 157 - invalid(`Invalid handle`); 158 - } 126 + let handle: Handle; 127 + if (data.domain === 'custom') { 128 + if (!isHandle(data.handle)) { 129 + invalid(`Invalid handle`); 130 + } 159 131 160 - handle = fullHandle; 132 + handle = data.handle; 133 + } else { 134 + const fullHandle = `${data.handle}${data.domain}`; 135 + if (!isHandle(fullHandle)) { 136 + invalid(`Invalid handle`); 161 137 } 162 138 163 - // validate the handle (checks TLD, service domain constraints, external domain resolution) 164 - try { 165 - handle = await accountManager.validateHandle(handle, { did }); 166 - } catch (err) { 167 - if (err instanceof XRPCError && err.status === 400) { 168 - switch (err.error) { 169 - case 'InvalidHandle': { 170 - invalid(err.description ?? `Invalid handle`); 171 - } 172 - case 'UnsupportedDomain': { 173 - invalid(`Handle must resolve to your DID via DNS or .well-known`); 174 - } 139 + handle = fullHandle; 140 + } 141 + 142 + // validate the handle (checks TLD, service domain constraints, external domain resolution) 143 + try { 144 + handle = await ctx.accountManager.validateHandle(handle, { did }); 145 + } catch (err) { 146 + if (err instanceof XRPCError && err.status === 400) { 147 + switch (err.error) { 148 + case 'InvalidHandle': { 149 + invalid(err.description ?? `Invalid handle`); 150 + } 151 + case 'UnsupportedDomain': { 152 + invalid(`Handle must resolve to your DID via DNS or .well-known`); 175 153 } 176 154 } 177 - throw err; 178 155 } 156 + throw err; 157 + } 179 158 180 - // check if handle is already taken by another account 181 - const existing = accountManager.getAccount(handle, { 182 - includeDeactivated: true, 183 - includeTakenDown: true, 184 - }); 159 + // check if handle is already taken by another account 160 + const existing = ctx.accountManager.getAccount(handle, { 161 + includeDeactivated: true, 162 + includeTakenDown: true, 163 + }); 185 164 186 - if (existing !== null) { 187 - if (existing.did === did) { 188 - return; 189 - } 165 + if (existing !== null) { 166 + if (existing.did === did) { 167 + return; 168 + } 190 169 191 - invalid(`Handle is already taken`); 192 - } 170 + invalid(`Handle is already taken`); 171 + } 193 172 194 - // update PLC document for did:plc accounts 195 - if (did.startsWith('did:plc:')) { 173 + // update PLC document for did:plc accounts 174 + if (did.startsWith('did:plc:')) { 175 + try { 196 176 await updatePlcHandle(ctx, did as Did<'plc'>, handle); 177 + } catch (err) { 178 + if (err instanceof PlcClientError) { 179 + invalid(`Unable to update DID document, please try again later`); 180 + } 181 + 182 + throw err; 197 183 } 184 + } 198 185 199 - // update local database and emit identity event 200 - accountManager.updateAccountHandle(did, handle); 201 - await ctx.sequencer.emitIdentity(did, handle); 202 - }, 203 - ); 186 + // update local database and emit identity event 187 + ctx.accountManager.updateAccountHandle(did, handle); 188 + await ctx.sequencer.emitIdentity(did, handle); 189 + }, 190 + ); 204 191 205 - /** 206 - * triggers identity event to refresh handle caches after verifying handle still resolves. 207 - */ 208 - const refreshHandleForm = form(v.object({}), async () => { 209 - const { did } = verifyCredentials(); 192 + /** 193 + * triggers identity event to refresh handle caches after verifying handle still resolves. 194 + */ 195 + export const refreshHandleForm = form(v.object({}), async () => { 196 + const { accountManager, sequencer } = getAppContext(); 197 + const { did } = getSession(); 210 198 211 - const account = accountManager.getAccount(did)!; 212 - if (!account.handle) { 213 - invalid(`Handle not set`); 214 - } 199 + const account = accountManager.getAccount(did)!; 200 + if (!account.handle) { 201 + invalid(`Handle not set`); 202 + } 215 203 216 - // verify handle still resolves correctly 217 - try { 218 - await accountManager.validateHandle(account.handle, { did }); 219 - } catch (err) { 220 - if (err instanceof XRPCError && err.status === 400) { 221 - switch (err.error) { 222 - case 'InvalidHandle': { 223 - invalid(err.description ?? `Handle is no longer valid`); 224 - } 225 - case 'UnsupportedDomain': { 226 - invalid(`Handle no longer resolves to your DID`); 227 - } 204 + // verify handle still resolves correctly 205 + try { 206 + await accountManager.validateHandle(account.handle, { did }); 207 + } catch (err) { 208 + if (err instanceof XRPCError && err.status === 400) { 209 + switch (err.error) { 210 + case 'InvalidHandle': { 211 + invalid(err.description ?? `Handle is no longer valid`); 212 + } 213 + case 'UnsupportedDomain': { 214 + invalid(`Handle no longer resolves to your DID`); 228 215 } 229 216 } 230 - throw err; 231 217 } 218 + throw err; 219 + } 232 220 233 - // emit identity event with current handle to trigger cache refresh 234 - await ctx.sequencer.emitIdentity(did, account.handle); 235 - }); 236 - 237 - return { 238 - signInForm, 239 - createAppPasswordForm, 240 - deleteAppPasswordForm, 241 - updateHandleForm, 242 - refreshHandleForm, 243 - }; 244 - }; 221 + // emit identity event with current handle to trigger cache refresh 222 + await sequencer.emitIdentity(did, account.handle); 223 + }); 245 224 246 225 /** 247 226 * updates the handle in a did:plc document. ··· 271 250 services: state.services, 272 251 }; 273 252 274 - // sign with PDS rotation key 253 + // sign with PDS rotation key and submit to PLC directory 275 254 const signedOp = await signOperation(unsignedOp, config.secrets.plcRotationKey); 276 - 277 - // submit to PLC directory 278 255 await plcClient.submitOperation(did, signedOp); 279 256 }
-730
packages/danaus/src/web/account/index.tsx
··· 1 - import type { Did } from '@atcute/lexicons'; 2 - 3 - import { Hono, type Context } from 'hono'; 4 - import { HTTPException } from 'hono/http-exception'; 5 - import { jsxRenderer } from 'hono/jsx-renderer'; 6 - 7 - import { AppPasswordPrivilege } from '#app/accounts/db/schema.ts'; 8 - import type { WebSession } from '#app/accounts/manager.ts'; 9 - import { readWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts'; 10 - import type { AppContext } from '#app/context.ts'; 11 - 12 - import AsideItem from '../admin/components/aside-item.tsx'; 13 - import { IdProvider } from '../components/id.tsx'; 14 - import { registerForms } from '../forms/index.ts'; 15 - import DotGrid1x3HorizontalOutlined from '../icons/central/dot-grid-1x3-horizontal-outlined.tsx'; 16 - import Key2Outlined from '../icons/central/key-2-outlined.tsx'; 17 - import PasskeysOutlined from '../icons/central/passkeys-outlined.tsx'; 18 - import PasswordOutlined from '../icons/central/password-outlined.tsx'; 19 - import PersonOutlined from '../icons/central/person-outlined.tsx'; 20 - import PhoneOutlined from '../icons/central/phone-outlined.tsx'; 21 - import PlusLargeOutlined from '../icons/central/plus-large-outlined.tsx'; 22 - import ShieldOutlined from '../icons/central/shield-outlined.tsx'; 23 - import UsbOutlined from '../icons/central/usb-outlined.tsx'; 24 - import Button from '../primitives/button.tsx'; 25 - import DialogActions from '../primitives/dialog-actions.tsx'; 26 - import DialogBody from '../primitives/dialog-body.tsx'; 27 - import DialogClose from '../primitives/dialog-close.tsx'; 28 - import DialogContent from '../primitives/dialog-content.tsx'; 29 - import DialogSurface from '../primitives/dialog-surface.tsx'; 30 - import DialogTitle from '../primitives/dialog-title.tsx'; 31 - import DialogTrigger from '../primitives/dialog-trigger.tsx'; 32 - import Dialog from '../primitives/dialog.tsx'; 33 - import Field from '../primitives/field.tsx'; 34 - import Input from '../primitives/input.tsx'; 35 - import MenuDivider from '../primitives/menu-divider.tsx'; 36 - import MenuItem from '../primitives/menu-item.tsx'; 37 - import MenuList from '../primitives/menu-list.tsx'; 38 - import MenuPopover from '../primitives/menu-popover.tsx'; 39 - import MenuTrigger from '../primitives/menu-trigger.tsx'; 40 - import Menu from '../primitives/menu.tsx'; 41 - import MessageBarBody from '../primitives/message-bar-body.tsx'; 42 - import MessageBarTitle from '../primitives/message-bar-title.tsx'; 43 - import MessageBar from '../primitives/message-bar.tsx'; 44 - import Select from '../primitives/select.tsx'; 45 - 46 - import { createAccountForms } from './forms.ts'; 47 - 48 - export const createAccountApp = (ctx: AppContext) => { 49 - const app = new Hono(); 50 - 51 - const forms = createAccountForms(ctx); 52 - app.use(registerForms(forms)); 53 - 54 - // #region verify credentials helper 55 - const verifyCredentials = (c: Context): WebSession => { 56 - const token = readWebSessionToken(c.req.raw); 57 - if (!token) { 58 - throw new HTTPException(302, { 59 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 60 - }); 61 - } 62 - 63 - const sessionId = verifyWebSessionToken(ctx.config.secrets.jwtKey, token); 64 - if (!sessionId) { 65 - throw new HTTPException(302, { 66 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 67 - }); 68 - } 69 - 70 - const session = ctx.accountManager.getWebSession(sessionId); 71 - if (!session) { 72 - throw new HTTPException(302, { 73 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 74 - }); 75 - } 76 - 77 - return session; 78 - }; 79 - // #endregion 80 - 81 - // #region base HTML renderer 82 - app.use( 83 - jsxRenderer(({ children }) => { 84 - return ( 85 - <IdProvider> 86 - <html lang="en"> 87 - <head> 88 - <meta charset="utf-8" /> 89 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 90 - <link rel="stylesheet" href="/assets/style.css" /> 91 - </head> 92 - 93 - <body> 94 - <div class="flex min-h-dvh flex-col">{children}</div> 95 - </body> 96 - </html> 97 - </IdProvider> 98 - ); 99 - }), 100 - ); 101 - // #endregion 102 - 103 - // #region login route (unauthenticated) 104 - app.on(['GET', 'POST'], '/login', (c) => { 105 - const { signInForm } = forms; 106 - const { fields } = signInForm; 107 - 108 - return c.render( 109 - <> 110 - <title>sign in - danaus</title> 111 - 112 - <div class="flex flex-1 items-center justify-center p-4"> 113 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 114 - <form {...signInForm} class="flex flex-col gap-6"> 115 - <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 116 - 117 - <Field 118 - label="Handle or email" 119 - required 120 - validationMessageText={fields.identifier.issues()[0]?.message} 121 - > 122 - <Input {...fields.identifier.as('text')} placeholder="alice.bsky.social" required autofocus /> 123 - </Field> 124 - 125 - <Field label="Password" required validationMessageText={fields.password.issues()[0]?.message}> 126 - <Input {...fields.password.as('password')} required /> 127 - </Field> 128 - 129 - <Button type="submit" variant="primary"> 130 - Sign in 131 - </Button> 132 - </form> 133 - </div> 134 - </div> 135 - </>, 136 - ); 137 - }); 138 - // #endregion 139 - 140 - // #region overview route 141 - app.on(['GET', 'POST'], '/', (c) => { 142 - const session = verifyCredentials(c); 143 - const account = ctx.accountManager.getAccount(session.did); 144 - const { updateHandleForm, refreshHandleForm } = forms; 145 - 146 - // determine current handle parts for form prefill 147 - const currentHandle = account?.handle ?? ''; 148 - const isServiceHandle = ctx.config.identity.serviceHandleDomains.some((d) => currentHandle.endsWith(d)); 149 - const currentDomain = isServiceHandle 150 - ? (ctx.config.identity.serviceHandleDomains.find((d) => currentHandle.endsWith(d)) ?? 'custom') 151 - : 'custom'; 152 - const currentLocalPart = isServiceHandle ? currentHandle.slice(0, -currentDomain.length) : currentHandle; 153 - 154 - return c.render( 155 - <AccountLayout> 156 - <title>My account - Danaus</title> 157 - 158 - <div class="flex flex-col gap-4"> 159 - <div class="flex h-8 items-center"> 160 - <h3 class="text-base-400 font-medium">Account overview</h3> 161 - </div> 162 - 163 - <div class="flex flex-col gap-8"> 164 - <div class="flex flex-col gap-2"> 165 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Your identity</h4> 166 - 167 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 168 - <div class="flex items-center gap-4 px-4 py-3"> 169 - <div class="min-w-0 grow"> 170 - <p class="text-base-300 font-medium wrap-break-word">@{account?.handle}</p> 171 - <p class="text-base-300 text-neutral-foreground-3">Your username on the network</p> 172 - </div> 173 - 174 - <Menu> 175 - <MenuTrigger> 176 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 177 - <DotGrid1x3HorizontalOutlined size={16} /> 178 - </button> 179 - </MenuTrigger> 180 - 181 - <MenuPopover> 182 - <MenuList> 183 - <Dialog> 184 - <DialogTrigger> 185 - <MenuItem>Change handle</MenuItem> 186 - </DialogTrigger> 187 - 188 - <DialogSurface> 189 - <DialogBody> 190 - <DialogTitle>Change handle</DialogTitle> 191 - 192 - <form {...updateHandleForm} class="contents"> 193 - <DialogContent class="flex flex-col gap-4"> 194 - <p class="text-base-300 text-neutral-foreground-3"> 195 - Your handle is your unique identity on the AT Protocol network. 196 - </p> 197 - 198 - <Field 199 - label="Domain" 200 - validationMessageText={ 201 - updateHandleForm.fields.domain.issues()[0]?.message 202 - } 203 - > 204 - <Select 205 - {...updateHandleForm.fields.domain.as('select')} 206 - value={updateHandleForm.fields.domain.value() || currentDomain} 207 - options={[ 208 - ...ctx.config.identity.serviceHandleDomains.map((d) => ({ 209 - value: d, 210 - label: d, 211 - })), 212 - { value: 'custom', label: 'I have my own domain' }, 213 - ]} 214 - /> 215 - </Field> 216 - 217 - <Field 218 - label="Handle" 219 - required 220 - validationMessageText={ 221 - updateHandleForm.fields.handle.issues()[0]?.message 222 - } 223 - > 224 - <Input 225 - {...updateHandleForm.fields.handle.as('text')} 226 - value={updateHandleForm.fields.handle.value() || currentLocalPart} 227 - placeholder="alice" 228 - required 229 - /> 230 - </Field> 231 - 232 - <p class="text-base-200 text-neutral-foreground-3"> 233 - Custom domains must have a DNS TXT record or .well-known file pointing to 234 - your DID. 235 - </p> 236 - </DialogContent> 237 - 238 - <DialogActions> 239 - <DialogClose> 240 - <Button>Cancel</Button> 241 - </DialogClose> 242 - 243 - <Button type="submit" variant="primary"> 244 - Save 245 - </Button> 246 - </DialogActions> 247 - </form> 248 - </DialogBody> 249 - </DialogSurface> 250 - </Dialog> 251 - 252 - <Dialog> 253 - <DialogTrigger> 254 - <MenuItem>Request refresh</MenuItem> 255 - </DialogTrigger> 256 - 257 - <DialogSurface> 258 - <DialogBody> 259 - <DialogTitle>Request handle refresh</DialogTitle> 260 - 261 - <form {...refreshHandleForm} class="contents"> 262 - <DialogContent> 263 - <p class="text-base-300"> 264 - This will notify the network to re-verify your handle. Use this if apps 265 - are marking your handle as invalid despite being set up correctly. 266 - </p> 267 - </DialogContent> 268 - 269 - <DialogActions> 270 - <DialogClose> 271 - <Button>Cancel</Button> 272 - </DialogClose> 273 - 274 - <Button type="submit" variant="primary"> 275 - Refresh 276 - </Button> 277 - </DialogActions> 278 - </form> 279 - </DialogBody> 280 - </DialogSurface> 281 - </Dialog> 282 - </MenuList> 283 - </MenuPopover> 284 - </Menu> 285 - </div> 286 - </div> 287 - </div> 288 - 289 - <div class="flex flex-col gap-2"> 290 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account management</h4> 291 - 292 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 293 - <div class="flex items-center gap-4 px-4 py-3"> 294 - <div class="min-w-0 grow"> 295 - <p class="text-base-300 font-medium">Data export</p> 296 - <p class="text-base-300 text-neutral-foreground-3">Download your repository and blobs</p> 297 - </div> 298 - 299 - <Button disabled>Export</Button> 300 - </div> 301 - 302 - <div class="flex items-center gap-4 px-4 py-3"> 303 - <div class="min-w-0 grow"> 304 - <p class="text-base-300 font-medium">Deactivate account</p> 305 - <p class="text-base-300 text-neutral-foreground-3">Temporarily disable your account</p> 306 - </div> 307 - 308 - <Button disabled>Deactivate</Button> 309 - </div> 310 - 311 - <div class="flex items-center gap-4 px-4 py-3"> 312 - <div class="min-w-0 grow"> 313 - <p class="text-base-300 font-medium">Delete account</p> 314 - <p class="text-base-300 text-neutral-foreground-3">Permanently delete your account</p> 315 - </div> 316 - 317 - <Button disabled>Delete</Button> 318 - </div> 319 - </div> 320 - </div> 321 - </div> 322 - </div> 323 - </AccountLayout>, 324 - ); 325 - }); 326 - // #endregion 327 - 328 - // #region app passwords route 329 - app.on(['GET', 'POST'], '/app-passwords', (c) => { 330 - const session = verifyCredentials(c); 331 - const did = session.did as Did; 332 - const { createAppPasswordForm, deleteAppPasswordForm } = forms; 333 - 334 - const passwords = ctx.accountManager.listAppPasswords(did); 335 - 336 - const newPasswordResult = createAppPasswordForm.result; 337 - const newPasswordError = createAppPasswordForm.fields.allIssues().at(0); 338 - 339 - return c.render( 340 - <AccountLayout> 341 - <title>App passwords - Danaus</title> 342 - 343 - <div class="flex flex-col gap-4"> 344 - <div class="flex h-8 shrink-0 items-center justify-between"> 345 - <h3 class="text-base-400 font-medium">App passwords</h3> 346 - 347 - <Button commandfor="create-app-password-dialog" command="show-modal" variant="primary"> 348 - <PlusLargeOutlined size={16} /> 349 - New 350 - </Button> 351 - </div> 352 - 353 - {newPasswordResult && ( 354 - <MessageBar intent="success" layout="multiline"> 355 - <MessageBarBody> 356 - <MessageBarTitle>App password created</MessageBarTitle> 357 - 358 - <div class="mt-2 flex flex-col gap-2"> 359 - <code class="rounded-md bg-neutral-background-3 px-2 py-1 font-mono text-base-300"> 360 - {newPasswordResult.secret} 361 - </code> 362 - <p class="text-base-200 text-neutral-foreground-3"> 363 - Copy this password now. You won't be able to see it again. 364 - </p> 365 - </div> 366 - </MessageBarBody> 367 - </MessageBar> 368 - )} 369 - 370 - {newPasswordError && ( 371 - <MessageBar intent="error" layout="singleline"> 372 - <MessageBarBody>{newPasswordError.message}</MessageBarBody> 373 - </MessageBar> 374 - )} 375 - 376 - {/* {passwords.length === 0 ? ( 377 - <p class="py-8 text-center text-base-300 text-neutral-foreground-3">no app passwords yet.</p> 378 - ) : ( 379 - <ul class="divide-y divide-neutral-stroke-2"> 380 - {passwords.map((password) => ( 381 - <li class="flex items-center justify-between gap-4 py-3"> 382 - <div class="flex flex-col"> 383 - <span class="text-base-300 font-medium">{password.name}</span> 384 - <span class="text-base-200 text-neutral-foreground-3"> 385 - {formatAppPasswordPrivilege(password.privilege)} ยท created{' '} 386 - {password.created_at.toLocaleDateString()} 387 - </span> 388 - </div> 389 - <form {...deleteAppPasswordForm} class="contents"> 390 - <input type="hidden" name="name" value={password.name} /> 391 - <Button type="submit" variant="subtle"> 392 - <TrashCanOutlined size={16} /> 393 - Delete 394 - </Button> 395 - </form> 396 - </li> 397 - ))} 398 - </ul> 399 - )} */} 400 - 401 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 402 - {passwords.length === 0 && ( 403 - <div class="flex flex-col gap-1 p-8 text-center"> 404 - <p class="text-base-300 font-medium">No app passwords created</p> 405 - <p class="text-base-300 text-neutral-foreground-3"> 406 - App passwords lets you sign into legacy AT Protocol apps. 407 - </p> 408 - </div> 409 - )} 410 - 411 - {passwords.map((password) => { 412 - let privilege = `Unknown`; 413 - switch (password.privilege) { 414 - case AppPasswordPrivilege.Full: { 415 - privilege = `Full access`; 416 - break; 417 - } 418 - case AppPasswordPrivilege.Privileged: { 419 - privilege = `Privileged access`; 420 - break; 421 - } 422 - case AppPasswordPrivilege.Limited: { 423 - privilege = `Limited access`; 424 - break; 425 - } 426 - } 427 - 428 - return ( 429 - <div class="flex items-center gap-4 px-4 py-3"> 430 - <Key2Outlined size={24} class="shrink-0" /> 431 - 432 - <div class="min-w-0 grow"> 433 - <p class="text-base-300">{password.name}</p> 434 - <p class="text-base-300 text-neutral-foreground-3">{privilege}</p> 435 - </div> 436 - 437 - <Menu> 438 - <MenuTrigger> 439 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 440 - <DotGrid1x3HorizontalOutlined size={16} /> 441 - </button> 442 - </MenuTrigger> 443 - 444 - <MenuPopover> 445 - <MenuList> 446 - <Dialog> 447 - <DialogTrigger> 448 - <MenuItem>Delete</MenuItem> 449 - </DialogTrigger> 450 - 451 - <DialogSurface> 452 - <DialogBody> 453 - <DialogTitle>Delete this app password?</DialogTitle> 454 - 455 - <form {...deleteAppPasswordForm} class="contents"> 456 - <DialogContent> 457 - <p class="text-base-300"> 458 - You'll no longer be able to sign in to legacy apps using the{' '} 459 - <strong>{password.name}</strong> app password. 460 - </p> 461 - 462 - <input {...deleteAppPasswordForm.fields.name.as('hidden', password.name)} /> 463 - </DialogContent> 464 - 465 - <DialogActions> 466 - <DialogClose> 467 - <Button>Cancel</Button> 468 - </DialogClose> 469 - 470 - <Button type="submit" variant="primary"> 471 - Delete 472 - </Button> 473 - </DialogActions> 474 - </form> 475 - </DialogBody> 476 - </DialogSurface> 477 - </Dialog> 478 - </MenuList> 479 - </MenuPopover> 480 - </Menu> 481 - </div> 482 - ); 483 - })} 484 - </div> 485 - </div> 486 - 487 - <Dialog id="create-app-password-dialog"> 488 - <DialogSurface> 489 - <DialogBody> 490 - <DialogTitle>Create app password</DialogTitle> 491 - 492 - <form {...createAppPasswordForm} class="contents"> 493 - <DialogContent class="flex flex-col gap-6"> 494 - <Field label="Name" required> 495 - <Input {...createAppPasswordForm.fields.name.as('text')} placeholder="My app" required /> 496 - </Field> 497 - 498 - <Field label="Privilege"> 499 - <Select 500 - {...createAppPasswordForm.fields.privilege.as('select')} 501 - options={[ 502 - { value: 'limited', label: 'Limited - cannot access DMs' }, 503 - { value: 'privileged', label: 'Privileged - can access DMs' }, 504 - { value: 'full', label: 'Full - full account access' }, 505 - ]} 506 - /> 507 - </Field> 508 - </DialogContent> 509 - 510 - <DialogActions> 511 - <Button commandfor="create-app-password-dialog" command="close" variant="outlined"> 512 - Cancel 513 - </Button> 514 - <Button type="submit" variant="primary"> 515 - Create 516 - </Button> 517 - </DialogActions> 518 - </form> 519 - </DialogBody> 520 - </DialogSurface> 521 - </Dialog> 522 - </AccountLayout>, 523 - ); 524 - }); 525 - // #endregion 526 - 527 - // #region security route 528 - app.get('/security', (c) => { 529 - const session = verifyCredentials(c); 530 - const account = ctx.accountManager.getAccount(session.did); 531 - 532 - return c.render( 533 - <AccountLayout> 534 - <title>Security - Danaus</title> 535 - 536 - <div class="flex flex-col gap-4"> 537 - <div class="flex h-8 shrink-0 items-center"> 538 - <h3 class="text-base-400 font-medium">Security</h3> 539 - </div> 540 - 541 - <div class="flex flex-col gap-8"> 542 - <div class="flex flex-col gap-2"> 543 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account information</h4> 544 - 545 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 546 - <div class="flex items-center gap-4 px-4 py-3"> 547 - <div class="min-w-0 grow"> 548 - <p class="text-base-300 font-medium wrap-break-word">{account?.email}</p> 549 - <p class="text-base-300 text-neutral-foreground-3"> 550 - {account?.email_confirmed_at ? 'Verified' : 'Not verified'} 551 - </p> 552 - </div> 553 - 554 - {!account?.email_confirmed_at && <Button>Verify</Button>} 555 - 556 - <Menu> 557 - <MenuTrigger> 558 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 559 - <DotGrid1x3HorizontalOutlined size={16} /> 560 - </button> 561 - </MenuTrigger> 562 - 563 - <MenuPopover> 564 - <MenuList> 565 - <MenuItem>Change email</MenuItem> 566 - </MenuList> 567 - </MenuPopover> 568 - </Menu> 569 - </div> 570 - </div> 571 - </div> 572 - 573 - <div class="flex flex-col gap-2"> 574 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Ways to prove who you are</h4> 575 - 576 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 577 - <div class="flex items-center gap-4 px-4 py-3"> 578 - <PasswordOutlined size={24} class="shrink-0" /> 579 - 580 - <div class="min-w-0 grow"> 581 - <p class="text-base-300 font-medium wrap-break-word">Password</p> 582 - <p class="text-base-300 text-neutral-foreground-3">Last changed yesterday</p> 583 - </div> 584 - 585 - <Menu> 586 - <MenuTrigger> 587 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 588 - <DotGrid1x3HorizontalOutlined size={16} /> 589 - </button> 590 - </MenuTrigger> 591 - 592 - <MenuPopover> 593 - <MenuList> 594 - <MenuItem>Change password</MenuItem> 595 - </MenuList> 596 - </MenuPopover> 597 - </Menu> 598 - </div> 599 - 600 - <div class="flex items-center gap-4 px-4 py-3"> 601 - <PhoneOutlined size={24} class="shrink-0" /> 602 - 603 - <div class="min-w-0 grow"> 604 - <p class="text-base-300 font-medium wrap-break-word">Bitwarden</p> 605 - <p class="text-base-300 text-neutral-foreground-3">Authenticator ยท Added yesterday</p> 606 - </div> 607 - 608 - <Menu> 609 - <MenuTrigger> 610 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 611 - <DotGrid1x3HorizontalOutlined size={16} /> 612 - </button> 613 - </MenuTrigger> 614 - 615 - <MenuPopover> 616 - <MenuList> 617 - <MenuItem>Rename</MenuItem> 618 - <MenuDivider /> 619 - <MenuItem>Remove</MenuItem> 620 - </MenuList> 621 - </MenuPopover> 622 - </Menu> 623 - </div> 624 - 625 - <div class="flex items-center gap-4 px-4 py-3"> 626 - <UsbOutlined size={24} class="shrink-0" /> 627 - 628 - <div class="min-w-0 grow"> 629 - <p class="text-base-300 font-medium wrap-break-word">YubiKey 5</p> 630 - <p class="text-base-300 text-neutral-foreground-3">Security key ยท Added 2 weeks ago</p> 631 - </div> 632 - 633 - <Menu> 634 - <MenuTrigger> 635 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 636 - <DotGrid1x3HorizontalOutlined size={16} /> 637 - </button> 638 - </MenuTrigger> 639 - 640 - <MenuPopover> 641 - <MenuList> 642 - <MenuItem>Rename</MenuItem> 643 - <MenuDivider /> 644 - <MenuItem>Remove</MenuItem> 645 - </MenuList> 646 - </MenuPopover> 647 - </Menu> 648 - </div> 649 - 650 - <div class="flex items-center gap-4 px-4 py-3"> 651 - <PasskeysOutlined size={24} class="shrink-0" /> 652 - 653 - <div class="min-w-0 grow"> 654 - <p class="text-base-300 font-medium wrap-break-word">iCloud Keychain</p> 655 - <p class="text-base-300 text-neutral-foreground-3">Passkey ยท Added last month</p> 656 - </div> 657 - 658 - <Menu> 659 - <MenuTrigger> 660 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 661 - <DotGrid1x3HorizontalOutlined size={16} /> 662 - </button> 663 - </MenuTrigger> 664 - 665 - <MenuPopover> 666 - <MenuList> 667 - <MenuItem>Rename</MenuItem> 668 - <MenuDivider /> 669 - <MenuItem>Remove</MenuItem> 670 - </MenuList> 671 - </MenuPopover> 672 - </Menu> 673 - </div> 674 - 675 - <button class="flex items-center gap-4 bg-subtle-background px-4 py-3 text-left outline-2 -outline-offset-2 outline-transparent transition select-none first:rounded-t-md last:rounded-b-md hover:bg-subtle-background-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active"> 676 - <div class="grid h-6 w-6 shrink-0 place-items-center"> 677 - <PlusLargeOutlined size={16} /> 678 - </div> 679 - 680 - <div class="min-w-0 grow"> 681 - <p class="text-base-300">Add another way to sign in</p> 682 - </div> 683 - </button> 684 - </div> 685 - </div> 686 - </div> 687 - </div> 688 - </AccountLayout>, 689 - ); 690 - }); 691 - // #endregion 692 - 693 - return app; 694 - }; 695 - 696 - // #region account layout component 697 - interface AccountLayoutProps { 698 - children?: unknown; 699 - } 700 - 701 - const AccountLayout = (props: AccountLayoutProps) => { 702 - return ( 703 - <div class="flex flex-col gap-4 p-4 sm:p-16 sm:pt-24 lg:grid lg:grid-cols-[280px_minmax(0,640px)] lg:justify-center"> 704 - <aside class="-ml-2 flex flex-col gap-4 sm:ml-0"> 705 - <div class="flex h-8 shrink-0 items-center pl-4"> 706 - <h2 class="text-base-400 font-medium">Account</h2> 707 - </div> 708 - 709 - <div class="flex flex-col gap-px"> 710 - <AsideItem href="/account" exact icon={<PersonOutlined size={20} />}> 711 - Overview 712 - </AsideItem> 713 - 714 - <AsideItem href="/account/app-passwords" icon={<Key2Outlined size={20} />}> 715 - App passwords 716 - </AsideItem> 717 - 718 - <AsideItem href="/account/security" icon={<ShieldOutlined size={20} />}> 719 - Security 720 - </AsideItem> 721 - </div> 722 - </aside> 723 - 724 - <hr class="border-neutral-stroke-1 sm:hidden" /> 725 - 726 - <main>{props.children}</main> 727 - </div> 728 - ); 729 - }; 730 - // #endregion
-53
packages/danaus/src/web/admin/components/aside-item.tsx
··· 1 - import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 - import { useRequestContext } from 'hono/jsx-renderer'; 4 - 5 - const root = cva({ 6 - base: [ 7 - 'relative ml-2 flex gap-2 rounded-md px-2 py-2', 8 - 'text-base-300 font-medium text-neutral-foreground-2 no-underline', 9 - 'outline-2 -outline-offset-2 outline-transparent', 10 - 'transition duration-100 ease-fluent', 11 - 'hover:bg-subtle-background-hover', 12 - 'active:bg-subtle-background-active', 13 - 'focus-visible:z-10 focus-visible:outline-stroke-focus-2', 14 - ], 15 - }); 16 - 17 - const indicator = cva({ 18 - base: 'absolute -left-1.5 h-5 w-1 rounded-md bg-compound-brand-background', 19 - }); 20 - 21 - export interface AsideItemProps { 22 - href: string; 23 - /** whether to match the path exactly (default: false) */ 24 - exact?: boolean; 25 - icon?: Child; 26 - children?: Child; 27 - } 28 - 29 - /** 30 - * navigation item for the admin sidebar 31 - * @param props.href the path to link to 32 - * @param props.exact whether to match the path exactly 33 - * @param props.icon optional icon to display 34 - */ 35 - const AsideItem = (props: AsideItemProps) => { 36 - const { href, exact = false, icon, children } = props; 37 - 38 - const c = useRequestContext(); 39 - const currentPath = c.req.path; 40 - const isActive = exact ? currentPath === href : currentPath.startsWith(href); 41 - 42 - return ( 43 - <a href={href} class={root()} aria-current={isActive}> 44 - {isActive && <span class={indicator()} />} 45 - 46 - {icon !== undefined && <span class="grid size-5 place-items-center text-[20px]">{icon}</span>} 47 - 48 - {children} 49 - </a> 50 - ); 51 - }; 52 - 53 - export default AsideItem;
+61 -57
packages/danaus/src/web/admin/forms.ts
··· 1 1 import type { Handle } from '@atcute/lexicons'; 2 2 import { XRPCError } from '@atcute/xrpc-server'; 3 + import { redirect } from '@oomfware/fetch-router'; 4 + import { form, invalid } from '@oomfware/forms'; 3 5 4 6 import * as v from 'valibot'; 5 7 6 8 import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '#app/accounts/passwords.ts'; 7 9 import { provisionAccount } from '#app/api/local.danaus/account.createAccount.ts'; 8 - import type { AppContext } from '#app/context.ts'; 9 10 10 - import { form, invalid, redirect } from '../forms/index.ts'; 11 + import { getAppContext } from '../middlewares/app-context.ts'; 11 12 12 - export const createAdminForms = (ctx: AppContext) => { 13 - const createAccountForm = form( 14 - v.object({ 15 - handle: v.pipe( 16 - v.string(), 17 - v.minLength(1, `Handle is required`), 18 - v.maxLength(63, `Handle is too long`), 19 - v.regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/, `Invalid handle`), 20 - ), 21 - domain: v.picklist(ctx.config.identity.serviceHandleDomains), 22 - email: v.pipe(v.string(), v.minLength(1, `Email is required`), v.email(`Invalid email`)), 23 - password: v.pipe( 24 - v.string(), 25 - v.minLength(1, `Password is required`), 26 - v.minLength(MIN_PASSWORD_LENGTH, `Password is too short`), 27 - v.maxLength(MAX_PASSWORD_LENGTH, `Password is too long`), 28 - ), 29 - }), 30 - async (data, issue) => { 31 - const handle = `${data.handle}${data.domain}` as Handle; 13 + export const createAccountForm = form( 14 + v.object({ 15 + handle: v.pipe( 16 + v.string(), 17 + v.minLength(1, `Handle is required`), 18 + v.maxLength(63, `Handle is too long`), 19 + v.regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/, `Invalid handle`), 20 + ), 21 + domain: v.string(), 22 + email: v.pipe(v.string(), v.minLength(1, `Email is required`), v.email(`Invalid email`)), 23 + password: v.pipe( 24 + v.string(), 25 + v.minLength(1, `Password is required`), 26 + v.minLength(MIN_PASSWORD_LENGTH, `Password is too short`), 27 + v.maxLength(MAX_PASSWORD_LENGTH, `Password is too long`), 28 + ), 29 + }), 30 + async (data, issue) => { 31 + const ctx = getAppContext(); 32 32 33 - try { 34 - await provisionAccount(ctx, { 35 - handle, 36 - email: data.email, 37 - password: data.password, 38 - }); 33 + // validate domain against config 34 + if (!ctx.config.identity.serviceHandleDomains.includes(data.domain)) { 35 + invalid(issue.domain(`Invalid domain`)); 36 + } 39 37 40 - redirect(302, '/admin/accounts'); 41 - } catch (err) { 42 - if (err instanceof XRPCError && err.status === 400) { 43 - switch (err.error) { 44 - case 'InvalidHandle': 45 - case 'UnsupportedDomain': { 46 - invalid(issue.handle(`Invalid handle`)); 47 - } 48 - case 'HandleTaken': { 49 - invalid(issue.handle(`Handle is already taken`)); 50 - } 51 - case 'InvalidEmail': { 52 - invalid(issue.email(`Invalid email`)); 53 - } 54 - case 'EmailTaken': { 55 - invalid(issue.email(`Email is already taken`)); 56 - } 57 - case 'InvalidPassword': { 58 - invalid(issue.password(`Invalid password`)); 59 - } 60 - default: { 61 - invalid(`Something went wrong`); 62 - } 38 + const handle = `${data.handle}${data.domain}` as Handle; 39 + 40 + try { 41 + await provisionAccount(ctx, { 42 + handle, 43 + email: data.email, 44 + password: data.password, 45 + }); 46 + 47 + redirect('/admin/accounts'); 48 + } catch (err) { 49 + if (err instanceof XRPCError && err.status === 400) { 50 + switch (err.error) { 51 + case 'InvalidHandle': 52 + case 'UnsupportedDomain': { 53 + invalid(issue.handle(`Invalid handle`)); 54 + } 55 + case 'HandleTaken': { 56 + invalid(issue.handle(`Handle is already taken`)); 57 + } 58 + case 'InvalidEmail': { 59 + invalid(issue.email(`Invalid email`)); 60 + } 61 + case 'EmailTaken': { 62 + invalid(issue.email(`Email is already taken`)); 63 + } 64 + case 'InvalidPassword': { 65 + invalid(issue.password(`Invalid password`)); 66 + } 67 + default: { 68 + invalid(`Something went wrong`); 63 69 } 64 70 } 65 - 66 - throw err; 67 71 } 68 - }, 69 - ); 70 72 71 - return { createAccountForm }; 72 - }; 73 + throw err; 74 + } 75 + }, 76 + );
-286
packages/danaus/src/web/admin/index.tsx
··· 1 - import { Hono } from 'hono'; 2 - import { jsxRenderer } from 'hono/jsx-renderer'; 3 - 4 - import { parseBasicAuth } from '#app/auth/verifier.ts'; 5 - import type { AppContext } from '#app/context.ts'; 6 - 7 - import { IdProvider } from '../components/id.tsx'; 8 - import { registerForms } from '../forms/index.ts'; 9 - import Group1Outlined from '../icons/central/group-1-outlined.tsx'; 10 - import HomeOpenOutlined from '../icons/central/home-open-outlined.tsx'; 11 - import MagnifyingGlassOutlined from '../icons/central/magnifying-glass-outlined.tsx'; 12 - import PlusLargeOutlined from '../icons/central/plus-large-outlined.tsx'; 13 - import Button from '../primitives/button.tsx'; 14 - import Field from '../primitives/field.tsx'; 15 - import Input from '../primitives/input.tsx'; 16 - import Select from '../primitives/select.tsx'; 17 - 18 - import AsideItem from './components/aside-item.tsx'; 19 - import StatCard from './components/stat-card.tsx'; 20 - import { createAdminForms } from './forms.ts'; 21 - 22 - const REALM = `admin`; 23 - 24 - export const createAdminApp = (ctx: AppContext) => { 25 - const app = new Hono(); 26 - const main = new Hono(); 27 - 28 - const adminPassword = ctx.config.secrets.adminPassword; 29 - if (adminPassword === null) { 30 - app.use(async (c, _next) => { 31 - return c.text(`Administration UI is disabled`); 32 - }); 33 - 34 - return app; 35 - } 36 - 37 - app.use(async (c, next) => { 38 - const auth = parseBasicAuth(c.req.raw); 39 - if (auth === null || auth.password !== adminPassword) { 40 - return c.text(`Unauthorized`, 401, { 41 - 'www-authenticate': `Basic realm="${REALM}", charset="UTF-8"`, 42 - }); 43 - } 44 - 45 - await next(); 46 - }); 47 - 48 - const forms = createAdminForms(ctx); 49 - app.use(registerForms(forms)); 50 - 51 - app.use( 52 - jsxRenderer(({ children }) => { 53 - return ( 54 - <IdProvider> 55 - <html lang="en"> 56 - <head> 57 - <meta charset="utf-8" /> 58 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 59 - <link rel="stylesheet" href="/assets/style.css" /> 60 - </head> 61 - 62 - <body> 63 - <div class="flex min-h-dvh flex-col">{children}</div> 64 - </body> 65 - </html> 66 - </IdProvider> 67 - ); 68 - }), 69 - ); 70 - 71 - main.use( 72 - jsxRenderer(({ children, Layout: Html }) => { 73 - return ( 74 - <Html> 75 - <div class="flex flex-col gap-4 p-4 sm:p-16 sm:pt-24 lg:grid lg:grid-cols-[280px_minmax(0,640px)] lg:justify-center"> 76 - <aside class="-ml-2 flex flex-col gap-2 sm:ml-0"> 77 - <h2 class="pb-2 pl-4 text-base-400 font-medium">PDS administration</h2> 78 - 79 - <div class="flex flex-col gap-px"> 80 - <AsideItem href="/admin" exact icon={<HomeOpenOutlined size={20} />}> 81 - Home 82 - </AsideItem> 83 - 84 - <AsideItem href="/admin/accounts" icon={<Group1Outlined size={20} />}> 85 - Accounts 86 - </AsideItem> 87 - </div> 88 - </aside> 89 - 90 - <hr class="border-neutral-stroke-1 sm:hidden" /> 91 - 92 - <main>{children}</main> 93 - </div> 94 - </Html> 95 - ); 96 - }), 97 - ); 98 - 99 - // #region home route 100 - main.get('/', (c) => { 101 - const accountStats = ctx.accountManager.getAccountStats(); 102 - const inviteCodeStats = ctx.accountManager.getInviteCodeStats(); 103 - const sequencerStats = ctx.sequencer.getStats(); 104 - 105 - return c.render( 106 - <> 107 - <title>Home - Danaus admin</title> 108 - 109 - <div class="flex flex-col gap-4"> 110 - <h3 class="text-base-400 font-medium">Home</h3> 111 - 112 - <div class="flex flex-col gap-6"> 113 - <div class="flex flex-col gap-2"> 114 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Accounts</h4> 115 - <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> 116 - <StatCard label="Total" value={accountStats.total} /> 117 - <StatCard label="Active" value={accountStats.active} /> 118 - <StatCard label="Deactivated" value={accountStats.deactivated} /> 119 - <StatCard label="Taken down" value={accountStats.takendown} /> 120 - <StatCard label="Delete scheduled" value={accountStats.deleteScheduled} /> 121 - </div> 122 - </div> 123 - 124 - <div class="flex flex-col gap-2"> 125 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Invite codes</h4> 126 - <div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> 127 - <StatCard label="Total" value={inviteCodeStats.total} /> 128 - <StatCard label="Available" value={inviteCodeStats.available} /> 129 - <StatCard label="Used" value={inviteCodeStats.used} /> 130 - <StatCard label="Disabled" value={inviteCodeStats.disabled} /> 131 - </div> 132 - </div> 133 - 134 - <div class="flex flex-col gap-2"> 135 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Sequencer</h4> 136 - <div class="grid grid-cols-2 gap-3 sm:grid-cols-3"> 137 - <StatCard label="Last seq" value={sequencerStats.lastSeq} /> 138 - <StatCard label="Total events" value={sequencerStats.totalEvents} /> 139 - <StatCard label="Invalidated events" value={sequencerStats.invalidatedEvents} /> 140 - </div> 141 - </div> 142 - </div> 143 - </div> 144 - </>, 145 - ); 146 - }); 147 - // #endregion 148 - 149 - // #region accounts routes 150 - main.get('/accounts', (c) => { 151 - const query = c.req.query('q') ?? ''; 152 - const cursor = c.req.query('cursor'); 153 - 154 - const { accounts, cursor: nextCursor } = ctx.accountManager.listAccounts({ 155 - query: query || undefined, 156 - cursor, 157 - limit: 50, 158 - }); 159 - 160 - const buildHref = (nextCursor: string) => { 161 - const params = new URLSearchParams(); 162 - if (query) { 163 - params.set('q', query); 164 - } 165 - params.set('cursor', nextCursor); 166 - return `/admin/accounts?${params.toString()}`; 167 - }; 168 - 169 - return c.render( 170 - <> 171 - <title>Accounts - Danaus admin</title> 172 - 173 - <div class="flex flex-col gap-4"> 174 - <h3 class="text-base-400 font-medium">Accounts</h3> 175 - 176 - <div class="flex gap-2"> 177 - <form method="get" action="/admin/accounts" class="contents"> 178 - <Input 179 - type="search" 180 - name="q" 181 - value={query} 182 - placeholder="Search by handle or email..." 183 - contentBefore={<MagnifyingGlassOutlined size={16} />} 184 - class="grow" 185 - /> 186 - </form> 187 - 188 - <Button label="New account" href="/admin/accounts/new" variant="primary"> 189 - <PlusLargeOutlined size={16} /> 190 - New 191 - </Button> 192 - </div> 193 - 194 - <div class="flex flex-col"> 195 - {accounts.length === 0 ? ( 196 - <p class="py-8 text-center text-base-300 text-neutral-foreground-3"> 197 - {query ? 'No accounts found matching your search.' : 'No accounts yet.'} 198 - </p> 199 - ) : ( 200 - <ul class="divide-y divide-neutral-stroke-2"> 201 - {accounts.map((account) => ( 202 - <li class="flex items-center justify-between gap-4 py-3"> 203 - <div class="flex min-w-0 flex-col"> 204 - <span class="truncate text-base-300 font-medium">@{account.handle}</span> 205 - <span class="truncate text-base-200 text-neutral-foreground-3">{account.email}</span> 206 - </div> 207 - <div class="flex shrink-0 gap-2 text-base-200 text-neutral-foreground-3"> 208 - {account.deactivated_at && ( 209 - <span class="rounded-md bg-neutral-background-3 px-1.5 py-0.5 text-neutral-foreground-2"> 210 - deactivated 211 - </span> 212 - )} 213 - {account.takedown_ref && ( 214 - <span class="rounded-md bg-status-danger-background-1 px-1.5 py-0.5 text-status-danger-foreground-1"> 215 - taken down 216 - </span> 217 - )} 218 - </div> 219 - </li> 220 - ))} 221 - </ul> 222 - )} 223 - </div> 224 - 225 - {nextCursor && ( 226 - <div class="flex justify-end"> 227 - <Button href={buildHref(nextCursor)} variant="outlined"> 228 - Next page 229 - </Button> 230 - </div> 231 - )} 232 - </div> 233 - </>, 234 - ); 235 - }); 236 - 237 - main.on(['GET', 'POST'], '/accounts/new', (c) => { 238 - const domains = ctx.config.identity.serviceHandleDomains; 239 - const domainOptions = domains.map((d) => ({ value: d, label: d })); 240 - 241 - const { createAccountForm } = forms; 242 - const { fields } = createAccountForm; 243 - 244 - return c.render( 245 - <> 246 - <title>New account - Danaus admin</title> 247 - 248 - <div class="flex flex-col gap-4"> 249 - <h3 class="text-base-400 font-medium">New account</h3> 250 - 251 - <form {...createAccountForm} class="flex max-w-96 flex-col gap-6"> 252 - <Field label="Handle" required validationMessageText={fields.handle.issues()[0]?.message}> 253 - <div class="flex gap-2"> 254 - <Input {...fields.handle.as('text')} placeholder="alice" required class="grow" /> 255 - 256 - <Select {...fields.domain.as('select')} options={domainOptions} /> 257 - </div> 258 - </Field> 259 - 260 - <Field label="Email" required validationMessageText={fields.email.issues()[0]?.message}> 261 - <Input {...fields.email.as('email')} placeholder="alice@example.com" required /> 262 - </Field> 263 - 264 - <Field label="Password" required validationMessageText={fields.password.issues()[0]?.message}> 265 - <Input {...fields.password.as('password')} required /> 266 - </Field> 267 - 268 - <div class="flex gap-3 pt-2"> 269 - <Button type="submit" variant="primary"> 270 - Create account 271 - </Button> 272 - <Button href="/admin/accounts" variant="outlined"> 273 - Cancel 274 - </Button> 275 - </div> 276 - </form> 277 - </div> 278 - </>, 279 - ); 280 - }); 281 - // #endregion 282 - 283 - app.route('/', main); 284 - 285 - return app; 286 - };
-18
packages/danaus/src/web/app.ts
··· 1 - import { Hono } from 'hono'; 2 - 3 - import type { AppContext } from '../context.ts'; 4 - 5 - import { createAccountApp } from './account/index.tsx'; 6 - import { createAdminApp } from './admin/index.tsx'; 7 - import { createOAuthApp } from './oauth/index.tsx'; 8 - 9 - export const createWebApp = (ctx: AppContext): Hono => { 10 - const app = new Hono(); 11 - 12 - app.get('/', (c) => c.text(`This is an AT Protocol personal data server.`)); 13 - app.route('/admin', createAdminApp(ctx)); 14 - app.route('/account', createAccountApp(ctx)); 15 - app.route('/oauth', createOAuthApp(ctx)); 16 - 17 - return app; 18 - };
+54
packages/danaus/src/web/components/aside-item.tsx
··· 1 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 2 + import type { JSXNode } from '@oomfware/jsx'; 3 + 4 + import { cva } from 'cva'; 5 + 6 + const root = cva({ 7 + base: [ 8 + 'relative ml-2 flex gap-2 rounded-md px-2 py-2', 9 + 'text-base-300 font-medium text-neutral-foreground-2 no-underline', 10 + 'outline-2 -outline-offset-2 outline-transparent', 11 + 'transition duration-100 ease-fluent', 12 + 'hover:bg-subtle-background-hover', 13 + 'active:bg-subtle-background-active', 14 + 'focus-visible:z-10 focus-visible:outline-stroke-focus-2', 15 + ], 16 + }); 17 + 18 + const indicator = cva({ 19 + base: 'absolute -left-1.5 h-5 w-1 rounded-md bg-compound-brand-background', 20 + }); 21 + 22 + export interface AsideItemProps { 23 + href: string; 24 + /** whether to match the path exactly (default: false) */ 25 + exact?: boolean; 26 + icon?: JSXNode; 27 + children?: JSXNode; 28 + } 29 + 30 + /** 31 + * navigation item for the admin sidebar 32 + * @param props.href the path to link to 33 + * @param props.exact whether to match the path exactly 34 + * @param props.icon optional icon to display 35 + */ 36 + const AsideItem = (props: AsideItemProps) => { 37 + const { href, exact = false, icon, children } = props; 38 + 39 + const { url } = getContext(); 40 + const currentPath = url.pathname; 41 + const isActive = exact ? currentPath === href : currentPath.startsWith(href); 42 + 43 + return ( 44 + <a href={href} class={root()} aria-current={isActive}> 45 + {isActive && <span class={indicator()} />} 46 + 47 + {icon !== undefined && <span class="grid size-5 place-items-center text-[20px]">{icon}</span>} 48 + 49 + {children} 50 + </a> 51 + ); 52 + }; 53 + 54 + export default AsideItem;
+3 -3
packages/danaus/src/web/components/id.tsx
··· 1 - import { createContext, useContext, type Child } from 'hono/jsx'; 1 + import { createContext, use, type JSXNode } from '@oomfware/jsx'; 2 2 3 3 export interface IdContextValue { 4 4 count: number; ··· 7 7 export const IdContext = createContext<IdContextValue | null>(null); 8 8 9 9 export const useId = (): string => { 10 - const context = useContext(IdContext); 10 + const context = use(IdContext); 11 11 if (context === null) { 12 12 throw new Error(`expected useId() to be used under <IdProvider>`); 13 13 } ··· 16 16 }; 17 17 18 18 export interface IdProviderProps { 19 - children?: Child; 19 + children?: JSXNode; 20 20 } 21 21 22 22 export const IdProvider = (props: IdProviderProps) => {
+711
packages/danaus/src/web/controllers/account.tsx
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import type { Controller } from '@oomfware/fetch-router'; 3 + import { forms } from '@oomfware/forms'; 4 + import { render } from '@oomfware/jsx'; 5 + 6 + import { AppPasswordPrivilege } from '#app/accounts/db/schema.ts'; 7 + 8 + import { 9 + createAppPasswordForm, 10 + deleteAppPasswordForm, 11 + refreshHandleForm, 12 + updateHandleForm, 13 + } from '../account/forms.ts'; 14 + import AtOutlined from '../icons/central/at-outlined.tsx'; 15 + import DotGrid1x3HorizontalOutlined from '../icons/central/dot-grid-1x3-horizontal-outlined.tsx'; 16 + import Key2Outlined from '../icons/central/key-2-outlined.tsx'; 17 + import PasskeysOutlined from '../icons/central/passkeys-outlined.tsx'; 18 + import PasswordOutlined from '../icons/central/password-outlined.tsx'; 19 + import PhoneOutlined from '../icons/central/phone-outlined.tsx'; 20 + import PlusLargeOutlined from '../icons/central/plus-large-outlined.tsx'; 21 + import UsbOutlined from '../icons/central/usb-outlined.tsx'; 22 + import { AccountLayout } from '../layouts/account.tsx'; 23 + import { getAppContext } from '../middlewares/app-context.ts'; 24 + import { getSession, requireSession } from '../middlewares/session.ts'; 25 + import AccordionHeader from '../primitives/accordion-header.tsx'; 26 + import AccordionItem from '../primitives/accordion-item.tsx'; 27 + import AccordionPanel from '../primitives/accordion-panel.tsx'; 28 + import Accordion from '../primitives/accordion.tsx'; 29 + import Button from '../primitives/button.tsx'; 30 + import DialogActions from '../primitives/dialog-actions.tsx'; 31 + import DialogBody from '../primitives/dialog-body.tsx'; 32 + import DialogClose from '../primitives/dialog-close.tsx'; 33 + import DialogContent from '../primitives/dialog-content.tsx'; 34 + import DialogSurface from '../primitives/dialog-surface.tsx'; 35 + import DialogTitle from '../primitives/dialog-title.tsx'; 36 + import DialogTrigger from '../primitives/dialog-trigger.tsx'; 37 + import Dialog from '../primitives/dialog.tsx'; 38 + import Field from '../primitives/field.tsx'; 39 + import Input from '../primitives/input.tsx'; 40 + import MenuDivider from '../primitives/menu-divider.tsx'; 41 + import MenuItem from '../primitives/menu-item.tsx'; 42 + import MenuList from '../primitives/menu-list.tsx'; 43 + import MenuPopover from '../primitives/menu-popover.tsx'; 44 + import MenuTrigger from '../primitives/menu-trigger.tsx'; 45 + import Menu from '../primitives/menu.tsx'; 46 + import MessageBarBody from '../primitives/message-bar-body.tsx'; 47 + import MessageBarTitle from '../primitives/message-bar-title.tsx'; 48 + import MessageBar from '../primitives/message-bar.tsx'; 49 + import Select from '../primitives/select.tsx'; 50 + import type { routes } from '../routes.ts'; 51 + 52 + export default { 53 + middleware: [ 54 + requireSession(), 55 + forms({ 56 + updateHandleForm, 57 + refreshHandleForm, 58 + createAppPasswordForm, 59 + deleteAppPasswordForm, 60 + }), 61 + ], 62 + actions: { 63 + overview() { 64 + const ctx = getAppContext(); 65 + const session = getSession(); 66 + const account = ctx.accountManager.getAccount(session.did); 67 + 68 + // determine current handle parts for form prefill 69 + const currentHandle = account?.handle ?? ''; 70 + const isServiceHandle = ctx.config.identity.serviceHandleDomains.some((d) => currentHandle.endsWith(d)); 71 + const currentDomain = isServiceHandle 72 + ? (ctx.config.identity.serviceHandleDomains.find((d) => currentHandle.endsWith(d)) ?? 'custom') 73 + : 'custom'; 74 + const currentLocalPart = isServiceHandle 75 + ? currentHandle.slice(0, -currentDomain.length) 76 + : currentHandle; 77 + 78 + const updateHandleError = updateHandleForm.fields.allIssues()?.[0]; 79 + const refreshHandleError = refreshHandleForm.fields.allIssues()?.[0]; 80 + 81 + return render( 82 + <AccountLayout> 83 + <title>My account - Danaus</title> 84 + 85 + <div class="flex flex-col gap-4"> 86 + <div class="flex h-8 items-center"> 87 + <h3 class="text-base-400 font-medium">Account overview</h3> 88 + </div> 89 + 90 + {updateHandleError && ( 91 + <MessageBar intent="error" layout="singleline"> 92 + <MessageBarBody>{updateHandleError.message}</MessageBarBody> 93 + </MessageBar> 94 + )} 95 + 96 + {refreshHandleError && ( 97 + <MessageBar intent="error" layout="singleline"> 98 + <MessageBarBody>{refreshHandleError.message}</MessageBarBody> 99 + </MessageBar> 100 + )} 101 + 102 + <div class="flex flex-col gap-8"> 103 + <div class="flex flex-col gap-2"> 104 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Your identity</h4> 105 + 106 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 107 + <div class="flex items-center gap-4 px-4 py-3"> 108 + <div class="min-w-0 grow"> 109 + <p class="text-base-300 font-medium wrap-break-word">@{account?.handle}</p> 110 + <p class="text-base-300 text-neutral-foreground-3">Your username on the network</p> 111 + </div> 112 + 113 + <Menu> 114 + <MenuTrigger> 115 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 116 + <DotGrid1x3HorizontalOutlined size={16} /> 117 + </button> 118 + </MenuTrigger> 119 + 120 + <MenuPopover> 121 + <MenuList> 122 + <MenuItem command="show-modal" commandfor="change-service-handle-dialog"> 123 + Change handle 124 + </MenuItem> 125 + 126 + <MenuItem command="show-modal" commandfor="refresh-handle-dialog"> 127 + Request refresh 128 + </MenuItem> 129 + </MenuList> 130 + </MenuPopover> 131 + </Menu> 132 + </div> 133 + </div> 134 + </div> 135 + 136 + <div class="flex flex-col gap-2"> 137 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account management</h4> 138 + 139 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 140 + <div class="flex items-center gap-4 px-4 py-3"> 141 + <div class="min-w-0 grow"> 142 + <p class="text-base-300 font-medium">Data export</p> 143 + <p class="text-base-300 text-neutral-foreground-3"> 144 + Download your repository and blobs 145 + </p> 146 + </div> 147 + 148 + <Button disabled>Export</Button> 149 + </div> 150 + 151 + <div class="flex items-center gap-4 px-4 py-3"> 152 + <div class="min-w-0 grow"> 153 + <p class="text-base-300 font-medium">Deactivate account</p> 154 + <p class="text-base-300 text-neutral-foreground-3">Temporarily disable your account</p> 155 + </div> 156 + 157 + <Button disabled>Deactivate</Button> 158 + </div> 159 + 160 + <div class="flex items-center gap-4 px-4 py-3"> 161 + <div class="min-w-0 grow"> 162 + <p class="text-base-300 font-medium">Delete account</p> 163 + <p class="text-base-300 text-neutral-foreground-3">Permanently delete your account</p> 164 + </div> 165 + 166 + <Button disabled>Delete</Button> 167 + </div> 168 + </div> 169 + </div> 170 + </div> 171 + </div> 172 + 173 + <Dialog id="change-service-handle-dialog"> 174 + <DialogSurface> 175 + <DialogBody> 176 + <DialogTitle>Change handle</DialogTitle> 177 + 178 + <form {...updateHandleForm} class="contents"> 179 + <DialogContent class="flex flex-col gap-4"> 180 + <p class="text-base-300 text-neutral-foreground-3"> 181 + Your handle is your unique identity on the AT Protocol network. 182 + </p> 183 + 184 + <Field label="Handle" required> 185 + <div class="flex gap-2"> 186 + <Input 187 + {...updateHandleForm.fields.handle.as('text')} 188 + value={updateHandleForm.fields.handle.value() || currentLocalPart} 189 + placeholder="alice" 190 + contentBefore={<AtOutlined size={16} />} 191 + class="grow" 192 + /> 193 + 194 + <Select 195 + {...updateHandleForm.fields.domain.as('select')} 196 + value={updateHandleForm.fields.domain.value() || currentDomain} 197 + options={ctx.config.identity.serviceHandleDomains.map((d) => ({ 198 + value: d, 199 + label: d, 200 + }))} 201 + /> 202 + </div> 203 + </Field> 204 + </DialogContent> 205 + 206 + <DialogActions> 207 + <Button command="show-modal" commandfor="change-custom-handle-dialog"> 208 + Use my own domain 209 + </Button> 210 + 211 + <div class="grow"></div> 212 + 213 + <DialogClose> 214 + <Button>Cancel</Button> 215 + </DialogClose> 216 + 217 + <Button type="submit" variant="primary"> 218 + Change 219 + </Button> 220 + </DialogActions> 221 + </form> 222 + </DialogBody> 223 + </DialogSurface> 224 + </Dialog> 225 + 226 + <Dialog id="refresh-handle-dialog"> 227 + <DialogSurface> 228 + <DialogBody> 229 + <DialogTitle>Request handle refresh</DialogTitle> 230 + 231 + <form {...refreshHandleForm} class="contents"> 232 + <DialogContent> 233 + <p class="text-base-300"> 234 + This will notify the network to re-verify your handle. Use this if apps are marking your 235 + handle as invalid despite being set up correctly. 236 + </p> 237 + </DialogContent> 238 + 239 + <DialogActions> 240 + <DialogClose> 241 + <Button>Cancel</Button> 242 + </DialogClose> 243 + 244 + <Button type="submit" variant="primary"> 245 + Refresh 246 + </Button> 247 + </DialogActions> 248 + </form> 249 + </DialogBody> 250 + </DialogSurface> 251 + </Dialog> 252 + 253 + <Dialog id="change-custom-handle-dialog"> 254 + <DialogSurface> 255 + <DialogBody> 256 + <DialogTitle>Change handle</DialogTitle> 257 + 258 + <form {...updateHandleForm} class="contents"> 259 + <DialogContent class="flex flex-col gap-4"> 260 + <p class="text-base-300 text-neutral-foreground-3"> 261 + Your handle is your unique identity on the AT Protocol network. 262 + </p> 263 + 264 + <Field label="Handle" required> 265 + <Input 266 + {...updateHandleForm.fields.handle.as('text')} 267 + placeholder="alice.com" 268 + contentBefore={<AtOutlined size={16} />} 269 + /> 270 + </Field> 271 + 272 + <input {...updateHandleForm.fields.domain.as('hidden', 'custom')} /> 273 + 274 + <Accordion class="flex flex-col gap-2"> 275 + <AccordionItem name="handle-method" open> 276 + <AccordionHeader>DNS record</AccordionHeader> 277 + <AccordionPanel> 278 + <div class="flex flex-col gap-3"> 279 + <p class="text-base-300 text-neutral-foreground-3"> 280 + Add the following DNS record to your domain: 281 + </p> 282 + 283 + <div class="flex flex-col gap-2 rounded-md bg-neutral-background-3 p-3"> 284 + <div class="flex flex-col gap-0.5"> 285 + <span class="text-base-200 text-neutral-foreground-3">Host</span> 286 + <input 287 + type="text" 288 + readonly 289 + value="_atproto.<your-domain>" 290 + class="font-mono text-base-300 outline-none" 291 + /> 292 + </div> 293 + <div class="flex flex-col gap-0.5"> 294 + <span class="text-base-200 text-neutral-foreground-3">Type</span> 295 + <input 296 + type="text" 297 + readonly 298 + value="TXT" 299 + class="font-mono text-base-300 outline-none" 300 + /> 301 + </div> 302 + <div class="flex flex-col gap-0.5"> 303 + <span class="text-base-200 text-neutral-foreground-3">Value</span> 304 + <input 305 + type="text" 306 + readonly 307 + value={`did=${session.did}`} 308 + class="font-mono text-base-300 outline-none" 309 + /> 310 + </div> 311 + </div> 312 + </div> 313 + </AccordionPanel> 314 + </AccordionItem> 315 + 316 + <AccordionItem name="handle-method"> 317 + <AccordionHeader>HTTP well-known entry</AccordionHeader> 318 + <AccordionPanel> 319 + <div class="flex flex-col gap-3"> 320 + <p class="text-base-300 text-neutral-foreground-3"> 321 + Upload a text file to the following URL: 322 + </p> 323 + 324 + <div class="flex flex-col gap-2 rounded-md bg-neutral-background-3 p-3"> 325 + <div class="flex flex-col gap-0.5"> 326 + <span class="text-base-200 text-neutral-foreground-3">URL</span> 327 + <input 328 + type="text" 329 + readonly 330 + value="https://<your-domain>/.well-known/atproto-did" 331 + class="font-mono text-base-300 outline-none" 332 + /> 333 + </div> 334 + <div class="flex flex-col gap-0.5"> 335 + <span class="text-base-200 text-neutral-foreground-3">Contents</span> 336 + <input 337 + type="text" 338 + readonly 339 + value={session.did} 340 + class="font-mono text-base-300 outline-none" 341 + /> 342 + </div> 343 + </div> 344 + </div> 345 + </AccordionPanel> 346 + </AccordionItem> 347 + </Accordion> 348 + </DialogContent> 349 + 350 + <DialogActions> 351 + <DialogClose> 352 + <Button>Cancel</Button> 353 + </DialogClose> 354 + 355 + <Button type="submit" variant="primary"> 356 + Change 357 + </Button> 358 + </DialogActions> 359 + </form> 360 + </DialogBody> 361 + </DialogSurface> 362 + </Dialog> 363 + </AccountLayout>, 364 + ); 365 + }, 366 + 367 + appPasswords() { 368 + const ctx = getAppContext(); 369 + const session = getSession(); 370 + const did = session.did as Did; 371 + 372 + const passwords = ctx.accountManager.listAppPasswords(did); 373 + 374 + const newPasswordResult = createAppPasswordForm.result; 375 + const newPasswordError = createAppPasswordForm.fields.allIssues()?.[0]; 376 + 377 + return render( 378 + <AccountLayout> 379 + <title>App passwords - Danaus</title> 380 + 381 + <div class="flex flex-col gap-4"> 382 + <div class="flex h-8 shrink-0 items-center justify-between"> 383 + <h3 class="text-base-400 font-medium">App passwords</h3> 384 + 385 + <Button commandfor="create-app-password-dialog" command="show-modal" variant="primary"> 386 + <PlusLargeOutlined size={16} /> 387 + New 388 + </Button> 389 + </div> 390 + 391 + {newPasswordResult && ( 392 + <MessageBar intent="success" layout="multiline"> 393 + <MessageBarBody> 394 + <MessageBarTitle>App password created</MessageBarTitle> 395 + 396 + <div class="mt-2 flex flex-col gap-2"> 397 + <code class="rounded-md bg-neutral-background-3 px-2 py-1 font-mono text-base-300"> 398 + {newPasswordResult.secret} 399 + </code> 400 + <p class="text-base-200 text-neutral-foreground-3"> 401 + Copy this password now. You won't be able to see it again. 402 + </p> 403 + </div> 404 + </MessageBarBody> 405 + </MessageBar> 406 + )} 407 + 408 + {newPasswordError && ( 409 + <MessageBar intent="error" layout="singleline"> 410 + <MessageBarBody>{newPasswordError.message}</MessageBarBody> 411 + </MessageBar> 412 + )} 413 + 414 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 415 + {passwords.length === 0 && ( 416 + <div class="flex flex-col gap-1 p-8 text-center"> 417 + <p class="text-base-300 font-medium">No app passwords created</p> 418 + <p class="text-base-300 text-neutral-foreground-3"> 419 + App passwords lets you sign into legacy AT Protocol apps. 420 + </p> 421 + </div> 422 + )} 423 + 424 + {passwords.map((password) => { 425 + let privilege = `Unknown`; 426 + switch (password.privilege) { 427 + case AppPasswordPrivilege.Full: { 428 + privilege = `Full access`; 429 + break; 430 + } 431 + case AppPasswordPrivilege.Privileged: { 432 + privilege = `Privileged access`; 433 + break; 434 + } 435 + case AppPasswordPrivilege.Limited: { 436 + privilege = `Limited access`; 437 + break; 438 + } 439 + } 440 + 441 + return ( 442 + <div class="flex items-center gap-4 px-4 py-3"> 443 + <Key2Outlined size={24} class="shrink-0 text-neutral-foreground-3" /> 444 + 445 + <div class="min-w-0 grow"> 446 + <p class="text-base-300 font-medium">{password.name}</p> 447 + <p class="text-base-300 text-neutral-foreground-3"> 448 + {privilege} ยท Created {password.created_at.toLocaleDateString()} 449 + </p> 450 + </div> 451 + 452 + <Menu> 453 + <MenuTrigger> 454 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 455 + <DotGrid1x3HorizontalOutlined size={16} /> 456 + </button> 457 + </MenuTrigger> 458 + 459 + <MenuPopover> 460 + <MenuList> 461 + <Dialog> 462 + <DialogTrigger> 463 + <MenuItem>Remove</MenuItem> 464 + </DialogTrigger> 465 + 466 + <DialogSurface> 467 + <DialogBody> 468 + <DialogTitle>Remove app password?</DialogTitle> 469 + 470 + <form {...deleteAppPasswordForm} class="contents"> 471 + <input type="hidden" name="name" value={password.name} /> 472 + 473 + <DialogContent> 474 + <p class="text-base-300"> 475 + Any app signed in with "{password.name}" will be signed out immediately. 476 + </p> 477 + </DialogContent> 478 + 479 + <DialogActions> 480 + <DialogClose> 481 + <Button>Cancel</Button> 482 + </DialogClose> 483 + 484 + <Button type="submit" variant="primary"> 485 + Remove 486 + </Button> 487 + </DialogActions> 488 + </form> 489 + </DialogBody> 490 + </DialogSurface> 491 + </Dialog> 492 + </MenuList> 493 + </MenuPopover> 494 + </Menu> 495 + </div> 496 + ); 497 + })} 498 + </div> 499 + </div> 500 + 501 + <Dialog id="create-app-password-dialog"> 502 + <DialogSurface> 503 + <DialogBody> 504 + <DialogTitle>Create app password</DialogTitle> 505 + 506 + <form {...createAppPasswordForm} class="contents"> 507 + <DialogContent class="flex flex-col gap-4"> 508 + <p class="text-base-300 text-neutral-foreground-3"> 509 + App passwords let you sign into legacy AT Protocol apps without giving them access to 510 + your main password. 511 + </p> 512 + 513 + <Field label="Name" required> 514 + <Input {...createAppPasswordForm.fields.name.as('text')} placeholder="App" required /> 515 + </Field> 516 + 517 + <Field label="Privilege" required> 518 + <Select 519 + {...createAppPasswordForm.fields.privilege.as('select')} 520 + options={[ 521 + { value: 'limited', label: 'Limited access' }, 522 + { value: 'privileged', label: 'Privileged access' }, 523 + { value: 'full', label: 'Full access' }, 524 + ]} 525 + /> 526 + </Field> 527 + </DialogContent> 528 + 529 + <DialogActions> 530 + <DialogClose> 531 + <Button>Cancel</Button> 532 + </DialogClose> 533 + 534 + <Button type="submit" variant="primary"> 535 + Create 536 + </Button> 537 + </DialogActions> 538 + </form> 539 + </DialogBody> 540 + </DialogSurface> 541 + </Dialog> 542 + </AccountLayout>, 543 + ); 544 + }, 545 + 546 + security() { 547 + const ctx = getAppContext(); 548 + const session = getSession(); 549 + const account = ctx.accountManager.getAccount(session.did); 550 + 551 + return render( 552 + <AccountLayout> 553 + <title>Security - Danaus</title> 554 + 555 + <div class="flex flex-col gap-4"> 556 + <div class="flex h-8 shrink-0 items-center"> 557 + <h3 class="text-base-400 font-medium">Security</h3> 558 + </div> 559 + 560 + <div class="flex flex-col gap-8"> 561 + <div class="flex flex-col gap-2"> 562 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account information</h4> 563 + 564 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 565 + <div class="flex items-center gap-4 px-4 py-3"> 566 + <div class="min-w-0 grow"> 567 + <p class="text-base-300 font-medium wrap-break-word">{account?.email}</p> 568 + <p class="text-base-300 text-neutral-foreground-3"> 569 + {account?.email_confirmed_at ? 'Verified' : 'Not verified'} 570 + </p> 571 + </div> 572 + 573 + {!account?.email_confirmed_at && <Button>Verify</Button>} 574 + 575 + <Menu> 576 + <MenuTrigger> 577 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 578 + <DotGrid1x3HorizontalOutlined size={16} /> 579 + </button> 580 + </MenuTrigger> 581 + 582 + <MenuPopover> 583 + <MenuList> 584 + <MenuItem>Change email</MenuItem> 585 + </MenuList> 586 + </MenuPopover> 587 + </Menu> 588 + </div> 589 + </div> 590 + </div> 591 + 592 + <div class="flex flex-col gap-2"> 593 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Ways to prove who you are</h4> 594 + 595 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 596 + <div class="flex items-center gap-4 px-4 py-3"> 597 + <PasswordOutlined size={24} class="shrink-0" /> 598 + 599 + <div class="min-w-0 grow"> 600 + <p class="text-base-300 font-medium wrap-break-word">Password</p> 601 + <p class="text-base-300 text-neutral-foreground-3">Last changed yesterday</p> 602 + </div> 603 + 604 + <Menu> 605 + <MenuTrigger> 606 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 607 + <DotGrid1x3HorizontalOutlined size={16} /> 608 + </button> 609 + </MenuTrigger> 610 + 611 + <MenuPopover> 612 + <MenuList> 613 + <MenuItem>Change password</MenuItem> 614 + </MenuList> 615 + </MenuPopover> 616 + </Menu> 617 + </div> 618 + 619 + <div class="flex items-center gap-4 px-4 py-3"> 620 + <PhoneOutlined size={24} class="shrink-0" /> 621 + 622 + <div class="min-w-0 grow"> 623 + <p class="text-base-300 font-medium wrap-break-word">Bitwarden</p> 624 + <p class="text-base-300 text-neutral-foreground-3">Authenticator ยท Added yesterday</p> 625 + </div> 626 + 627 + <Menu> 628 + <MenuTrigger> 629 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 630 + <DotGrid1x3HorizontalOutlined size={16} /> 631 + </button> 632 + </MenuTrigger> 633 + 634 + <MenuPopover> 635 + <MenuList> 636 + <MenuItem>Rename</MenuItem> 637 + <MenuDivider /> 638 + <MenuItem>Remove</MenuItem> 639 + </MenuList> 640 + </MenuPopover> 641 + </Menu> 642 + </div> 643 + 644 + <div class="flex items-center gap-4 px-4 py-3"> 645 + <UsbOutlined size={24} class="shrink-0" /> 646 + 647 + <div class="min-w-0 grow"> 648 + <p class="text-base-300 font-medium wrap-break-word">YubiKey 5</p> 649 + <p class="text-base-300 text-neutral-foreground-3">Security key ยท Added 2 weeks ago</p> 650 + </div> 651 + 652 + <Menu> 653 + <MenuTrigger> 654 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 655 + <DotGrid1x3HorizontalOutlined size={16} /> 656 + </button> 657 + </MenuTrigger> 658 + 659 + <MenuPopover> 660 + <MenuList> 661 + <MenuItem>Rename</MenuItem> 662 + <MenuDivider /> 663 + <MenuItem>Remove</MenuItem> 664 + </MenuList> 665 + </MenuPopover> 666 + </Menu> 667 + </div> 668 + 669 + <div class="flex items-center gap-4 px-4 py-3"> 670 + <PasskeysOutlined size={24} class="shrink-0" /> 671 + 672 + <div class="min-w-0 grow"> 673 + <p class="text-base-300 font-medium wrap-break-word">iCloud Keychain</p> 674 + <p class="text-base-300 text-neutral-foreground-3">Passkey ยท Added last month</p> 675 + </div> 676 + 677 + <Menu> 678 + <MenuTrigger> 679 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 680 + <DotGrid1x3HorizontalOutlined size={16} /> 681 + </button> 682 + </MenuTrigger> 683 + 684 + <MenuPopover> 685 + <MenuList> 686 + <MenuItem>Rename</MenuItem> 687 + <MenuDivider /> 688 + <MenuItem>Remove</MenuItem> 689 + </MenuList> 690 + </MenuPopover> 691 + </Menu> 692 + </div> 693 + 694 + <button class="flex items-center gap-4 bg-subtle-background px-4 py-3 text-left outline-2 -outline-offset-2 outline-transparent transition select-none first:rounded-t-md last:rounded-b-md hover:bg-subtle-background-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active"> 695 + <div class="grid h-6 w-6 shrink-0 place-items-center"> 696 + <PlusLargeOutlined size={16} /> 697 + </div> 698 + 699 + <div class="min-w-0 grow"> 700 + <p class="text-base-300">Add another way to sign in</p> 701 + </div> 702 + </button> 703 + </div> 704 + </div> 705 + </div> 706 + </div> 707 + </AccountLayout>, 708 + ); 709 + }, 710 + }, 711 + } satisfies Controller<typeof routes.account>;
+209
packages/danaus/src/web/controllers/admin.tsx
··· 1 + import type { Controller } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render } from '@oomfware/jsx'; 4 + 5 + import StatCard from '../admin/components/stat-card.tsx'; 6 + import { createAccountForm } from '../admin/forms.ts'; 7 + import MagnifyingGlassOutlined from '../icons/central/magnifying-glass-outlined.tsx'; 8 + import PlusLargeOutlined from '../icons/central/plus-large-outlined.tsx'; 9 + import { AdminLayout } from '../layouts/admin.tsx'; 10 + import { getAppContext } from '../middlewares/app-context.ts'; 11 + import { requireAdmin } from '../middlewares/basic-auth.ts'; 12 + import Button from '../primitives/button.tsx'; 13 + import Field from '../primitives/field.tsx'; 14 + import Input from '../primitives/input.tsx'; 15 + import Select from '../primitives/select.tsx'; 16 + import { routes } from '../routes.ts'; 17 + 18 + export default { 19 + middleware: [requireAdmin(), forms({ createAccountForm })], 20 + actions: { 21 + dashboard() { 22 + const ctx = getAppContext(); 23 + const accountStats = ctx.accountManager.getAccountStats(); 24 + const inviteCodeStats = ctx.accountManager.getInviteCodeStats(); 25 + const sequencerStats = ctx.sequencer.getStats(); 26 + 27 + return render( 28 + <AdminLayout> 29 + <title>Home - Danaus admin</title> 30 + 31 + <div class="flex flex-col gap-4"> 32 + <h3 class="text-base-400 font-medium">Home</h3> 33 + 34 + <div class="flex flex-col gap-6"> 35 + <div class="flex flex-col gap-2"> 36 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Accounts</h4> 37 + <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> 38 + <StatCard label="Total" value={accountStats.total} /> 39 + <StatCard label="Active" value={accountStats.active} /> 40 + <StatCard label="Deactivated" value={accountStats.deactivated} /> 41 + <StatCard label="Taken down" value={accountStats.takendown} /> 42 + <StatCard label="Delete scheduled" value={accountStats.deleteScheduled} /> 43 + </div> 44 + </div> 45 + 46 + <div class="flex flex-col gap-2"> 47 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Invite codes</h4> 48 + <div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> 49 + <StatCard label="Total" value={inviteCodeStats.total} /> 50 + <StatCard label="Available" value={inviteCodeStats.available} /> 51 + <StatCard label="Used" value={inviteCodeStats.used} /> 52 + <StatCard label="Disabled" value={inviteCodeStats.disabled} /> 53 + </div> 54 + </div> 55 + 56 + <div class="flex flex-col gap-2"> 57 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Sequencer</h4> 58 + <div class="grid grid-cols-2 gap-3 sm:grid-cols-3"> 59 + <StatCard label="Last seq" value={sequencerStats.lastSeq} /> 60 + <StatCard label="Total events" value={sequencerStats.totalEvents} /> 61 + <StatCard label="Invalidated events" value={sequencerStats.invalidatedEvents} /> 62 + </div> 63 + </div> 64 + </div> 65 + </div> 66 + </AdminLayout>, 67 + ); 68 + }, 69 + 70 + accounts: { 71 + index({ url }) { 72 + const ctx = getAppContext(); 73 + const query = url.searchParams.get('q') ?? ''; 74 + const cursor = url.searchParams.get('cursor') ?? undefined; 75 + 76 + const { accounts, cursor: nextCursor } = ctx.accountManager.listAccounts({ 77 + query: query || undefined, 78 + cursor, 79 + limit: 50, 80 + }); 81 + 82 + const buildHref = (nextCursor: string) => { 83 + return routes.admin.accounts.index.href(undefined, { 84 + q: query || undefined, 85 + cursor: nextCursor, 86 + }); 87 + }; 88 + 89 + return render( 90 + <AdminLayout> 91 + <title>Accounts - Danaus admin</title> 92 + 93 + <div class="flex flex-col gap-4"> 94 + <h3 class="text-base-400 font-medium">Accounts</h3> 95 + 96 + <div class="flex gap-2"> 97 + <form method="get" action={routes.admin.accounts.index.href()} class="contents"> 98 + <Input 99 + type="search" 100 + name="q" 101 + value={query} 102 + placeholder="Search by handle or email..." 103 + contentBefore={<MagnifyingGlassOutlined size={16} />} 104 + class="grow" 105 + /> 106 + </form> 107 + 108 + <Button label="New account" href={routes.admin.accounts.create.href()} variant="primary"> 109 + <PlusLargeOutlined size={16} /> 110 + New 111 + </Button> 112 + </div> 113 + 114 + <div class="flex flex-col"> 115 + {accounts.length === 0 ? ( 116 + <p class="py-8 text-center text-base-300 text-neutral-foreground-3"> 117 + {query ? 'No accounts found matching your search.' : 'No accounts yet.'} 118 + </p> 119 + ) : ( 120 + <ul class="divide-y divide-neutral-stroke-2"> 121 + {accounts.map((account) => ( 122 + <li class="flex items-center justify-between gap-4 py-3"> 123 + <div class="flex min-w-0 flex-col"> 124 + <span class="truncate text-base-300 font-medium">@{account.handle}</span> 125 + <span class="truncate text-base-200 text-neutral-foreground-3"> 126 + {account.email} 127 + </span> 128 + </div> 129 + <div class="flex shrink-0 gap-2 text-base-200 text-neutral-foreground-3"> 130 + {account.deactivated_at && ( 131 + <span class="rounded-md bg-neutral-background-3 px-1.5 py-0.5 text-neutral-foreground-2"> 132 + deactivated 133 + </span> 134 + )} 135 + {account.takedown_ref && ( 136 + <span class="rounded-md bg-status-danger-background-1 px-1.5 py-0.5 text-status-danger-foreground-1"> 137 + taken down 138 + </span> 139 + )} 140 + </div> 141 + </li> 142 + ))} 143 + </ul> 144 + )} 145 + </div> 146 + 147 + {nextCursor && ( 148 + <div class="flex justify-end"> 149 + <Button href={buildHref(nextCursor)} variant="outlined"> 150 + Next page 151 + </Button> 152 + </div> 153 + )} 154 + </div> 155 + </AdminLayout>, 156 + ); 157 + }, 158 + 159 + create() { 160 + const ctx = getAppContext(); 161 + const domains = ctx.config.identity.serviceHandleDomains; 162 + const domainOptions = domains.map((d) => ({ value: d, label: d })); 163 + 164 + const { fields } = createAccountForm; 165 + 166 + return render( 167 + <AdminLayout> 168 + <title>New account - Danaus admin</title> 169 + 170 + <div class="flex flex-col gap-4"> 171 + <h3 class="text-base-400 font-medium">New account</h3> 172 + 173 + <form {...createAccountForm} class="flex max-w-96 flex-col gap-6"> 174 + <Field label="Handle" required validationMessageText={fields.handle.issues()?.[0]!.message}> 175 + <div class="flex gap-2"> 176 + <Input {...fields.handle.as('text')} placeholder="alice" required class="grow" /> 177 + 178 + <Select {...fields.domain.as('select')} options={domainOptions} /> 179 + </div> 180 + </Field> 181 + 182 + <Field label="Email" required validationMessageText={fields.email.issues()?.[0]!.message}> 183 + <Input {...fields.email.as('email')} placeholder="alice@example.com" required /> 184 + </Field> 185 + 186 + <Field 187 + label="Password" 188 + required 189 + validationMessageText={fields.password.issues()?.[0]!.message} 190 + > 191 + <Input {...fields.password.as('password')} required /> 192 + </Field> 193 + 194 + <div class="flex gap-3 pt-2"> 195 + <Button type="submit" variant="primary"> 196 + Create account 197 + </Button> 198 + <Button href={routes.admin.accounts.index.href()} variant="outlined"> 199 + Cancel 200 + </Button> 201 + </div> 202 + </form> 203 + </div> 204 + </AdminLayout>, 205 + ); 206 + }, 207 + }, 208 + }, 209 + } satisfies Controller<typeof routes.admin>;
+10
packages/danaus/src/web/controllers/home.tsx
··· 1 + import type { BuildAction } from '@oomfware/fetch-router'; 2 + 3 + import type { routes } from '../routes'; 4 + 5 + export default { 6 + middleware: [], 7 + action() { 8 + return new Response('This is an AT Protocol personal data server.'); 9 + }, 10 + } satisfies BuildAction<'ANY', typeof routes.home>;
+51
packages/danaus/src/web/controllers/login.tsx
··· 1 + import type { BuildAction } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render } from '@oomfware/jsx'; 4 + 5 + import { signInForm } from '../account/forms.ts'; 6 + import { BaseLayout } from '../layouts/base.tsx'; 7 + import Button from '../primitives/button.tsx'; 8 + import Field from '../primitives/field.tsx'; 9 + import Input from '../primitives/input.tsx'; 10 + import type { routes } from '../routes.ts'; 11 + 12 + export default { 13 + middleware: [forms({ signInForm })], 14 + action() { 15 + const { fields } = signInForm; 16 + 17 + return render( 18 + <BaseLayout> 19 + <title>sign in - danaus</title> 20 + 21 + <div class="flex flex-1 items-center justify-center p-4"> 22 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 23 + <form {...signInForm} class="flex flex-col gap-6"> 24 + <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 25 + 26 + <Field 27 + label="Handle or email" 28 + required 29 + validationMessageText={fields.identifier.issues()?.[0]!.message} 30 + > 31 + <Input {...fields.identifier.as('text')} placeholder="alice.bsky.social" required autofocus /> 32 + </Field> 33 + 34 + <Field 35 + label="Password" 36 + required 37 + validationMessageText={fields._password.issues()?.[0]!.message} 38 + > 39 + <Input {...fields._password.as('password')} required /> 40 + </Field> 41 + 42 + <Button type="submit" variant="primary"> 43 + Sign in 44 + </Button> 45 + </form> 46 + </div> 47 + </div> 48 + </BaseLayout>, 49 + ); 50 + }, 51 + } satisfies BuildAction<'ANY', typeof routes.home>;
+36
packages/danaus/src/web/controllers/oauth.tsx
··· 1 + import type { Controller } from '@oomfware/fetch-router'; 2 + import { render } from '@oomfware/jsx'; 3 + 4 + import { BaseLayout } from '../layouts/base.tsx'; 5 + import { requireSession } from '../middlewares/session.ts'; 6 + import Button from '../primitives/button.tsx'; 7 + import { routes } from '../routes.ts'; 8 + 9 + export default { 10 + authorize: { 11 + middleware: [requireSession()], 12 + action() { 13 + return render( 14 + <BaseLayout> 15 + <title>authorize - danaus</title> 16 + 17 + <div class="flex flex-1 items-center justify-center p-4"> 18 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 19 + <div class="flex flex-col gap-4"> 20 + <h1 class="text-base-500 font-semibold">authorize application</h1> 21 + 22 + <p class="text-base-300 text-neutral-foreground-3"> 23 + OAuth authorization is not yet implemented. 24 + </p> 25 + 26 + <Button href={routes.account.overview.href()} variant="outlined"> 27 + Back to account 28 + </Button> 29 + </div> 30 + </div> 31 + </div> 32 + </BaseLayout>, 33 + ); 34 + }, 35 + }, 36 + } satisfies Controller<typeof routes.oauth>;
-592
packages/danaus/src/web/forms/index.ts
··· 1 - import { AsyncLocalStorage } from 'node:async_hooks'; 2 - 3 - import type { StandardSchemaV1 } from '@standard-schema/spec'; 4 - import type { Context, MiddlewareHandler, Next } from 'hono'; 5 - import { HTTPException } from 'hono/http-exception'; 6 - import type { ContentfulStatusCode } from 'hono/utils/http-status'; 7 - 8 - // #region types 9 - export interface FormIssue { 10 - path: (string | number)[]; 11 - message: string; 12 - } 13 - 14 - interface FormState { 15 - input: Record<string, unknown>; 16 - /** flattened issues map keyed by path string, '$' contains all issues */ 17 - issues: Record<string, FormIssue[]>; 18 - result?: unknown; 19 - } 20 - 21 - interface FormConfig { 22 - id: string; 23 - action: string; 24 - } 25 - 26 - interface FormStore { 27 - definitions: WeakMap<FormDefinition<any, any>, FormConfig>; 28 - state: Map<string, FormState>; 29 - } 30 - 31 - interface FormHandlerStore { 32 - context: Context; 33 - } 34 - 35 - export interface FormDefinition<TInput, TOutput> { 36 - /** the form action URL */ 37 - readonly action: string; 38 - /** the form method */ 39 - readonly method: 'post'; 40 - /** proxy for accessing field values and issues */ 41 - readonly fields: FieldsProxy<TInput>; 42 - /** the result of the form handler, if successful */ 43 - readonly result: TOutput | undefined; 44 - /** internal metadata */ 45 - readonly __: { 46 - schema: StandardSchemaV1<TInput>; 47 - handler: (data: TInput, issue: IssueBuilder<TInput>) => Promise<TOutput>; 48 - }; 49 - } 50 - 51 - export type IssueBuilder<T> = { 52 - [K in keyof T]: T[K] extends Record<string, unknown> 53 - ? IssueBuilder<T[K]> & ((message: string) => FormIssue) 54 - : (message: string) => FormIssue; 55 - } & ((message: string) => FormIssue); 56 - 57 - export type FieldsProxy<T> = { 58 - [K in keyof T]: T[K] extends Record<string, unknown> 59 - ? FieldsProxy<T[K]> & FieldAccessor<T[K]> 60 - : FieldAccessor<T[K]>; 61 - } & FieldAccessor<T>; 62 - 63 - export type FormFieldValue = string | string[] | number | boolean | File | File[]; 64 - 65 - export type FormFieldType<T> = { 66 - [K in keyof InputTypeMap]: T extends InputTypeMap[K] ? K : never; 67 - }[keyof InputTypeMap]; 68 - 69 - export interface FieldAccessor<Value> { 70 - /** returns the current input value for this field */ 71 - value(): Value; 72 - /** returns validation issues for this exact field path */ 73 - issues(): { path: (string | number)[]; message: string }[]; 74 - /** returns validation issues for this field and all nested fields */ 75 - allIssues(): { path: (string | number)[]; message: string }[]; 76 - /** returns props for an input element */ 77 - as<T extends FormFieldType<Value>>(...args: AsArgs<T, Value>): Record<string, unknown>; 78 - } 79 - 80 - type InputTypeMap = { 81 - text: string; 82 - email: string; 83 - password: string; 84 - url: string; 85 - tel: string; 86 - search: string; 87 - number: number; 88 - range: number; 89 - date: string; 90 - 'datetime-local': string; 91 - time: string; 92 - month: string; 93 - week: string; 94 - color: string; 95 - checkbox: boolean | string[]; 96 - radio: string; 97 - file: File; 98 - hidden: string; 99 - submit: string; 100 - button: string; 101 - reset: string; 102 - image: string; 103 - select: string; 104 - 'select multiple': string[]; 105 - 'file multiple': File[]; 106 - }; 107 - 108 - type InputType = keyof InputTypeMap; 109 - 110 - type AsArgs<Type extends InputType, Value> = Type extends 'checkbox' 111 - ? Value extends string[] 112 - ? [type: Type, value: Value[number] | (string & {})] 113 - : [type: Type] 114 - : Type extends 'radio' | 'submit' | 'hidden' 115 - ? [type: Type, value: Value | (string & {})] 116 - : [type: Type]; 117 - // #endregion 118 - 119 - // #region async local storage 120 - const formStore = new AsyncLocalStorage<FormStore>(); 121 - const formHandlerStore = new AsyncLocalStorage<FormHandlerStore>(); 122 - 123 - const getFormConfig = (form: FormDefinition<any, any>): FormConfig | undefined => { 124 - return formStore.getStore()?.definitions.get(form); 125 - }; 126 - 127 - const getFormState = (id: string): FormState | undefined => { 128 - return formStore.getStore()?.state.get(id); 129 - }; 130 - 131 - /** 132 - * returns the current request context from within a form handler 133 - * @returns the hono context object 134 - * @throws if called outside of a form handler context 135 - */ 136 - export const getRequestContext = (): Context => { 137 - const store = formHandlerStore.getStore(); 138 - if (!store) { 139 - throw new Error('getRequestContext called outside of form handler'); 140 - } 141 - 142 - return store.context; 143 - }; 144 - // #endregion 145 - 146 - // #region form factory 147 - /** 148 - * creates a form definition with schema validation and handler 149 - * @param schema standard schema for input validation 150 - * @param handler async function to process validated form data 151 - * @returns form definition object 152 - */ 153 - export const form = <TInput extends Record<string, unknown>, TOutput>( 154 - schema: StandardSchemaV1<TInput>, 155 - handler: (data: TInput, issue: IssueBuilder<TInput>) => Promise<TOutput>, 156 - ): FormDefinition<TInput, TOutput> => { 157 - const definition = {} as FormDefinition<TInput, TOutput>; 158 - 159 - const getConfig = () => { 160 - const config = getFormConfig(definition); 161 - if (!config) { 162 - throw new Error('Form accessed outside of registered context'); 163 - } 164 - return config; 165 - }; 166 - 167 - // enumerable - included in spread 168 - Object.defineProperties(definition, { 169 - action: { 170 - enumerable: true, 171 - get: () => getConfig().action, 172 - }, 173 - method: { 174 - enumerable: true, 175 - value: 'post', 176 - }, 177 - }); 178 - 179 - // non-enumerable - excluded from spread 180 - Object.defineProperties(definition, { 181 - fields: { 182 - enumerable: false, 183 - get: () => { 184 - const state = getFormState(getConfig().id); 185 - return createFieldsProxy<TInput>( 186 - () => state?.input ?? {}, 187 - () => state?.issues ?? {}, 188 - ); 189 - }, 190 - }, 191 - result: { 192 - enumerable: false, 193 - get: () => { 194 - const config = getFormConfig(definition); 195 - if (!config) { 196 - return undefined; 197 - } 198 - return getFormState(config.id)?.result as TOutput | undefined; 199 - }, 200 - }, 201 - __: { 202 - enumerable: false, 203 - value: { schema, handler }, 204 - }, 205 - }); 206 - 207 - return definition; 208 - }; 209 - // #endregion 210 - 211 - // #region middleware 212 - const EMPTY_STATE = new Map<string, FormState>(); 213 - 214 - /** 215 - * registers form handlers as middleware 216 - * @param forms object mapping form IDs to form definitions 217 - * @returns hono middleware handler 218 - */ 219 - export const registerForms = (forms: Record<string, FormDefinition<any, any>>): MiddlewareHandler => { 220 - const definitions = new WeakMap<FormDefinition<any, any>, FormConfig>(); 221 - for (const [id, form] of Object.entries(forms)) { 222 - definitions.set(form, { id, action: `?__action=${id}` }); 223 - } 224 - 225 - return async (c: Context, next: Next) => { 226 - let state: Map<string, FormState> | undefined; 227 - 228 - jmp: { 229 - if (c.req.method !== 'POST') { 230 - break jmp; 231 - } 232 - 233 - const actionId = c.req.query('__action'); 234 - if (actionId === undefined) { 235 - break jmp; 236 - } 237 - 238 - const form = forms[actionId]; 239 - if (form === undefined) { 240 - break jmp; 241 - } 242 - 243 - const fetchSite = c.req.header('sec-fetch-site'); 244 - if (fetchSite !== 'same-origin') { 245 - throw new HTTPException(403, { message: 'cross-origin form submission rejected' }); 246 - } 247 - 248 - const formData = await c.req.formData(); 249 - const input = convertFormData(formData); 250 - 251 - // validate with schema 252 - const validated = await form.__.schema['~standard'].validate(input); 253 - 254 - state ??= new Map(); 255 - 256 - if (validated.issues) { 257 - state.set(actionId, { 258 - input, 259 - issues: flattenIssues(normalizeIssues(validated.issues)), 260 - }); 261 - 262 - break jmp; 263 - } 264 - 265 - const issueBuilder = createIssueBuilder<any>(); 266 - 267 - try { 268 - const result = await formHandlerStore.run({ context: c }, async () => { 269 - return await form.__.handler(validated.value, issueBuilder); 270 - }); 271 - 272 - state.set(actionId, { input: {}, issues: {}, result }); 273 - } catch (err) { 274 - if (err instanceof ValidationError) { 275 - state.set(actionId, { input, issues: flattenIssues(err.issues) }); 276 - } else { 277 - throw err; 278 - } 279 - } 280 - } 281 - 282 - await formStore.run({ definitions: definitions, state: state ?? EMPTY_STATE }, next); 283 - }; 284 - }; 285 - // #endregion 286 - 287 - // #region validation error 288 - /** 289 - * error thrown to indicate form validation failure 290 - */ 291 - export class ValidationError extends Error { 292 - readonly issues: FormIssue[]; 293 - 294 - constructor(issues: FormIssue[]) { 295 - super('Validation failed'); 296 - this.name = 'ValidationError'; 297 - this.issues = issues; 298 - } 299 - } 300 - 301 - /** 302 - * throws a validation error with the given issues 303 - * @param issues one or more form issues 304 - */ 305 - export const invalid: { 306 - (...issues: (FormIssue | string)[]): never; 307 - } = (...issues: (FormIssue | string)[]): never => { 308 - throw new ValidationError( 309 - issues.map((issue) => (typeof issue === 'string' ? { path: [], message: issue } : issue)), 310 - ); 311 - }; 312 - // #endregion 313 - 314 - // #region redirect 315 - type RedirectStatus = 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308; 316 - 317 - /** 318 - * throws an HTTP redirect exception 319 - * @param status redirect status code 320 - * @param location target URL 321 - */ 322 - export const redirect: { 323 - (status: RedirectStatus, location: string): never; 324 - } = (status: RedirectStatus, location: string): never => { 325 - throw new HTTPException(status as ContentfulStatusCode, { 326 - res: new Response(null, { status, headers: { location } }), 327 - }); 328 - }; 329 - // #endregion 330 - 331 - // #region issue builder 332 - const createIssueBuilder = <T>(): IssueBuilder<T> => { 333 - const createProxy = (path: (string | number)[]): any => { 334 - const issueFunc = (message: string): FormIssue => ({ path, message }); 335 - 336 - return new Proxy(issueFunc, { 337 - get(_, prop) { 338 - if (typeof prop === 'symbol') { 339 - return undefined; 340 - } 341 - 342 - const key = /^\d+$/.test(prop) ? parseInt(prop, 10) : prop; 343 - return createProxy([...path, key]); 344 - }, 345 - }); 346 - }; 347 - 348 - return createProxy([]); 349 - }; 350 - // #endregion 351 - 352 - // #region fields proxy 353 - const createFieldsProxy = <T>( 354 - getInput: () => Record<string, unknown>, 355 - getIssues: () => Record<string, FormIssue[]>, 356 - path: (string | number)[] = [], 357 - ): FieldsProxy<T> => { 358 - const getValue = (): unknown => { 359 - let current: unknown = getInput(); 360 - for (const key of path) { 361 - if (current == null || typeof current !== 'object') { 362 - return undefined; 363 - } 364 - current = (current as Record<string | number, unknown>)[key]; 365 - } 366 - return current; 367 - }; 368 - 369 - const buildName = (): string => { 370 - let name = ''; 371 - for (const segment of path) { 372 - if (typeof segment === 'number') { 373 - name += `[${segment}]`; 374 - } else { 375 - name += name === '' ? segment : `.${segment}`; 376 - } 377 - } 378 - return name; 379 - }; 380 - 381 - const pathKey = buildName() || '$'; 382 - 383 - const accessor = { 384 - value: getValue, 385 - issues: () => { 386 - const issues = getIssues()[pathKey] ?? []; 387 - const pathStr = path.join('.'); 388 - return issues 389 - .filter((issue) => issue.path.join('.') === pathStr) 390 - .map((issue) => ({ path: issue.path, message: issue.message })); 391 - }, 392 - allIssues: () => { 393 - const issues = getIssues()[pathKey] ?? []; 394 - return issues.map((issue) => ({ path: issue.path, message: issue.message })); 395 - }, 396 - as: (type: InputType, inputValue?: string) => { 397 - const baseName = buildName(); 398 - const issues = getIssues()[pathKey] ?? []; 399 - const pathStr = path.join('.'); 400 - const hasError = issues.some((i) => i.path.join('.') === pathStr); 401 - 402 - const isArray = 403 - type === 'file multiple' || 404 - type === 'select multiple' || 405 - (type === 'checkbox' && typeof inputValue === 'string'); 406 - 407 - const prefix = 408 - type === 'number' || type === 'range' ? 'n:' : type === 'checkbox' && !isArray ? 'b:' : ''; 409 - 410 - const props: Record<string, unknown> = { 411 - name: prefix + baseName + (isArray ? '[]' : ''), 412 - 'aria-invalid': hasError ? 'true' : undefined, 413 - }; 414 - 415 - // add type attribute for non-text, non-select elements 416 - if (type !== 'text' && type !== 'select' && type !== 'select multiple') { 417 - props.type = type === 'file multiple' ? 'file' : type; 418 - } 419 - 420 - // submit and hidden require inputValue 421 - if (type === 'submit' || type === 'hidden') { 422 - props.value = inputValue; 423 - return props; 424 - } 425 - 426 - // select inputs 427 - if (type === 'select' || type === 'select multiple') { 428 - props.multiple = isArray; 429 - props.value = getValue(); 430 - return props; 431 - } 432 - 433 - // checkbox and radio inputs 434 - if (type === 'checkbox' || type === 'radio') { 435 - props.value = inputValue ?? 'on'; 436 - const value = getValue(); 437 - 438 - if (type === 'radio') { 439 - props.checked = value === inputValue; 440 - } else if (isArray) { 441 - props.checked = Array.isArray(value) && value.includes(inputValue); 442 - } else { 443 - props.checked = !!value; 444 - } 445 - 446 - return props; 447 - } 448 - 449 - // file inputs 450 - if (type === 'file' || type === 'file multiple') { 451 - props.multiple = isArray; 452 - return props; 453 - } 454 - 455 - // all other text-like inputs 456 - const value = getValue(); 457 - props.value = value != null ? String(value) : ''; 458 - return props; 459 - }, 460 - }; 461 - 462 - return new Proxy(accessor as FieldsProxy<T>, { 463 - get(_, prop) { 464 - if (typeof prop === 'symbol') { 465 - return undefined; 466 - } 467 - 468 - // return accessor methods 469 - if (prop === 'value' || prop === 'issues' || prop === 'allIssues' || prop === 'as') { 470 - return accessor[prop]; 471 - } 472 - 473 - // nested field access 474 - const key = /^\d+$/.test(prop) ? parseInt(prop, 10) : prop; 475 - return createFieldsProxy(getInput, getIssues, [...path, key]); 476 - }, 477 - }); 478 - }; 479 - // #endregion 480 - 481 - // #region form data conversion 482 - const convertFormData = (data: FormData): Record<string, unknown> => { 483 - const result: Record<string, unknown> = {}; 484 - 485 - for (let key of data.keys()) { 486 - const isArray = key.endsWith('[]'); 487 - let values: unknown[] = data.getAll(key); 488 - 489 - if (isArray) { 490 - key = key.slice(0, -2); 491 - } 492 - 493 - // reject duplicate non-array keys 494 - if (values.length > 1 && !isArray) { 495 - throw new Error(`Form cannot contain duplicated keys โ€” "${key}" has ${values.length} values`); 496 - } 497 - 498 - // filter empty file inputs (browsers submit a File with empty name for empty file inputs) 499 - values = values.filter( 500 - (entry) => 501 - typeof entry === 'string' || (entry instanceof File && (entry.name !== '' || entry.size > 0)), 502 - ); 503 - 504 - // handle type coercion prefixes 505 - if (key.startsWith('n:')) { 506 - key = key.slice(2); 507 - values = values.map((v) => (v === '' ? undefined : parseFloat(v as string))); 508 - } else if (key.startsWith('b:')) { 509 - key = key.slice(2); 510 - values = values.map((v) => v === 'on'); 511 - } 512 - 513 - setNestedValue(result, key, isArray ? values : values[0]); 514 - } 515 - 516 - return result; 517 - }; 518 - 519 - const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); 520 - 521 - const setNestedValue = (obj: Record<string, unknown>, path: string, value: unknown): void => { 522 - const keys = path.split(/\.|\[|\]/).filter((k): k is string => k !== ''); 523 - 524 - if (keys.length === 0) { 525 - return; 526 - } 527 - 528 - let current = obj; 529 - 530 - for (let i = 0; i < keys.length - 1; i++) { 531 - const key = keys[i]!; 532 - 533 - if (DANGEROUS_KEYS.has(key)) { 534 - throw new Error(`Invalid key "${key}"`); 535 - } 536 - 537 - const nextKey = keys[i + 1]!; 538 - const isNextArray = /^\d+$/.test(nextKey); 539 - const exists = key in current; 540 - const inner = current[key]; 541 - 542 - if (exists && isNextArray !== Array.isArray(inner)) { 543 - throw new Error(`Invalid array key "${nextKey}"`); 544 - } 545 - 546 - if (!exists) { 547 - current[key] = isNextArray ? [] : {}; 548 - } 549 - 550 - current = current[key] as Record<string, unknown>; 551 - } 552 - 553 - const finalKey = keys[keys.length - 1]!; 554 - 555 - if (DANGEROUS_KEYS.has(finalKey)) { 556 - throw new Error(`Invalid key "${finalKey}"`); 557 - } 558 - 559 - current[finalKey] = value; 560 - }; 561 - 562 - const normalizeIssues = (issues: readonly StandardSchemaV1.Issue[]): FormIssue[] => { 563 - return issues.map((issue) => ({ 564 - path: (issue.path ?? []).map((segment) => (typeof segment === 'object' ? segment.key : segment)) as ( 565 - | string 566 - | number 567 - )[], 568 - message: issue.message, 569 - })); 570 - }; 571 - 572 - /** flattens issues into a map keyed by path prefix for O(1) lookups */ 573 - const flattenIssues = (issues: FormIssue[]): Record<string, FormIssue[]> => { 574 - const result: Record<string, FormIssue[]> = {}; 575 - 576 - for (const issue of issues) { 577 - (result.$ ??= []).push(issue); 578 - 579 - let name = ''; 580 - for (const key of issue.path) { 581 - if (typeof key === 'number') { 582 - name += `[${key}]`; 583 - } else { 584 - name += name === '' ? key : `.${key}`; 585 - } 586 - (result[name] ??= []).push(issue); 587 - } 588 - } 589 - 590 - return result; 591 - }; 592 - // #endregion
+18
packages/danaus/src/web/icons/central/at-outlined.tsx
··· 1 + import type { IconProps } from './_types.ts'; 2 + 3 + const ArrowInboxOutlined = (props: IconProps) => { 4 + const { size = 24, class: className } = props; 5 + 6 + return ( 7 + <svg viewBox="0 0 24 24" width={size} height={size} fill="none" class={className}> 8 + <path 9 + d="M16.7368 19.6541C15.361 20.5073 13.738 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 13.9262 20.0428 15.9154 17.8101 15.7125C15.9733 15.5455 14.6512 13.8737 14.9121 12.0479L15.4274 8.5M14.8581 12.4675C14.559 14.596 12.8066 16.1093 10.9442 15.8476C9.08175 15.5858 7.81444 13.6481 8.11358 11.5196C8.41272 9.39109 10.165 7.87778 12.0275 8.13953C13.8899 8.40128 15.1573 10.339 14.8581 12.4675Z" 10 + stroke="currentColor" 11 + stroke-width="2" 12 + stroke-linecap="round" 13 + /> 14 + </svg> 15 + ); 16 + }; 17 + 18 + export default ArrowInboxOutlined;
+48
packages/danaus/src/web/layouts/account.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 3 + import AsideItem from '../components/aside-item.tsx'; 4 + import Key2Outlined from '../icons/central/key-2-outlined.tsx'; 5 + import PersonOutlined from '../icons/central/person-outlined.tsx'; 6 + import ShieldOutlined from '../icons/central/shield-outlined.tsx'; 7 + import { routes } from '../routes.ts'; 8 + 9 + import { BaseLayout } from './base.tsx'; 10 + 11 + export interface AccountLayoutProps { 12 + children?: JSXNode; 13 + } 14 + 15 + /** 16 + * account management layout with sidebar navigation. 17 + */ 18 + export const AccountLayout = (props: AccountLayoutProps) => { 19 + return ( 20 + <BaseLayout> 21 + <div class="flex flex-col gap-4 p-4 sm:p-16 sm:pt-24 lg:grid lg:grid-cols-[280px_minmax(0,640px)] lg:justify-center"> 22 + <aside class="-ml-2 flex flex-col gap-4 sm:ml-0"> 23 + <div class="flex h-8 shrink-0 items-center pl-4"> 24 + <h2 class="text-base-400 font-medium">Account</h2> 25 + </div> 26 + 27 + <div class="flex flex-col gap-px"> 28 + <AsideItem href={routes.account.overview.href()} exact icon={<PersonOutlined size={20} />}> 29 + Overview 30 + </AsideItem> 31 + 32 + <AsideItem href={routes.account.appPasswords.href()} icon={<Key2Outlined size={20} />}> 33 + App passwords 34 + </AsideItem> 35 + 36 + <AsideItem href={routes.account.security.href()} icon={<ShieldOutlined size={20} />}> 37 + Security 38 + </AsideItem> 39 + </div> 40 + </aside> 41 + 42 + <hr class="border-neutral-stroke-1 sm:hidden" /> 43 + 44 + <main>{props.children}</main> 45 + </div> 46 + </BaseLayout> 47 + ); 48 + };
+41
packages/danaus/src/web/layouts/admin.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 3 + import AsideItem from '../components/aside-item.tsx'; 4 + import Group1Outlined from '../icons/central/group-1-outlined.tsx'; 5 + import HomeOpenOutlined from '../icons/central/home-open-outlined.tsx'; 6 + import { routes } from '../routes.ts'; 7 + 8 + import { BaseLayout } from './base.tsx'; 9 + 10 + export interface AdminLayoutProps { 11 + children?: JSXNode; 12 + } 13 + 14 + /** 15 + * admin layout with sidebar navigation. 16 + */ 17 + export const AdminLayout = (props: AdminLayoutProps) => { 18 + return ( 19 + <BaseLayout> 20 + <div class="flex flex-col gap-4 p-4 sm:p-16 sm:pt-24 lg:grid lg:grid-cols-[280px_minmax(0,640px)] lg:justify-center"> 21 + <aside class="-ml-2 flex flex-col gap-2 sm:ml-0"> 22 + <h2 class="pb-2 pl-4 text-base-400 font-medium">PDS administration</h2> 23 + 24 + <div class="flex flex-col gap-px"> 25 + <AsideItem href={routes.admin.dashboard.href()} exact icon={<HomeOpenOutlined size={20} />}> 26 + Home 27 + </AsideItem> 28 + 29 + <AsideItem href={routes.admin.accounts.index.href()} icon={<Group1Outlined size={20} />}> 30 + Accounts 31 + </AsideItem> 32 + </div> 33 + </aside> 34 + 35 + <hr class="border-neutral-stroke-1 sm:hidden" /> 36 + 37 + <main>{props.children}</main> 38 + </div> 39 + </BaseLayout> 40 + ); 41 + };
+29
packages/danaus/src/web/layouts/base.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 3 + import { IdProvider } from '../components/id.tsx'; 4 + 5 + export interface BaseLayoutProps { 6 + children?: JSXNode; 7 + } 8 + 9 + /** 10 + * base HTML layout wrapper for all pages. 11 + * includes the document structure, meta tags, and stylesheet. 12 + */ 13 + export const BaseLayout = (props: BaseLayoutProps) => { 14 + return ( 15 + <IdProvider> 16 + <html lang="en"> 17 + <head> 18 + <meta charset="utf-8" /> 19 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 20 + <link rel="stylesheet" href="/assets/style.css" /> 21 + </head> 22 + 23 + <body> 24 + <div class="flex min-h-dvh flex-col">{props.children}</div> 25 + </body> 26 + </html> 27 + </IdProvider> 28 + ); 29 + };
+33
packages/danaus/src/web/middlewares/app-context.ts
··· 1 + import { createInjectionKey, type Middleware } from '@oomfware/fetch-router'; 2 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 3 + 4 + import type { AppContext } from '#app/context.ts'; 5 + 6 + const appContextKey = createInjectionKey<AppContext>(); 7 + 8 + /** 9 + * middleware that provides the AppContext to the request context store. 10 + * @param ctx the application context to provide 11 + */ 12 + export const provideAppContext = (ctx: AppContext): Middleware => { 13 + return async ({ store }, next) => { 14 + store.provide(appContextKey, ctx); 15 + return next(); 16 + }; 17 + }; 18 + 19 + /** 20 + * retrieves the AppContext from the current request context. 21 + * must be called within a request handler after the provideAppContext middleware. 22 + * @returns the application context 23 + */ 24 + export const getAppContext = (): AppContext => { 25 + const { store } = getContext(); 26 + const ctx = store.inject(appContextKey); 27 + 28 + if (ctx === undefined) { 29 + throw new Error('AppContext not found in request context'); 30 + } 31 + 32 + return ctx; 33 + };
+32
packages/danaus/src/web/middlewares/basic-auth.ts
··· 1 + import type { Middleware } from '@oomfware/fetch-router'; 2 + 3 + import { parseBasicAuth } from '#app/auth/verifier.ts'; 4 + 5 + import { getAppContext } from './app-context.ts'; 6 + 7 + const REALM = 'admin'; 8 + 9 + /** 10 + * middleware that requires HTTP Basic Authentication for admin access. 11 + * uses the admin password from app config via async context. 12 + */ 13 + export const requireAdmin = (): Middleware => { 14 + return async ({ request }, next) => { 15 + const ctx = getAppContext(); 16 + const adminPassword = ctx.config.secrets.adminPassword; 17 + 18 + if (adminPassword === null) { 19 + return new Response('Administration UI is disabled', { status: 403 }); 20 + } 21 + 22 + const auth = parseBasicAuth(request); 23 + if (auth === null || auth.password !== adminPassword) { 24 + return new Response('Unauthorized', { 25 + status: 401, 26 + headers: { 'www-authenticate': `Basic realm="${REALM}", charset="UTF-8"` }, 27 + }); 28 + } 29 + 30 + return next(); 31 + }; 32 + };
+51
packages/danaus/src/web/middlewares/session.ts
··· 1 + import { createInjectionKey, redirect, type Middleware } from '@oomfware/fetch-router'; 2 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 3 + 4 + import type { WebSession } from '#app/accounts/manager.ts'; 5 + import { readWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts'; 6 + 7 + import { getAppContext } from './app-context.ts'; 8 + 9 + const sessionKey = createInjectionKey<WebSession>(); 10 + 11 + /** 12 + * middleware that requires a valid web session. 13 + * redirects to login page if no session is found. 14 + */ 15 + export const requireSession = (): Middleware => { 16 + return async ({ request, url, store }, next) => { 17 + const ctx = getAppContext(); 18 + const path = url.pathname; 19 + 20 + const token = readWebSessionToken(request); 21 + if (!token) { 22 + redirect(`/account/login?redirect=${encodeURIComponent(path)}`); 23 + } 24 + 25 + const sessionId = verifyWebSessionToken(ctx.config.secrets.jwtKey, token); 26 + if (!sessionId) { 27 + redirect(`/account/login?redirect=${encodeURIComponent(path)}`); 28 + } 29 + 30 + const session = ctx.accountManager.getWebSession(sessionId); 31 + if (!session) { 32 + redirect(`/account/login?redirect=${encodeURIComponent(path)}`); 33 + } 34 + 35 + store.provide(sessionKey, session); 36 + return next(); 37 + }; 38 + }; 39 + 40 + /** 41 + * retrieves the current web session from the request context. 42 + * must be called within a request handler after the requireSession middleware. 43 + * @returns the web session 44 + */ 45 + export const getSession = (): WebSession => { 46 + const session = getContext().store.inject(sessionKey); 47 + if (!session) { 48 + throw new Error('Session not found in request context'); 49 + } 50 + return session; 51 + };
-61
packages/danaus/src/web/oauth/index.tsx
··· 1 - import { Hono } from 'hono'; 2 - import { jsxRenderer } from 'hono/jsx-renderer'; 3 - 4 - import type { AppContext } from '#app/context.ts'; 5 - 6 - import { IdProvider } from '../components/id.tsx'; 7 - import Button from '../primitives/button.tsx'; 8 - 9 - export const createOAuthApp = (_ctx: AppContext) => { 10 - const app = new Hono(); 11 - 12 - // #region base HTML renderer 13 - app.use( 14 - jsxRenderer(({ children }) => { 15 - return ( 16 - <IdProvider> 17 - <html lang="en"> 18 - <head> 19 - <meta charset="utf-8" /> 20 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 21 - <link rel="stylesheet" href="/assets/style.css" /> 22 - </head> 23 - 24 - <body> 25 - <div class="flex min-h-dvh flex-col">{children}</div> 26 - </body> 27 - </html> 28 - </IdProvider> 29 - ); 30 - }), 31 - ); 32 - // #endregion 33 - 34 - // #region authorize route 35 - app.on(['GET', 'POST'], '/authorize', (c) => { 36 - return c.render( 37 - <> 38 - <title>authorize - danaus</title> 39 - 40 - <div class="flex flex-1 items-center justify-center p-4"> 41 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 42 - <div class="flex flex-col gap-4"> 43 - <h1 class="text-base-500 font-semibold">authorize application</h1> 44 - 45 - <p class="text-base-300 text-neutral-foreground-3"> 46 - OAuth authorization is not yet implemented. 47 - </p> 48 - 49 - <Button href="/account" variant="outlined"> 50 - Back to account 51 - </Button> 52 - </div> 53 - </div> 54 - </div> 55 - </>, 56 - ); 57 - }); 58 - // #endregion 59 - 60 - return app; 61 - };
+7 -6
packages/danaus/src/web/primitives/accordion-header.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import ChevronDownSmallOutlined from '../icons/central/chevron-down-small-outlined.tsx'; 5 6 ··· 34 35 }); 35 36 36 37 const expandIconStyle = cva({ 37 - base: ['flex shrink-0 items-center', 'text-base-500 leading-base-500'], 38 + base: ['flex shrink-0 items-center', 'leading-base-500 text-base-500'], 38 39 variants: { 39 40 position: { 40 41 start: 'pr-2', 41 - end: 'grow shrink basis-0 justify-end pl-2', 42 + end: 'shrink grow basis-0 justify-end pl-2', 42 43 }, 43 44 }, 44 45 }); 45 46 46 47 const iconStyle = cva({ 47 - base: 'flex shrink-0 items-center pr-2 text-base-500 leading-base-500', 48 + base: 'leading-base-500 flex shrink-0 items-center pr-2 text-base-500', 48 49 }); 49 50 50 51 export interface AccordionHeaderProps { 51 52 size?: 'small' | 'medium' | 'large' | 'extra-large'; 52 53 expandIconPosition?: 'start' | 'end'; 53 54 /** slot for custom icon before the text */ 54 - icon?: Child; 55 + icon?: JSXNode; 55 56 class?: string; 56 - children?: Child; 57 + children?: JSXNode; 57 58 } 58 59 59 60 /**
+2 -2
packages/danaus/src/web/primitives/accordion-item.tsx
··· 1 - import type { Child } from 'hono/jsx'; 1 + import type { JSXNode } from '@oomfware/jsx'; 2 2 3 3 export interface AccordionItemProps { 4 4 /** whether the accordion item is open by default */ ··· 6 6 /** group name for exclusive accordion behavior (only one open at a time) */ 7 7 name?: string; 8 8 class?: string; 9 - children?: Child; 9 + children?: JSXNode; 10 10 } 11 11 12 12 /**
+3 -2
packages/danaus/src/web/primitives/accordion-panel.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: 'px-3 pb-3', ··· 7 8 8 9 export interface AccordionPanelProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+2 -2
packages/danaus/src/web/primitives/accordion.tsx
··· 1 - import type { Child } from 'hono/jsx'; 1 + import type { JSXNode } from '@oomfware/jsx'; 2 2 3 3 export interface AccordionProps { 4 4 class?: string; 5 - children?: Child; 5 + children?: JSXNode; 6 6 } 7 7 8 8 /**
+3 -2
packages/danaus/src/web/primitives/button.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva, type VariantProps } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import type { InvokerCommand } from './utils/types.ts'; 5 6 ··· 57 58 /** invoker command action */ 58 59 command?: InvokerCommand; 59 60 class?: string; 60 - children?: Child; 61 + children?: JSXNode; 61 62 } 62 63 63 64 const Button = (props: ButtonProps) => {
+3 -2
packages/danaus/src/web/primitives/checkbox.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useId } from '../components/id.tsx'; 5 6 import CheckmarkIcon from '../icons/central/checkmark-1-solid.tsx'; ··· 75 76 disabled?: boolean; 76 77 labelPosition?: 'before' | 'after'; 77 78 class?: string; 78 - children?: Child; 79 + children?: JSXNode; 79 80 } 80 81 81 82 const Checkbox = (props: CheckboxProps) => {
+3 -2
packages/danaus/src/web/primitives/dialog-actions.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva, type VariantProps } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: [ ··· 18 19 19 20 export interface DialogActionsProps extends VariantProps<typeof root> { 20 21 class?: string; 21 - children?: Child; 22 + children?: JSXNode; 22 23 } 23 24 24 25 /**
+3 -2
packages/danaus/src/web/primitives/dialog-body.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['grid gap-2', '@container/dialog-body'], ··· 7 8 8 9 export interface DialogBodyProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+2 -3
packages/danaus/src/web/primitives/dialog-close.tsx
··· 1 - import { cloneElement } from 'hono/jsx'; 2 - import type { JSX } from 'hono/jsx/jsx-runtime'; 1 + import { cloneElement, type JSXElement } from '@oomfware/jsx'; 3 2 4 3 import { useDialogContext } from './utils/dialog-context.tsx'; 5 4 6 5 export interface DialogCloseProps { 7 - children: JSX.Element; 6 + children: JSXElement; 8 7 } 9 8 10 9 const DialogClose = (props: DialogCloseProps) => {
+3 -2
packages/danaus/src/web/primitives/dialog-content.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['min-h-8 overflow-y-auto', 'text-base-300'], ··· 7 8 8 9 export interface DialogContentProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+5 -2
packages/danaus/src/web/primitives/dialog-surface.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva, type VariantProps } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useDialogContext } from './utils/dialog-context'; 5 6 ··· 15 16 'sm:items-center', 16 17 // backdrop 17 18 'backdrop:bg-background-overlay', 19 + // entry/exit animations 20 + 'dialog-animate dialog-backdrop-animate', 18 21 ], 19 22 }); 20 23 ··· 43 46 }); 44 47 45 48 export interface DialogSurfaceProps extends VariantProps<typeof surface> { 46 - children?: Child; 49 + children?: JSXNode; 47 50 } 48 51 49 52 /**
+4 -3
packages/danaus/src/web/primitives/dialog-title.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useDialogContext } from './utils/dialog-context.tsx'; 5 6 ··· 13 14 14 15 export interface DialogTitleProps { 15 16 /** optional action element (e.g., close button) */ 16 - action?: Child; 17 + action?: JSXNode; 17 18 class?: string; 18 - children?: Child; 19 + children?: JSXNode; 19 20 } 20 21 21 22 /**
+2 -3
packages/danaus/src/web/primitives/dialog-trigger.tsx
··· 1 - import { cloneElement } from 'hono/jsx'; 2 - import type { JSX } from 'hono/jsx/jsx-runtime'; 1 + import { cloneElement, type JSXElement } from '@oomfware/jsx'; 3 2 4 3 import { useDialogContext } from './utils/dialog-context.tsx'; 5 4 6 5 export interface DialogTriggerProps { 7 - children: JSX.Element; 6 + children: JSXElement; 8 7 } 9 8 10 9 const DialogTrigger = (props: DialogTriggerProps) => {
+2 -2
packages/danaus/src/web/primitives/dialog.tsx
··· 1 - import type { Child } from 'hono/jsx'; 1 + import type { JSXNode } from '@oomfware/jsx'; 2 2 3 3 import { useId } from '../components/id.tsx'; 4 4 ··· 6 6 7 7 export interface DialogProps { 8 8 id?: string; 9 - children?: Child; 9 + children?: JSXNode; 10 10 } 11 11 12 12 /**
+8 -7
packages/danaus/src/web/primitives/field.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useId } from '../components/id.tsx'; 5 6 import CheckCircle2Solid from '../icons/central/check-circle-2-solid.tsx'; ··· 61 62 export interface FieldProps { 62 63 required?: boolean; 63 64 validationStatus?: ValidationStatus; 64 - label?: Child; 65 - description?: Child; 66 - hint?: Child; 67 - validationMessageText?: Child; 68 - validationMessageIcon?: Child; 65 + label?: JSXNode; 66 + description?: JSXNode; 67 + hint?: JSXNode; 68 + validationMessageText?: JSXNode; 69 + validationMessageIcon?: JSXNode; 69 70 class?: string; 70 - children?: Child; 71 + children?: JSXNode; 71 72 } 72 73 73 74 const Field = (props: FieldProps) => {
+4 -3
packages/danaus/src/web/primitives/input.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useFieldContext } from './utils/field-context.tsx'; 5 6 ··· 67 68 autofocus?: boolean; 68 69 autocomplete?: string; 69 70 required?: boolean; 70 - contentBefore?: Child; 71 - contentAfter?: Child; 71 + contentBefore?: JSXNode; 72 + contentAfter?: JSXNode; 72 73 class?: string; 73 74 } 74 75
+3 -2
packages/danaus/src/web/primitives/label.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useFieldContext } from './utils/field-context.tsx'; 5 6 ··· 15 16 for?: string; 16 17 required?: boolean; 17 18 class?: string; 18 - children?: Child; 19 + children?: JSXNode; 19 20 } 20 21 21 22 const Label = (props: LabelProps) => {
+3 -2
packages/danaus/src/web/primitives/menu-item.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import type { InvokerCommand } from './utils/types.ts'; 5 6 ··· 24 25 /** invoker command action */ 25 26 command?: InvokerCommand; 26 27 class?: string; 27 - children?: Child; 28 + children?: JSXNode; 28 29 } 29 30 30 31 /**
+3 -2
packages/danaus/src/web/primitives/menu-list.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['flex flex-col gap-0.5'], ··· 7 8 8 9 export interface MenuListProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+5 -2
packages/danaus/src/web/primitives/menu-popover.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useMenuContext } from './utils/menu-context.tsx'; 5 6 ··· 7 8 base: [ 8 9 'm-0 box-border w-max max-w-75 min-w-35 overflow-x-hidden rounded-md border border-transparent bg-neutral-background-1 p-1 text-neutral-foreground-1 shadow-16', 9 10 'anchored anchored-bottom-span-left try-flip-y', 11 + // entry/exit animations (slides down from anchor) 12 + 'popover-animate popover-slide-down', 10 13 ], 11 14 }); 12 15 13 16 export interface MenuPopoverProps { 14 17 class?: string; 15 - children?: Child; 18 + children?: JSXNode; 16 19 } 17 20 18 21 /**
+4 -4
packages/danaus/src/web/primitives/menu-trigger.tsx
··· 1 + import { cloneElement, type JSXElement } from '@oomfware/jsx'; 2 + 1 3 import { cx } from 'cva'; 2 - import { cloneElement } from 'hono/jsx'; 3 - import type { JSX } from 'hono/jsx/jsx-runtime'; 4 4 5 5 import { useMenuContext } from './utils/menu-context.tsx'; 6 6 7 7 export interface MenuTriggerProps { 8 - children: JSX.Element; 8 + children: JSXElement; 9 9 } 10 10 11 11 /** ··· 16 16 const { children } = props; 17 17 const { menuId } = useMenuContext(); 18 18 19 - const childProps = (children as any).props as Record<string, unknown> | undefined; 19 + const childProps = children.props as Record<string, unknown>; 20 20 21 21 return cloneElement(children, { 22 22 commandfor: menuId,
+4 -2
packages/danaus/src/web/primitives/menu.tsx
··· 1 - import { useId, type Child } from 'hono/jsx'; 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 3 + import { useId } from '../components/id.tsx'; 2 4 3 5 import { MenuContext, type MenuContextValue } from './utils/menu-context.tsx'; 4 6 5 7 export interface MenuProps { 6 8 id?: string; 7 - children?: Child; 9 + children?: JSXNode; 8 10 } 9 11 10 12 /**
+3 -2
packages/danaus/src/web/primitives/message-bar-actions.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useMessageBarContext } from './utils/message-bar-context.tsx'; 5 6 ··· 15 16 16 17 export interface MessageBarActionsProps { 17 18 class?: string; 18 - children?: Child; 19 + children?: JSXNode; 19 20 } 20 21 21 22 /**
+3 -2
packages/danaus/src/web/primitives/message-bar-body.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['pr-3', 'text-base-300'], ··· 7 8 8 9 export interface MessageBarBodyProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+3 -2
packages/danaus/src/web/primitives/message-bar-title.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['mr-1', 'text-base-300 font-semibold'], ··· 7 8 8 9 export interface MessageBarTitleProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+5 -4
packages/danaus/src/web/primitives/message-bar.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import CheckCircle2Solid from '../icons/central/check-circle-2-solid.tsx'; 5 6 import CircleInfoSolid from '../icons/central/circle-info-solid.tsx'; ··· 13 14 type MessageBarLayout, 14 15 } from './utils/message-bar-context.tsx'; 15 16 16 - const getIntentIcon = (intent: MessageBarIntent): Child => { 17 + const getIntentIcon = (intent: MessageBarIntent): JSXNode => { 17 18 switch (intent) { 18 19 case 'info': 19 20 return <CircleInfoSolid size={20} />; ··· 71 72 /** layout of the message bar */ 72 73 layout: MessageBarLayout; 73 74 /** optional icon to display */ 74 - icon?: Child; 75 + icon?: JSXNode; 75 76 class?: string; 76 - children?: Child; 77 + children?: JSXNode; 77 78 } 78 79 79 80 /**
+3 -3
packages/danaus/src/web/primitives/radio-group.tsx
··· 1 - import { createContext, useContext, type Child } from 'hono/jsx'; 1 + import { createContext, use, type JSXNode } from '@oomfware/jsx'; 2 2 3 3 import { useId } from '../components/id.tsx'; 4 4 ··· 13 13 export const RadioGroupContext = createContext<RadioGroupContextValue | null>(null); 14 14 15 15 export const useRadioGroupContext = () => { 16 - const context = useContext(RadioGroupContext); 16 + const context = use(RadioGroupContext); 17 17 if (context === null) { 18 18 throw new Error('<Radio> must be used under <RadioGroup>'); 19 19 } ··· 27 27 disabled?: boolean; 28 28 autofocus?: boolean; 29 29 class?: string; 30 - children?: Child; 30 + children?: JSXNode; 31 31 } 32 32 33 33 const RadioGroup = (props: RadioGroupProps) => {
+3 -2
packages/danaus/src/web/primitives/radio.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useId } from '../components/id.tsx'; 5 6 ··· 65 66 value: string; 66 67 disabled?: boolean; 67 68 class?: string; 68 - children?: Child; 69 + children?: JSXNode; 69 70 } 70 71 71 72 const Radio = (props: RadioProps) => {
+2 -2
packages/danaus/src/web/primitives/utils/dialog-context.tsx
··· 1 - import { createContext, useContext } from 'hono/jsx'; 1 + import { createContext, use } from '@oomfware/jsx'; 2 2 3 3 export interface DialogContextValue { 4 4 dialogId: string; ··· 15 15 (fallback: null): DialogContextValue | null; 16 16 (fallback?: DialogContextValue): DialogContextValue; 17 17 } = (fallback?: DialogContextValue | null): any => { 18 - const context = useContext(DialogContext); 18 + const context = use(DialogContext); 19 19 if (context === null) { 20 20 if (fallback !== undefined) { 21 21 return fallback;
+2 -2
packages/danaus/src/web/primitives/utils/field-context.tsx
··· 1 - import { createContext, useContext } from 'hono/jsx'; 1 + import { createContext, use } from '@oomfware/jsx'; 2 2 3 3 export type ValidationStatus = 'error' | 'warning' | 'success' | 'none'; 4 4 ··· 17 17 (fallback: null): FieldContextValue | null; 18 18 (fallback?: FieldContextValue): FieldContextValue; 19 19 } = (fallback?: FieldContextValue | null): any => { 20 - const context = useContext(FieldContext); 20 + const context = use(FieldContext); 21 21 if (context === null) { 22 22 if (fallback !== undefined) { 23 23 return fallback;
+2 -2
packages/danaus/src/web/primitives/utils/menu-context.tsx
··· 1 - import { createContext, useContext } from 'hono/jsx'; 1 + import { createContext, use } from '@oomfware/jsx'; 2 2 3 3 export interface MenuContextValue { 4 4 menuId: string; ··· 14 14 (fallback: null): MenuContextValue | null; 15 15 (fallback?: MenuContextValue): MenuContextValue; 16 16 } = (fallback?: MenuContextValue | null): any => { 17 - const context = useContext(MenuContext); 17 + const context = use(MenuContext); 18 18 if (context === null) { 19 19 if (fallback !== undefined) { 20 20 return fallback;
+2 -2
packages/danaus/src/web/primitives/utils/message-bar-context.tsx
··· 1 - import { createContext, useContext } from 'hono/jsx'; 1 + import { createContext, use } from '@oomfware/jsx'; 2 2 3 3 export type MessageBarIntent = 'info' | 'success' | 'warning' | 'error'; 4 4 export type MessageBarLayout = 'singleline' | 'multiline'; ··· 18 18 (fallback: null): MessageBarContextValue | null; 19 19 (fallback?: MessageBarContextValue): MessageBarContextValue; 20 20 } = (fallback?: MessageBarContextValue | null): any => { 21 - const context = useContext(MessageBarContext); 21 + const context = use(MessageBarContext); 22 22 if (context === null) { 23 23 if (fallback !== undefined) { 24 24 return fallback;
+31
packages/danaus/src/web/router.ts
··· 1 + import { createRouter } from '@oomfware/fetch-router'; 2 + import { asyncContext } from '@oomfware/fetch-router/middlewares/async-context'; 3 + 4 + import type { AppContext } from '#app/context.ts'; 5 + 6 + import accountController from './controllers/account.tsx'; 7 + import adminController from './controllers/admin.tsx'; 8 + import homeController from './controllers/home.tsx'; 9 + import loginController from './controllers/login.tsx'; 10 + import oauthController from './controllers/oauth.tsx'; 11 + import { provideAppContext } from './middlewares/app-context.ts'; 12 + import { routes } from './routes.ts'; 13 + 14 + /** 15 + * creates the web router with all routes and middleware. 16 + * @param ctx application context 17 + * @returns the configured router 18 + */ 19 + export const createWebRouter = (ctx: AppContext) => { 20 + const router = createRouter({ 21 + middleware: [asyncContext(), provideAppContext(ctx)], 22 + }); 23 + 24 + router.map(routes.home, homeController); 25 + router.map(routes.admin, adminController); 26 + router.map(routes.login, loginController); 27 + router.map(routes.account, accountController); 28 + router.map(routes.oauth, oauthController); 29 + 30 + return router; 31 + };
+27
packages/danaus/src/web/routes.ts
··· 1 + import { route } from '@oomfware/fetch-router'; 2 + 3 + export const routes = route({ 4 + home: '/', 5 + 6 + admin: { 7 + dashboard: '/admin', 8 + accounts: { 9 + index: '/admin/accounts', 10 + create: '/admin/accounts/new', 11 + }, 12 + }, 13 + 14 + // login is separate - no session required 15 + login: '/account/login', 16 + 17 + // account routes - all require session 18 + account: { 19 + overview: '/account', 20 + appPasswords: '/account/app-passwords', 21 + security: '/account/security', 22 + }, 23 + 24 + oauth: { 25 + authorize: '/oauth/authorize', 26 + }, 27 + });
+128
packages/danaus/src/web/styles/main.css
··· 89 89 90 90 --ease-*: initial; 91 91 --ease-fluent: cubic-bezier(0.33, 0, 0.67, 1); 92 + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); 93 + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); 94 + 95 + --duration-*: initial; 96 + --duration-faster: 100ms; 97 + --duration-fast: 150ms; 98 + --duration-normal: 200ms; 99 + --duration-gentle: 250ms; 100 + --duration-slow: 300ms; 101 + --duration-slower: 400ms; 92 102 93 103 --animate-*: initial; 94 104 --animate-spin-linear: spin-linear 1.5s linear infinite; ··· 422 432 } 423 433 424 434 /* #endregion */ 435 + 436 + /* #region dialog animations */ 437 + 438 + /* 439 + * dialog entry/exit animations using @starting-style 440 + * inspired by FluentUI's Dialog motion: scale + fade with decelerate/accelerate easing 441 + */ 442 + 443 + @utility dialog-animate { 444 + /* transition properties for both dialog surface and backdrop */ 445 + transition-property: opacity, scale, overlay, display; 446 + transition-duration: var(--duration-faster); 447 + transition-timing-function: var(--ease-decelerate-mid); 448 + transition-behavior: allow-discrete; 449 + 450 + /* final open state */ 451 + &[open] { 452 + opacity: 1; 453 + scale: 1; 454 + } 455 + 456 + /* exit state (dialog closing) */ 457 + &:not([open]) { 458 + opacity: 0; 459 + scale: 0.95; 460 + transition-timing-function: var(--ease-accelerate-min); 461 + } 462 + 463 + /* entry starting state */ 464 + @starting-style { 465 + &[open] { 466 + opacity: 0; 467 + scale: 0.95; 468 + } 469 + } 470 + } 471 + 472 + /* backdrop animation (fade only, no scale) */ 473 + @utility dialog-backdrop-animate { 474 + &::backdrop { 475 + transition-property: opacity, overlay, display; 476 + transition-duration: var(--duration-faster); 477 + transition-timing-function: var(--ease-decelerate-mid); 478 + transition-behavior: allow-discrete; 479 + opacity: 1; 480 + } 481 + 482 + &:not([open])::backdrop { 483 + opacity: 0; 484 + transition-timing-function: var(--ease-accelerate-min); 485 + } 486 + 487 + @starting-style { 488 + &[open]::backdrop { 489 + opacity: 0; 490 + } 491 + } 492 + } 493 + 494 + /* #endregion */ 495 + 496 + /* #region popover animations */ 497 + 498 + /* 499 + * popover entry/exit animations using @starting-style 500 + * inspired by FluentUI's Menu motion: slide + fade based on placement 501 + */ 502 + 503 + @utility popover-animate { 504 + --_slide-x: 0; 505 + --_slide-y: 8px; 506 + 507 + transition-property: opacity, translate, overlay, display; 508 + transition-duration: var(--duration-faster); 509 + transition-timing-function: var(--ease-decelerate-mid); 510 + transition-behavior: allow-discrete; 511 + 512 + /* final open state */ 513 + &:popover-open { 514 + opacity: 1; 515 + translate: 0 0; 516 + } 517 + 518 + /* exit state */ 519 + &:not(:popover-open) { 520 + opacity: 0; 521 + translate: var(--_slide-x) var(--_slide-y); 522 + transition-timing-function: var(--ease-accelerate-min); 523 + } 524 + 525 + /* entry starting state */ 526 + @starting-style { 527 + &:popover-open { 528 + opacity: 0; 529 + translate: var(--_slide-x) var(--_slide-y); 530 + } 531 + } 532 + } 533 + 534 + /* slide direction variants based on anchor position */ 535 + @utility popover-slide-down { 536 + --_slide-x: 0; 537 + --_slide-y: -8px; 538 + } 539 + @utility popover-slide-up { 540 + --_slide-x: 0; 541 + --_slide-y: 8px; 542 + } 543 + @utility popover-slide-left { 544 + --_slide-x: 8px; 545 + --_slide-y: 0; 546 + } 547 + @utility popover-slide-right { 548 + --_slide-x: -8px; 549 + --_slide-y: 0; 550 + } 551 + 552 + /* #endregion */
+93 -86
packages/danaus/src/web/styles/main.out.css
··· 81 81 --color-subtle-background-hover: var(--color-subtle-background-hover); 82 82 --color-subtle-background: var(--color-subtle-background); 83 83 --ease-fluent: cubic-bezier(0.33, 0, 0.67, 1); 84 + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); 85 + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); 86 + --duration-faster: 100ms; 84 87 --shadow-2: var(--shadow-2); 85 88 --shadow-4: var(--shadow-4); 86 89 --shadow-8: var(--shadow-8); ··· 416 419 .min-h-9 { 417 420 min-height: calc(var(--spacing) * 9); 418 421 } 422 + .min-h-11 { 423 + min-height: calc(var(--spacing) * 11); 424 + } 419 425 .min-h-dvh { 420 426 min-height: 100dvh; 421 427 } ··· 479 485 .flex-1 { 480 486 flex: 1; 481 487 } 488 + .shrink { 489 + flex-shrink: 1; 490 + } 482 491 .shrink-0 { 483 492 flex-shrink: 0; 484 493 } 485 494 .grow { 486 495 flex-grow: 1; 487 496 } 497 + .basis-0 { 498 + flex-basis: calc(var(--spacing) * 0); 499 + } 500 + .popover-animate { 501 + --_slide-x: 0; 502 + --_slide-y: 8px; 503 + transition-property: opacity, translate, overlay, display; 504 + transition-duration: var(--duration-faster); 505 + transition-timing-function: var(--ease-decelerate-mid); 506 + transition-behavior: allow-discrete; 507 + &:popover-open { 508 + opacity: 1; 509 + translate: 0 0; 510 + } 511 + &:not(:popover-open) { 512 + opacity: 0; 513 + translate: var(--_slide-x) var(--_slide-y); 514 + transition-timing-function: var(--ease-accelerate-min); 515 + } 516 + @starting-style { 517 + &:popover-open { 518 + opacity: 0; 519 + translate: var(--_slide-x) var(--_slide-y); 520 + } 521 + } 522 + } 523 + .dialog-animate { 524 + transition-property: opacity, scale, overlay, display; 525 + transition-duration: var(--duration-faster); 526 + transition-timing-function: var(--ease-decelerate-mid); 527 + transition-behavior: allow-discrete; 528 + &[open] { 529 + opacity: 1; 530 + scale: 1; 531 + } 532 + &:not([open]) { 533 + opacity: 0; 534 + scale: 0.95; 535 + transition-timing-function: var(--ease-accelerate-min); 536 + } 537 + @starting-style { 538 + &[open] { 539 + opacity: 0; 540 + scale: 0.95; 541 + } 542 + } 543 + } 488 544 .cursor-pointer { 489 545 cursor: pointer; 490 546 } 547 + .list-none { 548 + list-style-type: none; 549 + } 491 550 .appearance-none { 492 551 appearance: none; 493 552 } ··· 610 669 .border { 611 670 border-style: var(--tw-border-style); 612 671 border-width: 1px; 672 + } 673 + .border-0 { 674 + border-style: var(--tw-border-style); 675 + border-width: 0px; 613 676 } 614 677 .border-b { 615 678 border-bottom-style: var(--tw-border-style); ··· 754 817 .pb-2 { 755 818 padding-bottom: calc(var(--spacing) * 2); 756 819 } 820 + .pb-3 { 821 + padding-bottom: calc(var(--spacing) * 3); 822 + } 757 823 .pl-1 { 758 824 padding-left: calc(var(--spacing) * 1); 759 825 } ··· 847 913 .no-underline { 848 914 text-decoration-line: none; 849 915 } 916 + .dialog-backdrop-animate { 917 + &::backdrop { 918 + transition-property: opacity, overlay, display; 919 + transition-duration: var(--duration-faster); 920 + transition-timing-function: var(--ease-decelerate-mid); 921 + transition-behavior: allow-discrete; 922 + opacity: 1; 923 + } 924 + &:not([open])::backdrop { 925 + opacity: 0; 926 + transition-timing-function: var(--ease-accelerate-min); 927 + } 928 + @starting-style { 929 + &[open]::backdrop { 930 + opacity: 0; 931 + } 932 + } 933 + } 850 934 .opacity-0 { 851 935 opacity: 0%; 852 936 } ··· 872 956 .outline-transparent { 873 957 outline-color: transparent; 874 958 } 875 - .filter { 876 - filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); 877 - } 878 959 .transition { 879 960 transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; 880 961 transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); ··· 892 973 --tw-outline-style: none; 893 974 outline-style: none; 894 975 } 976 + .popover-slide-down { 977 + --_slide-x: 0; 978 + --_slide-y: -8px; 979 + } 895 980 .select-none { 896 981 -webkit-user-select: none; 897 982 user-select: none; ··· 1105 1190 .open\:flex { 1106 1191 &:is([open], :popover-open, :open) { 1107 1192 display: flex; 1108 - } 1109 - } 1110 - .open\:items-end { 1111 - &:is([open], :popover-open, :open) { 1112 - align-items: flex-end; 1113 - } 1114 - } 1115 - .open\:justify-center { 1116 - &:is([open], :popover-open, :open) { 1117 - justify-content: center; 1118 1193 } 1119 1194 } 1120 1195 .hover\:border-neutral-stroke-1-hover { ··· 1375 1450 padding-top: calc(var(--spacing) * 24); 1376 1451 } 1377 1452 } 1378 - .open\:sm\:items-center { 1379 - &:is([open], :popover-open, :open) { 1380 - @media (width >= 40rem) { 1381 - align-items: center; 1382 - } 1383 - } 1384 - } 1385 1453 .lg\:grid { 1386 1454 @media (width >= 64rem) { 1387 1455 display: grid; ··· 1420 1488 .\@sm\/dialog-body\:justify-start { 1421 1489 @container dialog-body (width >= 24rem) { 1422 1490 justify-content: flex-start; 1491 + } 1492 + } 1493 + .\[\&\:\:-webkit-details-marker\]\:hidden { 1494 + &::-webkit-details-marker { 1495 + display: none; 1423 1496 } 1424 1497 } 1425 1498 } ··· 1681 1754 inherits: false; 1682 1755 initial-value: solid; 1683 1756 } 1684 - @property --tw-blur { 1685 - syntax: "*"; 1686 - inherits: false; 1687 - } 1688 - @property --tw-brightness { 1689 - syntax: "*"; 1690 - inherits: false; 1691 - } 1692 - @property --tw-contrast { 1693 - syntax: "*"; 1694 - inherits: false; 1695 - } 1696 - @property --tw-grayscale { 1697 - syntax: "*"; 1698 - inherits: false; 1699 - } 1700 - @property --tw-hue-rotate { 1701 - syntax: "*"; 1702 - inherits: false; 1703 - } 1704 - @property --tw-invert { 1705 - syntax: "*"; 1706 - inherits: false; 1707 - } 1708 - @property --tw-opacity { 1709 - syntax: "*"; 1710 - inherits: false; 1711 - } 1712 - @property --tw-saturate { 1713 - syntax: "*"; 1714 - inherits: false; 1715 - } 1716 - @property --tw-sepia { 1717 - syntax: "*"; 1718 - inherits: false; 1719 - } 1720 - @property --tw-drop-shadow { 1721 - syntax: "*"; 1722 - inherits: false; 1723 - } 1724 - @property --tw-drop-shadow-color { 1725 - syntax: "*"; 1726 - inherits: false; 1727 - } 1728 - @property --tw-drop-shadow-alpha { 1729 - syntax: "<percentage>"; 1730 - inherits: false; 1731 - initial-value: 100%; 1732 - } 1733 - @property --tw-drop-shadow-size { 1734 - syntax: "*"; 1735 - inherits: false; 1736 - } 1737 1757 @property --tw-duration { 1738 1758 syntax: "*"; 1739 1759 inherits: false; ··· 1763 1783 --tw-ring-offset-color: #fff; 1764 1784 --tw-ring-offset-shadow: 0 0 #0000; 1765 1785 --tw-outline-style: solid; 1766 - --tw-blur: initial; 1767 - --tw-brightness: initial; 1768 - --tw-contrast: initial; 1769 - --tw-grayscale: initial; 1770 - --tw-hue-rotate: initial; 1771 - --tw-invert: initial; 1772 - --tw-opacity: initial; 1773 - --tw-saturate: initial; 1774 - --tw-sepia: initial; 1775 - --tw-drop-shadow: initial; 1776 - --tw-drop-shadow-color: initial; 1777 - --tw-drop-shadow-alpha: 100%; 1778 - --tw-drop-shadow-size: initial; 1779 1786 --tw-duration: initial; 1780 1787 --tw-ease: initial; 1781 1788 }
+1 -1
packages/danaus/tsconfig.json
··· 16 16 17 17 /* JSX */ 18 18 "jsx": "react-jsx", 19 - "jsxImportSource": "hono/jsx", 19 + "jsxImportSource": "@oomfware/jsx", 20 20 21 21 /* Linting */ 22 22 "strict": true,
+1 -1
packages/dev-env/package.json
··· 13 13 "infra:down": "docker compose down" 14 14 }, 15 15 "dependencies": { 16 - "@atcute/client": "^4.2.0", 16 + "@atcute/client": "^4.2.1", 17 17 "@atcute/crypto": "^2.3.0", 18 18 "@atproto/bsky": "^0.0.203", 19 19 "@atproto/crypto": "^0.4.5",
+192 -154
pnpm-lock.yaml
··· 20 20 specifier: ^0.1.3 21 21 version: 0.1.3 22 22 oxlint: 23 - specifier: ^1.36.0 24 - version: 1.36.0 23 + specifier: ^1.38.0 24 + version: 1.38.0 25 25 prettier: 26 26 specifier: ^3.7.4 27 27 version: 3.7.4 ··· 50 50 specifier: ^2.3.0 51 51 version: 2.3.0 52 52 '@atcute/client': 53 - specifier: ^4.2.0 54 - version: 4.2.0 53 + specifier: ^4.2.1 54 + version: 4.2.1 55 55 '@atcute/crypto': 56 56 specifier: ^2.3.0 57 57 version: 2.3.0 ··· 89 89 specifier: ^1.0.5 90 90 version: 1.0.5 91 91 '@atcute/xrpc-server': 92 - specifier: ^0.1.7 93 - version: 0.1.7 92 + specifier: ^0.1.8 93 + version: 0.1.8 94 94 '@atcute/xrpc-server-bun': 95 95 specifier: ^0.1.1 96 - version: 0.1.1(@atcute/xrpc-server@0.1.7) 96 + version: 0.1.1(@atcute/xrpc-server@0.1.8) 97 97 '@kelinci/danaus-lexicons': 98 98 specifier: workspace:* 99 99 version: link:../lexicons 100 + '@oomfware/fetch-router': 101 + specifier: ^0.2.1 102 + version: 0.2.1 103 + '@oomfware/forms': 104 + specifier: ^0.2.0 105 + version: 0.2.0(@oomfware/fetch-router@0.2.1) 106 + '@oomfware/jsx': 107 + specifier: ^0.1.4 108 + version: 0.1.4 100 109 cva: 101 110 specifier: 1.0.0-beta.4 102 111 version: 1.0.0-beta.4(typescript@5.9.3) ··· 106 115 get-port: 107 116 specifier: ^7.1.0 108 117 version: 7.1.0 109 - hono: 110 - specifier: ^4.11.3 111 - version: 4.11.3 112 118 jose: 113 119 specifier: ^6.1.3 114 120 version: 6.1.3 115 121 nanoid: 116 122 specifier: ^5.1.6 117 123 version: 5.1.6 124 + p-queue: 125 + specifier: ^9.1.0 126 + version: 9.1.0 118 127 valibot: 119 128 specifier: ^1.2.0 120 129 version: 1.2.0(typescript@5.9.3) ··· 144 153 packages/dev-env: 145 154 dependencies: 146 155 '@atcute/client': 147 - specifier: ^4.2.0 148 - version: 4.2.0 156 + specifier: ^4.2.1 157 + version: 4.2.1 149 158 '@atcute/crypto': 150 159 specifier: ^2.3.0 151 160 version: 2.3.0 ··· 214 223 '@atcute/cid@2.3.0': 215 224 resolution: {integrity: sha512-1SRdkTuMs/l5arQ+7Ag0F7JAueZqtzYE0d2gmbkuzi8EPweNU1kYlQs0CE4dSd81YF8PMDTOQty0K2ATq9CW9g==} 216 225 217 - '@atcute/client@4.2.0': 218 - resolution: {integrity: sha512-vYixpXevM+dkzN4HGTfmxCJBOXNSRAMki1bfi1atFdmZGo9n1zStyClHqn0SRs8I5nOQoVG1XBkYAGSFJWqy2Q==} 226 + '@atcute/client@4.2.1': 227 + resolution: {integrity: sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==} 219 228 220 229 '@atcute/crypto@2.3.0': 221 230 resolution: {integrity: sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==} ··· 285 294 peerDependencies: 286 295 '@atcute/xrpc-server': ^0.1.3 287 296 288 - '@atcute/xrpc-server@0.1.7': 289 - resolution: {integrity: sha512-RKOWjWkhtOU4YsHqY++hycsuar2//7OCwl15J0bTEmI9k1DCa9LkgDekrCfLduCg+lBPFmVXL4gbDX0HMl/F1Q==} 297 + '@atcute/xrpc-server@0.1.8': 298 + resolution: {integrity: sha512-GFdPtaXQXfsqejx88CaJ6zU0Yexrh3n94/rotGk1xwNJLa1iQ5kuWQqzttcybXoYEOp5Z2CGGw7bx9WuCLarlw==} 290 299 291 300 '@atproto-labs/fetch-node@0.2.0': 292 301 resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} ··· 301 310 '@atproto-labs/xrpc-utils@0.0.24': 302 311 resolution: {integrity: sha512-wWXd2Ht47UsL/UbDCr3twMFSZrh0xSI56u4O3kz0DTU4G+530mCG71mMVE6eeYcR+j6FEjp7o2Ld6c7wFklYGw==} 303 312 304 - '@atproto/api@0.18.9': 305 - resolution: {integrity: sha512-ft+0+sczS0qsoxwjqO1VhCXSNG792QEr+uQ91OCc36DTa3sPtaTPL7yNOVTDyEHaYDfp8tYN4v+Pq5/bzz3EpA==} 313 + '@atproto/api@0.18.10': 314 + resolution: {integrity: sha512-q23wreAGhktrMLepulvljZWHsUOrTIDwhU3gr/uSX3R1TZIZ3i4SxQZVlMqaQHpNJ/5Xj8J1hozkwVpaOX37eA==} 306 315 307 316 '@atproto/bsky@0.0.203': 308 317 resolution: {integrity: sha512-IMtQhxTBeNO0gGA7Tf9ASQFurQZlK+JxLnuwKrxX6HS+khMOftEolHB4SsGwZEWPEuF7PyuvB/zkaubwJzN3BA==} 309 318 engines: {node: '>=18.7.0'} 310 319 311 - '@atproto/common-web@0.4.9': 312 - resolution: {integrity: sha512-RGt1rUjVC8FEUlF5JQyN3xYlqZJbFTN0XSBBxl+HozjZGhhVtAVFGa+F+TR6BCVs7q7TcitOv/y/YWz4jJWn9g==} 320 + '@atproto/common-web@0.4.10': 321 + resolution: {integrity: sha512-TLDZSgSKzT8ZgOrBrTGK87J1CXve9TEuY6NVVUBRkOMzRRtQzpFb9/ih5WVS/hnaWVvE30CfuyaetRoma+WKNw==} 313 322 314 323 '@atproto/common@0.1.0': 315 324 resolution: {integrity: sha512-OB5tWE2R19jwiMIs2IjQieH5KTUuMb98XGCn9h3xuu6NanwjlmbCYMv08fMYwIp3UQ6jcq//84cDT3Bu6fJD+A==} ··· 317 326 '@atproto/common@0.1.1': 318 327 resolution: {integrity: sha512-GYwot5wF/z8iYGSPjrLHuratLc0CVgovmwfJss7+BUOB6y2/Vw8+1Vw0n9DDI0gb5vmx3UI8z0uJgC8aa8yuJg==} 319 328 320 - '@atproto/common@0.5.5': 321 - resolution: {integrity: sha512-lA9xb9IXVE9P2TQK222JxbXVirL+fxD/Aus2jtOEJTZMtvXDYQgMTw/Ka/a7ST5D3g0lrURnsZ6NJlOTVSDyHw==} 329 + '@atproto/common@0.5.6': 330 + resolution: {integrity: sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==} 322 331 engines: {node: '>=18.7.0'} 323 332 324 333 '@atproto/crypto@0.1.0': ··· 328 337 resolution: {integrity: sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==} 329 338 engines: {node: '>=18.7.0'} 330 339 331 - '@atproto/did@0.2.3': 332 - resolution: {integrity: sha512-VI8JJkSizvM2cHYJa37WlbzeCm5tWpojyc1/Zy8q8OOjyoy6X4S4BEfoP941oJcpxpMTObamibQIXQDo7tnIjg==} 340 + '@atproto/did@0.2.4': 341 + resolution: {integrity: sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g==} 333 342 334 343 '@atproto/identity@0.4.10': 335 344 resolution: {integrity: sha512-nQbzDLXOhM8p/wo0cTh5DfMSOSHzj6jizpodX37LJ4S1TZzumSxAjHEZa5Rev3JaoD5uSWMVE0MmKEGWkPPvfQ==} 336 345 engines: {node: '>=18.7.0'} 337 346 338 - '@atproto/lex-cbor@0.0.5': 339 - resolution: {integrity: sha512-mv+DBAOTb9ds4qRUBxi6ZF5syrINI+ckAEERtPXPnDy0Sui0zIpo2SSlD+IgjKiTJbudr6vHEssrfKrPnnYoeA==} 347 + '@atproto/lex-cbor@0.0.6': 348 + resolution: {integrity: sha512-lee2T00owDy3I1plRHuURT6f98NIpYZZr2wXa5pJZz5JzefZ+nv8gJ2V70C2f+jmSG+5S9NTIy4uJw94vaHf4A==} 340 349 341 - '@atproto/lex-data@0.0.5': 342 - resolution: {integrity: sha512-nasD4eo2wKLyhHozC0vy7Jhp/fBwCKnYhQQogYtraUlT9il6lK1drhT8CNpWlglOhb0T73jLG5WpfNsPp6Pr/w==} 350 + '@atproto/lex-data@0.0.6': 351 + resolution: {integrity: sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A==} 343 352 344 - '@atproto/lex-json@0.0.5': 345 - resolution: {integrity: sha512-wgmET7fIWi77jxqHnrr0RvpAGhiFqIqjdO9Py3JK2whHMITyYgFRU0HfEtIeWSzx0Vb9z0S7F/fQW3P3gqb+yA==} 353 + '@atproto/lex-json@0.0.6': 354 + resolution: {integrity: sha512-EILnN5cditPvf+PCNjXt7reMuzjugxAL1fpSzmzJbEMGMUwxOf5pPWxRsaA/M3Boip4NQZ+6DVrPOGUMlnqceg==} 346 355 347 356 '@atproto/lexicon@0.6.0': 348 357 resolution: {integrity: sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==} ··· 362 371 resolution: {integrity: sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==} 363 372 engines: {node: '>=18.7.0'} 364 373 365 - '@atproto/xrpc-server@0.10.6': 366 - resolution: {integrity: sha512-lfE4FVEt8r3uDGR3VT99jGJNcOfxGHhchMcX7a3iaMtf78VgOmrnvcQaa+m0OL9FLDYQpssAv83czA7l87wQow==} 374 + '@atproto/xrpc-server@0.10.7': 375 + resolution: {integrity: sha512-7SWUeNeRIKGpg35b2OU4bkTr8CpgOHfEvyEfU3wuzfgeDgC3lxIhHB49O+8OxDipDSlVbHePziyoyHs3mFHnRA==} 367 376 engines: {node: '>=18.7.0'} 368 377 369 378 '@atproto/xrpc@0.7.7': ··· 826 835 cpu: [x64] 827 836 os: [win32] 828 837 829 - '@ioredis/commands@1.4.0': 830 - resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} 838 + '@ioredis/commands@1.5.0': 839 + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} 831 840 832 841 '@ipld/dag-cbor@7.0.3': 833 842 resolution: {integrity: sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA==} ··· 875 884 '@noble/secp256k1@3.0.0': 876 885 resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} 877 886 878 - '@optique/core@0.6.10': 879 - resolution: {integrity: sha512-tw04cITJV5IHhjsMZuE5bXpX6dihYulaWEl+MgV5P7N9T+0i7PjEWvA6LuhY2o42NzRjNUYC8OClkBim+37jgg==} 887 + '@oomfware/fetch-router@0.2.1': 888 + resolution: {integrity: sha512-WV0cSeKjyTmM2pXYlRzv1md3Dym1vMR8PnJ/GfZUg8i1GS7RIDezmMkqVaWI/9IpeOHhs+QeDO41q1u+z1EzSg==} 889 + 890 + '@oomfware/forms@0.2.0': 891 + resolution: {integrity: sha512-XNvTZzAAur4ahitZ5R5VSZSzJem9Myn1T5Vhv6RLhVALn8qTsOKD8ju+hYed9P/cdMGog4DhKpiyaXbS5Elicw==} 892 + peerDependencies: 893 + '@oomfware/fetch-router': ^0.2.1 894 + 895 + '@oomfware/jsx@0.1.4': 896 + resolution: {integrity: sha512-3mY2Iqdjl+mE1ni3i6x9TdmgYTndfgiK4hpqBpHfvZHLtUddLBPWT8+AEidC5EZRXS1E2B1AZvtHFPESdkscfQ==} 897 + 898 + '@optique/core@0.6.11': 899 + resolution: {integrity: sha512-GVLFihzBA1j78NFlkU5N1Lu0jRqET0k6Z66WK8VQKG/a3cxmCInVGSKMIdQG8i6pgC8wD5OizF6Y3QMztmhAxg==} 880 900 engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} 881 901 882 - '@optique/run@0.6.10': 883 - resolution: {integrity: sha512-vPcE9KhIZeWzX+S34fnZSUTP+5/GQzuKrtYIU9ZjqRB9xf/99Sa6CR7QWhurxM51J34P7ENpAoIwrRSldhUjRg==} 902 + '@optique/run@0.6.11': 903 + resolution: {integrity: sha512-tsXBEygGSzNpFK2gjsRlXBn7FiScUeLFWIZNpoAZ8iG85Km0/3K9xgqlQAXoQ+uEZBe4XplnzyCDvmEgbyNT8w==} 884 904 engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} 885 905 886 906 '@oxc-parser/binding-android-arm64@0.99.0': ··· 1075 1095 cpu: [x64] 1076 1096 os: [win32] 1077 1097 1078 - '@oxlint/darwin-arm64@1.36.0': 1079 - resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==} 1098 + '@oxlint/darwin-arm64@1.38.0': 1099 + resolution: {integrity: sha512-9rN3047QTyA4i73FKikDUBdczRcLtOsIwZ5TsEx5Q7jr5nBjolhYQOFQf9QdhBLdInxw1iX4+lgdMCf1g74zjg==} 1080 1100 cpu: [arm64] 1081 1101 os: [darwin] 1082 1102 1083 - '@oxlint/darwin-x64@1.36.0': 1084 - resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==} 1103 + '@oxlint/darwin-x64@1.38.0': 1104 + resolution: {integrity: sha512-Y1UHW4KOlg5NvyrSn/bVBQP8/LRuid7Pnu+BWGbAVVsFcK0b565YgMSO3Eu9nU3w8ke91dr7NFpUmS+bVkdkbw==} 1085 1105 cpu: [x64] 1086 1106 os: [darwin] 1087 1107 1088 - '@oxlint/linux-arm64-gnu@1.36.0': 1089 - resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==} 1108 + '@oxlint/linux-arm64-gnu@1.38.0': 1109 + resolution: {integrity: sha512-ZiVxPZizlXSnAMdkEFWX/mAj7U3bNiku8p6I9UgLrXzgGSSAhFobx8CaFGwVoKyWOd+gQgZ/ogCrunvx2k0CFg==} 1090 1110 cpu: [arm64] 1091 1111 os: [linux] 1092 1112 1093 - '@oxlint/linux-arm64-musl@1.36.0': 1094 - resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==} 1113 + '@oxlint/linux-arm64-musl@1.38.0': 1114 + resolution: {integrity: sha512-ELtlCIGZ72A65ATZZHFxHMFrkRtY+DYDCKiNKg6v7u5PdeOFey+OlqRXgXtXlxWjCL+g7nivwI2FPVsWqf05Qw==} 1095 1115 cpu: [arm64] 1096 1116 os: [linux] 1097 1117 1098 - '@oxlint/linux-x64-gnu@1.36.0': 1099 - resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==} 1118 + '@oxlint/linux-x64-gnu@1.38.0': 1119 + resolution: {integrity: sha512-E1OcDh30qyng1m0EIlsOuapYkqk5QB6o6IMBjvDKqIoo6IrjlVAasoJfS/CmSH998gXRL3BcAJa6Qg9IxPFZnQ==} 1100 1120 cpu: [x64] 1101 1121 os: [linux] 1102 1122 1103 - '@oxlint/linux-x64-musl@1.36.0': 1104 - resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==} 1123 + '@oxlint/linux-x64-musl@1.38.0': 1124 + resolution: {integrity: sha512-4AfpbM/4sQnr6S1dMijEPfsq4stQbN5vJ2jsahSy/QTcvIVbFkgY+RIhrA5UWlC6eb0rD5CdaPQoKGMJGeXpYw==} 1105 1125 cpu: [x64] 1106 1126 os: [linux] 1107 1127 1108 - '@oxlint/win32-arm64@1.36.0': 1109 - resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==} 1128 + '@oxlint/win32-arm64@1.38.0': 1129 + resolution: {integrity: sha512-OvUVYdI68OwXh3d1RjH9N/okCxb6PrOGtEtzXyqGA7Gk+IxyZcX0/QCTBwV8FNbSSzDePSSEHOKpoIB+VXdtvg==} 1110 1130 cpu: [arm64] 1111 1131 os: [win32] 1112 1132 1113 - '@oxlint/win32-x64@1.36.0': 1114 - resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==} 1133 + '@oxlint/win32-x64@1.38.0': 1134 + resolution: {integrity: sha512-7IuZMYiZiOcgg5zHvpJY6jRlEwh8EB/uq7GsoQJO9hANq96TIjyntGByhIjFSsL4asyZmhTEki+MO/u5Fb/WQA==} 1115 1135 cpu: [x64] 1116 1136 os: [win32] 1117 1137 ··· 1235 1255 '@protobufjs/utf8@1.1.0': 1236 1256 resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} 1237 1257 1258 + '@remix-run/route-pattern@0.16.0': 1259 + resolution: {integrity: sha512-Co6bPtODF7cLYVBweayRXfEb31ybz45WqwT/u72eDQJZgRSVKFf0Ps9fqinSaiX0Xp7jvkRCBAbSUgLuLLjzuw==} 1260 + 1238 1261 '@standard-schema/spec@1.1.0': 1239 1262 resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 1240 1263 ··· 1510 1533 1511 1534 '@types/node@22.19.3': 1512 1535 resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} 1513 - 1514 - '@types/node@25.0.3': 1515 - resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} 1516 1536 1517 1537 '@types/readable-stream@4.0.23': 1518 1538 resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} ··· 1966 1986 eventemitter3@4.0.7: 1967 1987 resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} 1968 1988 1989 + eventemitter3@5.0.1: 1990 + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} 1991 + 1969 1992 events@3.3.0: 1970 1993 resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} 1971 1994 engines: {node: '>=0.8.x'} ··· 2069 2092 2070 2093 hmac-drbg@1.0.1: 2071 2094 resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} 2072 - 2073 - hono@4.11.3: 2074 - resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} 2075 - engines: {node: '>=16.9.0'} 2076 2095 2077 2096 http-errors@2.0.1: 2078 2097 resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} ··· 2098 2117 resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} 2099 2118 engines: {node: '>=0.10.0'} 2100 2119 2101 - iconv-lite@0.7.1: 2102 - resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} 2120 + iconv-lite@0.7.2: 2121 + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} 2103 2122 engines: {node: '>=0.10.0'} 2104 2123 2105 2124 ieee754@1.2.1: ··· 2108 2127 inherits@2.0.4: 2109 2128 resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} 2110 2129 2111 - ioredis@5.8.2: 2112 - resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} 2130 + ioredis@5.9.0: 2131 + resolution: {integrity: sha512-T3VieIilNumOJCXI9SDgo4NnF6sZkd6XcmPi6qWtw4xqbt8nNz/ZVNiIH1L9puMTSHZh1mUWA4xKa2nWPF4NwQ==} 2113 2132 engines: {node: '>=12.22.0'} 2114 2133 2115 2134 ip3country@5.0.0: ··· 2447 2466 oxc-resolver@11.16.2: 2448 2467 resolution: {integrity: sha512-Uy76u47vwhhF7VAmVY61Srn+ouiOobf45MU9vGct9GD2ARy6hKoqEElyHDB0L+4JOM6VLuZ431KiLwyjI/A21g==} 2449 2468 2450 - oxlint@1.36.0: 2451 - resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==} 2469 + oxlint@1.38.0: 2470 + resolution: {integrity: sha512-XT7tBinQS+hVLxtfJOnokJ9qVBiQvZqng40tDgR6qEJMRMnpVq/JwYfbYyGntSq8MO+Y+N9M1NG4bAMFUtCJiw==} 2452 2471 engines: {node: ^20.19.0 || >=22.12.0} 2453 2472 hasBin: true 2454 2473 peerDependencies: ··· 2465 2484 resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} 2466 2485 engines: {node: '>=8'} 2467 2486 2487 + p-queue@9.1.0: 2488 + resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==} 2489 + engines: {node: '>=20'} 2490 + 2468 2491 p-timeout@3.2.0: 2469 2492 resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} 2470 2493 engines: {node: '>=8'} 2494 + 2495 + p-timeout@7.0.1: 2496 + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} 2497 + engines: {node: '>=20'} 2471 2498 2472 2499 p-wait-for@3.2.0: 2473 2500 resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==} ··· 2875 2902 undici-types@6.21.0: 2876 2903 resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 2877 2904 2878 - undici-types@7.16.0: 2879 - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} 2880 - 2881 2905 undici@5.29.0: 2882 2906 resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} 2883 2907 engines: {node: '>=14.0'} 2884 2908 2885 - undici@6.22.0: 2886 - resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} 2909 + undici@6.23.0: 2910 + resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} 2887 2911 engines: {node: '>=18.17'} 2888 2912 2889 2913 unicode-segmenter@0.14.5: ··· 2926 2950 resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} 2927 2951 engines: {node: '>=10'} 2928 2952 2929 - ws@8.18.3: 2930 - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} 2953 + ws@8.19.0: 2954 + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} 2931 2955 engines: {node: '>=10.0.0'} 2932 2956 peerDependencies: 2933 2957 bufferutil: ^4.0.1 ··· 2993 3017 '@atcute/multibase': 1.1.6 2994 3018 '@atcute/uint8array': 1.0.6 2995 3019 2996 - '@atcute/client@4.2.0': 3020 + '@atcute/client@4.2.1': 2997 3021 dependencies: 2998 3022 '@atcute/identity': 1.1.3 2999 3023 '@atcute/lexicons': 1.2.6 ··· 3042 3066 '@atcute/lexicon-resolver': 0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.3) 3043 3067 '@atcute/lexicons': 1.2.6 3044 3068 '@badrap/valita': 0.4.6 3045 - '@optique/core': 0.6.10 3046 - '@optique/run': 0.6.10 3069 + '@optique/core': 0.6.11 3070 + '@optique/run': 0.6.11 3047 3071 picocolors: 1.1.1 3048 3072 prettier: 3.7.4 3049 3073 ··· 3114 3138 3115 3139 '@atcute/varint@1.0.3': {} 3116 3140 3117 - '@atcute/xrpc-server-bun@0.1.1(@atcute/xrpc-server@0.1.7)': 3141 + '@atcute/xrpc-server-bun@0.1.1(@atcute/xrpc-server@0.1.8)': 3118 3142 dependencies: 3119 - '@atcute/xrpc-server': 0.1.7 3143 + '@atcute/xrpc-server': 0.1.8 3120 3144 3121 - '@atcute/xrpc-server@0.1.7': 3145 + '@atcute/xrpc-server@0.1.8': 3122 3146 dependencies: 3123 3147 '@atcute/cbor': 2.2.8 3124 3148 '@atcute/crypto': 2.3.0 ··· 3135 3159 '@atproto-labs/fetch': 0.2.3 3136 3160 '@atproto-labs/pipe': 0.1.1 3137 3161 ipaddr.js: 2.3.0 3138 - undici: 6.22.0 3162 + undici: 6.23.0 3139 3163 3140 3164 '@atproto-labs/fetch@0.2.3': 3141 3165 dependencies: ··· 3146 3170 '@atproto-labs/xrpc-utils@0.0.24': 3147 3171 dependencies: 3148 3172 '@atproto/xrpc': 0.7.7 3149 - '@atproto/xrpc-server': 0.10.6 3173 + '@atproto/xrpc-server': 0.10.7 3150 3174 transitivePeerDependencies: 3151 3175 - bufferutil 3152 3176 - supports-color 3153 3177 - utf-8-validate 3154 3178 3155 - '@atproto/api@0.18.9': 3179 + '@atproto/api@0.18.10': 3156 3180 dependencies: 3157 - '@atproto/common-web': 0.4.9 3181 + '@atproto/common-web': 0.4.10 3158 3182 '@atproto/lexicon': 0.6.0 3159 3183 '@atproto/syntax': 0.4.2 3160 3184 '@atproto/xrpc': 0.7.7 ··· 3167 3191 dependencies: 3168 3192 '@atproto-labs/fetch-node': 0.2.0 3169 3193 '@atproto-labs/xrpc-utils': 0.0.24 3170 - '@atproto/api': 0.18.9 3171 - '@atproto/common': 0.5.5 3194 + '@atproto/api': 0.18.10 3195 + '@atproto/common': 0.5.6 3172 3196 '@atproto/crypto': 0.4.5 3173 - '@atproto/did': 0.2.3 3197 + '@atproto/did': 0.2.4 3174 3198 '@atproto/identity': 0.4.10 3175 3199 '@atproto/lexicon': 0.6.0 3176 3200 '@atproto/repo': 0.8.12 3177 3201 '@atproto/sync': 0.1.39 3178 3202 '@atproto/syntax': 0.4.2 3179 - '@atproto/xrpc-server': 0.10.6 3203 + '@atproto/xrpc-server': 0.10.7 3180 3204 '@bufbuild/protobuf': 1.10.1 3181 3205 '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.1) 3182 3206 '@connectrpc/connect-express': 1.7.0(@bufbuild/protobuf@1.10.1)(@connectrpc/connect-node@1.7.0(@bufbuild/protobuf@1.10.1)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.1)))(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.1)) ··· 3191 3215 express: 4.22.1 3192 3216 http-errors: 2.0.1 3193 3217 http-terminator: 3.2.0 3194 - ioredis: 5.8.2 3218 + ioredis: 5.9.0 3195 3219 jose: 5.10.0 3196 3220 key-encoder: 2.0.3 3197 3221 kysely: 0.22.0 ··· 3207 3231 structured-headers: 1.0.1 3208 3232 typed-emitter: 2.1.0 3209 3233 uint8arrays: 3.0.0 3210 - undici: 6.22.0 3234 + undici: 6.23.0 3211 3235 zod: 3.23.8 3212 3236 transitivePeerDependencies: 3213 3237 - bufferutil ··· 3217 3241 - supports-color 3218 3242 - utf-8-validate 3219 3243 3220 - '@atproto/common-web@0.4.9': 3244 + '@atproto/common-web@0.4.10': 3221 3245 dependencies: 3222 - '@atproto/lex-data': 0.0.5 3223 - '@atproto/lex-json': 0.0.5 3224 - zod: 3.23.8 3246 + '@atproto/lex-data': 0.0.6 3247 + '@atproto/lex-json': 0.0.6 3248 + zod: 3.25.76 3225 3249 3226 3250 '@atproto/common@0.1.0': 3227 3251 dependencies: ··· 3237 3261 pino: 8.21.0 3238 3262 zod: 3.25.76 3239 3263 3240 - '@atproto/common@0.5.5': 3264 + '@atproto/common@0.5.6': 3241 3265 dependencies: 3242 - '@atproto/common-web': 0.4.9 3243 - '@atproto/lex-cbor': 0.0.5 3244 - '@atproto/lex-data': 0.0.5 3266 + '@atproto/common-web': 0.4.10 3267 + '@atproto/lex-cbor': 0.0.6 3268 + '@atproto/lex-data': 0.0.6 3245 3269 iso-datestring-validator: 2.2.2 3246 3270 multiformats: 9.9.0 3247 3271 pino: 8.21.0 ··· 3260 3284 '@noble/hashes': 1.8.0 3261 3285 uint8arrays: 3.0.0 3262 3286 3263 - '@atproto/did@0.2.3': 3287 + '@atproto/did@0.2.4': 3264 3288 dependencies: 3265 3289 zod: 3.23.8 3266 3290 3267 3291 '@atproto/identity@0.4.10': 3268 3292 dependencies: 3269 - '@atproto/common-web': 0.4.9 3293 + '@atproto/common-web': 0.4.10 3270 3294 '@atproto/crypto': 0.4.5 3271 3295 3272 - '@atproto/lex-cbor@0.0.5': 3296 + '@atproto/lex-cbor@0.0.6': 3273 3297 dependencies: 3274 - '@atproto/lex-data': 0.0.5 3298 + '@atproto/lex-data': 0.0.6 3275 3299 multiformats: 9.9.0 3276 3300 tslib: 2.8.1 3277 3301 3278 - '@atproto/lex-data@0.0.5': 3302 + '@atproto/lex-data@0.0.6': 3279 3303 dependencies: 3280 3304 '@atproto/syntax': 0.4.2 3281 3305 multiformats: 9.9.0 ··· 3283 3307 uint8arrays: 3.0.0 3284 3308 unicode-segmenter: 0.14.5 3285 3309 3286 - '@atproto/lex-json@0.0.5': 3310 + '@atproto/lex-json@0.0.6': 3287 3311 dependencies: 3288 - '@atproto/lex-data': 0.0.5 3312 + '@atproto/lex-data': 0.0.6 3289 3313 tslib: 2.8.1 3290 3314 3291 3315 '@atproto/lexicon@0.6.0': 3292 3316 dependencies: 3293 - '@atproto/common-web': 0.4.9 3317 + '@atproto/common-web': 0.4.10 3294 3318 '@atproto/syntax': 0.4.2 3295 3319 iso-datestring-validator: 2.2.2 3296 3320 multiformats: 9.9.0 ··· 3298 3322 3299 3323 '@atproto/repo@0.8.12': 3300 3324 dependencies: 3301 - '@atproto/common': 0.5.5 3302 - '@atproto/common-web': 0.4.9 3325 + '@atproto/common': 0.5.6 3326 + '@atproto/common-web': 0.4.10 3303 3327 '@atproto/crypto': 0.4.5 3304 3328 '@atproto/lexicon': 0.6.0 3305 3329 '@ipld/dag-cbor': 7.0.3 ··· 3310 3334 3311 3335 '@atproto/sync@0.1.39': 3312 3336 dependencies: 3313 - '@atproto/common': 0.5.5 3337 + '@atproto/common': 0.5.6 3314 3338 '@atproto/identity': 0.4.10 3315 3339 '@atproto/lexicon': 0.6.0 3316 3340 '@atproto/repo': 0.8.12 3317 3341 '@atproto/syntax': 0.4.2 3318 - '@atproto/xrpc-server': 0.10.6 3342 + '@atproto/xrpc-server': 0.10.7 3319 3343 multiformats: 9.9.0 3320 3344 p-queue: 6.6.2 3321 - ws: 8.18.3 3345 + ws: 8.19.0 3322 3346 transitivePeerDependencies: 3323 3347 - bufferutil 3324 3348 - supports-color ··· 3328 3352 3329 3353 '@atproto/ws-client@0.0.4': 3330 3354 dependencies: 3331 - '@atproto/common': 0.5.5 3332 - ws: 8.18.3 3355 + '@atproto/common': 0.5.6 3356 + ws: 8.19.0 3333 3357 transitivePeerDependencies: 3334 3358 - bufferutil 3335 3359 - utf-8-validate 3336 3360 3337 - '@atproto/xrpc-server@0.10.6': 3361 + '@atproto/xrpc-server@0.10.7': 3338 3362 dependencies: 3339 - '@atproto/common': 0.5.5 3363 + '@atproto/common': 0.5.6 3340 3364 '@atproto/crypto': 0.4.5 3341 - '@atproto/lex-cbor': 0.0.5 3342 - '@atproto/lex-data': 0.0.5 3365 + '@atproto/lex-cbor': 0.0.6 3366 + '@atproto/lex-data': 0.0.6 3343 3367 '@atproto/lexicon': 0.6.0 3344 3368 '@atproto/ws-client': 0.0.4 3345 3369 '@atproto/xrpc': 0.7.7 ··· 3347 3371 http-errors: 2.0.1 3348 3372 mime-types: 2.1.35 3349 3373 rate-limiter-flexible: 2.4.2 3350 - ws: 8.18.3 3374 + ws: 8.19.0 3351 3375 zod: 3.23.8 3352 3376 transitivePeerDependencies: 3353 3377 - bufferutil ··· 3827 3851 '@img/sharp-win32-x64@0.33.5': 3828 3852 optional: true 3829 3853 3830 - '@ioredis/commands@1.4.0': {} 3854 + '@ioredis/commands@1.5.0': {} 3831 3855 3832 3856 '@ipld/dag-cbor@7.0.3': 3833 3857 dependencies: ··· 3878 3902 3879 3903 '@noble/secp256k1@3.0.0': {} 3880 3904 3881 - '@optique/core@0.6.10': {} 3905 + '@oomfware/fetch-router@0.2.1': 3906 + dependencies: 3907 + '@remix-run/route-pattern': 0.16.0 3908 + 3909 + '@oomfware/forms@0.2.0(@oomfware/fetch-router@0.2.1)': 3910 + dependencies: 3911 + '@oomfware/fetch-router': 0.2.1 3912 + '@standard-schema/spec': 1.1.0 3913 + 3914 + '@oomfware/jsx@0.1.4': {} 3882 3915 3883 - '@optique/run@0.6.10': 3916 + '@optique/core@0.6.11': {} 3917 + 3918 + '@optique/run@0.6.11': 3884 3919 dependencies: 3885 - '@optique/core': 0.6.10 3920 + '@optique/core': 0.6.11 3886 3921 3887 3922 '@oxc-parser/binding-android-arm64@0.99.0': 3888 3923 optional: true ··· 3995 4030 '@oxc-resolver/binding-win32-x64-msvc@11.16.2': 3996 4031 optional: true 3997 4032 3998 - '@oxlint/darwin-arm64@1.36.0': 4033 + '@oxlint/darwin-arm64@1.38.0': 3999 4034 optional: true 4000 4035 4001 - '@oxlint/darwin-x64@1.36.0': 4036 + '@oxlint/darwin-x64@1.38.0': 4002 4037 optional: true 4003 4038 4004 - '@oxlint/linux-arm64-gnu@1.36.0': 4039 + '@oxlint/linux-arm64-gnu@1.38.0': 4005 4040 optional: true 4006 4041 4007 - '@oxlint/linux-arm64-musl@1.36.0': 4042 + '@oxlint/linux-arm64-musl@1.38.0': 4008 4043 optional: true 4009 4044 4010 - '@oxlint/linux-x64-gnu@1.36.0': 4045 + '@oxlint/linux-x64-gnu@1.38.0': 4011 4046 optional: true 4012 4047 4013 - '@oxlint/linux-x64-musl@1.36.0': 4048 + '@oxlint/linux-x64-musl@1.38.0': 4014 4049 optional: true 4015 4050 4016 - '@oxlint/win32-arm64@1.36.0': 4051 + '@oxlint/win32-arm64@1.38.0': 4017 4052 optional: true 4018 4053 4019 - '@oxlint/win32-x64@1.36.0': 4054 + '@oxlint/win32-x64@1.38.0': 4020 4055 optional: true 4021 4056 4022 4057 '@parcel/watcher-android-arm64@2.5.1': ··· 4112 4147 '@protobufjs/pool@1.1.0': {} 4113 4148 4114 4149 '@protobufjs/utf8@1.1.0': {} 4150 + 4151 + '@remix-run/route-pattern@0.16.0': {} 4115 4152 4116 4153 '@standard-schema/spec@1.1.0': {} 4117 4154 ··· 4342 4379 dependencies: 4343 4380 undici-types: 6.21.0 4344 4381 4345 - '@types/node@25.0.3': 4346 - dependencies: 4347 - undici-types: 7.16.0 4348 - 4349 4382 '@types/readable-stream@4.0.23': 4350 4383 dependencies: 4351 4384 '@types/node': 22.19.3 ··· 4484 4517 4485 4518 bun-types@1.3.5: 4486 4519 dependencies: 4487 - '@types/node': 25.0.3 4520 + '@types/node': 22.19.3 4488 4521 4489 4522 bundle-name@4.1.0: 4490 4523 dependencies: ··· 4748 4781 4749 4782 eventemitter3@4.0.7: {} 4750 4783 4784 + eventemitter3@5.0.1: {} 4785 + 4751 4786 events@3.3.0: {} 4752 4787 4753 4788 express-async-errors@3.1.1(express@4.22.1): ··· 4882 4917 minimalistic-assert: 1.0.1 4883 4918 minimalistic-crypto-utils: 1.0.1 4884 4919 4885 - hono@4.11.3: {} 4886 - 4887 4920 http-errors@2.0.1: 4888 4921 dependencies: 4889 4922 depd: 2.0.0 ··· 4921 4954 dependencies: 4922 4955 safer-buffer: 2.1.2 4923 4956 4924 - iconv-lite@0.7.1: 4957 + iconv-lite@0.7.2: 4925 4958 dependencies: 4926 4959 safer-buffer: 2.1.2 4927 4960 ··· 4929 4962 4930 4963 inherits@2.0.4: {} 4931 4964 4932 - ioredis@5.8.2: 4965 + ioredis@5.9.0: 4933 4966 dependencies: 4934 - '@ioredis/commands': 1.4.0 4967 + '@ioredis/commands': 1.5.0 4935 4968 cluster-key-slot: 1.1.2 4936 4969 debug: 4.4.3 4937 4970 denque: 2.1.0 ··· 5233 5266 '@oxc-resolver/binding-win32-ia32-msvc': 11.16.2 5234 5267 '@oxc-resolver/binding-win32-x64-msvc': 11.16.2 5235 5268 5236 - oxlint@1.36.0: 5269 + oxlint@1.38.0: 5237 5270 optionalDependencies: 5238 - '@oxlint/darwin-arm64': 1.36.0 5239 - '@oxlint/darwin-x64': 1.36.0 5240 - '@oxlint/linux-arm64-gnu': 1.36.0 5241 - '@oxlint/linux-arm64-musl': 1.36.0 5242 - '@oxlint/linux-x64-gnu': 1.36.0 5243 - '@oxlint/linux-x64-musl': 1.36.0 5244 - '@oxlint/win32-arm64': 1.36.0 5245 - '@oxlint/win32-x64': 1.36.0 5271 + '@oxlint/darwin-arm64': 1.38.0 5272 + '@oxlint/darwin-x64': 1.38.0 5273 + '@oxlint/linux-arm64-gnu': 1.38.0 5274 + '@oxlint/linux-arm64-musl': 1.38.0 5275 + '@oxlint/linux-x64-gnu': 1.38.0 5276 + '@oxlint/linux-x64-musl': 1.38.0 5277 + '@oxlint/win32-arm64': 1.38.0 5278 + '@oxlint/win32-x64': 1.38.0 5246 5279 5247 5280 p-finally@1.0.0: {} 5248 5281 ··· 5251 5284 eventemitter3: 4.0.7 5252 5285 p-timeout: 3.2.0 5253 5286 5287 + p-queue@9.1.0: 5288 + dependencies: 5289 + eventemitter3: 5.0.1 5290 + p-timeout: 7.0.1 5291 + 5254 5292 p-timeout@3.2.0: 5255 5293 dependencies: 5256 5294 p-finally: 1.0.0 5295 + 5296 + p-timeout@7.0.1: {} 5257 5297 5258 5298 p-wait-for@3.2.0: 5259 5299 dependencies: ··· 5620 5660 '@js-joda/core': 5.6.5 5621 5661 '@types/node': 22.19.3 5622 5662 bl: 6.1.6 5623 - iconv-lite: 0.7.1 5663 + iconv-lite: 0.7.2 5624 5664 js-md4: 0.3.2 5625 5665 native-duplexpair: 1.0.0 5626 5666 sprintf-js: 1.1.3 ··· 5666 5706 5667 5707 undici-types@6.21.0: {} 5668 5708 5669 - undici-types@7.16.0: {} 5670 - 5671 5709 undici@5.29.0: 5672 5710 dependencies: 5673 5711 '@fastify/busboy': 2.1.1 5674 5712 5675 - undici@6.22.0: {} 5713 + undici@6.23.0: {} 5676 5714 5677 5715 unicode-segmenter@0.14.5: {} 5678 5716 ··· 5703 5741 string-width: 4.2.3 5704 5742 strip-ansi: 6.0.1 5705 5743 5706 - ws@8.18.3: {} 5744 + ws@8.19.0: {} 5707 5745 5708 5746 wsl-utils@0.1.0: 5709 5747 dependencies: