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:
- Validate the access_request is
approved AND belongs to the caller AND has unconsumed wraps
- Resolve TTL from the matched policy (Slice L2's
reveal_ttl_seconds) — clamped to schema range
- Open a new
reveal_sessions row with the wraps' IDs
- Issue ONE
reveal.session.opened audit event (key_names[] inside metadata; NO values)
- 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
Part of #71 (EPIC: Slice M).
Scope
RevealSessionServiceorchestrating wrap issuance + audit + the new bulk-reveal endpoints. Replaces the per-clickrevealWrap()UX that L5 ships as a bridge.Service (
internal/services/reveal_sessions.go)Openis the load-bearing method:approvedAND belongs to the caller AND has unconsumed wrapsreveal_ttl_seconds) — clamped to schema rangereveal_sessionsrow with the wraps' IDsreveal.session.openedaudit event (key_names[] inside metadata; NO values)Endpoints
Hard invariants
reveal_sessionsrowswrap_ids(UUIDs).reveal.session.openedaudit metadatakey_names[]only. Testaudit_events.metadatafor the plaintext canary; 0 hits required.MarkExpiredrejects ifsession.user_id != caller.ListActivefilters by caller.WrapService.RetrievereturnsErrAlreadyConsumed/ErrExpired;Openpropagates.OpenreadsPolicyDecision.RevealTTLSeconds(L2) and applies it; no caller-controlled TTL.Tests
reveal_sessions_test.go:Acceptance criteria
cmd/api/main.goreveal.session.opened+reveal.session.expiredaudit events emittedSubmitDirectRevealpath (the direct-reveal request lands as the parent for the session)Dependencies
RevealSessionRepository