Skip to content

Slice M2 — RevealSessionService + bulk reveal endpoints #83

@haydercyber

Description

@haydercyber

Part of #71 (EPIC: Slice M).

Scope

RevealSessionService orchestrating wrap issuance + audit + the new bulk-reveal endpoints. Replaces the per-click revealWrap() UX that L5 ships as a bridge.

Service (internal/services/reveal_sessions.go)

type RevealSessionService struct {
    sessions storage.RevealSessionRepository
    wraps    *WrapService
    audit    storage.AuditEventRepository
    now      func() time.Time
}

// Open a reveal session bundling N wraps from an approved access_request.
// One call → N single-shot wraps issued + one reveal_sessions row.
func (s *RevealSessionService) Open(ctx, OpenInput) (*RevealSessionResponse, error)

// Mark a session expired ahead of TTL (user clicks Hide Now or navigates away).
func (s *RevealSessionService) MarkExpired(ctx, id, userID, reason) error

// List active sessions for the caller — UI uses this to detect orphan sessions
// on tab restore + decide whether to nuke them.
func (s *RevealSessionService) ListActiveForUser(ctx, userID) ([]*RevealSession, error)

Open is the load-bearing method:

  1. Validate the access_request is approved AND belongs to the caller AND has unconsumed wraps
  2. Resolve TTL from the matched policy (Slice L2's reveal_ttl_seconds) — clamped to schema range
  3. Open a new reveal_sessions row with the wraps' IDs
  4. Issue ONE reveal.session.opened audit event (key_names[] inside metadata; NO values)
  5. Return the wraps' encrypted envelopes — SPA decrypts with the agent's X25519 keypair (already shipped Piece 8b)

Endpoints

POST   /api/v1/reveal-sessions
       Auth: session, `secret.reveal.direct` OR `secret.request`+approved request
       Body: { access_request_id }
       Returns: { session_id, expires_at, ttl_seconds, wraps: [{wrap_id, key_name, sealed_envelope}] }

GET    /api/v1/reveal-sessions/me/active
       Auth: session
       Returns: list of caller's active reveal sessions (no envelopes)

POST   /api/v1/reveal-sessions/:id/expire
       Auth: session — must match the session's user_id
       Body: { reason: 'user_hide' | 'unmount' }
       Returns: 204

Hard invariants

Invariant How enforced
No secret values in reveal_sessions rows Schema has no value column. Only wrap_ids (UUIDs).
No secret values in reveal.session.opened audit metadata key_names[] only. Test audit_events.metadata for the plaintext canary; 0 hits required.
Caller owns the session MarkExpired rejects if session.user_id != caller. ListActive filters by caller.
Re-opening already-expired wraps fails cleanly WrapService.Retrieve returns ErrAlreadyConsumed / ErrExpired; Open propagates.
TTL is policy-driven Open reads PolicyDecision.RevealTTLSeconds (L2) and applies it; no caller-controlled TTL.

Tests

reveal_sessions_test.go:

  • Happy path: open → wraps returned → audit row written → row in DB with future expires_at
  • Wrong user opens against approved request → 403
  • Pending request → 409
  • Re-open with all wraps consumed → 410 (or empty session — design decision: error)
  • ListActive filters by caller
  • MarkExpired by non-owner → 403
  • TTL from policy clamps to schema range

Acceptance criteria

  • 3 new endpoints wired in cmd/api/main.go
  • reveal.session.opened + reveal.session.expired audit events emitted
  • No service writes secret values to disk; canary tests pass
  • Compatible with Slice L4's SubmitDirectReveal path (the direct-reveal request lands as the parent for the session)

Dependencies

  • M1 (#TBD) — needs RevealSessionRepository

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/featNew feature or capabilitypriority/p0Must-have; blocks MVP or production

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions