Workspace App — Production-Grade Architecture Handover
Status: Canonical Architecture Document
Audience: Solo developer initially, future small engineering team
Last Updated: May 2026
1. Executive Summary
This application is a modular, API-first productivity platform built as:
- A web application (Next.js App Router)
- A Progressive Web App (PWA)
- A future native iOS shell (Capacitor)
- A programmable API platform for agents, automations, workflows, and CLI tooling
The architecture intentionally prioritizes:
- Security through enforceable structure
- Long-term maintainability for a solo developer
- Incremental scalability
- Future offline-readiness
- API parity across browser, CLI, and agents
The system is NOT designed as a hyperscale enterprise platform from day one. Instead, it is designed to evolve safely without major rearchitecture.
2. Architectural Philosophy
2.1 API-First
All mutations flow through /api/*.
This guarantees:
- Consistent business logic
- Agent compatibility
- CLI compatibility
- Future sync compatibility
- Easier observability
- Centralized authorization
Even browser interactions ultimately use the same API layer.
2.2 Enforced Security > Convention
The system avoids “remember to do X” security patterns.
Instead:
- Route security is centralized
- RLS context is centralized
- Feature entitlement checks are centralized
- API scope checks are centralized
This reduces long-term drift and accidental vulnerabilities.
2.3 Modular But Pragmatic
Features are isolated into packages.
However, the MVP intentionally uses:
- Build-time feature registration
- Shared deployment artifact
- Shared runtime
This avoids premature complexity.
The architecture preserves the ability to evolve into:
- runtime plugin loading
- microservices
- independent deployments
- offline sync engines
without large-scale rewrites.
3. System Overview
┌─────────────────────────────────────────┐
│ Clients │
│ │
│ Browser │ PWA │ CLI │ Agents │ iOS │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ API Layer (/api/*) │
│ │
│ withApiGuard() │
│ ├── Auth │
│ ├── Feature Entitlement │
│ ├── RLS Context │
│ ├── Scope Validation │
│ └── Handler Execution │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ PostgreSQL (Supabase) │
│ │
│ RLS Policies │
│ Feature Tables │
│ Vector Search │
└─────────────────────────────────────────┘
4. Monorepo Structure
apps/
web/
cli/
packages/
core/
ui/
features-registry/
feature-notes/
feature-writing/
feature-bookmarks/
feature-recipes/
feature-budget/
feature-ai-chat/
5. Dependency Rules
5.1 Critical Rule
packages/* MUST NEVER import from apps/*.
This is a hard architectural constraint.
Violating this introduces:
- circular dependencies
- invalid build graphs
- hidden coupling
- future deployment problems
5.2 Allowed Dependency Direction
apps/*
↓
packages/features/*
↓
packages/core
5.3 Feature Registry
ALL_FEATURES lives in:
packages/features-registry
This package owns:
- feature manifests
- feature metadata
- feature registration
core MUST remain feature-agnostic.
6. Technology Stack
| Layer | Choice |
|---|---|
| Frontend | Next.js 16 App Router |
| Language | TypeScript Strict |
| DB | Supabase PostgreSQL |
| ORM | Drizzle ORM |
| Styling | Mantine |
| Editor | Tiptap |
| AI SDK | Vercel AI SDK |
| LLM | Anthropic Claude |
| Embeddings | OpenAI text-embedding-3-small |
| Monorepo | Turborepo + pnpm |
| Hosting | Vercel |
| Native Shell | Capacitor |
7. Security Architecture
7.1 Canonical API Security Model
ALL /api/* routes MUST use:
withApiGuard()
Direct route handlers are prohibited.
7.2 Security Flow
Every request passes through:
- Authentication
- Feature entitlement validation
- RLS context setup
- API scope validation
- Business logic execution
This flow is mandatory.
7.3 Why This Matters
Without centralization:
- routes drift over time
- developers forget checks
- RLS gets bypassed
- feature gating becomes inconsistent
The architecture intentionally makes secure behavior the default.
8. Authentication
8.1 Authentication Modes
Browser/PWA/iOS
Supabase cookie session.
CLI / Agents
Bearer API keys.
8.2 API Key Model
API keys support:
- SHA-256 hashing
- scopes
- expiry
- revocation
- last-used tracking
Example scopes:
notes:read
notes:write
recipes:read
chat:write
8.3 API Key Schema
export const apiKeys = pgTable('api_keys', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => profiles.id, {
onDelete: 'cascade'
}),
keyHash: text('key_hash').notNull(),
label: text('label'),
scopes: text('scopes').array().default(sql`'{}'`),
expiresAt: timestamp('expires_at'),
revokedAt: timestamp('revoked_at'),
lastUsedAt: timestamp('last_used_at'),
})
9. Row-Level Security (RLS)
9.1 Canonical Pattern
Every user-owned table MUST enforce:
ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users access own rows"
ON table_name
FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
9.2 RLS Wrapper
The application MUST NEVER manually inject RLS context inline.
Use:
withRLS(userId)
only.
9.3 Canonical RLS Helper
export async function withRLS(userId, fn) {
return db.transaction(async (tx) => {
await tx.execute(
sql`select set_config('request.jwt.claim.sub', ${userId}, true)`
)
return fn(tx)
})
}
This removes:
- duplicated SQL
- injection risk
- inconsistent setup
10. API Guard
10.1 Purpose
withApiGuard() centralizes:
- auth
- feature checks
- RLS
- scope validation
10.2 Canonical Implementation
export function withApiGuard(handler, opts = {}) {
return async (req) => {
const auth = await resolveApiCaller(req)
if (!auth?.userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
if (opts.feature) {
const features = await getEnabledFeatures(auth.userId)
if (!features.some(f => f.slug === opts.feature)) {
return Response.json({ error: 'Feature disabled' }, { status: 403 })
}
}
return withRLS(auth.userId, async (tx) => {
if (opts.requireScope && auth.isApiKey) {
if (!auth.scopes?.includes(opts.requireScope)) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
}
return handler({
tx,
userId: auth.userId,
req,
})
})
}
}
11. API Route Pattern
11.1 Required Structure
export const GET = withApiGuard(handler, options)
This is mandatory.
11.2 Example
export const GET = withApiGuard(
async ({ tx }) => {
const rows = await tx.select().from(notes)
return Response.json({ data: rows })
},
{
feature: 'notes',
requireScope: 'notes:read'
}
)
12. Repository Architecture
12.1 Canonical Mutation Flow
UI → Repository → API → Database
12.2 Why This Exists
This abstraction is intentionally future-oriented.
Today:
Repository → API
Future:
Repository → Local DB → Sync Engine → API
The UI layer must not care.
12.3 Server Actions Policy
Server Actions ARE allowed.
However:
- they must use repositories
- they must not bypass APIs
- they must not bypass authorization
This preserves API-first guarantees.
13. Offline-Ready Design
13.1 MVP Status
Offline mode is NOT an MVP feature.
However, the architecture intentionally avoids blocking future implementation.
13.2 Required Constraints
Client-generated IDs
Clients generate UUIDs.
This prevents sync collision problems later.
Idempotent APIs
Repeated requests with the same ID must produce the same result.
This is critical for sync reliability.
Repository Layer Mandatory
The repository layer MUST NOT be bypassed.
This is the primary abstraction boundary enabling future sync support.
updatedAt Is Source of Truth
Every syncable entity includes:
createdAt
updatedAt
deletedAt
Future conflict resolution depends on updatedAt.
deletedAt enables soft deletes. This is mandatory from day one — hard deletes cannot propagate to offline clients and cannot be undone after sync data exists. Never use hard deletes on syncable entities.
Soft Deletes Are Mandatory
All syncable entities MUST use soft deletes.
deletedAt: timestamp('deleted_at') // null = active, timestamp = deleted
Hard deletes are prohibited on syncable tables because:
- Offline clients cannot receive a deletion event for a row that no longer exists
- Sync conflict resolution requires tombstone records
- Data recovery becomes impossible after a hard delete
All queries against syncable tables MUST filter deleted records:
where(isNull(table.deletedAt))
14. Embedding Pipeline
14.1 Requirements
Embedding writes must be:
- asynchronous
- atomic
- retryable
- observable
14.2 Embedding Status Field
Every content table that participates in the embedding pipeline MUST include an embeddingStatus field:
embeddingStatus: text('embedding_status')
.notNull()
.default('pending') // 'pending' | 'complete' | 'failed'
This field is the source of truth for embedding state. It enables:
- querying for un-embedded or failed content
- manual or automated retry of failed jobs
- visibility into pipeline health without log-scraping
- safe re-embedding after model upgrades
Status transitions:
pending → complete (successful embedding write)
pending → failed (all retries exhausted)
failed → pending (manual or automated retry trigger)
Any content with embeddingStatus = 'failed' MUST be logged and retryable. Silent failures are not acceptable.
14.3 Atomic Writes
Embeddings must be written in a transaction.
Never:
delete → insert (outside transaction)
This can temporarily remove embeddings.
14.4 Retry Policy
Embedding jobs:
- retry twice
- exponential backoff
- log all failures
- set
embeddingStatus = 'failed'after retries exhausted
14.5 Observability
At minimum:
- structured logs
- failed embedding logs
- latency visibility
MVP does NOT require full observability infrastructure.
15. Feature System
15.1 Current Design
Features are:
- build-time registered
- package isolated
- entitlement controlled
This is intentional.
15.2 Current Limitation
Inactive features are still included in the build.
This is acceptable for MVP.
15.3 Future Evolution
The architecture can later evolve toward:
- runtime plugin loading
- independent deployments
- feature microservices
without major rewrites.
16. Database Migrations
16.1 Package Ownership
Every feature package owns:
schema.ts
drizzle.config.ts
migrations/
There is NO global drizzle config.
16.2 Migration Orchestration
Root command:
pnpm db:migrate
This script:
- discovers packages
- executes migrations in deterministic order
- fails fast on errors
16.3 Why This Matters
This prevents:
- schema drift
- inconsistent environments
- hidden migration dependencies
17. Background Jobs
17.1 MVP Strategy
Use lightweight async background execution:
waitUntil()- Vercel background execution
- retry wrappers
17.2 Future Strategy
Can evolve toward:
- Inngest
- queues
- cron workflows
- distributed workers
without changing API contracts.
18. Observability
18.1 Minimal MVP Requirements
- request logging
- failed job logging
- API latency logging
- auth failure logging
18.2 Recommended Logging Placement
Inside:
withApiGuard()
This provides centralized visibility.
19. Developer Rules
Critical Enforcement Rules
Rule 1
All API routes MUST use:
withApiGuard()
Rule 2
Never manually set RLS context.
Use:
withRLS()
only.
Rule 3
Never mutate data outside the API layer.
Rule 4
Never import from apps/* inside packages/*.
Rule 5
Repository layer must not be bypassed.
Rule 6
All syncable APIs should be idempotent.
Rule 7
Never hard-delete syncable entities.
Use soft deletes:
deletedAt: timestamp('deleted_at')
All queries against syncable tables must filter where(isNull(table.deletedAt)).
Rule 8
All content tables participating in the embedding pipeline MUST include an embeddingStatus field.
Set it to 'failed' after retries are exhausted. Never silently drop failed embedding jobs.
20. Operational Details Preserved From Original Handover
20.1 Environment Variables
The original handover contained important operational environment variables that must remain part of the canonical architecture.
Supabase
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= # renamed from NEXT_PUBLIC_SUPABASE_ANON_KEY in 2025
SUPABASE_SECRET_KEY= # renamed from SUPABASE_SERVICE_ROLE_KEY in 2025
DATABASE_URL= # port 6543, pooler — runtime queries
DATABASE_DIRECT_URL= # port 5432, direct — Drizzle migrations only (not needed in Vercel)
AI Providers
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
Billing
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
App
NEXT_PUBLIC_APP_URL=
20.2 Supabase Client Separation
The architecture intentionally separates four clients. Never mix them.
| Client | File | Key | Used In |
|---|---|---|---|
createBrowserClient() | browser.ts | publishable key | Client Components ('use client') |
createServerClient() | server.ts | publishable key + cookies | Server Components, Route Handlers (Node.js runtime) |
createProxyClient(req, res) | proxy.ts | publishable key + request cookies | proxy.ts only (Edge runtime) |
createAdminClient() | admin.ts | secret key (bypasses RLS) | Server-only, trusted operations |
Import via subpath: @sidekick/core/supabase/browser, @sidekick/core/supabase/server, etc.
Why the proxy client is separate
proxy.ts runs in the Edge runtime, which cannot import next/headers. createServerClient() uses next/headers internally — calling it from proxy.ts would crash at runtime. createProxyClient(request, response) reads cookies from the incoming Request and outgoing Response directly, with no next/headers dependency. It must be used exclusively in proxy.ts and nowhere else.
20.3 Middleware Responsibilities (Next.js 16: proxy.ts)
Next.js 16 renamed the middleware convention:
- File:
middleware.ts→proxy.ts - Export:
export function middleware→export function proxy - The
configexport is unchanged
proxy.ts is responsible for:
- session refresh (via
createProxyClient) - redirecting unauthenticated browser users
- excluding API routes from redirect behavior
proxy.ts MUST NOT contain business authorization logic.
Authorization belongs in:
withApiGuard()
20.4 Mantine Setup Requirements
The original handover included important Mantine setup requirements.
Required Imports
@import '@mantine/core/styles.css';
@import '@mantine/notifications/styles.css';
@import '@mantine/tiptap/styles.css';
Required Providers
<MantineProvider>
<Notifications />
PostCSS Plugins
postcss-preset-mantine
postcss-simple-vars
These are required for Mantine CSS variable resolution.
Hydration Fix
Add suppressHydrationWarning to the <html> element. ColorSchemeScript injects a data-mantine-color-scheme attribute via a script tag before React hydrates — without suppressHydrationWarning, React will emit a hydration warning because the attribute wasn’t present during server render.
Set defaultColorScheme="auto" on BOTH ColorSchemeScript AND MantineProvider. A mismatch between the two causes hydration errors.
<html suppressHydrationWarning>
<head>
<ColorSchemeScript defaultColorScheme="auto" />
</head>
<body>
<MantineProvider defaultColorScheme="auto">
{children}
</MantineProvider>
</body>
</html>
20.4a CSS Modules Convention
All styling uses CSS modules. No exceptions.
What is banned
Pure Mantine style props that set visual styles inline:
// BANNED — style props
<Box h={100} px="md" fw={700} c="red" mt={8} />
What is allowed
Mantine behavioral props that configure component behavior, not visual style:
// ALLOWED — behavioral props
<AppShell navbar= withBorder shadow="sm" />
Enforcement
packages/eslint-plugin-sidekick contains the no-mantine-style-props rule. It is compiled with tsup (not raw tsc) because ESLint plugins must run as CommonJS in Node.js and cannot load .ts files directly.
The rule is registered in the root eslint.config.js and fails lint immediately on any violation.
20.4b packages/copy — Centralized String Copy
All user-visible strings live in packages/copy. Never hardcode strings directly in source files.
import { copy } from '@sidekick/copy'
// Use
<Button>{copy.auth.signIn}</Button>
Why: Consistent copy across apps/web and apps/cli. Copy changes happen in one place. TypeScript as const makes copy type-safe and autocomplete-friendly.
20.4c Runtime Patterns
useNavigation hook
Always use useNavigation() instead of calling router.push() alone. The hook calls router.push() followed by router.refresh() together. Forgetting router.refresh() after auth actions leaves the UI in a stale server-rendered state.
const { navigate } = useNavigation()
navigate('/dashboard') // push + refresh
force-dynamic on Supabase-touching route groups
Add export const dynamic = 'force-dynamic' to the layout of every route group that touches Supabase (e.g. (app)/layout.tsx, (auth)/layout.tsx). Without it, Next.js may attempt to statically pre-render these layouts at build time, which fails because Supabase cookie reads are request-time operations.
20.4d Profile Creation — Postgres Trigger
User profiles are created via a Postgres trigger on auth.users, not an API route.
CREATE FUNCTION public.create_profile_for_new_user()
RETURNS trigger AS $$
BEGIN
INSERT INTO public.profiles (id, email, created_at)
VALUES (NEW.id, NEW.email, NOW());
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.create_profile_for_new_user();
Critical: the function must use public.profiles (fully qualified) because triggers run in the auth schema context. An unqualified profiles reference would fail to resolve.
Why a trigger instead of an API route:
- Works for all auth providers (email, OAuth, magic link) without per-provider app-level code
- Cannot fail silently after auth succeeds — the profile creation is part of the same database transaction
- No race conditions between auth completion and API calls
20.4e Deferred Decisions
GraphQL + Relay — Deferred to Post-MVP
REST API for MVP. GraphQL evaluation is deferred.
Why not now:
- Cognitive load during learning phase
- Relay + App Router integration is unresolved upstream
withApiGuardmaps cleanly to REST; adapting it to a GraphQL resolver layer requires rethinking
When to revisit: After MVP ships, when data-fetching complexity justifies it, or when Relay/App Router integration matures.
How to add: Could replace or augment REST without full architectural rework. withApiGuard would need a GraphQL resolver adapter.
API Versioning (/api/v1/) — Deferred to Post-MVP
Current API uses /api/ with no version prefix.
Why not now: Adds URL complexity for no current benefit. MVP has one client. Breaking changes can be coordinated directly.
When to add: When multiple external clients need migration time, or when breaking changes become frequent.
How to add: Route group at /api/v1/ in Next.js. No architectural rework needed — just move route handlers into the versioned group.
20.5 Tiptap Requirements
The original architecture contained several important editor requirements.
Requirements
- JSON storage format
- Markdown export support
- Rich text toolbar
- Mobile-friendly editing
- Semantic chunk generation for embeddings
Important Constraint
Embedding generation should operate on semantic markdown output rather than raw text extraction whenever possible.
20.6 AI / RAG Requirements
The original handover included important AI pipeline details.
Requirements
- pgvector enabled
- HNSW index
- semantic chunking
- overlap chunk strategy
- async embedding generation
- retrieval-augmented generation
- streaming AI responses
Required Database Function
match_content()
This function remains part of the canonical design.
20.7 CLI Requirements
The CLI remains a first-class architectural citizen.
Responsibilities
- authenticated API access
- streaming chat support
- automation support
- local scripting support
- future agent interoperability
Canonical Principle
The CLI must use the same public API surface as external agents.
20.8 PWA Requirements
The original handover included important PWA constraints.
Requirements
- installable web app
- manifest.json
- service worker
- offline asset caching
- mobile-compatible shell
Canonical Tooling
Serwist
20.9 Capacitor / iOS Strategy
The MVP native strategy remains:
Capacitor + hosted Next.js application
The architecture intentionally delays:
- embedded offline DB
- native sync engine
- fully local-first execution
until post-MVP.
20.10 Implementation Phases
The phased rollout strategy from the original handover remains valid.
Canonical Order
- Monorepo foundation
- Auth + shell
- Notes feature
- Writing feature
- Content features
- AI layer
- Billing
- Bots / workflows
- Native shell
This phased sequence intentionally reduces architectural risk.
20.11 Remaining Important Constraints From Original Handover
Constraint 1
Feature manifests remain the canonical feature contract.
Constraint 2
All features must enforce entitlement checks at API boundaries.
Constraint 3
Background embedding generation must never block user writes.
After all retries are exhausted, embeddingStatus must be set to 'failed'. Failed jobs must remain queryable and retryable.
Constraint 3a
All syncable entities must include deletedAt for soft delete support. Hard deletes are prohibited on syncable tables.
Constraint 4
Server Components are preferred for data-fetching.
Constraint 5
Client Components should only exist where interactivity is required.
Constraint 6
Drizzle must never execute in browser/client components.
Constraint 7
Repository abstractions are mandatory for all mutations.
21. Repository Visibility
21.1 Decision
The GitHub repository is public.
This is intentional. The project is built in the open as a learning exercise and portfolio. Friends and collaborators can view progress without requiring explicit invitations.
21.2 Why This Is Safe
Security in this architecture comes from correct implementation, not obscurity:
- RLS policies enforce data isolation at the database level regardless of who reads the source code
withApiGuard()centralizes authorization — knowing the code exists doesn’t bypass it- API keys are hashed (SHA-256) before storage — the schema being public is irrelevant
.env.localis gitignored — real secrets never enter the repository
Making the architecture and implementation decisions public is consistent with standard open-source practice. The actual security surface is the running application, not the source code.
21.3 Permanent Caution — Never Commit Secrets
The following must never be committed to the repository under any circumstances:
.env.localor any file containing real environment variable values- Supabase service role keys
- API keys (Anthropic, OpenAI, Stripe)
- Database connection strings with credentials
- Any token, password, or private key
The .gitignore blocks .env* files (with the exception of .env.example). This is a technical safeguard, not a substitute for vigilance. Always verify git status before committing.
If a secret is ever accidentally committed:
- Immediately rotate the exposed key/token in the relevant service dashboard
- Remove the secret from git history using
git filter-repoor GitHub’s secret scanning remediation tools - Force-push the cleaned history
Rotation is mandatory — removing from git history is not sufficient on its own because the secret may already have been cloned or cached.
22. Final Architectural Position
This architecture intentionally optimizes for:
- maintainability
- correctness
- solo-developer velocity
- future extensibility
while explicitly avoiding:
- premature microservices
- premature offline complexity
- runtime plugin overengineering
- unnecessary infrastructure
The system is designed to evolve safely over time without foundational rewrites.