Workspace — 회사 단위 작업 공간
한눈에 보기. Workspace는 iri에서 가장 큰 단위입니다. 보통 회사 하나에 워크스페이스 하나로 사용합니다. 모든 데이터는 워크스페이스 안에 격리되어 있어, 다른 회사의 정보가 절대 섞이지 않습니다.
회사 안에서 팀이나 프로젝트별로 공간을 나누고 싶으시다면 Sphere를 사용하시면 됩니다.
개발자용 상세
A workspace is the top-level tenant in iri. One team = one workspace. Every row in the database belongs to exactly one workspace, and every query runs under a workspace context that Postgres RLS enforces.
team
└── workspace ← this page
├── notes (personal garden, no sphere)
├── documents
├── atoms / entities
├── tasks
└── spheres ← see docs/concepts/spheres.md
└── folders ← see docs/concepts/spheres.md
└── notes + memory.md
Use a workspace when you need a hard isolation boundary (separate company, separate paying customer, separate billing). Use a sphere when you need a sub-team or project space inside the same team.
Schema
workspaces (migration 003):
| column | notes |
|---|---|
id |
UUID primary key. Set as app.workspace_id on every tenant query. |
name |
Display name. |
slug |
URL slug (/workspaces/<slug>). Globally unique. |
owner_id |
Better-Auth user id. |
plan |
free | pro | team | enterprise. |
settings |
JSONB. Per-workspace flags: show_agent_attribution, agent_write_approval_required, agent_writes_per_hour, max_doc_size_mb. |
workspace_members:
| column | notes |
|---|---|
user_id or agent_id |
One row per principal. Agents are first-class members. |
role |
admin | editor | viewer | agent_read | agent_write. |
display_name, avatar_emoji |
Per-workspace identity overrides. |
joined_at, invited_by |
Audit trail. |
Note: workspace member roles (admin/editor/viewer) are distinct from sphere member roles (owner/editor/writer/viewer). Same word, different meaning. A workspace admin is not automatically a sphere owner.
Lifecycle
Auto-created on first login
Every Better-Auth user gets a personal-<id> workspace on first login. defaultWorkspaceForUser self-heals if the row is missing. This is what gives a brand-new user somewhere to write before they think about teams.
Manual create
POST /api/workspaces
body: { name, slug }
Implemented in app/api/workspaces/route.ts → createWorkspace (in src/lib/workspaces.ts). The creator is added as admin in the same transaction. Slug must be [a-z0-9-]. 409 on slug collision.
Creation runs on the admin connection because there's no workspace context yet — RLS can't validate a workspace that doesn't exist. The application-side ownerId is the auth gate.
Delete
DELETE /api/workspaces/[id]
Admin-only. Two safety rules in app/api/workspaces/[id]/route.ts:
- Can't delete your last workspace.
listUserWorkspaces(userId).length > 1required, else 409 ("Create another one first"). - Workspace must be empty. All workspace-owned tables (
notes,atoms,documents,conversion_jobs, …) are FK'dON DELETE RESTRICT. If anything is left, Postgres rejects and the route returns 409 ("Notepad is not empty").
deleteWorkspace runs on the admin connection (you can't set app.workspace_id to a workspace you're about to remove). Members are cleared first, then the workspace row.
Why deletion is paranoid
Workspaces hold months of accumulated atoms, briefings, and folder memory. The "delete a workspace" verb crosses the entire app and undoing it means restoring from a Neon backup. Anything calling deleteWorkspace — UI, MCP, API — must require explicit confirmation. See docs/mcp/tools.md for the MCP destructive-confirm contract.
Row-level security (RLS)
Strict since migration 013. Every app query runs through withWorkspace(workspaceId, fn) (in src/lib/db.ts), which sets the Postgres session variable app.workspace_id. Every tenant table has a policy that joins against that variable.
- Use
getSql()for tenant-scoped queries (RLS-enforced role). - Use
getAdminSql()only for:- Auth lookups that happen before a workspace context exists (key verification, session resolution,
getMemberRole). - Cross-workspace cron jobs (ingestion, embedding backfill) and cross-workspace user views (
listUserWorkspaces,getPendingInvitations). - Workspace creation/deletion (the row itself isn't yet, or is no longer, valid for context).
- Auth lookups that happen before a workspace context exists (key verification, session resolution,
When you add a tenant table, include workspace_id plus RLS policies matching migrations 012/013 (and 028 for the FORCE ROW LEVEL SECURITY pattern used for newer tables). Pair RLS-affecting migrations with updates to scripts/test-rls-isolation.mjs.
Two auth lanes
Better Auth (web)
Cookie session for browser users. Email + password, Google, GitHub. Config in src/lib/better-auth.ts. Account linking, reset-password, email-verification stubs (dev console log; swap to Resend for prod), dynamic trustedOrigins accepting *.vercel.app, onAPIError redirecting to /login?error=….
API keys (agents, MCP, sync)
Two key types, both sent as x-write-key (or Authorization: Bearer):
| Prefix | Type | Scope | Where to create |
|---|---|---|---|
iri_ws_… |
Workspace key | One workspace. Can carry an agent_id. |
/workspaces/<slug> → API Keys |
iri_… |
Per-user key | User's default workspace. | /dashboard → API Keys |
Scopes: read and write. Revocation is immediate (checked on every request). Keys are SHA-256 hashed server-side; the raw key is shown once at creation.
Auth resolution order
resolveUser(request) checks in order and returns the first match:
x-write-key: iri_ws_…→ workspace key →{ userId: owner, workspaceId: bound, agentId? }x-write-key: iri_…→ per-user key →{ userId, workspaceId: defaultForUser }- Better Auth session cookie →
{ userId, workspaceId: defaultForUser }
A workspaceId is always present when auth succeeds. Cron routes bypass this via getAdminSql + explicit workspace loops.
What lives in a workspace
Every tenant table follows the workspace_id NOT NULL DEFAULT current_setting('app.workspace_id') pattern, including the recent additions:
notes,documents,chunks,atoms,entities,contradictionsspheres,folders(mig 028),folder_memory_history(mig 030)note_attachments(mig 029)tasks(mig 023)audit_log,llm_calls
The notes table has an extra wrinkle: a note can live directly in the workspace (sphere_id IS NULL, the user's "personal garden") or inside a sphere. Sphere-scoped reads filter by sphere_id; workspace-scoped reads (/api/notes) only return rows with no sphere.
Cross-workspace edges
Most things stop at the workspace boundary. The exceptions, all intentional:
- Sphere invitations (
sphere_invitations, mig-020) — recipients may not yet be in the inviter's workspace, so the table has no RLS and is gated by a 24-byte token instead. See spheres.md. - Sphere membership — a user can be a member of spheres in workspaces other than their own default.
withSphereContextenters the sphere's workspace before reading sphere content. See spheres.md. - Workspace listings for a user —
listUserWorkspacesruns on the admin connection so a user can see every workspace they belong to without picking one first.
See also
- spheres.md — sub-team / project spaces inside a workspace.
- memory-layers.md — what gets stored at each layer.
- atoms.md — Layer 2 entity for typed knowledge.
brainstorming/rls-tenancy-plan.md— historical design notes for RLS strict mode.