Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified apps/api/data/appbase.sqlite-shm
Binary file not shown.
Binary file modified apps/api/data/appbase.sqlite-wal
Binary file not shown.
5 changes: 5 additions & 0 deletions apps/api/src/plugins/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export async function registerInfrastructure(app: FastifyInstance, env: AppEnv)
version: "0.1.0",
},
servers: [{ url: env.BASE_URL }],
tags: [
{ name: "auth", description: "Registration, login, session, and tokens." },
{ name: "database", description: "User-scoped collections and records (Bearer JWT)." },
{ name: "system", description: "Operational and health endpoints." },
],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
Expand Down
210 changes: 206 additions & 4 deletions apps/api/src/routes/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,204 @@
}
}

const dbApiErrorSchema = {
type: "object",
properties: {
success: { type: "boolean", const: false },
error: {
type: "object",
properties: { code: { type: "string" }, message: { type: "string" } },
required: ["code", "message"],
},
},
required: ["success", "error"],
} as const;

const dbRecordSchema = {
type: "object",
properties: {
id: { type: "string" },
collection: { type: "string" },
ownerId: { type: "string" },
data: { type: "object", additionalProperties: true },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
required: ["id", "collection", "ownerId", "data", "createdAt", "updatedAt"],
} as const;

const collectionParamsSchema = {
type: "object",
required: ["collection"],
properties: {
collection: {
type: "string",
description: "Collection name (letters, digits, hyphens, underscores; max 64).",
},
},
} as const;

const collectionIdParamsSchema = {
type: "object",
required: ["collection", "id"],
properties: {
collection: {
type: "string",
description: "Collection name (letters, digits, hyphens, underscores; max 64).",
},
id: { type: "string", description: "Record id." },
},
} as const;

const dbSecurityBearer = [{ bearerAuth: [] }] as const;

const dbPostCreateOpenApi = {
tags: ["database"],
summary: "Create record",
description: "Insert a JSON document into a user-scoped collection.",
security: dbSecurityBearer,
params: collectionParamsSchema,
body: {
type: "object",
properties: {
data: { type: "object", additionalProperties: true, description: "JSON object to store (required by API)." },
},
},
response: {
201: {
type: "object",
properties: {
success: { type: "boolean", const: true },
data: dbRecordSchema,
},
required: ["success", "data"],
},
400: dbApiErrorSchema,
401: dbApiErrorSchema,
500: dbApiErrorSchema,
},
} as const;

const dbGetListOpenApi = {
tags: ["database"],
summary: "List records",
description: "Paginated list of records in a collection, with optional JSON filter on top-level data keys.",
security: dbSecurityBearer,
params: collectionParamsSchema,
querystring: {
type: "object",
properties: {
limit: { type: "string", description: "Page size (1–100, default 50)." },
offset: { type: "string", description: "Offset for pagination." },
filter: { type: "string", description: "URL-encoded JSON object of field equality filters." },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean", const: true },
data: {
type: "object",
properties: {
items: { type: "array", items: dbRecordSchema },
total: { type: "integer" },
},
required: ["items", "total"],
},
},
required: ["success", "data"],
},
400: dbApiErrorSchema,
401: dbApiErrorSchema,
},
} as const;

const dbSubscribeOpenApi = {
tags: ["database"],
summary: "Subscribe to collection changes",
description: "Server-Sent Events stream of created, updated, and deleted records for this collection.",
security: dbSecurityBearer,
params: collectionParamsSchema,
response: {
200: {
description: "Event stream (text/event-stream).",
type: "string",
},
400: dbApiErrorSchema,
401: dbApiErrorSchema,
},
} as const;

const dbGetOneOpenApi = {

Check failure on line 238 in apps/api/src/routes/db.ts

View workflow job for this annotation

GitHub Actions / build-and-test

'dbGetOneOpenApi' is assigned a value but never used

Check failure on line 238 in apps/api/src/routes/db.ts

View workflow job for this annotation

GitHub Actions / build-and-test

'dbGetOneOpenApi' is assigned a value but never used
tags: ["database"],
summary: "Get record",
description: "Fetch a single record by collection and id.",
security: dbSecurityBearer,
params: collectionIdParamsSchema,
response: {
200: {
type: "object",
properties: {
success: { type: "boolean", const: true },
data: dbRecordSchema,
},
required: ["success", "data"],
},
400: dbApiErrorSchema,
401: dbApiErrorSchema,
404: dbApiErrorSchema,
},
} as const;

const dbPutUpdateOpenApi = {
tags: ["database"],
summary: "Update record",
description: "Replace stored JSON data for a record.",
security: dbSecurityBearer,
params: collectionIdParamsSchema,
body: {
type: "object",
properties: {
data: { type: "object", additionalProperties: true, description: "JSON object to store (required by API)." },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean", const: true },
data: dbRecordSchema,
},
required: ["success", "data"],
},
400: dbApiErrorSchema,
401: dbApiErrorSchema,
404: dbApiErrorSchema,
},
} as const;

const dbDeleteOpenApi = {
tags: ["database"],
summary: "Delete record",
description: "Remove a record from a collection.",
security: dbSecurityBearer,
params: collectionIdParamsSchema,
response: {
200: {
type: "object",
properties: {
success: { type: "boolean", const: true },
data: { type: "object", properties: { deleted: { type: "boolean", const: true } }, required: ["deleted"] },
},
required: ["success", "data"],
},
400: dbApiErrorSchema,
401: dbApiErrorSchema,
404: dbApiErrorSchema,
},
} as const;

export async function registerDbRoutes(app: FastifyInstance) {
await app.register(
async (instance) => {
Expand All @@ -117,7 +315,7 @@
instance.post<{
Params: { collection: string };
Body: { data?: unknown };
}>("/collections/:collection", async (request, reply) => {
}>("/collections/:collection", { schema: dbPostCreateOpenApi }, async (request, reply) => {
const userId = requireUserId(request);
if (!userId) {
return reply.status(401).send(apiError("INVALID_TOKEN", "The provided access token is invalid or expired."));
Expand Down Expand Up @@ -182,7 +380,10 @@
});

// GET /db/collections/:collection/subscribe — must be before :id
instance.get<{ Params: { collection: string } }>("/collections/:collection/subscribe", async (request, reply) => {
instance.get<{ Params: { collection: string } }>(
"/collections/:collection/subscribe",
{ schema: dbSubscribeOpenApi },
async (request, reply) => {
const userId = requireUserId(request);
if (!userId) {
return reply.status(401).send(apiError("INVALID_TOKEN", "The provided access token is invalid or expired."));
Expand Down Expand Up @@ -219,7 +420,7 @@
instance.get<{
Params: { collection: string };
Querystring: { limit?: string; offset?: string; filter?: string };
}>("/collections/:collection", async (request, reply) => {
}>("/collections/:collection", { schema: dbGetListOpenApi }, async (request, reply) => {
const userId = requireUserId(request);
if (!userId) {
return reply.status(401).send(apiError("INVALID_TOKEN", "The provided access token is invalid or expired."));
Expand Down Expand Up @@ -296,7 +497,7 @@
instance.put<{
Params: { collection: string; id: string };
Body: { data?: unknown };
}>("/collections/:collection/:id", async (request, reply) => {
}>("/collections/:collection/:id", { schema: dbPutUpdateOpenApi }, async (request, reply) => {
const userId = requireUserId(request);
if (!userId) {
return reply.status(401).send(apiError("INVALID_TOKEN", "The provided access token is invalid or expired."));
Expand Down Expand Up @@ -363,6 +564,7 @@
// DELETE /db/collections/:collection/:id
instance.delete<{ Params: { collection: string; id: string } }>(
"/collections/:collection/:id",
{ schema: dbDeleteOpenApi },
async (request, reply) => {
const userId = requireUserId(request);
if (!userId) {
Expand Down
6 changes: 6 additions & 0 deletions apps/dashboard/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
1 change: 1 addition & 0 deletions docs/API-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,5 +548,6 @@ data: {"type":"created","collection":"passwords","record":{"id":"rec_123","site"
- This file defines the public AppBase contract.
- `docs/ARCHITECTURE.md` explains how that contract is hosted and routed.
- `better-auth` is an internal implementation choice; the public contract is cookie session + JWT access as defined here and in ADR-003.
- **Storage (`/storage/*`):** backend strategy decisions (FS-first driver abstraction, container volume persistence, metadata-based versioning) are recorded in `docs/adr/ADR-005-file-storage-strategy.md`.
- **Database (`/db/*`):** implementation decisions (owner scoping, M1 filter semantics, SSE in-process bus) are recorded in `docs/adr/ADR-004-database-api-service.md`.
- Dashboard authentication is intentionally separate from the public API contract.
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,3 +464,4 @@ Architecture decisions that shaped this document:
| [ADR-002 — ORM and Migration Strategy](./adr/ADR-002-orm-and-migration-strategy.md) | Drizzle ORM + `better-sqlite3` + `drizzle-kit` | Schema tables in §4 sourced directly from `packages/db/src/schema/`; `createDb(path)` factory enables per-app SQLite in M2+ |
| [ADR-003 — Auth Implementation](./adr/ADR-003-auth-implementation.md) | 3-token model (refresh token + JWT + API key); argon2id hashing; EdDSA signing | Auth flow in §3.1; `refresh_tokens` and `api_keys` schema in §4; JWT-on-hot-path pattern in §3.2 |
| [ADR-004 — Database API Service](./adr/ADR-004-database-api-service.md) | Fastify `/db/*` plugin; `records` document model; owner isolation; in-process SSE | §3.4 sequence; `records` in §4; `/db/*` routes in §5; complements API-SPEC §7 |
| [ADR-005 — File Storage Strategy](./adr/ADR-005-file-storage-strategy.md) | FS-first storage driver abstraction; container volume persistence; metadata-based versioning | §3.3 storage flow, `files.storage_path` in §4, persistence layout in §7; complements API-SPEC §6 |
Loading
Loading