# Dealspace — Architecture Specification **Version:** 0.2 — 2026-02-28 **Status:** Pre-implementation. This document is the ground truth. Code follows the spec, never the reverse. --- ## Changelog ### v0.2 (2026-02-28) **Security hardening based on architectural review. All P0/P1 issues addressed.** #### P0 — Critical Security - **6.1:** Replaced deterministic encryption with blind indexes (HMAC-SHA256 truncated to 128 bits) - **6.1:** Added BoringCrypto requirement (`GOEXPERIMENT=boringcrypto` for FIPS 140-3) - **5.5/18:** Added MFA requirement (TOTP RFC 6238, mandatory for ib_admin/ib_member) - **7:** Fixed object store dedup contradiction — ObjectID is SHA-256 of ciphertext; dedup is per-project only #### P1 — Required Before Launch - **4.1:** Added `version` column for optimistic locking - **4.1:** Added `deleted_at` + `deleted_by` for soft delete - **6.1:** Added `key_version` column for key rotation - **4.3:** Added `broadcasts` table for idempotent answer broadcasting - **5.2:** Added `can_grant` column and role hierarchy for access control - **16.3:** Clarified routing chain traversal algorithm (stack-based) - **16.4:** Added `channel_participants` table; unknown senders quarantined - **18:** Added session management section (opaque tokens, single active session) - **18:** Added delegations table for vacation/unavailability coverage - **4.4:** Clarified `broadcast_to` semantics ### v0.1 (2026-02-28) - Initial specification --- ## 1. What This Is A workflow platform for M&A deal management. Investment Banks, Sellers, and Buyers collaborate on a structured request-and-answer system. The core primitive is a **Request** — not a document, not a folder. Documents are how requests get resolved. **Not** a VDR that grew features. Designed clean, from first principles. --- ## 2. What This Is Not - Not a document repository with a request list bolted on - Not a project management tool with deal branding - Not a clone of any existing product - Not feature-complete on day one — the spec defines the architecture; MVP scope is separate --- ## 3. The Flow ``` IB creates Project → configures Workstreams (Finance, Legal, IT, HR, Operations...) → invites Participants (assigns role per workstream) → issues Request List to Seller Seller receives Requests → assigns internally → uploads Answers (documents, data) → marks complete IB vets Answers → approves → Answer published to Data Room → rejects → back to Seller with comment Buyers enter → submit Requests (via Data Room interface) → AI matches against existing Answers (human confirms) → unmatched → routed to IB/Seller for resolution → Answer published → broadcast to all Buyers who asked equivalent question ``` --- ## 4. Core Data Model — Entry-Based Inspired directly by inou's entry architecture. **One table to rule them all.** ### 4.1 The Entry ```go type Entry struct { EntryID string // UUID, plain (never encrypted) ProjectID string // UUID, plain ParentID string // UUID, plain (empty = project root) Type string // structural kind (see 4.2) Depth int // 0=project, 1=workstream, 2=list, 3=request/answer SearchKey string // blind index: HMAC-SHA256(projectKey, primary lookup), 128-bit hex SearchKey2 string // blind index: HMAC-SHA256(projectKey, secondary lookup), 128-bit hex Summary string // packed: structural/navigational ONLY — no content Data []byte // packed: all content, metadata, routing, status Stage string // plain: "pre_dataroom" | "dataroom" | "closed" Version int // optimistic locking — increment on every write KeyVersion int // encryption key version — for key rotation DeletedAt *int64 // soft delete timestamp (null = active) DeletedBy *string // who deleted CreatedAt int64 // unix ms, plain UpdatedAt int64 // unix ms, plain CreatedBy string // user ID, plain } ``` **Rule:** Summary is navigational only. Never put content in Summary. LLMs and MCP tools read Data. **Optimistic Locking:** All updates must include `WHERE version = ?`. Increment version on success. Return conflict error if version mismatch. **Soft Delete:** Entries are never hard-deleted. Set `deleted_at` and `deleted_by`. Queries exclude deleted entries by default. Admins can view/restore deleted entries. ### 4.2 Entry Types (Type field) | Type | Depth | Description | |----------------|-------|------------------------------------------| | `project` | 0 | Top-level container | | `workstream` | 1 | RBAC anchor (Finance, Legal, IT...) | | `request_list` | 2 | Named collection of requests | | `request` | 3 | A single request item | | `answer` | 3 | A response to one or more requests | | `comment` | N+1 | Threaded comment on any entry (parent depth + 1) | ### 4.3 Answer → Request Links (many-to-many) One answer can satisfy N requests. When an answer is published, all linked requests are notified — broadcast to all requesting parties who have access. ```sql CREATE TABLE answer_links ( answer_id TEXT NOT NULL REFERENCES entries(entry_id), request_id TEXT NOT NULL REFERENCES entries(entry_id), linked_by TEXT NOT NULL, linked_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'confirmed' | 'rejected' ai_score REAL, reviewed_by TEXT, reviewed_at INTEGER, reject_reason TEXT, PRIMARY KEY (answer_id, request_id) ); ``` **Broadcasts Table** — ensures idempotent notification delivery: ```sql CREATE TABLE broadcasts ( id TEXT PRIMARY KEY, answer_id TEXT NOT NULL REFERENCES entries(entry_id), request_id TEXT NOT NULL REFERENCES entries(entry_id), recipient_id TEXT NOT NULL REFERENCES users(id), sent_at INTEGER NOT NULL, UNIQUE(answer_id, request_id, recipient_id) ); ``` Before sending a broadcast notification, check the UNIQUE constraint. If exists, skip. This prevents duplicate notifications during race conditions or retries. ### 4.4 Entry Data (JSON inside packed blob) For `request`: ```json { "title": "Provide audited financials FY2024", "body": "...", "priority": "high|normal|low", "due_date": "2026-03-15", "assigned_to": ["user_id"], "status": "open|assigned|answered|vetted|published|closed", "ref": "FIN-042", "routing_chain": [] } ``` For `answer`: ```json { "title": "...", "body": "...", "files": ["object_id"], "status": "draft|submitted|approved|rejected|published", "rejection_reason": "...", "broadcast_to": "linked_requesters|all_workstream|all_dataroom" } ``` **broadcast_to Semantics:** | Value | Who receives notification | When to use | |-------|---------------------------|-------------| | `linked_requesters` | Only buyers whose requests are linked via `answer_links` with `status='confirmed'` | Default. Targeted response to specific questions. | | `all_workstream` | All buyers with access to this workstream | Answer has broad relevance within domain (e.g., key contract affecting all Legal requests). | | `all_dataroom` | All buyers with any data room access | Major disclosure affecting entire deal (e.g., material adverse change). | IB admin sets `broadcast_to` during publish. Default is `linked_requesters`. Changing to broader scope requires explicit confirmation ("This will notify N additional parties"). --- ## 5. RBAC ### 5.1 Roles | Role | Scope | Permissions | MFA Required | |-----------------|----------------|----------------------------------------------------------|--------------| | `ib_admin` | Project | Full control, all workstreams | **Yes** | | `ib_member` | Workstream(s) | Manage requests + vet answers in assigned workstreams | **Yes** | | `seller_admin` | Project | See all requests directed at seller, manage seller team | Recommended | | `seller_member` | Workstream(s) | Answer requests in assigned workstreams | Recommended | | `buyer_admin` | Project | Manage buyer team, see data room | Recommended | | `buyer_member` | Workstream(s) | Submit requests, view published data room answers | No | | `observer` | Workstream(s) | Read-only, no submission | No | **Observer Visibility:** Observers see published answers only. They cannot see `entry_events` (workflow thread), `routing_chain` (internal forwarding), or draft/rejected answers. For auditor access requiring full visibility, use `ib_member` with read-only `ops`. ### 5.2 Access Table ```sql CREATE TABLE access ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, workstream_id TEXT, -- null = all workstreams in this project user_id TEXT NOT NULL, role TEXT NOT NULL, ops TEXT NOT NULL, -- "r", "rw", "rwdm" can_grant INTEGER NOT NULL DEFAULT 0, -- 1 = can grant roles at or below own level granted_by TEXT NOT NULL, granted_at INTEGER NOT NULL, revoked_at INTEGER, -- soft revocation timestamp revoked_by TEXT ); ``` **RBAC anchor:** The `workstream` entry (depth 1) is the access root. Every operation walks up to the workstream to resolve permissions. ### 5.3 Role Hierarchy & Grant Rules ```go var RoleHierarchy = map[string]int{ "ib_admin": 100, "ib_member": 80, "seller_admin": 70, "seller_member": 50, "buyer_admin": 40, "buyer_member": 30, "observer": 10, } ``` **Grant Rules:** - User can only grant access if `can_grant = 1` in their access record - User can only grant roles with hierarchy value ≤ their own role - User can only grant access to workstreams they have access to - `ib_admin` can always grant any role (implicit `can_grant = 1`) **Revoke Rules:** - User can revoke access they granted (`granted_by = self`) - `ib_admin` can revoke any access in their project - `*_admin` roles can revoke access for their organization type - Revocation sets `revoked_at` and `revoked_by` (soft revoke) - Active sessions are immediately invalidated on revocation (see Section 18) ### 5.4 The Single Throat Three choke points. No exceptions. Not even "just this once." ``` All Reads → EntryRead(actorID, projectID, filter) → CheckAccess → query All Writes → EntryWrite(actorID, entries...) → CheckAccess → save All Deletes → EntryDelete(actorID, projectID, filter) → CheckAccess → soft delete Object I/O → ObjectRead/ObjectWrite/ObjectDelete → CheckAccess → storage ``` No handler ever touches the DB directly. No raw SQL outside lib/dbcore.go. ### 5.5 Data Room Visibility `Stage = "pre_dataroom"` entries are invisible to buyer roles — not filtered in the UI, invisible at the DB layer via CheckAccess. Buyers cannot see a question exists, let alone its answer, until the IB publishes it to the data room. --- ## 6. Security ### 6.1 Encryption & Key Management **FIPS 140-3 Compliance:** All builds must use BoringCrypto: ```bash GOEXPERIMENT=boringcrypto go build ./... ``` Go stdlib `crypto/aes` alone is NOT FIPS 140-3 validated. BoringCrypto provides the validated module. **Encryption Scheme:** All string content fields use Pack / Unpack: ``` Pack: raw string → zstd compress → AES-256-GCM encrypt → []byte Unpack: []byte → decrypt → decompress → string ``` **Key Derivation (HKDF-SHA256):** ```go func DeriveProjectKey(master []byte, projectID string) []byte { return hkdf.Expand(sha256.New, master, []byte("dealspace-project-"+projectID), 32) } ``` **Field Encryption Strategy:** | Field | Method | Purpose | |-------|--------|---------| | SearchKey, SearchKey2 | **Blind Index** | Indexed lookups without exposing plaintext | | Summary, Data | AES-256-GCM (random nonce) | Content protection | | IDs, integers, Stage | Plain text | Structural, never sensitive | **Blind Index Implementation:** SearchKey and SearchKey2 use HMAC-SHA256 truncated to 128 bits, stored as hex: ```go func BlindIndex(projectKey []byte, plaintext string) string { h := hmac.New(sha256.New, projectKey) h.Write([]byte(plaintext)) sum := h.Sum(nil)[:16] // truncate to 128 bits return hex.EncodeToString(sum) } ``` This allows indexed lookups (`WHERE search_key = ?`) without deterministic encryption, which leaks information via frequency analysis. **Key Rotation:** Each entry has `key_version` indicating which key version was used to encrypt it. ```go // On read: check key_version, use appropriate key // On write: always use current key version, set key_version field // Rotation: background job re-encrypts entries with old key_version ``` Rotation procedure: 1. Generate new master key, increment global key version 2. Background job: `SELECT entry_id FROM entries WHERE key_version < current` 3. For each: decrypt with old key, re-encrypt with new key, update key_version 4. After all migrated: old key can be archived (keep for N days, then destroy) ### 6.2 File Protection Pipeline Files are never served raw. Every file goes through the protection pipeline at serve time. The stored file is always the clean original. | Type | Protection | |---------------|------------------------------------------------------------------| | PDF | Dynamic watermark (user + timestamp + org) rendered per-request | | Word (.docx) | Watermark injected into document XML before serve | | Excel (.xlsx) | Sheet protection + watermark header row injected before serve | | Images | Watermark text burned into pixel data per-request | | Video | Watermark overlay via ffmpeg, served as stream | | Other | Encrypted download only, no preview | Watermark content (configurable per project): ``` {user_name} · {org_name} · {datetime} · CONFIDENTIAL ``` Watermarks are generated at serve time. Parameters are project-level config, not hardcoded. ### 6.3 Storage Pricing (Competitive Advantage) Files stored compressed + encrypted. No per-MB extortion. Competitors charge up to $20/MB for "secure storage." We store at actual cost. This is a direct and easy competitive win for Misha. ### 6.4 Audit Log Every access grant change, file download, status transition — logged. ```sql CREATE TABLE audit ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, actor_id TEXT NOT NULL, action TEXT NOT NULL, -- packed target_id TEXT, details TEXT, -- packed ip TEXT, ts INTEGER NOT NULL ); ``` --- ## 7. Object Store ```go type ObjectStore interface { Write(projectID string, data []byte) (objectID string, error) Read(projectID string, objectID string) ([]byte, error) Delete(projectID string, objectID string) error Exists(projectID string, objectID string) bool } ``` **Object ID Calculation:** ```go // 1. Encrypt data with project key ciphertext := Encrypt(projectKey, plaintext) // 2. ObjectID = SHA-256 of ciphertext objectID := sha256Hex(ciphertext) // 3. Store ciphertext at objectID path ``` **Deduplication Scope:** Per-project keys mean identical files uploaded to different projects produce different ciphertext, thus different ObjectIDs. **Deduplication is per-project only.** There is no cross-project dedup — this is a security feature, not a limitation. Implementations: local filesystem (default), S3-compatible (plug-in). App code never knows the difference. --- ## 8. AI Matching Pipeline When a buyer submits a request: 1. Embed request text (Fireworks nomic-embed-text-v1.5 — zero retention) 2. Cosine similarity vs all published answers in same workstream 3. Score ≥ 0.72 → suggest match, require human confirmation 4. Score < 0.72 → route to IB/Seller for manual response 5. Human confirms → `answer_links.status = 'confirmed'`, broadcast fires (idempotent via `broadcasts` table) **Retroactive Matching:** When a new request is submitted, search includes all existing published answers. When a new answer is published, optionally re-run matching against pending requests in the workstream (configurable per project). Private data never leaves Fireworks (zero retention policy). Same infra as inou. --- ## 9. Themes Theme = CSS custom properties bundle. Zero hardcoded colors in templates. Every color references a CSS var. ```go type Theme struct { ID string Name string ProjectID string // null = system theme Properties string // packed — CSS vars as JSON } ``` Built-in: Light, Dark, High-contrast. Projects can define a custom theme (brand colors, logo). Users can override with personal preference. Theme switching = swap one class on ``. No JavaScript framework required. --- ## 10. MCP Support MCP server exposes deal context to AI tools. Follows inou's MCP pattern: - All tools operate within `(actor, project)` context — full RBAC enforced - Read tools: list requests, query answers, check status, get workstream summary - Write tools: AI-suggested routing (human confirmation required before any state change) - Gating: AI cannot read pre-dataroom content without explicit unlock (mirrors inou's tier-1/tier-2 pattern) Detailed MCP spec written separately after core schema is stable. --- ## 11. Go Implementation Rules Non-negotiable. Violations require explicit discussion. ### 11.1 Package Structure ``` dealspace/ cmd/server/ main entry point, config loading lib/ types.go All shared types — Entry, User, Project, Theme, etc. dbcore.go EntryRead, EntryWrite, EntryDelete — the three choke points rbac.go CheckAccess, permission resolution, role definitions crypto.go Pack, Unpack, BlindIndex, ObjectEncrypt, ObjectDecrypt store.go ObjectStore interface + implementations watermark.go Per-type watermark injection (PDF, DOCX, XLSX, image, video) embed.go AI embedding client + cosine similarity notify.go Broadcast logic — answer published → notify requesters (idempotent) session.go Session management — create, validate, revoke mfa.go TOTP implementation (RFC 6238) api/ middleware.go Auth, MFA check, logging, rate limiting, CORS handlers.go Thin handlers only — extract input, call lib, return response routes.go Route registration portal/ templates/ HTML templates (no hardcoded colors) static/ CSS (theme vars), JS (minimal) mcp/ server.go MCP tool registration and dispatch ``` ### 11.2 Handler Rules - Handlers: extract input → call lib → return response. Nothing else. - No SQL in handlers. Ever. - No business logic in handlers. Ever. - If two handlers share logic → extract to lib. - Error responses: one helper function, used everywhere. `{"error": "...", "code": "..."}` ### 11.3 DB Access Rules - No `db.Query` / `db.Exec` outside `lib/dbcore.go` - No raw SQL in any file outside `lib/dbcore.go` - Entry access: `EntryRead`, `EntryWrite`, `EntryDelete` only - Object access: `ObjectRead`, `ObjectWrite`, `ObjectDelete` only - User/project/access operations: dedicated functions in dbcore, never inline SQL - **All updates require version check:** `WHERE version = ? ... SET version = version + 1` ### 11.4 Naming Conventions - RBAC-enforced functions: exported, full name (`EntryRead`, `EntryWrite`) - System-only bypass: unexported, explicit suffix (`entryReadSystem`) - The distinction must be obvious from the name alone --- ## 12. UI Philosophy - **Project = select box at the top.** One line. You pick your project and you're in it. - No project browser consuming 20% of screen real estate. - Workstream tabs within a project: Finance | Legal | IT | HR | Operations - Information hierarchy: Workstream → Request List → Request → Answer - Status visible without clicking in - Competitor trap to avoid: adding features without removing complexity. Every new feature must justify its screen cost. --- ## 13. Out of Scope for MVP - Email notifications (inbound channel ingestion is in scope; outbound notifications are not) - Mobile app - Third-party integrations (DocuSign, Salesforce) - Public API - Per-firm white-labeling --- ## 14. Retired Code Previous attempt archived at `/home/johan/dev/dealroom-retired-20260228/` **Carried forward:** - AI matching concept (embeddings + cosine similarity at 0.72 threshold) - Broadcast answer semantics - Color palette **Everything else starts fresh.** --- ## 15. Schema Change Checklist When modifying the data model: 1. Update this SPEC.md first 2. Update `lib/types.go` 3. Update `lib/dbcore.go` 4. Update `lib/rbac.go` if access model changes 5. Update migration files 6. Update MCP tools if query patterns changed 7. No exceptions to this order --- ## 16. Workflow & Task Model ### 16.1 The Core Insight Most users are **workers, not deal managers**. When the accountant logs in they see their task inbox — not a deal room, not workstream dashboards, not buyer activity. Just: what do I need to do today. The big picture (deal progress, buyer activity, request completion %) is the IB admin's view. Role determines UI surface entirely. Same platform, completely different experience. ### 16.2 The Routing Chain Tasks don't just get assigned — they have a return path. Every forward creates an obligation to return. ``` Buyer → IB analyst → CFO → accountant ↓ (done) Buyer ← IB analyst ← CFO ←──┘ ``` Each hop knows where it came from and where it goes back when done. The IB analyst sees "waiting on CFO" — the buyer sees nothing until the answer is published. Internal routing is invisible to external parties. ### 16.3 Routing Chain Data Structure & Algorithm **Entry Fields (plain, indexed):** | Field | Purpose | |----------------|---------------------------------------------------------------| | `assignee_id` | Who has it RIGHT NOW — powers the personal task inbox | | `return_to_id` | Who it goes back to when done | | `origin_id` | The ultimate requestor (buyer) who triggered the chain | **routing_chain (in Data)** — a **stack** of hops: ```go type RoutingHop struct { ActorID string // who received the task at this hop ReturnToID string // who to return to when this actor completes Action string // "forward" | "delegate" | "escalate" Timestamp int64 Reason string // optional note } // routing_chain is []RoutingHop stored in Data JSON ``` **Chain Traversal Algorithm:** When current assignee marks task as "done": ```go func CompleteHop(entry *Entry) { chain := entry.Data.RoutingChain if len(chain) == 0 { // End of chain — task is complete entry.AssigneeID = "" entry.Status = "completed" return } // Pop the top of stack currentHop := chain[len(chain)-1] entry.Data.RoutingChain = chain[:len(chain)-1] // Set new assignee to the return_to from this hop entry.AssigneeID = currentHop.ReturnToID // Update return_to for the new assignee if len(entry.Data.RoutingChain) > 0 { entry.ReturnToID = entry.Data.RoutingChain[len(entry.Data.RoutingChain)-1].ReturnToID } else { entry.ReturnToID = "" // last hop, no return } } ``` **Forward Operation:** ```go func ForwardTask(entry *Entry, fromUserID, toUserID string, reason string) { // Push new hop onto stack hop := RoutingHop{ ActorID: toUserID, ReturnToID: fromUserID, Action: "forward", Timestamp: time.Now().UnixMilli(), Reason: reason, } entry.Data.RoutingChain = append(entry.Data.RoutingChain, hop) // Update plain fields entry.AssigneeID = toUserID entry.ReturnToID = fromUserID } ``` ### 16.4 Channel Ingestion & Authentication **entry_events Table** — the thread behind every entry: ```sql CREATE TABLE entry_events ( id TEXT PRIMARY KEY, entry_id TEXT NOT NULL REFERENCES entries(entry_id), actor_id TEXT NOT NULL, channel TEXT NOT NULL, -- "web" | "email" | "slack" | "teams" action TEXT NOT NULL, -- packed: "message"|"upload"|"forward"|"approve"|"reject"|"publish" data TEXT NOT NULL, -- packed: message body, file refs, status transition details ts INTEGER NOT NULL ); CREATE INDEX idx_events_entry ON entry_events(entry_id); CREATE INDEX idx_events_actor ON entry_events(actor_id); ``` **channel_threads Table** — maps external thread IDs to entries: ```sql CREATE TABLE channel_threads ( id TEXT PRIMARY KEY, entry_id TEXT NOT NULL REFERENCES entries(entry_id), channel TEXT NOT NULL, -- "email" | "slack" | "teams" thread_id TEXT NOT NULL, -- Message-ID, thread_ts, conversationId project_id TEXT NOT NULL, UNIQUE(channel, thread_id) ); ``` **channel_participants Table** — authorized participants per entry: ```sql CREATE TABLE channel_participants ( entry_id TEXT NOT NULL REFERENCES entries(entry_id), channel TEXT NOT NULL, -- "email" | "slack" | "teams" external_id TEXT NOT NULL, -- email address, slack user ID, teams user ID user_id TEXT, -- linked internal user (null if unverified) verified INTEGER NOT NULL DEFAULT 0, added_at INTEGER NOT NULL, PRIMARY KEY (entry_id, channel, external_id) ); ``` **Inbound Message Flow:** 1. Parse sender identity from message (email address, Slack user ID, etc.) 2. Look up `channel_threads` by `(channel, thread_id)` to find entry 3. Look up `channel_participants` for `(entry_id, channel, external_id)` 4. **If not found OR not verified:** - Create `entry_event` with `action = "quarantined"` - Notify IB admin: "Message from unknown sender on [entry] — review required" - Do NOT advance routing chain 5. **If verified:** - Create `entry_event` with appropriate action - Trigger routing chain progression if applicable **Email Authentication:** - Require DKIM pass OR quarantine - Store DKIM verification result in `entry_events.data` - SPF/DMARC failures → quarantine with warning ### 16.5 Final Table List ``` entries — the tree + workflow state (assignee_id, return_to_id, origin_id, version, key_version, deleted_at/by) entry_events — the thread / workflow history per entry channel_threads — external channel routing (email/Slack/Teams → entry) channel_participants — authorized senders per entry+channel answer_links — answer ↔ request, ai_score, status, reviewed_by/at, reject_reason broadcasts — idempotent broadcast tracking (answer_id, request_id, recipient_id, sent_at) users — accounts + auth + MFA secrets access — RBAC: (user, project, workstream, role, ops, can_grant, revoked_at/by) sessions — active sessions (opaque tokens, expiry, user binding) delegations — vacation/unavailability coverage embeddings — (entry_id, vector BLOB) for AI matching audit — security events: grants, downloads, logins, key transitions ``` --- ## 17. Serving Architecture ``` Internet → Caddy (TLS termination, port 443) → Dealspace binary (port 8080) ``` Caddy is the only thing that faces the internet. It handles TLS (Let's Encrypt, auto-renew) and proxies everything to the Dealspace Go binary on localhost:8080. The Go binary serves: - `/` → marketing website (static HTML/CSS/JS from embedded fs or disk) - `/app` → deal room UI (authenticated, role-based) - `/api` → REST API - `/mcp` → MCP server endpoint No nginx. No Apache. One Caddy config, one binary, one systemd unit. ### Embedding static files Website files (`website/`) are embedded into the binary at build time using Go's `embed.FS`. Zero separate file deployment — `make deploy` ships one binary and it's done. ### Caddy config (/etc/caddy/Caddyfile on 82.24.174.112) ``` muskepo.com, www.muskepo.com { reverse_proxy localhost:8080 encode gzip } ``` ### Deployment target - Server: 82.24.174.112 (Shannon/Dealspace VPS) - Binary: /opt/dealspace/bin/dealspace - Data: /opt/dealspace/data/ - Store: /opt/dealspace/store/ - Config: /opt/dealspace/.env --- ## 18. Authentication & Session Management ### 18.1 Multi-Factor Authentication (MFA) **TOTP Implementation (RFC 6238):** ```go // User setup type UserMFA struct { UserID string TOTPSecret []byte // 160-bit secret, encrypted at rest TOTPEnabled bool BackupCodes []string // 10 single-use codes, hashed EnabledAt int64 } // TOTP parameters (RFC 6238 defaults) const ( TOTPDigits = 6 TOTPPeriod = 30 // seconds TOTPAlgorithm = "SHA1" ) ``` **MFA Requirements by Role:** | Role | MFA Requirement | |------|-----------------| | `ib_admin` | **Mandatory** — cannot access without MFA enabled | | `ib_member` | **Mandatory** — cannot access without MFA enabled | | `seller_admin` | Strongly recommended, can be enforced per-project | | `seller_member` | Optional, project policy | | `buyer_admin` | Optional, project policy | | `buyer_member` | Optional | | `observer` | Optional | **Enforcement:** `ib_admin` and `ib_member` users cannot complete login without MFA configured. If MFA is not set up, redirect to mandatory setup flow after password verification. ### 18.2 Session Management **No JWT for sessions.** Use opaque tokens with server-side state. ```sql CREATE TABLE sessions ( id TEXT PRIMARY KEY, -- opaque token (32 bytes, hex encoded) user_id TEXT NOT NULL REFERENCES users(id), access_token TEXT NOT NULL, -- short-lived (1 hour) refresh_token TEXT NOT NULL, -- longer-lived (7 days) access_expires_at INTEGER NOT NULL, refresh_expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL, ip TEXT, user_agent TEXT, revoked INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_sessions_user ON sessions(user_id); CREATE INDEX idx_sessions_access ON sessions(access_token); ``` **Session Rules:** 1. **Single active session per user:** Creating a new session revokes all existing sessions for that user 2. **Token lifetimes:** - Access token: 1 hour (used for API requests) - Refresh token: 7 days (used to obtain new access token) 3. **Immediate revocation:** On access change (role change, revocation, MFA disable), all sessions for that user are immediately revoked 4. **Refresh flow:** Client presents refresh token → server validates → issues new access token (same refresh token until it expires) 5. **Activity tracking:** `last_used_at` updated on each access token use **Token Format:** ```go func GenerateToken() string { b := make([]byte, 32) crypto.Rand.Read(b) return hex.EncodeToString(b) } ``` Tokens are opaque — no embedded claims, no client-side validation. All validation hits the sessions table. ### 18.3 Delegations For vacation/unavailability coverage: ```sql CREATE TABLE delegations ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), delegate_id TEXT NOT NULL REFERENCES users(id), project_id TEXT, -- null = all projects user has access to starts_at INTEGER NOT NULL, ends_at INTEGER, -- null = indefinite created_by TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(user_id, delegate_id, project_id) ); CREATE INDEX idx_delegations_user ON delegations(user_id); CREATE INDEX idx_delegations_delegate ON delegations(delegate_id); ``` **Delegation Rules:** - Tasks assigned to `user_id` during active delegation period also appear in `delegate_id`'s inbox - Delegate can act on behalf of user (forward, respond, approve within their permission level) - Actions by delegate are logged with both `actor_id = delegate_id` and `on_behalf_of = user_id` - User can create their own delegations; admins can create delegations for their team --- --- ## 19. Organizations Organizations represent companies/firms participating in deals. They exist at the platform level and can be linked to multiple deals. ### 19.1 Data Model Organizations are entries with `type: "organization"`, `depth: 0`, no parent. They live at platform level. **OrgData (packed into entry.Data):** ```go type OrgData struct { Name string `json:"name"` Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"] Role string `json:"role"` // seller | buyer | ib | advisor Website string `json:"website,omitempty"` Description string `json:"description,omitempty"` ContactName string `json:"contact_name,omitempty"` ContactEmail string `json:"contact_email,omitempty"` } ``` A **deal_org** entry (`type: "deal_org"`, `depth: 1`, `parent = project entry_id`) links an organization into a specific deal: ```go type DealOrgData struct { OrgID string `json:"org_id"` // entry_id of the organization Role string `json:"role"` // seller | buyer | ib | advisor DomainLock bool `json:"domain_lock"` // if true, enforce domain check on invites } ``` ### 19.2 Domain Validation (ELIGIBILITY ONLY) **CRITICAL SECURITY BOUNDARY:** Organization domains determine **invite eligibility only**. They answer: "Is this email address allowed to be invited to this role in this deal?" **They do NOT grant any access whatsoever.** Example: PwC (@pwc.com) is a buyer in Project James AND a seller in Project Tanya. A PwC partner invited to Project James has **ZERO visibility** into Project Tanya, even though PwC is listed there. **Rules:** 1. `ListProjects` / `GET /api/projects` returns ONLY projects where the actor has an explicit row in the `access` table. NEVER derive project visibility from deal_org or org membership. 2. `CheckAccess` is the single gate — it checks the `access` table, not org membership. 3. The `deal_org` entry is queried ONLY during invite creation to validate the email domain. After that, it has no effect on access. 4. `super_admin` bypasses this (as already implemented) — but no other role does. **ValidateOrgDomain function:** - Called during invite creation only - Finds all `deal_org` entries for the project where role matches - For each with `DomainLock=true`: checks if email ends with @domain for ANY domain in org.Domains - If no match found → returns `ErrDomainMismatch` - If `DomainLock=false` or no matching deal_orgs → passes through ### 19.3 Multi-Domain Support Organizations can have multiple domains (e.g., post-acquisition companies). All domains are stored in the `Domains` array. Domain validation passes if the email matches **any** of the org's domains. Empty domains are not allowed. Domains are normalized to lowercase on create/update. ### 19.4 API Endpoints **Organizations (platform level):** - `GET /api/orgs` — list all orgs (super_admin sees all; others see orgs linked to their deals) - `POST /api/orgs` — create org (ib_admin or super_admin only). Domains required. - `GET /api/orgs/{orgID}` — get org - `PATCH /api/orgs/{orgID}` — update org **Deal orgs (per project):** - `GET /api/projects/{projectID}/orgs` — list orgs in this deal - `POST /api/projects/{projectID}/orgs` — add org to deal `{"org_id":"...","role":"seller","domain_lock":true}` - `DELETE /api/projects/{projectID}/orgs/{dealOrgID}` — remove org from deal --- *This document is the ground truth. If code disagrees with the spec, the code is wrong.*