# Self-hosted work hub implementation plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a secure, self-hosted daily-work MVP that lets a small team create, organize, discuss, schedule, document, and find authorized work without attempting full ClickUp parity.

**Architecture:** Implement a workspace-scoped modular monolith with a transactional API and worker process backed by PostgreSQL and private S3-compatible storage. Add a separately deployable collaboration runtime for live documents after the Stage 0 authorization, event, and recovery gates pass. List, Board, Table, and Calendar are projections of one work-item query contract; document storage is a schema-controlled rich-text subsystem with Markdown import/export compatibility.

**Tech Stack:** TypeScript, React, Node.js API runtime, PostgreSQL 16, PostgreSQL row-level security, Redis for ephemeral presence/rate limits only, S3-compatible object storage, Tiptap/ProseMirror, Yjs, Docker Compose, OpenTelemetry.

**Spec:** `/home/ubuntu/clickup-alternatives-research/build-option/00-build-strategy-synthesis.md`

## Global Constraints

- Maintain a single authoritative work-item graph: `workspace → space → project → task → one-level subtask`.
- Require `workspace_id` on every protected row and never accept it as an unvalidated client authority.
- Enforce `can(principal, action, resource)` on HTTP, WebSocket, search, files, exports, jobs, and webhooks; use PostgreSQL RLS only as defense in depth.
- Store rich document JSON as canonical content; Markdown is a lossy interchange format with explicit warnings.
- Use a transactional outbox for every state-changing side effect; consumers must be idempotent.
- Do not introduce OpenSearch, Kubernetes, Temporal, a plugin marketplace, public sharing, multiple task locations, recurrence, workload planning, or AI in Release 1.
- Run all local services through Docker Compose and verify a clean-room database-plus-file restore before production authorization.
- Preserve AGPL/GPL attribution and obtain license review before modifying or redistributing any upstream fork.

---

## File structure

| Path | Responsibility |
|---|---|
| `apps/web/` | React workspace, task views, Docs, and accessibility-focused UI |
| `apps/api/src/authz/` | Principal context, authorization evaluator, route guards, and permission tests |
| `apps/api/src/work/` | Workspaces, projects, tasks, views, comments, and validated query AST |
| `apps/api/src/documents/` | Document metadata, Tiptap schema, snapshots, Markdown adapters, and comments |
| `apps/api/src/files/` | Authorized upload/download issuance and attachment metadata |
| `apps/worker/src/outbox/` | Idempotent outbox processing, retry state, and dead-letter inspection |
| `apps/collaboration/` | Authenticated Yjs room admission, document updates, snapshots, and revocation |
| `packages/contracts/` | Shared Zod schemas, API DTOs, event names, and permissions |
| `packages/query-ast/` | Versioned saved-view AST validator and SQL compiler with allowlisted fields |
| `db/migrations/` | Versioned PostgreSQL schema, RLS policies, indexes, and seed roles |
| `infra/compose/` | Local production-like Compose manifests and environment templates |
| `tests/e2e/` | Permission matrix, restore, worker, collaboration, and core workflow tests |
| `docs/operations/` | Runbooks for backup, restore, upgrades, incident response, and secrets |

---

### Task 1: Establish the reproducible service baseline

**Files:**
- Create: `infra/compose/compose.yaml`
- Create: `infra/compose/.env.example`
- Create: `apps/api/Dockerfile`
- Create: `apps/worker/Dockerfile`
- Create: `docs/operations/local-environment.md`
- Test: `infra/compose/compose.smoke.sh`

**Interfaces:**
- Consumes: environment variables in `.env.example`
- Produces: `docker compose -f infra/compose/compose.yaml up --wait` starts PostgreSQL, MinIO, Redis, API, worker, and web services.

- [ ] **Step 1: Write the smoke check before the Compose manifest**

```bash
#!/usr/bin/env bash
set -euo pipefail
curl --fail --silent --show-error http://localhost:3000/healthz | grep -qx 'ok'
pg_isready --host localhost --port 5432 --username workhub
```

- [ ] **Step 2: Run the smoke check and record the expected failure**

Run: `bash infra/compose/compose.smoke.sh`

Expected: `FAIL` because the health endpoint and database do not yet exist.

- [ ] **Step 3: Define the minimum Compose topology**

```yaml
services:
  postgres:
    image: postgres:16.9-alpine
  minio:
    image: minio/minio:RELEASE.2025-04-08T15-41-24Z
  redis:
    image: redis:7.4-alpine
  api:
    build: ../../apps/api
  worker:
    build: ../../apps/worker
  web:
    build: ../../apps/web
```

- [ ] **Step 4: Run the smoke check after services start**

Run: `docker compose -f infra/compose/compose.yaml up --build --wait && bash infra/compose/compose.smoke.sh`

Expected: `PASS` with all declared persistent volumes named and no plaintext secrets committed.

- [ ] **Step 5: Commit**

```bash
git add infra/compose apps/api/Dockerfile apps/worker/Dockerfile docs/operations/local-environment.md
git commit -m "chore: add reproducible work hub environment"
```

### Task 2: Implement workspace isolation and authorization primitives

**Files:**
- Create: `packages/contracts/src/permissions.ts`
- Create: `apps/api/src/authz/can.ts`
- Create: `apps/api/src/authz/principal-context.ts`
- Create: `db/migrations/0001_workspace_security.sql`
- Test: `apps/api/src/authz/can.test.ts`
- Test: `tests/e2e/workspace-isolation.spec.ts`

**Interfaces:**
- Consumes: `Principal`, `Action`, `ResourceScope`
- Produces: `can(principal: Principal, action: Action, scope: ResourceScope): boolean`

- [ ] **Step 1: Write the permission contract and failing matrix test**

```ts
export type Action = 'workspace.view' | 'task.view' | 'task.comment' | 'task.edit' | 'project.manage';
export type Role = 'owner' | 'manager' | 'member' | 'viewer';

expect(can(member, 'task.edit', ownProject)).toBe(true);
expect(can(viewer, 'task.edit', ownProject)).toBe(false);
expect(can(member, 'task.view', otherWorkspaceProject)).toBe(false);
```

- [ ] **Step 2: Run the targeted test and verify it fails**

Run: `pnpm --filter api test src/authz/can.test.ts`

Expected: `FAIL` because `can` is not defined.

- [ ] **Step 3: Add application policy and RLS migration**

```sql
ALTER TABLE task ENABLE ROW LEVEL SECURITY;
CREATE POLICY task_workspace_scope ON task
  USING (workspace_id = current_setting('app.workspace_id', true)::uuid)
  WITH CHECK (workspace_id = current_setting('app.workspace_id', true)::uuid);
```

- [ ] **Step 4: Test cross-workspace denial on API and database paths**

Run: `pnpm --filter api test src/authz/can.test.ts && pnpm test:e2e tests/e2e/workspace-isolation.spec.ts`

Expected: `PASS`; a principal from Workspace A cannot fetch, count, update, search, or enumerate a Workspace B task.

- [ ] **Step 5: Commit**

```bash
git add packages/contracts apps/api/src/authz db/migrations tests/e2e
git commit -m "feat: enforce workspace authorization boundaries"
```

### Task 3: Deliver the canonical work-item command and query model

**Files:**
- Create: `apps/api/src/work/task-service.ts`
- Create: `apps/api/src/work/task-repository.ts`
- Create: `packages/query-ast/src/schema.ts`
- Create: `packages/query-ast/src/compile.ts`
- Create: `db/migrations/0002_work_items.sql`
- Test: `apps/api/src/work/task-service.test.ts`
- Test: `packages/query-ast/src/compile.test.ts`

**Interfaces:**
- Consumes: `CreateTaskCommand`, `TaskQueryAstV1`
- Produces: `createTask(command): Promise<Task>` and `compileTaskQuery(ast, principal): CompiledQuery`

- [ ] **Step 1: Write the failing command test**

```ts
const created = await createTask({
  workspaceId,
  projectId,
  title: 'Ship migration pilot',
  status: 'todo',
  assigneeIds: [memberId],
  dueOn: '2026-10-15'
});
expect(created.version).toBe(1);
expect(created.activity[0].type).toBe('task.created');
```

- [ ] **Step 2: Run the command test and verify it fails**

Run: `pnpm --filter api test src/work/task-service.test.ts`

Expected: `FAIL` because `createTask` has no implementation.

- [ ] **Step 3: Implement transactional command handling**

```ts
await database.transaction(async (tx) => {
  const task = await tasks.insert(tx, command);
  await activity.append(tx, { type: 'task.created', taskId: task.id });
  await outbox.insert(tx, { topic: 'task.created', aggregateId: task.id });
  return task;
});
```

- [ ] **Step 4: Add one versioned saved-view AST test**

```ts
const ast: TaskQueryAstV1 = {
  version: 1,
  filters: [{ field: 'status', op: 'in', values: ['todo', 'doing'] }],
  sort: [{ field: 'dueOn', direction: 'asc' }]
};
expect(compileTaskQuery(ast, principal).sql).not.toContain('status IN (');
```

- [ ] **Step 5: Run tests and commit**

Run: `pnpm --filter api test src/work/task-service.test.ts && pnpm --filter query-ast test`

Expected: `PASS`; all SQL values are parameterized and field names are allowlisted.

```bash
git add apps/api/src/work packages/query-ast db/migrations
git commit -m "feat: add canonical work item and saved view query model"
```

### Task 4: Project the same task model as List, Board, Table, and Calendar

**Files:**
- Create: `apps/web/src/features/tasks/TaskListView.tsx`
- Create: `apps/web/src/features/tasks/TaskBoardView.tsx`
- Create: `apps/web/src/features/tasks/TaskTableView.tsx`
- Create: `apps/web/src/features/tasks/TaskCalendarView.tsx`
- Create: `apps/web/src/features/tasks/useTaskQuery.ts`
- Test: `apps/web/src/features/tasks/views.spec.tsx`

**Interfaces:**
- Consumes: `TaskQueryAstV1`, `Task[]`, `updateTask(id, expectedVersion, patch)`
- Produces: four renderers that mutate the same task endpoint and display conflict resolution.

- [ ] **Step 1: Write a shared-view consistency test**

```tsx
render(<TaskBoardView query={dueSoonQuery} />);
await user.click(screen.getByRole('button', { name: 'Move Ship migration pilot to Doing' }));
expect(await screen.findByText('Doing')).toBeVisible();
expect(api.updateTask).toHaveBeenCalledWith(taskId, 1, { status: 'doing' });
```

- [ ] **Step 2: Verify the test fails**

Run: `pnpm --filter web test src/features/tasks/views.spec.tsx`

Expected: `FAIL` because `TaskBoardView` does not exist.

- [ ] **Step 3: Implement renderers over one hook**

```ts
export function useTaskQuery(query: TaskQueryAstV1) {
  return useQuery({ queryKey: ['tasks', query], queryFn: () => api.tasks.query(query) });
}
```

- [ ] **Step 4: Add optimistic-concurrency conflict behavior**

```ts
if (error.code === 'TASK_VERSION_CONFLICT') {
  showConflictDialog({ serverTask: error.current, attemptedPatch });
}
```

- [ ] **Step 5: Run component and accessibility tests, then commit**

Run: `pnpm --filter web test src/features/tasks/views.spec.tsx && pnpm --filter web test:a11y`

Expected: `PASS`; keyboard users can move a board task and each renderer exposes task state accessibly.

```bash
git add apps/web/src/features/tasks
git commit -m "feat: render common tasks across daily work views"
```

### Task 5: Add a rich Docs subsystem with recovery-safe Markdown interchange

**Files:**
- Create: `apps/api/src/documents/document-service.ts`
- Create: `apps/api/src/documents/markdown-adapter.ts`
- Create: `apps/collaboration/src/room-authorizer.ts`
- Create: `apps/web/src/features/docs/DocumentEditor.tsx`
- Create: `db/migrations/0003_documents.sql`
- Test: `apps/api/src/documents/markdown-adapter.test.ts`
- Test: `tests/e2e/document-recovery.spec.ts`

**Interfaces:**
- Consumes: `DocumentContentV1`, `RelativePosition`, `DocumentPermission`
- Produces: `importMarkdown(markdown): ImportResult` and `exportMarkdown(content): ExportResult` with explicit loss warnings.

- [ ] **Step 1: Write loss-aware Markdown fixture tests**

```ts
const result = importMarkdown('| Name | Status |\n| --- | --- |\n| Pilot | Active |');
expect(result.content.type).toBe('doc');
expect(result.warnings).toEqual([]);
expect(importMarkdown('<!-- anchored comment -->').warnings).toContain('comments-not-imported');
```

- [ ] **Step 2: Run the adapter test and verify it fails**

Run: `pnpm --filter api test src/documents/markdown-adapter.test.ts`

Expected: `FAIL` because the adapter is not implemented.

- [ ] **Step 3: Persist document metadata and snapshots separately**

```sql
CREATE TABLE document_snapshot (
  document_id uuid NOT NULL REFERENCES document(id),
  sequence bigint NOT NULL,
  content_json jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (document_id, sequence)
);
```

- [ ] **Step 4: Implement authenticated collaboration-room admission**

```ts
export async function authorizeRoom(principal: Principal, documentId: string) {
  await requireCan(principal, 'document.edit', { documentId });
  return { roomId: `document:${documentId}`, permission: 'edit' as const };
}
```

- [ ] **Step 5: Test reconnect and access revocation, then commit**

Run: `pnpm test:e2e tests/e2e/document-recovery.spec.ts`

Expected: `PASS`; two editors converge after reconnect, snapshot-plus-tail restores content, and revoked editors cannot reconnect or submit updates.

```bash
git add apps/api/src/documents apps/collaboration apps/web/src/features/docs db/migrations tests/e2e
git commit -m "feat: add recoverable collaborative project documents"
```

### Task 6: Secure files and deliver an auditable automation bridge

**Files:**
- Create: `apps/api/src/files/asset-service.ts`
- Create: `apps/worker/src/outbox/processor.ts`
- Create: `apps/worker/src/webhooks/receiver.ts`
- Create: `apps/web/src/features/operations/DeadLetterQueue.tsx`
- Create: `db/migrations/0004_outbox_and_assets.sql`
- Test: `apps/worker/src/outbox/processor.test.ts`
- Test: `tests/e2e/webhook-idempotency.spec.ts`

**Interfaces:**
- Consumes: `OutboxEvent`, `WebhookDelivery`, `AssetAuthorization`
- Produces: `issueUploadUrl`, `processOutboxBatch`, and `acceptWebhookDelivery`.

- [ ] **Step 1: Write a duplicate-delivery test**

```ts
await receiver.acceptWebhookDelivery(validDelivery);
await receiver.acceptWebhookDelivery(validDelivery);
expect(await sideEffects.count({ eventId: validDelivery.id })).toBe(1);
```

- [ ] **Step 2: Verify it fails**

Run: `pnpm --filter worker test src/outbox/processor.test.ts`

Expected: `FAIL` because the receiver has no receipt/deduplication behavior.

- [ ] **Step 3: Add the durable receipt and outbox schema**

```sql
CREATE TABLE webhook_receipt (
  source text NOT NULL,
  delivery_id text NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (source, delivery_id)
);
```

- [ ] **Step 4: Implement authorized uploads and post-upload validation**

```ts
export async function issueUploadUrl(principal: Principal, request: UploadRequest) {
  await requireCan(principal, 'asset.upload', request.scope);
  return objectStore.createPresignedPut({ key: request.key, expiresInSeconds: 300 });
}
```

- [ ] **Step 5: Exercise invalid signature, duplicate, retry, dead-letter, and replay paths**

Run: `pnpm test:e2e tests/e2e/webhook-idempotency.spec.ts`

Expected: `PASS`; invalid signatures have no durable work, duplicates have one side effect, and failed work is inspectable and replayable only by an operator.

```bash
git add apps/api/src/files apps/worker apps/web/src/features/operations db/migrations tests/e2e
git commit -m "feat: secure files and idempotent automation delivery"
```

### Task 7: Ship operated Release 1 and prove recoverability

**Files:**
- Create: `docs/operations/backup-restore.md`
- Create: `docs/operations/upgrade.md`
- Create: `docs/operations/incident-response.md`
- Create: `scripts/backup.sh`
- Create: `scripts/restore-clean-room.sh`
- Test: `tests/e2e/release-one-acceptance.spec.ts`

**Interfaces:**
- Consumes: production-like Compose volumes and encrypted backup location
- Produces: documented, reproducible clean-room restore and staging upgrade evidence.

- [ ] **Step 1: Write Release 1 acceptance test cases**

```ts
const workflows = ['create-task', 'move-board-card', 'edit-table-field', 'reschedule-calendar', 'write-doc', 'search-authorized-work'];
for (const workflow of workflows) {
  await expect(runWorkflowAsMember(workflow)).resolves.toMatchObject({ criticalFailure: false });
}
```

- [ ] **Step 2: Verify the test identifies missing workflows**

Run: `pnpm test:e2e tests/e2e/release-one-acceptance.spec.ts`

Expected: `FAIL` until all Release 1 UI and API capabilities are wired.

- [ ] **Step 3: Implement encrypted off-host backup and clean-room restore scripts**

```bash
pg_dump --format=custom --file "$BACKUP_DIR/postgres.dump" "$DATABASE_URL"
rclone sync "$OBJECT_STORAGE_EXPORT" "$OFFSITE_REMOTE/workhub/$BACKUP_ID/assets"
./scripts/restore-clean-room.sh "$BACKUP_ID"
```

- [ ] **Step 4: Rehearse restore and staged upgrade**

Run: `./scripts/backup.sh && ./scripts/restore-clean-room.sh latest && ./scripts/upgrade-staging.sh`

Expected: `PASS`; sampled tasks, documents, attachments, permissions, and outbox receipts are intact after restore and upgrade smoke tests.

- [ ] **Step 5: Commit and produce the go/no-go record**

```bash
git add docs/operations scripts tests/e2e
git commit -m "docs: prove Release 1 operation and recovery"
```

---

## Self-review

The plan covers the stated Release 1 work model, four daily task views, documents, permissions, files, automation, backups, and operations. It deliberately defers ClickUp’s more expansive portfolio planning, advanced reporting, integration ecosystem, public sharing, workload, full Docs parity, and AI features. All later tasks consume contracts created in earlier tasks: the permission model precedes routes, the task model precedes views, document durability precedes collaboration, and the outbox precedes integrations. No route depends on client-side authorization alone.

## Execution handoff

Plan complete and saved to `docs/superpowers/plans/2026-09-24-work-hub-mvp.md`. The recommended next step is **inline execution in a separate implementation project**, beginning with Task 1 and maintaining the Stage 0 gates. This research deliverable does not create the production work hub; it provides the reviewed build scope and acceptance plan needed to authorize it.
