Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
479bfd0
refactor(finance): remove dead CreatePeriod service method
ItsThompson Jul 11, 2026
822a1b6
refactor(finance): dedup yearMonth to package-level type
ItsThompson Jul 11, 2026
e9fc660
refactor(finance): drop redundant gRPC Unimplemented stubs
ItsThompson Jul 11, 2026
c053a6c
feat(pgutil): add shared pgx UUID/no-rows/unique-violation helpers
ItsThompson Jul 11, 2026
de7cf99
feat(apierr): add shared API error contract module
ItsThompson Jul 11, 2026
3ee545d
refactor(auth): drop redundant gRPC Unimplemented stubs
ItsThompson Jul 11, 2026
43bb693
refactor(auth): remove unused RefreshTokenTTL accessor
ItsThompson Jul 11, 2026
25c8e9f
feat(httpx): add request guards module
ItsThompson Jul 11, 2026
167cf8b
refactor(expense): drop dead SchemaInitializer and SQLResult.Columns
ItsThompson Jul 11, 2026
a6bd465
refactor(metrics): remove dead DB collector symbols and their tests
ItsThompson Jul 11, 2026
d2b6b85
test(metrics): cover UnaryServerInterceptor success and error outcomes
ItsThompson Jul 11, 2026
dbafe77
refactor(dbmigrate): drop filesystem Run variant and source/file driver
ItsThompson Jul 11, 2026
50f2fdb
refactor(gateway): drop write-only TokenValidationResult.Username
ItsThompson Jul 11, 2026
3922d90
refactor(access): remove dead Classifies helper
ItsThompson Jul 11, 2026
8e45dfd
test(pgutil): reword ParseUUID wrap assertion to drop literal %w token
ItsThompson Jul 11, 2026
0f3ad31
refactor(datarights): drop unused GRPCPort config
ItsThompson Jul 11, 2026
8b45242
refactor(datarights): remove empty CreateExportRequest
ItsThompson Jul 11, 2026
2ecfa85
refactor(datarights): drop unused BrandColors E/D/S fields
ItsThompson Jul 11, 2026
6e65a37
refactor(perf): delete unused AssertMaxAllocs alloc-bound helper
ItsThompson Jul 11, 2026
24317ef
fix(apierr): fall back to 500 when Error.Status is unset
ItsThompson Jul 11, 2026
29dbd7d
feat(serverkit): add shared bootstrap and serve/shutdown lifecycle
ItsThompson Jul 11, 2026
120191c
test(serverkit): cover bootstrap helpers and Serve lifecycle
ItsThompson Jul 11, 2026
41fd35f
test(metrics): assert active_connections series is absent from /metrics
ItsThompson Jul 11, 2026
ceb9436
Merge branch 'epic/gofin-consol/ticket-1' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
fb25306
Merge branch 'epic/gofin-consol/ticket-2' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
a9935c3
Merge branch 'epic/gofin-consol/ticket-3' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
81edabd
Merge branch 'epic/gofin-consol/ticket-4' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
8dae03e
Merge branch 'epic/gofin-consol/ticket-5' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
6b7e43a
refactor(finance): adopt serverkit bootstrap in main
ItsThompson Jul 11, 2026
a0c1161
refactor(finance): fold expense client and clock into constructor
ItsThompson Jul 11, 2026
540915b
feat(datarights): detect no-rows via pgutil.IsNoRows in job repos
ItsThompson Jul 11, 2026
e108830
fix(expense): remove advertised-but-ignored list filters (C1)
ItsThompson Jul 11, 2026
de273a8
refactor(access): rename Access type to Level
ItsThompson Jul 11, 2026
15c50ea
fix(expense): guard rowToExpense against short rows (C4)
ItsThompson Jul 11, 2026
b72d794
feat(datarights): adopt serverkit bootstrap, serve lifecycle, and pro…
ItsThompson Jul 11, 2026
31a9cb6
refactor(access): unexport Registry/ProxyPrefixes, inject prefixes in…
ItsThompson Jul 11, 2026
a6790b4
fix(expense): make dev in-memory client UPDATE loud (C3)
ItsThompson Jul 11, 2026
1fd5f31
refactor(finance): migrate error contract to apierr and httpx
ItsThompson Jul 11, 2026
b7cd947
fix(expense): remove empty-scope gRPC read RPCs and dev cross-user by…
ItsThompson Jul 11, 2026
75488a3
refactor(finance): adopt pgutil in postgres repo
ItsThompson Jul 11, 2026
6199a1c
feat(datarights): route errors through apierr and guards through httpx
ItsThompson Jul 11, 2026
48f4d21
refactor(gateway): adopt serverkit NewLogger and Serve lifecycle
ItsThompson Jul 11, 2026
6f1f621
chore(auth): wire serverkit/apierr/httpx/pgutil into go.mod
ItsThompson Jul 11, 2026
458850b
refactor(auth): replace pgx boilerplate with pgutil helpers
ItsThompson Jul 11, 2026
606450c
feat(auth): hoist JWT/cleanup/cookie config and name role constants
ItsThompson Jul 11, 2026
5091784
refactor(auth): route errors via apierr+httpx, adopt serverkit lifecycle
ItsThompson Jul 11, 2026
26a3220
feat(datarights): move rate-limit retry timing to Retry-After header …
ItsThompson Jul 11, 2026
17853d5
refactor(gateway): route API errors through apierr
ItsThompson Jul 11, 2026
e5b2600
refactor(gateway): unify healthcheck probe port with config
ItsThompson Jul 11, 2026
83687da
docs(gateway): drop stale AssertMaxAllocs mention in perf baseline
ItsThompson Jul 11, 2026
51235b3
feat(datarights): hoist protected-username list to config with test
ItsThompson Jul 11, 2026
614afd8
style(gateway): gofmt router_test validator fixtures
ItsThompson Jul 11, 2026
c035742
test(auth): clarify clearEnv comment on empty-vs-unset env handling
ItsThompson Jul 11, 2026
c341bf9
test(gateway): assert exact wire bytes for apierr error bodies
ItsThompson Jul 11, 2026
da60611
chore(datarights): tidy go.mod indirect deps and rename rate-limit test
ItsThompson Jul 11, 2026
b953a5d
style(finance): gofmt-sort cmd/main.go import group
ItsThompson Jul 11, 2026
b5df7fe
test(finance): assert onboarding emits field-level validation detail
ItsThompson Jul 11, 2026
cd8889a
Merge branch 'epic/gofin-consol/ticket-6' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
ef80deb
Merge branch 'epic/gofin-consol/ticket-7' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
1f855b7
Merge branch 'epic/gofin-consol/ticket-8' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
bdba71c
Merge branch 'epic/gofin-consol/ticket-10' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
ff13d31
Merge branch 'epic/gofin-consol/ticket-11' into epic/gofin-consolidation
ItsThompson Jul 11, 2026
b6f7c9a
refactor(expense): adopt serverkit bootstrap in main
ItsThompson Jul 11, 2026
d75a4fd
refactor(expense): fold clock into the service constructor
ItsThompson Jul 11, 2026
ce13c1e
feat(datarights): add jobrunner Pool and StatusStore seam
ItsThompson Jul 11, 2026
af6bcaa
refactor(expense): migrate error contract to apierr and httpx
ItsThompson Jul 11, 2026
5a2c2b0
refactor(datarights): de-stutter deletion engine/provider/registry names
ItsThompson Jul 11, 2026
9988e0e
refactor(datarights): run deletion engine on jobrunner.Pool
ItsThompson Jul 11, 2026
74e72e7
refactor(datarights): run export engine on jobrunner.Pool with live p…
ItsThompson Jul 11, 2026
259f94c
refactor(datarights): share startup job-recovery skeleton
ItsThompson Jul 11, 2026
8e2c295
refactor(datarights): mark jobs queued in Submit for precise pool tel…
ItsThompson Jul 11, 2026
a133f43
Merge branches 'epic/gofin-consol/ticket-9' and 'epic/gofin-consol/ti…
ItsThompson Jul 11, 2026
9d7ab74
refactor(datarights): collapse deletion providers into NewFuncProvider
ItsThompson Jul 11, 2026
b5d6143
refactor(datarights): fetch export finance data once, drop MemoizedFi…
ItsThompson Jul 11, 2026
4132a8b
refactor(datarights): dedup email templateData against BrandTokens
ItsThompson Jul 11, 2026
d29428b
docs(datarights): update export perf baseline for structural single-f…
ItsThompson Jul 11, 2026
9a3f563
merge: datarights jobrunner provider consolidation (#13)
ItsThompson Jul 11, 2026
08df1a0
fix(docker): copy shared modules into service builds
ItsThompson Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,6 @@ describe("ExportDataSection", () => {
body: {
code: "RATE_LIMITED",
message: `Export limit reached. You can request another export after ${formatDateOnly(retryAfter)}.`,
retryAfter,
},
},
]),
Expand Down Expand Up @@ -587,7 +586,6 @@ describe("ExportDataSection", () => {
body: {
code: "RATE_LIMITED",
message: `Export limit reached. Next available: ${retryAfter}`,
retryAfter,
},
},
]),
Expand Down
7 changes: 0 additions & 7 deletions frontend/apps/finance/src/features/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,6 @@ export interface ExportListResponse {
hasMore: boolean;
}

/** Error response from 429 rate-limited requests. */
export interface ExportRateLimitedResponse {
code: string;
message: string;
retryAfter: string;
}

/** Export lifecycle phases: exactly one is active at any time. */
export type ExportStatus = 'idle' | 'loading' | 'creating' | 'polling' | 'error';

Expand Down
10 changes: 5 additions & 5 deletions services/access/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@
// from the Registry) can import it without a build dependency on each other.
//
// The package owns three things:
// - the Access classification applied to every route (access.go),
// - the access Level applied to every gateway-facing route (access.go),
// - the Registry of concrete routes and their access levels (registry.go),
// - a gin-priority path resolver used by the gateway middleware (resolver.go).
package access

// Access is the classification applied to every gateway-facing route. It
// Level is the access classification applied to every gateway-facing route. It
// determines whether a request needs a token and, if so, which role may
// proceed.
type Access int
type Level int

const (
// Public routes are reachable with no token.
Public Access = iota
Public Level = iota
// Authenticated routes require any valid token, regardless of role.
Authenticated
// Personal routes require a valid token acting as a regular user (role == "user").
Expand All @@ -32,7 +32,7 @@ const (
)

// String returns the level name, used for readable logs and test diagnostics.
func (a Access) String() string {
func (a Level) String() string {
switch a {
case Public:
return "Public"
Expand Down
16 changes: 14 additions & 2 deletions services/access/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,33 @@ type ProxyPrefix struct {
Service string
}

// ProxyPrefixes is the ordered inventory of downstream prefixes the gateway
// proxyPrefixes is the ordered inventory of downstream prefixes the gateway
// proxies and the service each targets. It makes "what prefixes and services
// exist" part of the single source of truth: the gateway derives its proxy
// wiring from this list (see services/gateway/internal/router), and a
// cross-check test pins it against the Registry so every classified route sits
// under a proxied prefix and every proxied prefix has at least one classified
// route.
//
// It is set once at init and unexported so no caller can reassign it; the
// gateway reads a copy via Prefixes().
//
// Two prefixes map to the auth service: /api/auth (its own routes) and
// /api/admin (operator routes such as GET /api/admin/users, still served by
// auth even though their Access is Admin).
var ProxyPrefixes = []ProxyPrefix{
var proxyPrefixes = []ProxyPrefix{
{Prefix: "/api/auth", Service: "auth"},
{Prefix: "/api/admin", Service: "auth"},
{Prefix: "/api/expenses", Service: "expense"},
{Prefix: "/api/finance", Service: "finance"},
{Prefix: "/api/datarights", Service: "datarights"},
}

// Prefixes returns a copy of the proxy-prefix inventory. The copy is why the
// gateway can derive its routing wiring (router.New) from this list without
// being able to mutate the shared, init-time-fixed backing array.
func Prefixes() []ProxyPrefix {
out := make([]ProxyPrefix, len(proxyPrefixes))
copy(out, proxyPrefixes)
return out
}
8 changes: 4 additions & 4 deletions services/access/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func underPrefix(path, prefix string) bool {
// coveringPrefix returns the single ProxyPrefix a path sits under (prefixes are
// disjoint on segment boundaries, so at most one matches).
func coveringPrefix(path string) (ProxyPrefix, bool) {
for _, p := range ProxyPrefixes {
for _, p := range proxyPrefixes {
if underPrefix(path, p.Prefix) {
return p, true
}
Expand All @@ -30,7 +30,7 @@ func coveringPrefix(path string) (ProxyPrefix, bool) {
// same service that serves the route (else the gateway would forward it to the
// wrong downstream).
func TestProxyPrefixes_EveryRegistryRouteIsReachable(t *testing.T) {
for _, r := range Registry {
for _, r := range registry {
prefix, ok := coveringPrefix(r.Path)
if !ok {
t.Errorf("route %s (%s %s) is classified but sits under no ProxyPrefix; the gateway would never proxy it (add a ProxyPrefix for its prefix)", r.ID, r.Method, r.Path)
Expand All @@ -47,9 +47,9 @@ func TestProxyPrefixes_EveryRegistryRouteIsReachable(t *testing.T) {
// prefix with no Registry route is proxied-but-unclassified, so under
// deny-by-default every request to it would 403 (a silently dead prefix).
func TestProxyPrefixes_EveryPrefixHasARoute(t *testing.T) {
for _, p := range ProxyPrefixes {
for _, p := range proxyPrefixes {
found := false
for _, r := range Registry {
for _, r := range registry {
if underPrefix(r.Path, p.Prefix) {
found = true
break
Expand Down
27 changes: 7 additions & 20 deletions services/access/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,20 @@ type Route struct {
// "/api/finance/tags/:id" or "/api/expenses/:id/history".
Path string
// Access is the classification enforced by the gateway middleware.
Access Access
Access Level
}

// Registry is the single, exhaustive list of every gateway-facing downstream
// route. It is the sole source of truth: downstream services register their
// routes by iterating RoutesFor, and the gateway classifies each request by
// resolving against these patterns (see resolver.go).
// registry is the single, exhaustive list of every gateway-facing downstream
// route. It is the sole source of truth, set once at init and unexported so no
// caller can reassign it: downstream services read it via RoutesFor and the
// gateway classifies each request via Resolve (see resolver.go).
//
// Gateway-native endpoints (/health, /metrics) are intentionally absent: no
// downstream service serves them, so the gateway classifies them itself.
//
// Path strings must match gin's registered patterns byte-for-byte (verified by
// each service's registration coverage test against engine.Routes()).
var Registry = []Route{
var registry = []Route{
// --- auth service ---
{ID: "auth.register", Service: "auth", Method: http.MethodPost, Path: "/api/auth/register", Access: Public},
{ID: "auth.login", Service: "auth", Method: http.MethodPost, Path: "/api/auth/login", Access: Public},
Expand Down Expand Up @@ -89,23 +89,10 @@ var Registry = []Route{
// a route missing from the Registry can never be served.
func RoutesFor(service string) []Route {
var routes []Route
for _, r := range Registry {
for _, r := range registry {
if r.Service == service {
routes = append(routes, r)
}
}
return routes
}

// Classifies reports whether method+path is matched by an explicit Registry
// entry, as opposed to falling through to the fail-safe Deny default in
// Resolve. Services use it as a defense-in-depth check that every route they
// register is classified by the Registry.
func Classifies(method, path string) bool {
for _, r := range Registry {
if r.Method == method && matchPattern(r.Path, path) {
return true
}
}
return false
}
49 changes: 5 additions & 44 deletions services/access/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import "testing"
// TestRegistry_IDsAreUnique guards the bind-by-ID contract: services look up
// handlers by Route.ID, so a duplicate ID would silently shadow a handler.
func TestRegistry_IDsAreUnique(t *testing.T) {
seen := make(map[string]Route, len(Registry))
for _, r := range Registry {
seen := make(map[string]Route, len(registry))
for _, r := range registry {
if prev, dup := seen[r.ID]; dup {
t.Errorf("duplicate route ID %q: %s %s and %s %s", r.ID, prev.Method, prev.Path, r.Method, r.Path)
}
Expand All @@ -18,24 +18,13 @@ func TestRegistry_IDsAreUnique(t *testing.T) {
// directly from the Registry (no second hand-list): every entry's own
// method+path must resolve to the Access it declares.
func TestRegistry_EveryEntryResolvesToItsAccess(t *testing.T) {
for _, r := range Registry {
for _, r := range registry {
if got := Resolve(r.Method, r.Path); got != r.Access {
t.Errorf("Resolve(%q, %q) = %s, want %s (route %s)", r.Method, r.Path, got, r.Access, r.ID)
}
}
}

// TestRegistry_EveryEntryIsClassified confirms Classifies agrees with the
// Registry for every real route: each entry's path is matched by an explicit
// entry rather than the fail-safe default.
func TestRegistry_EveryEntryIsClassified(t *testing.T) {
for _, r := range Registry {
if !Classifies(r.Method, r.Path) {
t.Errorf("Classifies(%q, %q) = false, want true (route %s)", r.Method, r.Path, r.ID)
}
}
}

// TestRoutesFor_PartitionsRegistry proves RoutesFor slices the Registry cleanly:
// the four known services together account for every entry with no gaps or
// duplicates, and each returned route actually belongs to the requested service.
Expand All @@ -53,8 +42,8 @@ func TestRoutesFor_PartitionsRegistry(t *testing.T) {
total += len(routes)
}

if total != len(Registry) {
t.Errorf("RoutesFor over %v covered %d routes, but Registry has %d; a service is misnamed or missing", services, total, len(Registry))
if total != len(registry) {
t.Errorf("RoutesFor over %v covered %d routes, but Registry has %d; a service is misnamed or missing", services, total, len(registry))
}
}

Expand All @@ -65,31 +54,3 @@ func TestRoutesFor_UnknownServiceIsEmpty(t *testing.T) {
t.Errorf("RoutesFor(%q) = %d routes, want 0", "nope", len(routes))
}
}

// TestClassifies_UnknownRoutesAreUnclassified is the failure-mode guardrail: a
// route with no Registry entry, or a real path under the wrong method, is not
// classified, so Resolve returns the fail-safe Deny default (403 at the
// gateway).
func TestClassifies_UnknownRoutesAreUnclassified(t *testing.T) {
cases := []struct {
name string
method string
path string
}{
{"unknown service", "GET", "/api/newservice/records"},
{"wrong method on a real route", "GET", "/api/auth/login"},
{"sibling substring of a real route", "POST", "/api/datarights/exports-admin"},
{"bare group with no exact route", "GET", "/api/finance"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if Classifies(tc.method, tc.path) {
t.Errorf("Classifies(%q, %q) = true, want false", tc.method, tc.path)
}
if got := Resolve(tc.method, tc.path); got != Deny {
t.Errorf("Resolve(%q, %q) = %s, want Deny (fail-safe default)", tc.method, tc.path, got)
}
})
}
}
6 changes: 3 additions & 3 deletions services/access/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ import "strings"
// borrow "/api/datarights/exports"'s level: static segments must match
// byte-for-byte. This makes the leading-substring bug that segment-boundary
// prefix matching guarded against (commit ca37e4c) impossible by construction.
func Resolve(method, path string) Access {
func Resolve(method, path string) Level {
var best *Route
for i := range Registry {
entry := &Registry[i]
for i := range registry {
entry := &registry[i]
if entry.Method != method || !matchPattern(entry.Path, path) {
continue
}
Expand Down
3 changes: 2 additions & 1 deletion services/access/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ func TestResolve_WorkedExamples(t *testing.T) {
name string
method string
path string
want Access
want Level
}{
{"login is public", "POST", "/api/auth/login", Public},
{"register is public", "POST", "/api/auth/register", Public},
Expand Down Expand Up @@ -65,6 +65,7 @@ func TestResolve_FallsBackToDeny(t *testing.T) {
{"bare admin group", "GET", "/api/admin"},
{"bare datarights group", "GET", "/api/datarights"},
{"unknown api path", "GET", "/api/unknown"},
{"unknown service deep path", "GET", "/api/newservice/records"},
{"extra trailing segment", "GET", "/api/finance/tags/abc/extra"},
}

Expand Down
16 changes: 16 additions & 0 deletions services/apierr/codes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Package apierr single-sources the GoFin API error contract: the typed
// service error, the shared error-code constants, and the HTTP wire-response
// mapping. It is the one place the {code, message, fields?} contract lives so
// all four API services emit a byte-identical error shape.
package apierr

// Shared, cross-service error codes (domain truth). Service-specific codes
// (e.g. PERIOD_LOCKED, DUPLICATE_TAG) stay as local constants in each service;
// they are still valid Code strings and do not belong here.
const (
CodeUnauthorized = "UNAUTHORIZED"
CodeNotFound = "NOT_FOUND"
CodeValidation = "VALIDATION_ERROR"
CodeInternal = "INTERNAL_SERVER_ERROR"
CodeForbidden = "FORBIDDEN"
)
40 changes: 40 additions & 0 deletions services/apierr/error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package apierr

import "net/http"

// Error is the single typed service error. It carries everything the wire
// contract can express, including Fields (the field-level validation detail
// that most services drop today).
type Error struct {
Code string // maps to the "code" wire field
Message string // maps to "message"
Status int // HTTP status to write
Fields map[string]string // optional field-level validation detail
}

func (e *Error) Error() string { return e.Message }

// Unauthorized builds a 401 UNAUTHORIZED error.
func Unauthorized(msg string) *Error {
return &Error{Code: CodeUnauthorized, Message: msg, Status: http.StatusUnauthorized}
}

// NotFound builds a 404 NOT_FOUND error.
func NotFound(msg string) *Error {
return &Error{Code: CodeNotFound, Message: msg, Status: http.StatusNotFound}
}

// Validation builds a 400 VALIDATION_ERROR error carrying optional field detail.
func Validation(msg string, fields map[string]string) *Error {
return &Error{Code: CodeValidation, Message: msg, Status: http.StatusBadRequest, Fields: fields}
}

// Conflict builds a 409 error with a caller-supplied code (for DUPLICATE_* style codes).
func Conflict(code, msg string) *Error {
return &Error{Code: code, Message: msg, Status: http.StatusConflict}
}

// Internal builds a 500 INTERNAL_SERVER_ERROR error.
func Internal(msg string) *Error {
return &Error{Code: CodeInternal, Message: msg, Status: http.StatusInternalServerError}
}
Loading
Loading