dealspace/SPEC.md

32 KiB

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

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)

One answer can satisfy N requests. When an answer is published, all linked requests are notified — broadcast to all requesting parties who have access.

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:

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:

{
  "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:

{
  "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

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

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:

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):

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:

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.

// 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.

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

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:

// 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.

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 <html>. 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:

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":

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:

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:

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:

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:

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):

// 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.

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:

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:

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

This document is the ground truth. If code disagrees with the spec, the code is wrong.