+1
.prettierrc
+1
.prettierrc
+4
-4
CLAUDE.md
+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
+1
-1
package.json
+14
packages/danaus/drizzle/identity/20260106131826_whole_micromax/migration.sql
+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
+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
+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
+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
+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
packages/danaus/src/actors/blob-store/types.ts
+4
-2
packages/danaus/src/api/com.atproto/identity.resolveHandle.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
-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
+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
-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
-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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+2
-2
packages/danaus/src/web/primitives/accordion.tsx
+3
-2
packages/danaus/src/web/primitives/checkbox.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+2
-2
packages/danaus/src/web/primitives/dialog.tsx
+8
-7
packages/danaus/src/web/primitives/field.tsx
+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
+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
+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/message-bar-actions.tsx
+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
+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
+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
+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
+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
+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
+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
+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/message-bar-context.tsx
+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
+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
+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
+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
+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
+1
-1
packages/danaus/tsconfig.json
+1
-1
packages/dev-env/package.json
+1
-1
packages/dev-env/package.json
+192
-154
pnpm-lock.yaml
+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: