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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ jobs:
- name: Unit tests (@appbase/db)
run: pnpm --filter @appbase/db test

- name: Unit tests (@appbase/sdk)
run: pnpm --filter @appbase/sdk test
- name: Unit tests (@appbase-pfe/sdk)
run: pnpm --filter @appbase-pfe/sdk test

- name: Unit tests (@appbase/storage)
run: pnpm --filter @appbase/storage test

- name: Unit & integration tests (api)
run: pnpm --filter api test
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/publish-sdk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Publish SDK packages

on:
push:
tags:
- "sdk-v*"

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.0.0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
registry-url: "https://registry.npmjs.org"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Publish @appbase-pfe/types
run: pnpm --filter @appbase-pfe/types publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Publish @appbase-pfe/sdk
run: pnpm --filter @appbase-pfe/sdk publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,5 @@ yarn-error.log*

apps/api/data/*.sqlite
apps/api/data/*.sqlite-shm
apps/api/data/*.sqlite-wal
apps/api/data/*.sqlite-wal
apps/api/data/storage/objects/*
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ LAN / Private VPC

For a deeper, implementation-level view of both the current M1 architecture and the target platform architecture, see [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md). For the public request/response contract consumed by SDKs and client apps, see [`docs/API-SPEC.md`](./docs/API-SPEC.md).

### Using the SDK from npm

The client libraries **`@appbase-pfe/types`** and **`@appbase-pfe/sdk`** are published to the **public npm registry** (see [ADR-007](./docs/adr/ADR-007-sdk-package-distribution.md) for scope and naming). Install into any app that talks to your deployed AppBase API:

```bash
npm install @appbase-pfe/sdk
```

Point `AppBase.init` at your API base URL and credentials; the wire format is defined in [`docs/API-SPEC.md`](./docs/API-SPEC.md). Optional React exports: `import { … } from "@appbase-pfe/sdk/react"` (install `react` ≥ 18).

Maintainers: release process and pack checks are in [`docs/PUBLISHING-SDK.md`](./docs/PUBLISHING-SDK.md).

### Tech Stack

|Layer |Technology |
Expand Down Expand Up @@ -146,10 +158,12 @@ For a deeper, implementation-level view of both the current M1 architecture and
```
AppBase/
├── apps/
│ ├── api/ # Core BaaS API
│ └── dashboard/ # Admin UI (Next.js)
│ ├── api/ # Core BaaS API
│ ├── dashboard/ # Admin UI (Next.js)
│ ├── todo-app/ # Next.js todo demo (`workspace:*` SDK)
│ └── todo-vanilla-npm/ # Vite + TS demo (SDK from **npm**)
├── packages/
│ ├── sdk/ # JS/TS client SDK (@appbase/sdk)
│ ├── sdk/ # JS/TS client SDK (@appbase-pfe/sdk)
│ ├── db/ # Schema + migrations
│ ├── types/ # Shared TypeScript interfaces
│ └── config/ # Shared tsconfig, eslint, prettier
Expand Down Expand Up @@ -252,7 +266,7 @@ In M1, password reset is handled through the app-specific dashboard rather than
The SDK is what makes this feel like Amplify and not just a REST API. It needs to do three things internally: store and refresh tokens automatically, inject the ID token into every storage/db request header, and manage the SSE subscription lifecycle.

```typescript
import { AppBase } from '@appbase/sdk'
import { AppBase } from '@appbase-pfe/sdk'

const client = AppBase.init({
endpoint: 'http://localhost:3000',
Expand Down
17 changes: 17 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
# Use `development` for local servers. By default every route needs a valid `x-api-key` in the API DB
# (`pnpm --filter api create-api-key`). To skip that locally (same behavior as Vitest: no key on /auth, /db, /storage):
# DEV_SKIP_API_KEY=true
# Never set DEV_SKIP_API_KEY in production. It is rejected unless NODE_ENV=development.
NODE_ENV=development
HOST=localhost
PORT=3000
# Paths below are relative to the `apps/api` package (not repo cwd), so `pnpm dev` works from monorepo root.
DB_PATH=data/appbase.sqlite

# Dev default: apps/api/data/storage. Production default: /app/data/storage
# STORAGE_ROOT=data/storage
# STORAGE_MAX_UPLOAD_BYTES=52428800
# STORAGE_ALLOWED_MIME=image/*,application/pdf,text/*
# STORAGE_DRIVER=fs

LOG_LEVEL=info
BASE_URL=http://localhost:3000

# Comma-separated browser origins allowed for credentialed CORS (e.g. your Next.js dev URL).
# CORS_ORIGINS=http://localhost:3001
# Better Auth: signs sessions/tokens for the API process only. Do not use this as x-api-key or as DASHBOARD_API_KEY.
AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-chars

# Instance API key (x-api-key) is created in the DB on first API start if missing; the full key is logged once.
# Regenerate anytime from the operator dashboard (Settings → API key) and use it in your app clients.
11 changes: 9 additions & 2 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ In M1, this service is the core runtime for:
- admin-facing management endpoints
- OpenAPI documentation

At the moment, this package is still in the scaffolding phase. The infrastructure is in place, but only the `/health` route is implemented.
Core routes include `/health`, `/auth/*`, `/db/*`, `/storage/*`, and OpenAPI `/docs`.

## Current Responsibilities

Expand Down Expand Up @@ -38,9 +38,15 @@ src/
│ └── not-found.ts # 404 handling
├── plugins/
│ ├── database.ts # DB decoration and initialization
│ └── infrastructure.ts # CORS, multipart, Swagger, Swagger UI
│ ├── auth.ts # better-auth integration
│ ├── infrastructure.ts # CORS, multipart, Swagger, Swagger UI
│ └── storage.ts # StorageDriver + volume readiness
├── storage/ # API wiring: factory, reconcile (shared driver: `@appbase/storage`)
├── routes/
│ ├── health.ts # /health route + OpenAPI schema
│ ├── auth.ts
│ ├── db.ts
│ ├── storage.ts
│ └── index.ts
├── types/
│ └── fastify.d.ts # Fastify instance decorations
Expand All @@ -55,6 +61,7 @@ Default runtime values:
- `HOST=0.0.0.0`
- `PORT=3000`
- `DB_PATH=data/appbase.sqlite`
- `STORAGE_ROOT` — development default `./data/storage`; production default `/app/data/storage` (use a volume)
- `LOG_LEVEL=info`

Optional / reserved values:
Expand Down
2 changes: 1 addition & 1 deletion apps/api/auth.http
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
### After Register/Login, copy `appbase_session=...` from the `Set-Cookie` response header into @sessionCookie below.

@baseUrl = http://localhost:8000
@apiKey = hs_live_mnlIKCBxPqvSGZLcDsNqAstnMihqTQwVAcwJESDFLxpgpBuxrvWzQBkpSjJkQVwl
@apiKey = hs_live_RbLrRNoHnssCxsbqPOhtjSXrGaedLbFJQMEPuLGpFQKDTPKbaWWrzzMoeIVABCPk
@sessionCookie = appbase_session=PASTE_VALUE_FROM_SET_COOKIE

###############################################################################
Expand Down
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.
9 changes: 6 additions & 3 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@
"type": "module",
"scripts": {
"create-api-key": "tsx scripts/create-dev-api-key.ts",
"promote-operator-admin": "tsx scripts/promote-operator-admin.ts",
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"check-types": "tsc --noEmit",
"lint": "eslint src",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"storage:reconcile": "tsx scripts/storage-reconcile.ts"
},
"dependencies": {
"@appbase/db": "workspace:*",
"@appbase/types": "workspace:*",
"@appbase/storage": "workspace:*",
"@appbase-pfe/types": "workspace:*",
"drizzle-orm": "^0.45.1",
"@better-auth/api-key": "^1.5.5",
"@fastify/cookie": "^11.0.2",
Expand All @@ -34,7 +37,7 @@
},
"devDependencies": {
"@appbase/config": "workspace:*",
"@appbase/sdk": "workspace:*",
"@appbase-pfe/sdk": "workspace:*",
"@types/node": "^22.13.14",
"pino-pretty": "^13.0.0",
"tsx": "^4.21.0",
Expand Down
20 changes: 9 additions & 11 deletions apps/api/scripts/create-dev-api-key.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/**
* Creates an API key for local development.
* Run: pnpm --filter api exec tsx scripts/create-dev-api-key.ts
* Legacy CLI: creates an instance API key in the DB.
* Prefer the operator dashboard: Settings → API key → Generate (no env required for first key).
*
* Start the API at least once first so the DB exists.
* Use this script only for automation/CI. Start the API once so the DB exists.
* Run: pnpm --filter api exec tsx scripts/create-dev-api-key.ts
*/
import { config as loadDotenv } from "dotenv";
import path from "node:path";
Expand All @@ -11,23 +12,21 @@ import { createDb } from "@appbase/db";
import * as schema from "@appbase/db/schema";
import { createAuth } from "../src/lib/auth";
import { loadEnv } from "../src/config/env";
import { API_KEY_INSTANCE_USER_ID } from "../src/constants/bootstrap-user";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
loadDotenv({ path: path.resolve(__dirname, "../.env"), quiet: true });

const env = loadEnv(process.env);
const dbPath = path.resolve(process.cwd(), env.DB_PATH);
const db = createDb(dbPath);

const APP_USER_ID = "app-default-bootstrap";
const db = createDb(env.DB_PATH);

async function ensureBootstrapUser() {
const users = await db.select().from(schema.user);
if (users.some((u) => u.id === APP_USER_ID)) return;
if (users.some((u) => u.id === API_KEY_INSTANCE_USER_ID)) return;

const now = new Date();
await db.insert(schema.user).values({
id: APP_USER_ID,
id: API_KEY_INSTANCE_USER_ID,
name: "App Bootstrap",
email: "bootstrap@appbase.local",
emailVerified: false,
Expand All @@ -43,8 +42,7 @@ async function main() {
const result = await auth.api.createApiKey({
body: {
name: "dev-api-key",
userId: APP_USER_ID,
expiresIn: 60 * 60 * 24 * 365, // 1 year
userId: API_KEY_INSTANCE_USER_ID,
},
});

Expand Down
37 changes: 37 additions & 0 deletions apps/api/scripts/promote-operator-admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Grant better-auth admin role to a user by email (operator console access).
* Run: pnpm --filter api exec tsx scripts/promote-operator-admin.ts user@example.com
*/
import { config as loadDotenv } from "dotenv";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { eq } from "drizzle-orm";
import { createDb } from "@appbase/db";
import { user } from "@appbase/db/schema";
import { loadEnv } from "../src/config/env";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
loadDotenv({ path: path.resolve(__dirname, "../.env"), quiet: true });

async function main() {
const email = process.argv[2]?.trim();
if (!email) {
console.error("Usage: tsx scripts/promote-operator-admin.ts <email>");
process.exit(1);
}

const env = loadEnv(process.env);
const db = createDb(env.DB_PATH);
const rows = await db.select({ id: user.id }).from(user).where(eq(user.email, email)).limit(1);
if (!rows[0]) {
console.error(`No user with email: ${email}`);
process.exit(1);
}
await db.update(user).set({ role: "admin", updatedAt: new Date() }).where(eq(user.id, rows[0].id));
console.log(`Updated ${email} → role admin. Use this account to sign in at the dashboard.`);
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
22 changes: 22 additions & 0 deletions apps/api/scripts/storage-reconcile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Operator helper: list metadata↔disk drift (see docs/STORAGE-OPERATIONS.md).
* Usage: from repo root, `pnpm --filter api exec tsx scripts/storage-reconcile.ts`
* (with `.env` or env vars for DB_PATH, STORAGE_ROOT).
*/
import { config as loadDotenv } from "dotenv";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createDb } from "@appbase/db";
import { loadEnv } from "../src/config/env";
import { createStorageDriver } from "../src/storage/factory";
import { reconcileFileStorage } from "../src/storage/reconcile";

const dir = path.dirname(fileURLToPath(import.meta.url));
loadDotenv({ path: path.resolve(dir, "../.env"), quiet: true });

const env = loadEnv(process.env);
const db = createDb(env.DB_PATH);
const driver = createStorageDriver(env);
const report = await reconcileFileStorage(db, driver);
console.log(JSON.stringify(report, null, 2));
db.$client.close();
7 changes: 7 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { registerMiddleware } from "./middleware";
import { registerDatabase } from "./plugins/database";
import { registerInfrastructure } from "./plugins/infrastructure";
import { registerAuth } from "./plugins/auth";
import { registerStorage } from "./plugins/storage";
import { ensureInstanceApiKeyAtStartup } from "./bootstrap/ensure-instance-api-key";
import { registerRoutes } from "./routes";

export interface BuildAppOptions {
Expand All @@ -21,8 +23,13 @@ export async function buildApp({ env }: BuildAppOptions): Promise<FastifyInstanc
await registerDatabase(app, env);
await registerAuth(app, env);
await registerInfrastructure(app, env);
await registerStorage(app, env);
await registerRoutes(app);
registerMiddleware(app);

app.addHook("onReady", async () => {
await ensureInstanceApiKeyAtStartup(app);
});

return app;
}
52 changes: 52 additions & 0 deletions apps/api/src/bootstrap/ensure-instance-api-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { apiKeys, user } from "@appbase/db/schema";
import { API_KEY_INSTANCE_USER_ID } from "../constants/bootstrap-user";

/**
* M1: ensure exactly one instance-scoped API key exists after DB + auth are ready.
* Skipped in tests. Logs the full key once so operators can set DASHBOARD_API_KEY without using the CLI script.
*/
export async function ensureInstanceApiKeyAtStartup(app: FastifyInstance): Promise<void> {
if (app.config.NODE_ENV === "test") return;

const existing = await app.db
.select({ id: apiKeys.id })
.from(apiKeys)
.where(eq(apiKeys.referenceId, API_KEY_INSTANCE_USER_ID))
.limit(1);
if (existing[0]) return;

const u = await app.db.select({ id: user.id }).from(user).where(eq(user.id, API_KEY_INSTANCE_USER_ID)).limit(1);
if (!u[0]) {
const now = new Date();
await app.db.insert(user).values({
id: API_KEY_INSTANCE_USER_ID,
name: "App Bootstrap",
email: "bootstrap@appbase.local",
emailVerified: false,
createdAt: now,
updatedAt: now,
});
}

try {
const created = (await app.auth.api.createApiKey({
body: {
name: "instance",
userId: API_KEY_INSTANCE_USER_ID,
},
})) as { key?: string };
const key = created.key;
if (key) {
app.log.warn(
{ event: "instance_api_key_auto_created" },
`Instance API key created at startup. Copy this value for your SDK/client x-api-key configuration: ${key}`,
);
} else {
app.log.error("instance_api_key_auto_create returned no key");
}
} catch (err) {
app.log.error({ err }, "instance_api_key_auto_create_failed");
}
}
Loading
Loading