Single Bun + TypeScript service providing a package repository API (Aptly-backed) with user management, S3 storage, and task scheduling. This is not a monorepo.
- Framework: Hono (Bun-optimized HTTP framework) with OpenAPI spec generation via
hono-openapi - Database: SQLite via Drizzle ORM (
bun:sqlitedriver) with migrations insrc/db/drizzle/ - Validation: Zod for request/response validation and OpenAPI schema generation
- Task Queue: In-process
TaskSchedulerwith a DB-backed processing loop - Package Storage: S3-compatible (configurable endpoint via
LRA_S3_*env vars) - Auth: Session tokens (
lra_sess_) and API keys (lra_apikey_) with Bearer token header
src/
├── index.ts # Bootstrap, graceful shutdown
├── api/
│ ├── index.ts # Hono app setup, CORS, error handler, version registration
│ ├── utils/
│ │ ├── api-res.ts # APIResponse class + Zod schemas + types
│ │ ├── specHelpers.ts # APIRouteSpec, APIResponseSpec (OpenAPI helpers)
│ │ ├── apiVersionRouter.ts # Abstract APIVersionRouter base class
│ │ ├── authHandler.ts # AuthHandler, SessionHandler, APIKeyHandler
│ │ ├── metadata.ts # RuntimeMetadata (generic DB-backed key-value)
│ │ └── shared-models/ # Zod models per resource (package, auth, publisher, etc.)
│ └── versions/v1/
│ ├── index.ts # V1 router, mounts sub-routers
│ ├── middleware/auth.ts # Bearer token auth middleware
│ ├── docs/index.ts # OpenAPI tag constants
│ └── routes/ # Route handlers organized by resource
│ ├── auth/
│ ├── account/
│ ├── packages/
│ ├── publishers/
│ └── admin/
├── db/
│ ├── index.ts # DB class + DB.Tables + DB.Models namespaces
│ ├── schema.ts # Drizzle table/view definitions (snake_case columns)
│ └── drizzle/ # Migration files (auto-generated)
├── aptly/api-client/ # Generated — DO NOT hand-edit
├── utils/
│ ├── config.ts # ConfigSchema builder + ConfigHandler
│ ├── permission-helper.ts # Role-based access control
│ ├── logger.ts # Leveled logger
│ └── index.ts # General utilities (Utils class)
├── types/ # Shared types
└── middleware/ # Global middleware
- Main boot (
src/index.ts): load config → init DB → init permission helper → ensure log dir → start task queue processing → init Aptly → sync additional live-repo files → init API → start Aptly → start Hono API bun run start(and compiled binaries) usescripts/entrypoint.ts, which force-setsLRA_DB_AUTO_MIGRATE=true(auto-migrate on startup)bun run devrunssrc/index.tsdirectly — does not force migrations- When the users table is empty, startup creates a default
adminuser and writes a reset URL to${LRA_CONFIG_BASE_DIR}/initial_admin_password_reset_token.txt
- Required env vars are enforced in
src/utils/config.ts; missing required keys exit the process - Required keys:
LRA_PRIVATE_KEY_PATH,LRA_PUBLIC_KEY_PATH, allLRA_S3_*settings - Boolean env parsing is strict: only the string
"true"→true; everything else →false - First Aptly startup downloads a binary to
${LRA_APTLY_ROOT}/bin/aptly; host needs network access andunzip - Package upload verification runs
dpkg --info; keepdpkgavailable for release upload flows and tests
| Action | Command |
|---|---|
| Install deps | bun install |
| Dev server | bun run dev |
| Start (auto-migrate) | bun run start |
| Typecheck | bun run typecheck (checks src, tests, scripts) |
| Run all tests | bun test |
| Run one test file | bun test tests/permission-helper.test.ts |
| DB workflow after schema edits | bun run db:generate then bun run db:migrate |
| Regenerate Aptly API client | bun run aptly-api-client:generate |
| Compile binary | bun run compile <platform|auto|all> [version] [--no-version-tag] |
| Docker build input | build/bin/leios-api-linux-x64-baseline — generate with bun run compile linux-x64-baseline --no-version-tag |
bunfig.tomlpreloadstests/helpers/preload.tsfor every test run- Preload builds a self-contained test env: temp DB, generated GPG keys, local
s3rver, then starts the Hono API on port 12151 and Aptly on a random free dynamic port - Tests are integration-heavy (DB + Aptly + S3-style publish config + generated local GPG keys), not pure unit tests
- The test preload does not start
TaskScheduler.processQueue()— routes that enqueue tasks only create DB task records unless a test starts the scheduler explicitly tests/aptly.test.tsneeds.debfixtures undertestdata/
Every route handler follows this exact sequence:
router.get('/',
// 1. OpenAPI spec
APIRouteSpec.authenticated({ summary: "List packages", ... }),
// 2. Zod validation (query, param, body, or form)
zValidator("query", PackageModel.GetAll.Query),
// 3. Handler — destructure valid data + auth context
async (c) => {
const { limit, offset } = c.req.valid("query");
const authContext = c.get("authContext") as AuthHandler.AuthContext;
// 4. Return via APIResponse
return APIResponse.success(c, "Packages retrieved successfully", results);
}
);- Route composition: Each resource exports a
Honorouter with.basePath(); sub-routers are mounted viaparentRouter.route('/:param', subRouter) - Parameter loading middleware:
router.use('/:fullPackageName/*', zValidator("param", ...), async (c, next) => { ... })loads a DB resource and stores it withc.set("key", value), thenreturn await next() - Auth check inside handlers: Always check
authContext.type— it can be'unauthenticated','session', or'apikey'. Some endpoints branch behavior based on auth level - @ts-ignore: Used before
c.get("authContext")casts because Hono context isn't strictly typed for customc.setvalues
All success responses have the shape:
{ success: true, code: 200, message: string, data: T }| Method | HTTP Code |
|---|---|
APIResponse.success(c, msg, data) |
200 |
APIResponse.successNoData(c, msg) |
200 (data: null) |
APIResponse.created(c, msg, data) |
201 |
APIResponse.createdNoData(c, msg) |
201 |
APIResponse.accepted(c, msg, data) |
202 |
Error responses have no data field — shape is:
{ success: false, code: 4xx|5xx, message: string }| Method | HTTP Code |
|---|---|
APIResponse.badRequest(c, msg) |
400 |
APIResponse.unauthorized(c, msg) |
401 |
APIResponse.forbidden(c, msg) |
403 |
APIResponse.notFound(c, msg) |
404 |
APIResponse.conflict(c, msg) |
409 |
APIResponse.tooManyRequests(c, msg) |
429 |
APIResponse.serverError(c, msg) |
500 |
APIResponse.Types— Type-level helpers (RequiredReturnData,NonRequiredReturnData,BasicReturnData)APIResponse.Schema— Zod schemas for each response (used in OpenAPI spec)APIResponse.Utils—genericErrorSchema()factory,createErrorSchemaFactory()curried helper
APIRouteSpec.authenticated({...})— wrapsdescribeRoutewithsecurity: [{ bearerAuth: [] }]APIRouteSpec.unauthenticated({...})— no securityAPIResponseSpec.success(msg, dataSchema)/.created()/.accepted()/.serverError()etc.APIResponseSpec.describeBasic(responses)— merges multiple response schemasAPIResponseSpec.describeWithWrongInputs(responses)— same as basic but auto-adds 400 response
- Global middleware (
src/api/versions/v1/middleware/auth.ts) runs on every v1 route - If no
Authorizationheader: setsc.set("authContext", { type: 'unauthenticated' })and continues - If
Bearer <token>present: validates viaAuthHandler.getAuthContext(token), sets authenticated context on success, returns 401 on failure - Per-subtree guards: admin routes add a second middleware checking
authContext.user_role !== 'admin'; account routes checkauthContext.type !== 'session'
type AuthContext = {
type: 'unauthenticated'
} | {
type: 'session' | 'apikey'
user_id: number
user_role: string
publisher_memberships: number[]
session_id?: number
}- Session tokens prefix:
lra_sess_ - API keys prefix:
lra_apikey_ - Format:
{prefix}{id}:{base64random} - Bases are hashed with
Bun.password.hash()before DB storage
All queries go through Drizzle ORM via the DB singleton:
import { DB } from "../../../db";
import { and, eq, ilike, or, SQL } from "drizzle-orm";// Simple select with get()
const user = DB.instance().select()
.from(DB.Tables.users)
.where(eq(DB.Tables.users.username, username))
.get();
// Select specific columns
const publisher = DB.instance()
.select({ id: DB.Tables.publishers.id })
.from(DB.Tables.publishers)
.where(eq(DB.Tables.publishers.name, publisherName))
.get();
// Dynamic filter arrays (preferred for optional filters)
const filters: Array<SQL<unknown> | undefined> = [];
if (publisherID) filters.push(eq(DB.Tables.packages.publisher_id, publisherID));
if (search) filters.push(or(ilike(DB.Tables.packages.name, `%${search}%`)));
const results = await DB.instance().select()
.from(DB.Tables.packages)
.where(filters.length > 0 ? and(...filters) : undefined)
.limit(limit).offset(offset);
// Insert with returning
const newUser = DB.instance().insert(DB.Tables.users)
.values({ username, hashed_password })
.returning().get();
// Update
DB.instance().update(DB.Tables.users)
.set({ role: 'admin' })
.where(eq(DB.Tables.users.id, userId));
// Transaction
DB.instance().transaction(async (tx) => {
await tx.delete(DB.Tables.sessionTokens).where(eq(DB.Tables.sessionTokens.user_id, userId));
await tx.insert(DB.Tables.auditLog).values({ ... });
});
// Dynamic query with $dynamic()
let query = DB.instance().select({ ... }).from(DB.Tables.publishers).$dynamic();
if (condition) query = query.innerJoin(DB.Tables.publisherMembers, and(...));DB.Tables.*— Drizzle table instances (maps camelCase to snake_case)DB.Models.*— TypeScript type aliases for table rows (DB.Models.PackageFullView)- All tables have snake_case DB columns but camelCase TS property names (Drizzle handles the mapping)
Every major module uses a class with a same-namespace for associated types:
export class AuthHandler { ... }
export namespace AuthHandler.AuthContext { /* types */ }
export namespace AuthHandler.TokenParts { /* types */ }Used by: APIResponse, AuthHandler, PermissionHelper, DB, Logger, Utils, APIVersionRouter
Each route model group uses nested namespaces for request/response types:
export namespace PackageModel.GetAll {
export const Query = z.object({ limit: z.number(), offset: z.number() });
export type Query = z.infer<typeof Query>;
export const Response = z.object({ ... });
export type Response = z.infer<typeof Response>;
}strict: true,noUncheckedIndexedAccess: true,verbatimModuleSyntax: true- Use
import typefor type-only imports (required byverbatimModuleSyntax) satisfiesoverasfor return type verification:results satisfies PackageModel.GetAll.Responseascasts forc.get():c.get("authContext") as AuthHandler.AuthContextUtils.asExact<Shape>()— identity function enforcing exact shape (no extra properties)
| Category | Convention | Examples |
|---|---|---|
| File names | kebab-case | api-res.ts, permission-helper.ts, os-release-utils.ts |
| Module entry | index.ts |
Always the barrel/entry file |
| Functions/methods | camelCase | getUserRole(), createSession(), isValidSession() |
| Classes/types/namespaces | PascalCase | APIResponse, AuthHandler, PackageModel |
| Constants | UPPER_SNAKE_CASE | SESSION_TOKEN_PREFIX, LOGIN_MAX_ATTEMPTS |
| Env vars | UPPER_SNAKE_CASE | LRA_LOG_LEVEL, LRA_DB_PATH |
| DB tables/columns | snake_case | publisher_members, created_at, user_id |
| Test files | *.test.ts |
permission-helper.test.ts |
- Global error handler registered in
src/api/index.tsviaapp.onError() - Catches
HTTPException(Hono's native error) — extracts Zod validation issues from the response body - Unknown errors → log with
Logger.error()and return 500 - Per-route: use
try/catcharound DB/aptly operations, returning appropriateAPIResponse.*error - Per-route validation:
zValidatormiddleware auto-returns 400 on schema mismatch
Uses a custom fluent ConfigSchema builder (not Zod):
private static schema = new ConfigSchema()
.add("LRA_LOG_LEVEL", false, ["debug", "info", "warn", "error", "critical"]) // optional, validated
.add("LRA_API_DISABLE_DOCS", false, [true, false]) // optional boolean (parses "true"/"false")
.add("LRA_DB_PATH", false) // optional string
.add("LRA_PRIVATE_KEY_PATH", true) // required string (exit if missing)- Config is typed via conditional types: required fields are
string | boolean, optional fields arestring | boolean | undefined - Parsed once at startup via
ConfigHandler.loadConfig(), cached in a private static field - Accessed via
ConfigHandler.getConfig()typed getter
src/aptly/api-client/**is generated byopenapi-ts— do not hand-edit- After DB schema changes, commit both
drizzle/*.sqlanddrizzle/meta/* - Keep all API responses aligned with
APIResponse(never return ad-hoc JSON from handlers) - Package/release delete/upload routes enqueue
testing-repo:updatetasks — these only execute when the task scheduler loop is running
Custom / commands defined in .claude/settings.json:
/verify— Typecheck then run relevant tests/typecheck— Run TypeScript typecheck only/test— Run the test suite (with test env context)