···11+import type { BuildAction } from '@oomfware/fetch-router';
22+33+import type { routes } from '../routes';
44+55+export default {
66+ middleware: [],
77+ action() {
88+ return new Response('This is an AT Protocol personal data server.');
99+ },
1010+} satisfies BuildAction<'ANY', typeof routes.home>;
+51
packages/danaus/src/web/controllers/login.tsx
···11+import type { BuildAction } from '@oomfware/fetch-router';
22+import { forms } from '@oomfware/forms';
33+import { render } from '@oomfware/jsx';
44+55+import { signInForm } from '../account/forms.ts';
66+import { BaseLayout } from '../layouts/base.tsx';
77+import Button from '../primitives/button.tsx';
88+import Field from '../primitives/field.tsx';
99+import Input from '../primitives/input.tsx';
1010+import type { routes } from '../routes.ts';
1111+1212+export default {
1313+ middleware: [forms({ signInForm })],
1414+ action() {
1515+ const { fields } = signInForm;
1616+1717+ return render(
1818+ <BaseLayout>
1919+ <title>sign in - danaus</title>
2020+2121+ <div class="flex flex-1 items-center justify-center p-4">
2222+ <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16">
2323+ <form {...signInForm} class="flex flex-col gap-6">
2424+ <h1 class="text-base-500 font-semibold">Sign in to your account</h1>
2525+2626+ <Field
2727+ label="Handle or email"
2828+ required
2929+ validationMessageText={fields.identifier.issues()?.[0]!.message}
3030+ >
3131+ <Input {...fields.identifier.as('text')} placeholder="alice.bsky.social" required autofocus />
3232+ </Field>
3333+3434+ <Field
3535+ label="Password"
3636+ required
3737+ validationMessageText={fields._password.issues()?.[0]!.message}
3838+ >
3939+ <Input {...fields._password.as('password')} required />
4040+ </Field>
4141+4242+ <Button type="submit" variant="primary">
4343+ Sign in
4444+ </Button>
4545+ </form>
4646+ </div>
4747+ </div>
4848+ </BaseLayout>,
4949+ );
5050+ },
5151+} satisfies BuildAction<'ANY', typeof routes.home>;
···11+import { createInjectionKey, type Middleware } from '@oomfware/fetch-router';
22+import { getContext } from '@oomfware/fetch-router/middlewares/async-context';
33+44+import type { AppContext } from '#app/context.ts';
55+66+const appContextKey = createInjectionKey<AppContext>();
77+88+/**
99+ * middleware that provides the AppContext to the request context store.
1010+ * @param ctx the application context to provide
1111+ */
1212+export const provideAppContext = (ctx: AppContext): Middleware => {
1313+ return async ({ store }, next) => {
1414+ store.provide(appContextKey, ctx);
1515+ return next();
1616+ };
1717+};
1818+1919+/**
2020+ * retrieves the AppContext from the current request context.
2121+ * must be called within a request handler after the provideAppContext middleware.
2222+ * @returns the application context
2323+ */
2424+export const getAppContext = (): AppContext => {
2525+ const { store } = getContext();
2626+ const ctx = store.inject(appContextKey);
2727+2828+ if (ctx === undefined) {
2929+ throw new Error('AppContext not found in request context');
3030+ }
3131+3232+ return ctx;
3333+};
+32
packages/danaus/src/web/middlewares/basic-auth.ts
···11+import type { Middleware } from '@oomfware/fetch-router';
22+33+import { parseBasicAuth } from '#app/auth/verifier.ts';
44+55+import { getAppContext } from './app-context.ts';
66+77+const REALM = 'admin';
88+99+/**
1010+ * middleware that requires HTTP Basic Authentication for admin access.
1111+ * uses the admin password from app config via async context.
1212+ */
1313+export const requireAdmin = (): Middleware => {
1414+ return async ({ request }, next) => {
1515+ const ctx = getAppContext();
1616+ const adminPassword = ctx.config.secrets.adminPassword;
1717+1818+ if (adminPassword === null) {
1919+ return new Response('Administration UI is disabled', { status: 403 });
2020+ }
2121+2222+ const auth = parseBasicAuth(request);
2323+ if (auth === null || auth.password !== adminPassword) {
2424+ return new Response('Unauthorized', {
2525+ status: 401,
2626+ headers: { 'www-authenticate': `Basic realm="${REALM}", charset="UTF-8"` },
2727+ });
2828+ }
2929+3030+ return next();
3131+ };
3232+};
+51
packages/danaus/src/web/middlewares/session.ts
···11+import { createInjectionKey, redirect, type Middleware } from '@oomfware/fetch-router';
22+import { getContext } from '@oomfware/fetch-router/middlewares/async-context';
33+44+import type { WebSession } from '#app/accounts/manager.ts';
55+import { readWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts';
66+77+import { getAppContext } from './app-context.ts';
88+99+const sessionKey = createInjectionKey<WebSession>();
1010+1111+/**
1212+ * middleware that requires a valid web session.
1313+ * redirects to login page if no session is found.
1414+ */
1515+export const requireSession = (): Middleware => {
1616+ return async ({ request, url, store }, next) => {
1717+ const ctx = getAppContext();
1818+ const path = url.pathname;
1919+2020+ const token = readWebSessionToken(request);
2121+ if (!token) {
2222+ redirect(`/account/login?redirect=${encodeURIComponent(path)}`);
2323+ }
2424+2525+ const sessionId = verifyWebSessionToken(ctx.config.secrets.jwtKey, token);
2626+ if (!sessionId) {
2727+ redirect(`/account/login?redirect=${encodeURIComponent(path)}`);
2828+ }
2929+3030+ const session = ctx.accountManager.getWebSession(sessionId);
3131+ if (!session) {
3232+ redirect(`/account/login?redirect=${encodeURIComponent(path)}`);
3333+ }
3434+3535+ store.provide(sessionKey, session);
3636+ return next();
3737+ };
3838+};
3939+4040+/**
4141+ * retrieves the current web session from the request context.
4242+ * must be called within a request handler after the requireSession middleware.
4343+ * @returns the web session
4444+ */
4545+export const getSession = (): WebSession => {
4646+ const session = getContext().store.inject(sessionKey);
4747+ if (!session) {
4848+ throw new Error('Session not found in request context');
4949+ }
5050+ return session;
5151+};
···11-import type { Child } from 'hono/jsx';
11+import type { JSXNode } from '@oomfware/jsx';
2233export interface AccordionItemProps {
44 /** whether the accordion item is open by default */
···66 /** group name for exclusive accordion behavior (only one open at a time) */
77 name?: string;
88 class?: string;
99- children?: Child;
99+ children?: JSXNode;
1010}
11111212/**