5 Building Block View
Level 1 - Top-Level Decomposition
nebu/
├── gateway/ ← Go API Gateway (+ Admin UI)
├── media/ ← Go Media Gateway
├── core/ ← Elixir/OTP Umbrella
├── proto/ ← Shared gRPC .proto definitions
└── docs/ ← Architecture docs (this file)
Level 2 - Go Gateway Internal Structure
gateway/
├── cmd/gateway/main.go ← Startup: migrate → registry → HTTP routing
└── internal/
├── auth/ ← OIDC token validation, bootstrap mode
│ ├── oidc.go ← go-oidc provider, token validation
│ └── bootstrap.go ← First-admin bootstrap mode (Story 14-3b: extended with Step 4
│ User Import; BootstrapHandler gains oidcFetcher OIDCDirectoryFetcher,
│ core BulkImportClient, serverName; WithImportServices fluent setter;
│ GET ?step=4 renders import step; StepHandler case 4 handles
│ action=preview + action=import; OIDCDirectoryFetcher and
│ BulkImportClient interfaces defined here for testability;
│ Story 14-3c: scimFetcher SCIMFetcher field added; SCIM takes
│ priority over OIDC when both enabled (AC1); singleton importInProgress
│ atomic.Bool guard → HTTP 409 on concurrent import (HR-3);
│ importProgress *importProgressState updated atomically during import;
│ OIDCDirectoryEnabled = oidcEnabled || scimEnabled in Handler and
│ StepHandler step 4; preview action uses SCIM-preferred fetcher
│ selection (MINOR-2 fix))
│ └── bootstrap_scim.go ← Story 14-3c: SCIM 2.0 extensions to BootstrapHandler;
│ SCIMFetcher interface (mirrors OIDCDirectoryFetcher);
│ importProgressState struct (imported/total/failed atomic.Int32
│ + done atomic.Bool); package-level singletons importInProgress
│ + importProgress; importStatusHandler → JSON response shape
│ {imported,total,failed,done}; WithSCIMFetcher fluent setter;
│ resetImportState() test helper (in production file to avoid
│ duplicate-symbol build errors; no testing import)
├── matrix/ ← Matrix Client-Server API handlers
│ ├── login.go ← POST /_matrix/client/v3/login (SSO + OIDC)
│ ├── logout.go ← POST /_matrix/client/v3/logout; NewLogoutHandlerWithCore cleans up
│ │ per-device sync_tokens via gRPC InvalidateUserSessions (Story 9-22)
│ ├── sync.go ← GET /_matrix/client/v3/sync (long-poll); forwards device_id from
│ │ JWT session to GetSyncDeltaRequest (GAP-SINCE-IGNORED, Story 9-22);
│ │ buildLeaveRooms uses sinceMs filter (GAP-LEAVE-ONCE) +
│ │ forgotten_rooms exclusion (GAP-FORGET); queryForgottenRoomIDs +
│ │ querySinceTsMs helpers; top-level AccountData field in syncResponse
│ │ populated via injectGlobalAccountData on all 4 sync paths
│ │ (initial, incremental, FallbackToInitial, buffer fast-path - Story 9-24);
│ │ syntheticNextBatch() + syntheticBatchSeq atomic counter generate
│ │ buf_<ms>_<seq> next_batch on buffer fast-path (GAP-BUFFER-NEXT-BATCH,
│ │ Story 9-25) - replaces echoed sinceToken to prevent stuck-token loops;
│ │ syncUnsigned.MRelations carries bundled m.thread aggregations from
│ │ proto Event.unsigned_relations (Story 9-28)
│ ├── relations.go ← All three /relations route variants (Story 9-28 / 9-29):
│ │ • GET /relations/{eventId} (base, Story 9-29)
│ │ • GET /relations/{eventId}/{relType} (Story 9-28)
│ │ • GET /relations/{eventId}/{relType}/{eventType} (Story 9-29)
│ │ GetRelationsHandler + GetRelationsCoreClient consumer interface;
│ │ query params: dir (f/b, default b), limit (default 20, max 100),
│ │ recurse (bool, accepted without error), from (pagination token);
│ │ invalid dir → 400 M_BAD_PARAM; invalid recurse → 400 M_BAD_PARAM;
│ │ maps gRPC PERMISSION_DENIED → 403 M_FORBIDDEN,
│ │ NOT_FOUND → 404 M_NOT_FOUND (Story 9-28 / 9-29)
│ ├── account_data.go ← AccountDataDB + GlobalAccountDataDB interfaces; GlobalAccountDataRow
│ │ struct; AccountDataHandler (GET/PUT global + room-scoped endpoints)
│ ├── send.go ← PUT /rooms/{id}/send/...
│ ├── rooms.go ← POST /createRoom, POST /join/{id}; Story 15-12: PostInviteUser
│ │ guards empty user_id → 400 M_BAD_JSON (Oracle Finding #4)
│ ├── room_moderation.go ← POST /forget inserts into forgotten_rooms (GAP-FORGET, Story 9-19);
│ │ Story 15-12: PostUnbanUser forwards `reason` to Core via
│ │ pb.UnbanUserRequest.Reason (Oracle Finding #2 — previously discarded)
│ ├── room_redaction.go ← Story 15-12 — PUT /_matrix/client/v3/rooms/{roomId}/redact/{eventId}/{txnId};
│ │ RedactionHandler + RedactionCoreClient consumer interface;
│ │ optional body {"reason":"..."} (empty body = {} per Matrix CS API §11.6);
│ │ maps PermissionDenied→403, NotFound→404, Unavailable→503;
│ │ bodyLimit1MiB + jwtWithStatusCheck middleware applied
│ ├── profile.go ← GET/PUT /profile/{userId}
│ ├── presence.go ← GET/PUT /presence/{userId}/status
│ ├── search.go ← POST /_matrix/client/v3/search (Story 11-4); SearchCoreClient
│ │ consumer interface; user_id from JWT context only (never body);
│ │ forwards to gRPC SearchMessages with x-user-id metadata;
│ │ builds §11.14.1 response with groups-by-room_id + highlights
│ ├── event.go ← GET /_matrix/client/v3/rooms/{roomId}/event/{eventId} (Story 11-8);
│ │ GetEventCoreClient consumer interface; validates roomId + eventId format;
│ │ maps gRPC PERMISSION_DENIED → 403 M_FORBIDDEN (non-member),
│ │ NOT_FOUND → 404 M_NOT_FOUND; returns Matrix event JSON via
│ │ protoEventToMatrix (shared with other event handlers)
│ ├── oidc_discovery.go ← MSC2965 OIDC discovery endpoints (Story 13-7):
│ │ AuthIssuerHandler - GET auth_issuer returns {"issuer":"<cfg.OIDCIssuer>"};
│ │ AuthMetadataHandler - GET auth_metadata proxies OIDC provider's
│ │ /.well-known/openid-configuration; 5-minute TTL cache (metadataCache,
│ │ sync.RWMutex); 503 M_UNAVAILABLE on provider error; registered on
│ │ both unstable/org.matrix.msc2965/ and stable v1/ paths; unauthenticated
│ └── ... ← typing, receipts, messages, keys
├── admin/ ← Admin UI (Go Templates + SSR) + Admin API
│ ├── api.go ← /api/v1/* Router (oapi-codegen StrictHandler)
│ ├── users.go ← User CRUD UI + API
│ ├── rooms.go ← Room Management UI + API
│ ├── spaces.go ← Spaces master-detail UI (Stories 15.10a, 15.10b): GET /admin/spaces (list);
│ │ GET /admin/spaces/{spaceId} (detail); POST /admin/spaces/{spaceId}/children
│ │ (add child); POST /admin/spaces/{spaceId}/children/{childId}/remove (remove
│ │ child); SpacesHandler{tmpl, serverName}; stub-only (HTML UI layer, gRPC
│ │ wiring lives in Story 15-11 Admin API layer below); StubSpaceChild{RoomID,
│ │ Name,Suggested}; SpaceDetailPageData embeds SpacesPageData for dual
│ │ list+detail rendering;
│ │
│ │ [Admin API — REST JSON, Story 15-11]
│ ├── api/spaces_repo.go ← SpaceRepository interface + dbSpaceRepo PostgreSQL implementation;
│ │ ListSpaces(ctx, afterID, afterCreatedAt, limit, search) filters
│ │ WHERE room_type = 'm.space' with cursor pagination (same keyset pattern
│ │ as rooms_repo.go); GetSpace(ctx, spaceID) filters on BOTH room_id AND
│ │ room_type = 'm.space' for IDOR prevention; reuses AdminRoom /
│ │ AdminRoomDetail structs from rooms_repo.go;
│ │ Migration 000051_rooms_room_type adds room_type TEXT column + index
│ │
│ ├── api/server.go ← AdminServer gains Spaces SpaceRepository + ServerName string fields
│ │ (15-11 additions) (Story 15-11); ListAdminSpaces handler: cursor/limit/search → ListSpaces;
│ │ GetAdminSpace handler: GetSpace + 404 on nil; AddAdminSpaceChild:
│ │ IDOR-checks spaceId via GetSpace, builds m.space.child content with
│ │ "via": [serverName] + "suggested": bool (Oracle invariant: "via" REQUIRED
│ │ non-empty), calls CoreClient.SendEvent with IsStateEvent=true + unique
│ │ txn_id; DeleteAdminSpaceChild: IDOR-checks spaceId, sends m.space.child
│ │ with content={} (empty object = Matrix removal); both child handlers use
│ │ coregrpc.WithUserMetadata(ctx, actorID, "instance_admin") for gRPC;
│ │ audit.LogEvent on success; SpaceListResponseMeta{Total, NextCursor};
│ │ listAdminSpaces200Resp + spaceChild403Resp response-visitor types;
│ │
│ ├── api/router.go ← 4 new routes registered with jwtMW + RequireRole("instance_admin"):
│ │ (15-11 additions) GET /api/v1/admin/spaces, GET /api/v1/admin/spaces/{spaceId},
│ │ POST /api/v1/admin/spaces/{spaceId}/children,
│ │ DELETE /api/v1/admin/spaces/{spaceId}/children/{childId};
│ │ wrapper functions: listAdminSpacesHandler (param parsing),
│ │ getAdminSpaceHandler, addAdminSpaceChildHandler (nil-guard + body pre-check),
│ │ deleteAdminSpaceChildHandler (nil-guard);
│ ├── compliance.go ← Four-eyes compliance UI
│ ├── claim_mapping.go ← ClaimMappingHandler (Story 11-10): GET/POST /admin/config/claim-mapping;
│ │ reads oidc_user_id_claim, oidc_displayname_claim, oidc_email_claim from
│ │ server_config via ServerConfigReader.LoadClaimMapping (Nebu defaults:
│ │ sub/name/email if keys absent); validates claim names against
│ │ oidcClaimNameRe (^[a-zA-Z0-9:_\-.]+$, 1-50 chars); persists via
│ │ SaveClaimMapping with PRG redirect (?flash=...); audit log includes
│ │ previous_oidc_* before-values; displays identity-stability warning banner
│ ├── auth.go ← ClaimSelectionHandler extended (Story 11-10): atomically persists
│ │ oidc_user_id_claim, oidc_displayname_claim, oidc_email_claim inside the
│ │ same runInTx as admin_group_claim + bootstrap_completed (AC2);
│ │ ServerConfigReader interface extended with LoadClaimMapping +
│ │ SaveClaimMapping methods on postgresServerConfigReader;
│ │ Story 14-3b: post-bootstrap redirect changed from /admin/dashboard
│ │ to /admin/bootstrap?step=4 (User Import wizard step)
│ ├── config.go ← ConfigHandler (Story 7.10/9.4/14-2a): GET/POST /admin/config;
│ │ serves server configuration page; gRPC path calls Core UpdateServerConfig
│ │ for string fields; direct DB upsert via ConfigKeyWriter interface for
│ │ proto3 bool fields (oidc_directory_enabled - proto3 default false
│ │ indistinguishable from "not set"); WithConfigDB(repo) wires the direct
│ │ DB path; StubConfig gains OidcDirectoryEnabled bool + OidcDirectoryEndpoint
│ │ string (Story 14-2a); config.html renders toggle + conditional endpoint
│ │ field (plain JS onchange - no Alpine.js in templates);
│ │ Story 14-3c: secret []byte field + WithSecret(secret) fluent setter
│ │ for AES-256-GCM encryption of scim_bearer_token on save (CR-1);
│ │ StubConfig gains ScimEnabled bool, ScimBaseURL string,
│ │ ScimBearerTokenSet bool; UpdateConfigHandler parses scim_enabled/
│ │ scim_base_url/scim_bearer_token; HTTPS validation at save time (CR-2);
│ │ token encrypted via encryptAES256GCM before DB upsert; only persisted
│ │ when form field is non-empty (leave-existing semantics)
│ ├── page_data.go ← PageData struct + newPageData() helper + SetBuildInfo();
│ │ BuildVersion/GitCommit/BuildTime fields on PageData; SetBuildInfo
│ │ called once from main.go; newPageData() used by all authenticated
│ │ handlers to pre-populate build info for the footer (Story 11-9);
│ │ ErrorMode bool suppresses footer on error pages;
│ │ ClaimMappingPageData (Story 11-10): OIDCUserIDClaim,
│ │ OIDCDisplaynameClaim, OIDCEmailClaim + per-field validation errors;
│ │ Story 14-2c: UserRowData gains IsOIDCOnly bool + MatrixIDPreview string
│ │ for OIDC-only users (never logged into Nebu); UsersPageData gains
│ │ OIDCWarning bool + OIDCWarningBanner AlertBannerData for non-blocking
│ │ availability warning when OIDC directory is unreachable;
│ │ Story 14-3b: BootstrapPageData extended with Step 4 fields -
│ │ OIDCDirectoryEnabled bool, ImportPreview []ImportPreviewUser,
│ │ ImportResult *ImportResult, ImportError string;
│ │ ImportPreviewUser{DisplayName, Email, MatrixUserID} - one row in preview table;
│ │ ImportResult{Imported, Skipped, Failed int32} - BulkImportUsers response counts
│ ├── oidc_directory.go ← OIDCDirectoryService (Story 14-2b): outbound HTTP client for OIDC
│ │ user directory endpoint; secretString type masks bearer token in logs
│ │ (CR-3); HTTPS-only validation at each call (CR-1); CheckRedirect
│ │ ErrUseLastResponse (CR-2); io.LimitReader 10 MB cap (CR-4);
│ │ 30-second cache keyed on SHA-256(endpoint+token) (MR-1);
│ │ singleflight.Group collapses concurrent refreshes (MR-4);
│ │ per-session rate limiter via sync.Map[sessionID → *rate.Limiter]
│ │ at 5 req/s (CR-5 - caller calls Allow(sessionID) before FetchUsers);
│ │ explicit HTTP status handling (MR-3); HR-2 SSRF trust boundary
│ │ documented (Option B - private IP blocking tracked as follow-up);
│ │ Story 14-2c: IsEnabled() method exposes enabled flag for handler-level
│ │ distinction between "disabled (no calls)" vs "enabled but empty (possibly
│ │ unreachable)";
│ │ validateEndpoint() reused by scim_client.go (Story 14-3c)
│ ├── scim_client.go ← SCIMClient (Story 14-3c / ADR-015 Protocol B): SCIM 2.0 RFC 7644
│ │ paginated user fetch; SCIMClientConfig{BaseURL, BearerToken, Enabled,
│ │ HTTPClient, Logger}; NewSCIMClient constructor with hardened default
│ │ http.Client (no redirect, 10s timeout); IsEnabled() + FetchUsers();
│ │ secretString CR-1: token only in Authorization header, never logged;
│ │ validateEndpoint() CR-2: HTTPS-only before any outbound call;
│ │ io.LimitReader 5 MB per page CR-4; 100k user total cap HR-1
│ │ (checked via totalResults field AND running count);
│ │ truncate() on all SCIM string fields HR-3;
│ │ buildPageURL: base URL is SCIM service root, /Users appended if absent;
│ │ scimSub() prefers userName → id (email-style sub from Azure AD/Okta);
│ │ primaryEmail() prefers primary=true → first → empty;
│ │ SSRF HR-2: private IPs not blocked (same accepted risk as OIDC dir);
│ │ RFC 7644 pagination: 1-based startIndex, terminates on empty Resources
│ └── templates/ ← Embedded HTML templates (go:embed);
│ ├── claim-mapping.html ← Admin UI Claim Mapping settings page (Story 11-10):
│ │ DaisyUI form with datalist suggestions (sub/preferred_username/email)
│ │ for oidc_user_id_claim; PRG flash pattern; per-field 422 errors;
│ │ identity-stability warning banner
│ └── layouts/base.html ← DaisyUI footer rendered on every authenticated page (Story 11-9):
│ `nebu gateway v{{.BuildVersion}} · {{.GitCommit}} · built {{.BuildTime}}`
│ guarded by `{{ if not .LoginMode }}{{ if not .ErrorMode }}`;
│ sidebar gains "Claim Mapping" nav link (Story 11-10)
├── grpc/ ← gRPC CoreService client
│ ├── client.go ← gRPC connection, CoreService stub
│ ├── stream.go ← EventBus server-streaming + exponential backoff
│ ├── fallback.go ← Unary GetPendingEvents (GELB status)
│ └── metadata.go ← FormatUserIDFromClaims refactored (Story 11-10):
│ new signature (claimName string, claims map[string]interface{},
│ serverName string); extracts claims[claimName], sanitises via
│ sanitiseLocalpart, falls back to FormatUserID(sub, serverName)
│ (SHA-256 path) when claim is absent or invalid
├── buffer/ ← message_buffer for ROT-status writes
│ ├── buffer.go ← In-memory ring buffer per user
│ ├── drain.go ← Drain worker + DrainStrategy interface
│ └── strategy/ ← linear.go (MVP), aimd.go (Phase 2)
├── middleware/ ← Auth, rate limiting, body limit, CORS, security headers;
│ NewIPRateLimiter (per-IP token-bucket, Stories 5.21/5.29a);
│ NewUserRateLimiter (per-user token-bucket keyed on ContextKeyUserID,
│ Story 11-5: 10 req/min for POST /search; IP fallback for defense-in-depth;
│ NEBU_RATE_LIMIT_DISABLED=true no-op; retry_after_ms in 429 body);
│ JWTMiddleware gains 5th parameter userIDClaimLoader func(ctx context.Context)
│ string (Story 11-10): per-request DB lookup for oidc_user_id_claim;
│ nil loader falls back to "name" claim (backward-compat)
├── registry/ ← Elixir node registry (/internal/nodes/*)
├── compliance/ ← Compliance API handlers (four-eyes, export, anonymize, GDPR deletion)
│ │ GdprDeleteHandler (Story 14.4): DELETE /api/v1/admin/users/{userId}
│ │ Orchestrates: DeactivateUser gRPC → DeleteUserKeys gRPC (best-effort) →
│ │ anonymizeUser TX → gdpr_deletion audit (never-raise) → 200
├── health/ ← /health + /ready handlers; info.go adds GET /info
│ │ (NewInfoHandler - static JSON, no DB/gRPC, zero allocs per request;
│ │ component/version/gitCommit/buildTime set via ldflags at Docker build
│ │ time; fallback "unknown" when built locally without ldflags - Story 11-9)
└── config/ ← NEBU_* env-var configuration
Level 2 - Go Media Gateway Internal Structure
media/
├── cmd/media/main.go ← Startup: readSecretFile → selectStorer(cfg) → DB pool → HTTP routing
│ mediaConfig struct: serverName, storagePath, storageBackend,
│ minioEndpoint, minioAccessKey, minioSecretKey, minioBucket, minioUseSSL;
│ routes: v3 unauthenticated (upload, download, thumbnail, config) +
│ v1 authenticated (config, download, download/{fileName}, thumbnail)
│ via authMW.Wrap(handler) pattern (Story 12.16)
└── internal/
├── crypto/ ← AES-256-GCM key generation, encrypt, decrypt
│ └── aes.go
├── storage/ ← Storage abstraction (Story 12.2)
│ ├── storage.go ← Storer interface: Put / Get / Delete
│ │ Sentinel errors (Story 12.4):
│ │ ErrNotFound → HTTP 404 M_NOT_FOUND
│ │ ErrStorageUnavailable → HTTP 502 M_UNKNOWN
│ ├── local.go ← LocalStorer: filesystem backend (BasePath/<key>)
│ │ Get: os.ErrNotExist → ErrNotFound (Story 12.4)
│ └── minio.go ← MinIOStorer: S3-compatible backend via minio-go/v7
│ Get: calls obj.Stat() eagerly to detect NoSuchKey;
│ ClassifyMinIOError: NoSuchKey/404 → ErrNotFound,
│ network/other → ErrStorageUnavailable (Story 12.4)
├── auth/ ← Bearer token auth middleware (Story 12.16)
│ └── middleware.go ← TokenVerifier interface (consumer-defined, no import cycle);
│ Middleware.Wrap(http.Handler) http.Handler;
│ missing/non-Bearer Authorization → 401 M_MISSING_TOKEN;
│ empty Bearer token → 401 M_MISSING_TOKEN;
│ verifier error → 401 M_UNKNOWN_TOKEN;
│ nil verifier guard → 503 M_UNAVAILABLE (fail-closed);
│ *upload.OIDCTokenVerifier satisfies TokenVerifier structurally
├── config/ ← Media config handler (Story 12.16)
│ └── handler.go ← Handler struct with MaxBytes int64;
│ ServeHTTP returns {"m.upload.size": N} as JSON;
│ no auth logic - auth applied via middleware at routing layer
├── upload/ ← POST /_matrix/media/v3/upload
│ └── upload.go ← Handler; depends on MediaStore (DB) + Storer (storage);
│ encrypts body AES-256-GCM, calls Storer.Put("<server>/<id>"),
│ inserts row in media_files, returns mxc:// URI
├── download/ ← GET /_matrix/media/v3/download/{serverName}/{mediaId}
│ GET /_matrix/client/v1/media/download/{serverName}/{mediaId}[/{fileName}]
│ └── handler.go ← Handler; looks up row in media_files, calls Storer.Get,
│ decrypts AES-256-GCM, streams plaintext to client;
│ ErrNotFound → 404 M_NOT_FOUND; storage errors → 502 M_UNKNOWN;
│ slog.Error logs raw error; response body sanitized (no leaks)
│ (Story 12.4);
│ r.PathValue("fileName") for Content-Disposition filename when
│ non-empty, falls back to mediaId (AC-6, Story 12.16);
│ Content-Security-Policy + Cross-Origin-Resource-Policy: cross-origin
│ headers set on all 200 responses (Matrix spec §Media Repository
│ SHOULD requirements - Story 12.16)
└── thumbnail/ ← GET /_matrix/media/v3/thumbnail/{serverName}/{mediaId}
GET /_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}
├── thumbnail.go ← GenerateThumbnail + DetectMIMEType + ThumbnailParams
│ Library: github.com/disintegration/imaging v1.6.2 (pure Go, MIT,
│ no cgo - sandboxed by construction, AC4 Story 12.5)
│ method=scale → imaging.Fit (aspect-ratio-preserved, ≤W×H)
│ method=crop → imaging.Fill (center-crop, exactly W×H)
│ animated=true + GIF → generateAnimatedGIFThumbnail (all frames)
│ animated=false → static JPEG (spec MUST NOT animate)
│ MIME detection: net/http.DetectContentType on first 512 bytes
│ (magic bytes, NOT Content-Type header)
│ AllowedMIMETypes: {image/jpeg, image/png, image/gif, image/webp}
│ All others (SVG, PDF, PS, EPS) → 400 M_BAD_JSON (deny-by-default)
└── handler.go ← HTTP handler; consumer-defined MediaStore interface;
width+height required (400 M_BAD_JSON if missing/non-integer);
mediaId validated: ^[A-Za-z0-9_\-]+$ (path traversal prevention);
Content-Disposition: inline; filename="thumbnail.ext" (spec v1.12);
Cache-Control: max-age=86400 (AC5);
ErrNotFound → 404 M_NOT_FOUND; storage errors → 502 M_UNKNOWN
(Story 12.5);
Content-Security-Policy + Cross-Origin-Resource-Policy: cross-origin
headers set on all 200 responses (Story 12.16)
pgThumbnailStore adapter (Story 12.5): thumbnail.MediaStore and download.MediaStore both
require GetMediaFile returning different row types (Go structural constraint). pgThumbnailStore
wraps pgMediaStore and converts *download.MediaFileRow to *thumbnail.MediaFileRow - zero
additional SQL queries. Both handlers share the same underlying DB access.
Storer injection (Story 12.3): main.go uses selectStorer(cfg mediaConfig) to select the backend:
NEBU_STORAGE_BACKEND=local(default) →&storage.LocalStorer{BasePath: storagePath}NEBU_STORAGE_BACKEND=minio→&storage.MinIOStorer{Client: minioClient, Bucket: cfg.minioBucket}
Credentials are loaded from Docker Secrets via readSecretFile(path) (mirrors the Gateway PSK pattern):
NEBU_MINIO_ACCESS_KEY_FILE=/run/secrets/minio_app_access_key → file read at startup.
The minio-go/v7 client is lazily constructed in selectStorer; empty minioEndpoint, minioAccessKey, or minioSecretKey returns an error (fail-fast, no silent anonymous access).
Level 2 - Elixir/OTP Core Internal Structure
core/apps/
├── nebu_db/ ← Shared Ecto Repo (DB connection)
├── room_manager/ ← FR7–24: Horde.DynamicSupervisor + Room GenServer
│ └── lib/nebu/room/
│ ├── manager.ex ← Horde.DynamicSupervisor
│ ├── server.ex ← Room GenServer (state, history, power levels)
│ ├── db.ex ← PostgreSQL queries; get_recently_left_rooms_for_user/1 added (Story 9-19);
│ │ fetch_events_by_relation/5 (events by m.relates_to event_id; optional
│ │ rel_type filter - empty = all types; opts: event_type filter, dir for
│ │ ORDER BY ASC/DESC; dynamic WHERE builder - Story 9-28/9-29);
│ │ count_thread_children/2 (reply count for thread root);
│ │ event_in_room?/2 (membership guard) added (Story 9-28);
│ │ fetch_event/2 (single event by event_id scoped to room_id -
│ │ SELECT … WHERE event_id=$1 AND room_id=$2; returns {:ok, map} |
│ │ {:error, :not_found} | {:error, reason} - Story 11-8)
│ ├── db_behaviour.ex ← @callback contract for db.ex (mockable in tests);
│ │ corresponding callbacks for Story 9-28/9-29 DB functions
│ ├── power_level.ex ← Room policy enforcement
│ ├── join_rules.ex ← Nebu.Room.JoinRules — MSC3083 restricted join rule evaluator (Story 15.5a);
│ │ check_join_allowed/3(user_id, join_rules_content, room_state_events) →
│ │ :ok | {:error, :forbidden} | {:error, :not_restricted};
│ │ algorithm: invite override check first (m.room.member membership:invite),
│ │ then OR-logic over allow[].type=="m.room_membership" Space membership checks;
│ │ unknown allow types silently ignored (forward compat); empty/missing allow → :forbidden;
│ │ session_manager_module() injected via Application.get_env (testable without ETS/Horde)
│ ├── invite_db.ex ← Nebu.Room.InviteDB — PostgreSQL invite persistence;
│ │ get_pending_invitees/1(room_id) added (Story 15.5a): returns {:ok, [invitee_id]}
│ │ for all pending (not accepted, not rejected) invitations in a room
│ └── room_manager/
│ └── space_hierarchy.ex ← Nebu.RoomManager.SpaceHierarchy — BFS traversal over m.space.child
│ state events (Story 15.6b); get_hierarchy/3(root_room_id, user_id, opts)
│ → {:ok, [%{room_id, room_type, join_rule}], next_batch_token | nil};
│ queue-based BFS with cycle detection (visited MapSet);
│ visibility: public join_rule OR is_member?; private rooms excluded from
│ response but children still traversed; suggested_only: true filters
│ m.space.child edges with suggested: true; max_depth: 0 = unlimited;
│ pagination: Base64url JSON cursor {visited, queue} serialized as token;
│ room_type: "m.space" for sub-spaces, nil for regular rooms;
│ db_module() injected via Application.get_env (testable with FakeDB)
├── session_manager/ ← ETS + PostgreSQL Hybrid since-Token (per-device since Story 9-22)
│ └── lib/nebu/session/
│ ├── manager.ex ← GenServer owning ETS table; is_member?/2(room_id, user_id) added
│ │ (Story 15.5a): ETS-backed Space membership check via
│ │ Nebu.Room.RoomSupervisor.lookup_room/1 + Room.Server.get_state/1;
│ │ returns false conservatively if room GenServer not running
│ ├── token.ex ← v1_<base64url(ts+cursor_map)> format
│ ├── pg_store/postgres.ex ← persist_since_token/3 (legacy) + /4 (per-device);
│ │ get_since_token/1 + /2; invalidate_session/1 + /2
│ ├── session_supervisor.ex ← destroy_session/1 (all devices) + /2 (per-device)
│ ├── bulk_importer.ex ← Nebu.Session.BulkImporter (Story 14-3a): admin bulk user provisioning;
│ │ import_users([%{user_id, system_role, display_name, email}]) →
│ │ {:ok, %{imported, skipped, failed}}; delegates to lookup_module (DB
│ │ lookup: signing_key_id IS NOT NULL = skip), user_store_module (upsert),
│ │ provisioner_module (keypairs + PII encryption); identical flow to
│ │ TokenValidator.Postgres.provision_new_user; partial success: exceptions
│ │ in import_one/2 are rescued → :failed; batch continues
│ └── bulk_importer/
│ └── postgres.ex ← Nebu.Session.BulkImporter.Postgres: lookup/1 checks signing_key_id
│ IS NOT NULL → :already_provisioned | :not_provisioned | {:error, reason}
├── presence/ ← FR15: Presence status (online/offline/unavailable)
├── event_dispatcher/ ← EventBus gRPC streaming + pg Process Groups fanout + FTS search layer
│ └── lib/nebu/
│ ├── build_info.ex ← Nebu.BuildInfo.get/0 - returns component/version/git_commit/build_time;
│ │ reads Application.get_env(:event_dispatcher, :build_info, %{}) with
│ │ System.get_env("RELEASE_VERSION"/"GIT_COMMIT"/"BUILD_TIME", "unknown")
│ │ as fallback; used by health/server.ex GET /info route (Story 11-9)
│ ├── health/
│ │ ├── health.ex ← Nebu.Health module (existing)
│ │ └── server.ex ← existing health server extended with GET /info route (Story 11-9):
│ │ new `handle_connection/1` clause delegates to Nebu.BuildInfo.get/0
│ │ and returns JSON; inserted before the 404 catch-all clause
│ └── event_dispatcher/
│ ├── server.ex ← gRPC handlers: join_room/2 evaluates MSC3083 restricted join rule
│ │ before Room.Server.join/2 (Story 15.5a): reads m.room.join_rules via
│ │ get_join_rules_content/1 (injectable messages_db_module, falls back to
│ │ "public" on error); reads pending invites via get_room_state_events_for_join_check/1
│ │ (injectable db_module_invite.get_pending_invitees/1); delegates to
│ │ Nebu.Room.JoinRules.check_join_allowed/3; {:error, :forbidden} raises
│ │ GRPC.RPCError permission_denied "M_FORBIDDEN: restricted join rule";
│ │ join_room/2 broadcasts {:new_join} to user :pg group;
│ │ leave_room/2 broadcasts {:new_leave}; do_incremental_sync handles
│ │ {:new_join}/{:new_leave} to wake long-poll sync Tasks (GAP-JOIN-PUBLIC);
│ │ upgrade_room/2 implements full Matrix §11.35.1 flow: tombstone →
│ │ create → join → set_power_levels → copy_state → invite → archive_old
│ │ → terminate_old_genserver; uses GRPC.RPCError instead of bare `:ok =`
│ │ pattern matches; wraps entire body in try/rescue with audit-trail
│ │ on failure (Story 9-27);
│ │ get_relations/2 gRPC handler reads event_type, dir, recurse from
│ │ proto request; validates membership + event_in_room?; delegates to
│ │ fetch_events_by_relation/5 with opts map; returns
│ │ Core.GetRelationsResponse (Story 9-28/9-29);
│ │ get_event/2 gRPC handler (Story 11-8): looks up room via
│ │ Horde registry, enforces MapSet membership guard, delegates to
│ │ messages_db_module().fetch_event/2, attaches thread aggregations
│ │ via attach_thread_aggregations/3, returns Core.GetEventResponse;
│ │ attach_thread_aggregations/3 private fn: for each timeline event
│ │ calls count_thread_children/2 + fetch latest reply, encodes
│ │ JSON {"m.thread":{count,latest_event,current_user_participated}}
│ │ into Event.unsigned_relations field (Story 9-28)
│ │ on failure (Story 9-27);
│ │ search_messages/2 executes FTS via Nebu.Search.DB.search_messages/5;
│ │ user_id sourced exclusively from trusted_identity(stream) metadata
│ │ (NEVER from request.user_id - security invariant); offset capped at
│ │ 10_000 to prevent expensive deep-page queries; next_batch is
│ │ Base64(offset+limit) for cursor pagination (Story 11-3);
│ │ bulk_import_users/2 (Story 14-3a): admin bulk provisioning RPC;
│ │ delegates to configurable bulk_importer_module() → Nebu.Session.BulkImporter;
│ │ returns BulkImportUsersResponse{imported, skipped, failed}; partial success
│ │ (exception in import_one/2 rescued → :failed, batch continues);
│ │ configurable via :event_dispatcher, :bulk_importer_module
│ ├── dispatcher.ex ← Routes events to rooms + subscribers
│ ├── bus.ex ← gRPC ServerStream to Go Gateway
│ └── search/
│ └── db.ex ← Nebu.Search.DB - membership-scoped full-text search SQL layer (Story 11-2);
│ search_messages/4 (user_id, term, limit, offset) executes canonical SQL
│ against the events.search_vector GIN index (migration 000042); membership
│ filter enforced at SQL layer via subquery on room_members WHERE left_at IS NULL
│ (NOT application-layer post-filter); encrypted rooms excluded via NOT EXISTS
│ on m.room.encryption state events; sql_search_messages/0 exposes the SQL
│ constant for structural testing (AC2); Story 11.3 wires this module to the
│ SearchMessages gRPC handler
├── signature/ ← FR25–29: Ed25519 signing + Canonical JSON + Event-ID
│ └── lib/nebu/
│ ├── signature.ex ← :crypto.sign/4 with eddsa
│ ├── event_id.ex ← Nebu.EventId.generate/1 (SHA-256 content hash)
│ └── canonical_json.ex ← RFC 8785 canonical JSON
├── permissions/ ← System roles + room power levels
│ └── lib/nebu/permissions/
│ ├── system_role.ex ← instance_admin | compliance_officer | user
│ └── room_policy.ex ← Power-level checks for room operations
└── compliance/ ← FR30–35: Four-eyes access, audit-log writers, signed export
Room upgrade flow (Story 9-27):
upgrade_room/2inevent_dispatcher/server.exnow implements the complete Matrix spec §11.35.1 sequence atomically: (1) tombstone the old room, (2) create the new room withpredecessorfield, (3) join creator, (4) set power levels, (5) copy state events, (6) invite old members, (7)admin_db_module().archive_room_atomic/1(SELECT FOR UPDATE, idempotent on:not_found) marks the old room row as archived in PostgreSQL, (8)Horde.DynamicSupervisor.terminate_child/2stops the old room GenServer. Error handling throughout usesGRPC.RPCError+GRPC.Status.internal()to surface Elixir errors as gRPCINTERNAL→ Go gateway HTTP 500 (not MatchError →codes.Unknown). A top-leveltry/rescuewrites a failure entry to the audit trail before reraising.
Note:
gateway/internal/additionally contains support packages not visualized above (api/,audit/,db/,ui/,validate/). They wrap shared infrastructure rather than represent distinct architectural blocks.
PostgreSQL tables added in Story 9-19:
forgotten_rooms (user_id, room_id, forgotten_at_ms BIGINT)
- migration 000040. Tracks rooms the user has permanently dismissed via
POST /forget. Excluded from all/syncsections (join, leave, invite). Primary key(user_id, room_id); cascade delete onusersremoval.
sync_tokensschema change in Story 9-22: migration 000041 addsdevice_id TEXT NOT NULL DEFAULT ''and replaces theuser_id-only primary key with a composite(user_id, device_id)PK. Legacy rows are preserved withdevice_id = ''. Each device now maintains an independent sync checkpoint, preventing parallel sessions on different devices from overwriting each other'ssincetoken.
Full-text search column in Story 11-1 (ADR-010): migration 000042 adds a
search_vector tsvectorcolumn to theeventstable, populated by a PL/pgSQL trigger (events_search_vector_trigger) on everyINSERT OR UPDATE OF content. The trigger callsto_tsvector('pg_catalog.simple', coalesce(content->>'body', '')), using thesimpletext search configuration (language-agnostic, no stemming - appropriate for a multilingual chat server). A GIN index (events_search_vector_gin_idx) enables efficient@@ tsqueryqueries. All existing events were backfilled during the migration. This is the database foundation forPOST /_matrix/client/v3/search(Epic 11). Scope enforcement at query time:WHERE room_id = ANY($membership_room_ids)prevents cross-room leakage.
Global account data in sync responses (Story 9-24):
syncResponsegains a top-levelAccountData syncAccountDataSectionfield (JSON keyaccount_data, never omitted) that carries globalm.*account data events per Matrix spec §6.3. TheGlobalAccountDataDBinterface (defined ingateway/internal/matrix/account_data.go) exposes a single methodListGlobalAccountData(ctx, userID) ([]GlobalAccountDataRow, error). The implementationPostgresAccountDataDB.ListGlobalAccountData(ingateway/internal/db/account_data_store.go) queriesroom_account_data WHERE room_id = ''inside awithUserDBtransaction to satisfy the RLS policy (GUCapp.user_id). The buffer fast-path returns an emptyaccount_data.eventsslice (no DB call) - global account data changes are rare and are picked up on the next full sync cycle.
Synthetic next_batch token on buffer fast-path (Story 9-25, GAP-BUFFER-NEXT-BATCH):
syntheticNextBatch()ingateway/internal/matrix/sync.gogenerates abuf_<unix_ms>_<seq>token for every response served from the local ring buffer. A package-levelsyntheticBatchSeq(sync/atomic.Int64) increments on each call, ensuring uniqueness within a process even for sub-millisecond bursts. ThesinceTokenparameter was removed frombuildResponseFromBufferedEvents(it is no longer used). The synthetic token is never persisted tosync_tokens; if the client sends it on the next request, Elixir'sGetSyncDeltatriggersFallbackToInitial = true, which issues a safe full re-sync. No schema change and no new interfaces were introduced.
Level 2 - Proto / gRPC Contract
proto/
├── core.proto ← CoreService: all RPC definitions
└── gen/
├── go/ ← Generated Go stubs (buf generate)
└── elixir/ ← Generated Elixir stubs
Key gRPC services: SendEvent, CreateRoom, JoinRoom, GetMessages, GetRoomState, SetPresence,
SetTyping, ValidateToken, GetPendingEvents (fallback), EventBus (streaming),
GetSyncDelta (incremental sync with per-device device_id field, Story 9-22),
InvalidateUserSessions (per-device or full-user session cleanup, Story 9-22),
GetRelations (thread relation events for a parent event_id, Story 9-28/9-29),
SearchMessages (full-text search with membership enforcement, Story 11-3),
GetEvent (single event fetch by event_id scoped to a room, membership-enforced, Story 11-8),
BulkImportUsers (admin bulk provisioning of OIDC users, identical flow to first login, Story 14-3a),
GetSpaceHierarchy (BFS traversal of a space's child rooms with pagination, Story 15-6a).
GetSyncDeltaRequest fields (Story 9-22):
| Field | Type | Description |
|---|---|---|
user_id | string | Matrix user ID |
since_token | string | Client-supplied sync token |
timeout_ms | int64 | Long-poll timeout |
device_id | string | Device-scoped checkpoint key; empty string = legacy fallback |
InvalidateUserSessionsRequest fields (Story 9-22):
| Field | Type | Description |
|---|---|---|
user_id | string | Matrix user ID |
device_id | string | When set, only invalidates this device; when empty, invalidates all user sessions |
Source: _bmad-output/planning-artifacts/architecture.md, §Project Structure & Boundaries, §Complete Project Directory Structure; Story 9-19 (room_moderation.go, sync.go, event_dispatcher/server.ex, forgotten_rooms migration); Story 9-22 (per-device sync tokens, device_id in proto); Story 9-24 (GlobalAccountDataDB interface, ListGlobalAccountData, top-level account_data in syncResponse); Story 9-25 (syntheticNextBatch helper, syntheticBatchSeq atomic counter, sinceToken param removed from buildResponseFromBufferedEvents); Story 9-27 (upgrade_room/2 full Matrix §11.35.1 flow, GRPC.RPCError error handling, archive_room_atomic, terminate_child, try/rescue failure audit); Story 11-2 (Nebu.Search.DB, membership-scoped FTS SQL contract, encrypted-room exclusion, integration test infrastructure); Story 11-10 (ClaimMappingHandler admin settings page, ClaimSelectionHandler bootstrap extension, ServerConfigReader interface with LoadClaimMapping/SaveClaimMapping, FormatUserIDFromClaims refactored signature, JWTMiddleware userIDClaimLoader param, migration 000044 default claim mapping seed); Story 12.16 (media/internal/auth middleware package - TokenVerifier interface + fail-closed nil guard; media/internal/config handler package; CSP + Cross-Origin-Resource-Policy headers on download/thumbnail; v1 authenticated media routes in main.go)
Event message - unsigned_relations field (Story 9-28):
The shared Event proto message gained field 9:
Set by attach_thread_aggregations/3 in Elixir for events that have at least one thread reply.
Empty (zero bytes) for events with no relations - the Go gateway omits m.relations from the
unsigned JSON object when the field is absent.
GetRelationsRequest / GetRelationsResponse fields (Story 9-28 / 9-29):
| Field | Type | Description |
|---|---|---|
user_id | string | Caller; membership guard enforced in Elixir |
room_id | string | Room that contains the parent event |
event_id | string | Parent event ID (thread root) |
rel_type | string | Relation type, e.g. "m.thread"; empty = all relation types (Story 9-29) |
limit | int32 | Max events returned; 0 = default 20; clamped to 100 |
event_type | string | Filter by event type; empty = all event types (Story 9-29, field 6) |
dir | string | "f" = oldest-first ASC; "b" = newest-first DESC (default) (Story 9-29, field 7) |
recurse | bool | Accepted without error; MVP passes through (Story 9-29, field 8) |
from | string | Opaque pagination token; empty = first page (Story 9-29, field 9) |
Response: repeated Event events + string next_batch (empty when no more pages) + string prev_batch (for dir=b backward pagination, Story 9-29, field 3).
Migration 000042 (Story 9-28):
CREATE INDEX CONCURRENTLY … ON events ((content->'m.relates_to'->>'event_id')) WHERE content ? 'm.relates_to'- expression index on them.relates_toJSONB field; required byfetch_events_by_relation/5andcount_thread_children/2to avoid sequential scans on the events table.
Nebu.Search.DB- membership-scoped FTS query layer (Story 11-2):core/apps/event_dispatcher/lib/nebu/search/db.exdefines the SQL contract forPOST /_matrix/client/v3/search(Epic 11). Key design invariants:
SQL-layer membership enforcement - the subquery
WHERE room_id IN (SELECT room_id FROM room_members WHERE user_id = $1 AND left_at IS NULL)runs inside the same PostgreSQL query. There is no application-layer post-filter; membership is checked at query execution time. This prevents cross-room IDOR leakage even if Elixir application logic is bypassed.Encrypted-room exclusion - rooms that have an
m.room.encryptionstate event (state_key = '' OR state_key IS NULL) are excluded from search results viaNOT EXISTS (SELECT 1 FROM events enc WHERE enc.room_id = e.room_id AND enc.event_type = 'm.room.encryption'). Ciphertext bodies are never returned in plaintext search responses.
user_idsecurity invariant - theuser_idparameter MUST be sourced from the validated session (gRPC metadata or JWT claim), never from the request payload. Passing a caller-supplied user_id bypasses all membership enforcement and enables cross-room IDOR. This invariant is enforced by the caller (Story 11.3 SearchMessages handler) not byNebu.Search.DBitself.
websearch_to_tsquery+pg_catalog.simple- consistent with the trigger configuration in migration 000042 (ADR-010).ts_rank_cdfor result ordering (density-aware ranking).Module placement - in
event_dispatcher(notroom_manager) because the gRPC search handler (Story 11.3) lives inNebu.EventDispatcher.Server. Adding search toNebu.Room.DBwould violate the single-responsibility principle and polluteNebu.Room.DBBehaviour.
Integration test infrastructure (Story 11-2):
Makefilegains atest-integration-elixirtarget that runs ExUnit tests tagged@tag :integrationagainst a live PostgreSQL instance (NEBU_TEST_DB_URL). The 6 search integration tests (AC1 cross-room scope, AC2 structural SQL shape, AC3 kicked-user exclusion, zero-membership guard, encrypted-room exclusion, multi-room inclusion) are excluded from thetest-unit-elixirtarget viaExUnit.configure(exclude: [:integration])inevent_dispatcher/test/test_helper.exs.
SearchMessages gRPC handler (Story 11-3):
Nebu.EventDispatcher.Server.search_messages/2is the gRPC entry point forPOST /_matrix/client/v3/search(implemented in Story 11-4). Key design decisions:
Delegated search module - the handler delegates to
search_db_module()(runtime-swappable viaApplication.get_env(:event_dispatcher, :search_db_module, Nebu.Search.DB)) to keep SQL logic inNebu.Search.DBand make the handler unit-testable with a fake viaFakeSearchDB.
user_idfrom trusted metadata only -{user_id, _} = Nebu.Grpc.Metadata.trusted_identity(stream). Therequest.user_idfield is intentionally ignored. This enforces the Story 11-2 security invariant at the transport layer.Pagination -
next_batchisBase64(Integer.to_string(offset + limit)). An empty string signals no more pages. The handler capsoffsetat10_000to prevent unbounded deep-paging queries (Kassandra MEDIUM finding, fixed inline).Limit clamping -
limitis clamped to[1, 100]; zero defaults to 10.Proto additions -
core.protogainsProfileInfo,SearchResult,SearchMessagesRequest,SearchMessagesResponsemessage types and theSearchMessagesRPC. Go stubs auto-regenerated viamake proto; Elixircore_grpc.pb.exservice stub updated manually (protoc-gen-elixir does not auto-update the service module).
GetEventRequest / GetEventResponse fields (Story 11-8):
| Field | Type | Description |
|---|---|---|
room_id | string | Room that owns the event; scopes the DB query |
event_id | string | Unique event ID to fetch |
user_id | string | Caller; Elixir enforces joined-member check via Horde room state |
Response: Event event - the full event proto (same Event message used by sync and relations endpoints).
GetEventgRPC handler design (Story 11-8):Nebu.EventDispatcher.Server.get_event/2looks up the room viaNebu.Room.RoomSupervisor.lookup_room/1(Horde registry). If the room is unknown, it raisesGRPC.RPCErrorwithNOT_FOUND. It then callsroom_registry_module().get_state/1and checksMapSet.member?(state.members, user_id)- non-members receivePERMISSION_DENIED. On success it delegates tomessages_db_module().fetch_event/2(SQL:SELECT … WHERE event_id=$1 AND room_id=$2 LIMIT 1) and callsattach_thread_aggregations/3so the returned event already carries bundledm.threadaggregation data (same as sync responses). TheGetEventRPC and its message types were added tocore.proto; the Elixircore_grpc.pb.exservice stub was updated manually (rpc :GetEvententry added alongside the pre-existingrpc :GetRelationsfix).
GetSpaceHierarchyRequest / SpaceSummaryRoom / GetSpaceHierarchyResponse fields (Story 15-6a):
GetSpaceHierarchyRequest:
| Field | Type | Description |
|---|---|---|
room_id | string | Space root room ID |
user_id | string | Requesting user ID |
limit | int32 | Max rooms per page; 0 = server default |
max_depth | int32 | Max BFS depth; 0 = unlimited |
suggested_only | bool | Filter to suggested=true children only |
from_token | string | Pagination token; empty = start from root |
SpaceSummaryRoom (one room in the hierarchy):
| Field | Type | Description |
|---|---|---|
room_id | string | Matrix room ID |
name | string | Room name |
canonical_alias | string | Room alias |
topic | string | Room topic |
num_joined_members | int32 | Live member count |
world_readable | bool | Always false for Nebu rooms |
guest_can_join | bool | Always false for Nebu rooms |
room_type | string | "m.space" for sub-spaces; empty for regular rooms |
via | repeated string | Server names from m.space.child |
suggested | bool | From m.space.child.suggested |
order | string | From m.space.child.order |
GetSpaceHierarchyResponse: repeated SpaceSummaryRoom rooms + string next_batch_token (empty if no more pages).
Proto codegen note (Story 15-6a):
core.protogainsGetSpaceHierarchyRequest,SpaceSummaryRoom,GetSpaceHierarchyResponsemessage types and theGetSpaceHierarchyRPC. Hand-written Go stubs ingateway/internal/grpc/pb/space_hierarchy.gosatisfygo build ./...untilmake protoregeneratescore.pb.go. Elixircore.pb.exandcore_grpc.pb.exare updated manually. Whenmake protoruns,space_hierarchy.gobecomes an empty marker file (same pattern asevent_context.goandpublic_rooms.go).
Core gRPC handler (Story 15-6c):
def get_space_hierarchy/2inNebu.EventDispatcher.Serverdelegates to the configurablespace_hierarchy_module()(injectable viaApplication.put_env/3; defaultNebu.RoomManager.SpaceHierarchy). Pagination tokens are passed through transparently — the BFS module manages opaque cursor state; the handler only mapsnil→""and binary token → unchanged. On{:error, :not_found}the handler raisesGRPC.RPCErrorwithGRPC.Status.not_found().map_rooms/1converts BFS atom-keyed maps toCore.SpaceSummaryRoomproto structs (onlyroom_idandroom_typepopulated in MVP; other fields default to proto3 zero values).
Gateway HTTP handler (Story 15-7):
GET /_matrix/client/v1/rooms/{roomId}/hierarchyregistered inmain.gobehindjwtWithStatusCheck. Handler:gateway/internal/matrix/space_hierarchy.go—SpaceHierarchyHandler.GetSpaceHierarchywith consumer interfaceSpaceHierarchyCoreClient. Query params:limit(default 50, max 1000 — over-limit silently clamped; negative → 400 M_BAD_PARAM),max_depth(default 0 = unlimited),suggested_only(must be"true"or"false"exactly — else 400 M_BAD_PARAM),from(opaque pagination cursor).next_batch_token(proto) maps tonext_batch(JSON) withomitempty— omitted when Core returns"".roomsalways serialises as[]notnull. gRPC wrapper methodClient.GetSpaceHierarchyadded togateway/internal/grpc/client.go.
_Source: _bmad-output/planning-artifacts/architecture.md, §Project Structure & Boundaries, §Complete Project Directory Structure; Story 9-19 (room_moderation.go, sync.go, event_dispatcher/server.ex, forgotten_rooms migration); Story 9-22 (per-device sync tokens, device_id in proto); Story 9-24 (GlobalAccountDataDB interface, ListGlobalAccountData, top-level account_data in syncResponse); Story 9-25 (syntheticNextBatch helper, syntheticBatchSeq atomic counter, sinceToken param removed from buildResponseFromBufferedEvents); Story 9-27 (upgrade_room/2 full Matrix §11.35.1 flow, GRPC.RPCError error handling, archive_room_atomic, terminate_child, try/rescue failure audit); Story 9-28 (GetRelations RPC, unsigned_relations field on Event, attach_thread_aggregations, fetch_events_by_relation, count_thread_children, event_in_room?, migration 000042); Story 9-29 (base /relations/{eventId} route, three-segment /{relType}/{eventType} route, dir/event_type/recurse/from query params, prev_batch in response, fetch_events_by_relation/5 dynamic WHERE builder); Story 11-3 (SearchMessages gRPC handler, proto extension, delegated search_db_module pattern, offset-cap security fix); Story 11-4 (search.go Gateway handler, SearchCoreClient consumer interface, §11.14.1 response shape, gRPC error mapping); Story 11-5 (NewUserRateLimiter middleware, per-user 10 req/min for /search, retry_after_ms in body); Story 11-8 (GetEvent RPC, event.go Go handler, fetch_event/2 DB function, core_grpc.pb.ex rpc :GetEvent + rpc :GetRelations bug fix); Story 11-9 (health/info.go NewInfoHandler, Nebu.BuildInfo module, GET /info on pubMux + health server, Admin UI footer via page_data.go SetBuildInfo/newPageData, ErrorMode on PageData, Dockerfile ARG/ldflags injection, docker-compose.yml build args); Story 11-10 (ClaimMappingHandler + ClaimMappingPageData + ClaimMappingConfig in claim_mapping.go, claim-mapping.html template, ClaimSelectionHandler atomic claim persistence in auth.go, ServerConfigReader LoadClaimMapping/SaveClaimMapping extension, FormatUserIDFromClaims signature refactor in grpc/metadata.go, JWTMiddleware userIDClaimLoader param, LoginHandler per-request claim resolution, migration 000044 claim mapping defaults seed); Story 12.16 (media/internal/auth middleware - TokenVerifier interface + fail-closed nil guard; media/internal/config handler; CSP + Cross-Origin-Resource-Policy headers on download/thumbnail; v1 authenticated routes in main.go); Story 15-6a (GetSpaceHierarchy RPC + message types, hand-written pb stubs, companion file pattern); Story 15-6b (Nebu.RoomManager.SpaceHierarchy BFS module — get_hierarchy/3, visibility filter, suggested_only, max_depth, Base64url JSON pagination cursor); Story 15-6c (get_space_hierarchy/2 gRPC handler in Nebu.EventDispatcher.Server — injectable space_hierarchy_module, transparent token passthrough, map_rooms/1 proto conversion); Story 15-7 (SpaceHierarchyHandler Gateway HTTP handler, GET /matrix/client/v1/rooms/{roomId}/hierarchy, query param validation, Client.GetSpaceHierarchy wrapper)