iri 문서

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:

  1. Can't delete your last workspace. listUserWorkspaces(userId).length > 1 required, else 409 ("Create another one first").
  2. Workspace must be empty. All workspace-owned tables (notes, atoms, documents, conversion_jobs, …) are FK'd ON 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).

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:

  1. x-write-key: iri_ws_… → workspace key → { userId: owner, workspaceId: bound, agentId? }
  2. x-write-key: iri_… → per-user key → { userId, workspaceId: defaultForUser }
  3. 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, contradictions
  • spheres, 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. withSphereContext enters the sphere's workspace before reading sphere content. See spheres.md.
  • Workspace listings for a user — listUserWorkspaces runs 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.