# Blog Template

The blog template creates a fixed-org backend for a blog or CMS, deployed to Cloudflare Workers with D1. Public visitors can read content without authentication, while admins manage posts through their membership in one pinned organization.

This template uses [pinned organization mode](/configure/single-tenant). Organization support stays on, but the generated project hard-codes one organization id and hides org switching UI.

## Create the Project

```bash
quickback create blog my-site
cd my-site
```

This scaffolds a project with:

```
my-site/
├── quickback/
│   ├── quickback.config.ts       # Pinned-organization config
│   └── features/
│       └── posts/
│           ├── posts.ts          # Schema + security (defineTable)
│           └── actions/
│               ├── publish.ts    # Publish action
│               └── unpublish.ts  # Unpublish action
├── src/                          # Compiled output (generated)
├── package.json
├── tsconfig.json
├── wrangler.toml
└── drizzle.config.ts
```

## Generated Configuration

### quickback.config.ts

```typescript
export default {
  name: "my-site",
  template: "hono",
  features: {
    pinnedOrganizationId: "org_replace_me",
  },
  providers: {
    runtime: { name: "cloudflare", config: {} },
    database: {
      name: "cloudflare-d1",
      config: { binding: "DB" },
    },
    auth: { name: "better-auth", config: {} },
  },
};
```

Replace `org_replace_me` with a real Better Auth organization id before deploying. Unlike the removed single-tenant mode, this template keeps the Better Auth organization plugin and resolves roles from membership in that pinned org. See [Pinned Organization Mode](/configure/single-tenant) for details.

### Posts Feature (posts.ts)

The template includes a `posts` feature with public read access and admin-only write access:

```typescript
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { defineTable } from "@quickback/compiler";

export const posts = sqliteTable("posts", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  content: text("content"),
  excerpt: text("excerpt"),
  status: text("status").notNull().default("draft"),
  publishedAt: text("published_at"),

  // Author tracking only — this is metadata, not access control.
  // Named "authorId" (not "userId") because a `userId` column on a feature
  // table makes the compiler complain: it is not auto-detected as the owner
  // column (that name is `ownerId`), and it errors when it is the only
  // candidate column on the table. Reads here are gated by PUBLIC; writes by
  // the admin access check below.
  authorId: text("author_id").notNull(),

  // Compiler-managed columns — the audit quartet on every table, plus the
  // soft-delete pair because `delete.mode` is "soft".
  createdAt: text("created_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
  modifiedAt: text("modified_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
  createdBy: text("created_by"),
  modifiedBy: text("modified_by"),
  deletedAt: text("deleted_at"),
  deletedBy: text("deleted_by"),
});

export default defineTable(posts, {
  // PUBLIC reads — no isolation predicate at the firewall layer.
  // Anonymous viewers have no ctx.userId, so a userId/orgId WHERE clause
  // would gate every row out. softDelete keeps deleted posts hidden.
  firewall: {
    softDelete: {},
  },
  guards: {
    createable: ["title", "slug", "content", "excerpt", "status", "publishedAt"],
    updatable: ["title", "slug", "content", "excerpt", "status", "publishedAt"],
    immutable: ["id", "authorId"],
  },
  read: {
    access: { roles: ["PUBLIC"] },
  },
  // "admin" = org-membership admin role, resolved from membership in
  // the pinned organization (features.pinnedOrganizationId).
  create: { access: { roles: ["admin"] } },
  update: { access: { roles: ["admin"] } },
  delete: { access: { roles: ["admin"] }, mode: "soft" },
});
```

- **`PUBLIC`** role on `read.access` means no authentication required for `GET /` and `GET /:id`
- **`admin`** is the org-membership admin role — an authenticated user whose membership in the pinned organization has `role: "admin"` (or above). For platform-wide roles stored on `user.role`, use `userRole: [...]` instead — see [Pinned Organization Mode](/configure/single-tenant)
- **`authorId`** is plain metadata recording who wrote the post — it is deliberately *not* a firewall scope, because an owner predicate would hide every post from anonymous readers
- **`softDelete`** marks posts as deleted rather than removing them from the database

### Post Actions (actions/publish.ts, actions/unpublish.ts)

The template scaffolds one file per action under `features/posts/actions/`. Each is a `defineAction` with a record-level access condition and an `execute` handler:

```typescript
// quickback/features/posts/actions/publish.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
// The generated per-feature helper re-exports the bound table, so one import
// gets you both — and `posts` here is the GENERATED table, with the audit
// columns visible on `record` without `as any`.
import { defineAction, posts } from "../.quickback/define-action";

export default defineAction({
  description: "Publish a draft post",
  input: z.object({}),
  access: {
    roles: ["admin"],
    record: { status: { equals: "draft" } },
  },
  sideEffects: "sync",
  async execute({ db, record }) {
    const [updated] = await db
      .update(posts)
      .set({
        status: "published",
        publishedAt: new Date().toISOString(),
      })
      .where(eq(posts.id, record.id))
      .returning();
    return updated;
  },
});
```

`unpublish.ts` is the mirror image — it requires `status: "published"` and reverts the post to draft. These actions are admin-only and include record-level access conditions: you can only publish drafts and unpublish published posts.

## Setup Steps

### 1. Install Dependencies

```bash
npm install
```

### 2. Log In to the Compiler

```bash
quickback login
```

### 3. Compile Your Definitions

```bash
quickback compile
```

### 4. Create D1 Databases

Like every D1 project, the blog template uses dual database mode by default — separate databases for auth and application data:

```bash
npx wrangler d1 create my-site-auth
npx wrangler d1 create my-site-features
```

Copy the database IDs from the output and update your `wrangler.toml`:

```toml
[[d1_databases]]
binding = "AUTH_DB"
database_name = "my-site-auth"
database_id = "paste-auth-id-here"

[[d1_databases]]
binding = "DB"
database_name = "my-site-features"
database_id = "paste-features-id-here"
```

### 5. Run Migrations (Local)

```bash
npm run db:migrate:local
```

### 6. Start Development Server

```bash
npm run dev
```

Your API is running at `http://localhost:8787`.

## Using the API

### Public Endpoints (No Auth Required)

```bash
# List all posts
curl http://localhost:8787/api/v1/posts

# Get a single post
curl http://localhost:8787/api/v1/posts/post-id-here
```

### Admin Endpoints (Auth Required)

```bash
# Create a post (admin only)
curl -X POST http://localhost:8787/api/v1/posts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "Hello World", "slug": "hello-world", "content": "..."}'

# Publish a post (admin only) — record actions mount at /:id/{actionName}
curl -X POST http://localhost:8787/api/v1/posts/post-id/publish \
  -H "Authorization: Bearer $TOKEN"
```

### Pairing with a Frontend

Since this is a JSON API, pair it with any frontend framework:

```typescript
// Astro, Next.js, SvelteKit, etc.
const posts = await fetch('https://api.mysite.com/api/v1/posts').then(r => r.json());

// Admin operations require authentication
const res = await fetch('https://api.mysite.com/api/v1/posts', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ title: 'New Post', slug: 'new-post', content: '...' }),
});
```

## Adding More Features

Add new content types by creating feature directories. For example, a `pages` feature for static pages:

```typescript
// quickback/features/pages/pages.ts
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { defineTable } from "@quickback/compiler";

export const pages = sqliteTable("pages", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  content: text("content"),
  authorId: text("author_id").notNull(),

  createdAt: text("created_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
  modifiedAt: text("modified_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
  createdBy: text("created_by"),
  modifiedBy: text("modified_by"),
  deletedAt: text("deleted_at"),
  deletedBy: text("deleted_by"),
});

export default defineTable(pages, {
  // PUBLIC reads need an open firewall — an owner predicate would hide
  // every row from anonymous visitors. softDelete still hides deletions.
  firewall: { softDelete: {} },
  read: {
    access: { roles: ["PUBLIC"] },
  },
  create: { access: { roles: ["admin"] } },
  update: { access: { roles: ["admin"] } },
  delete: { access: { roles: ["admin"] }, mode: "soft" },
});
```

Then recompile:

```bash
quickback compile
```

## Deploying to Production

```bash
# Apply remote migrations
npm run db:migrate:remote

# Deploy to Cloudflare
npm run deploy
```

## Next Steps

- [Pinned Organization Mode](/configure/single-tenant) — How fixed-org mode works
- [Access Control](/define/access) — PUBLIC role and role-based permissions
- [Custom Actions](/define/actions) — Add business logic beyond CRUD
- [Cloudflare Template](/start/template-cloudflare) — Multi-tenant alternative
