From c1346f7fc5e572bb2add1f5b0f711bbc1f903c42 Mon Sep 17 00:00:00 2001 From: midagedev Date: Fri, 22 May 2026 17:07:19 +0900 Subject: [PATCH 1/3] Support isolated billing workspaces Allow one running server to hold several fully isolated billing datasets so parallel test suites no longer have to restart the server or reset shared state between runs. Requests select a workspace with the X-Billtap-Workspace header or the workspace query parameter; unselected requests keep using the backward-compatible default workspace backed by the configured database_url. Named workspaces open their own SQLite database lazily under a workspaces/ directory and get an independent API handler, so customers, invoices, webhooks, idempotency keys, and test clocks are all isolated. GET /workspaces lists the known workspaces. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 + README.md | 31 ++++ cmd/billtap/main.go | 9 +- internal/server/server.go | 95 +++++++--- internal/server/workspace.go | 291 ++++++++++++++++++++++++++++++ internal/server/workspace_test.go | 183 +++++++++++++++++++ 6 files changed, 588 insertions(+), 27 deletions(-) create mode 100644 internal/server/workspace.go create mode 100644 internal/server/workspace_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d5cdc..2292dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Added multi-workspace support so one running server can hold several fully + isolated billing datasets. Requests select a workspace with the + `X-Billtap-Workspace` header or `workspace` query parameter, unselected + requests keep using the backward-compatible `default` workspace, named + workspaces open their own SQLite database lazily under `workspaces/`, and + `GET /workspaces` lists the known workspaces. - Added a manual invoice-backed one-time payment flow for local SaaS usage charges, including `POST /v1/invoices`, `POST /v1/invoiceitems`, `POST /v1/invoices/{id}/finalize`, metadata preservation, expanded diff --git a/README.md b/README.md index b684d6c..deea31e 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,37 @@ calls are prefix-aware: The published GHCR image is runtime-prefix safe. You do not need to rebuild the frontend for each mount path. +## Workspaces + +Billtap can hold several fully isolated billing datasets in one running server, +so parallel test suites do not have to restart Billtap or reset shared state +between runs. + +- Requests with no workspace selector use the `default` workspace, backed by + the configured `database_url`. Existing integrations keep working unchanged. +- Name a workspace to get an independent dataset (its own customers, invoices, + webhooks, idempotency keys, and test clocks). It is created on first use. +- Select a workspace with the `X-Billtap-Workspace` request header or the + `workspace` query parameter. The resolved name is echoed on the + `X-Billtap-Workspace` response header. + +```bash +# default workspace (backward compatible) +curl http://localhost:8080/v1/customers + +# isolated dataset for one test suite +curl -H 'X-Billtap-Workspace: suite-a' http://localhost:8080/v1/customers +curl 'http://localhost:8080/v1/customers?workspace=suite-a' + +# list known workspaces +curl http://localhost:8080/workspaces +``` + +Workspace names accept letters, digits, `.`, `-`, and `_`, must start with a +letter or digit, and are case-insensitive. Each named workspace is stored next +to the default database under a `workspaces/` directory (for example +`.billtap/workspaces/suite-a.db`). + ## Fixture And Assertion APIs Billtap includes local integration-test helpers: diff --git a/cmd/billtap/main.go b/cmd/billtap/main.go index eb017d2..0e79f6b 100644 --- a/cmd/billtap/main.go +++ b/cmd/billtap/main.go @@ -59,9 +59,16 @@ func main() { } }() + appServer := server.New(server.Options{Config: cfg, Store: store}) + defer func() { + if err := appServer.Close(); err != nil { + slog.Warn("close workspaces", "error", err) + } + }() + srv := &http.Server{ Addr: cfg.Addr, - Handler: server.New(server.Options{Config: cfg, Store: store}), + Handler: appServer, ReadHeaderTimeout: 5 * time.Second, } diff --git a/internal/server/server.go b/internal/server/server.go index 3a45ce2..ef28b61 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "errors" "net/http" "os" "path/filepath" @@ -21,22 +22,33 @@ type Options struct { } type Server struct { - cfg config.Config - store storage.Store - mux *http.ServeMux + cfg config.Config + store storage.Store + mux *http.ServeMux + workspaces *workspaceManager } -func New(opts Options) http.Handler { +func New(opts Options) *Server { s := &Server{ cfg: opts.Config, store: opts.Store, mux: http.NewServeMux(), } s.cfg.PublicBasePath = config.NormalizePublicBasePath(s.cfg.PublicBasePath) + s.workspaces = newWorkspaceManager(s.cfg, s.store, s.buildAPIHandler) s.routes() return s } +// Close releases workspace storage opened on demand. The default store passed +// via Options is owned by the caller and is not closed here. +func (s *Server) Close() error { + if s.workspaces == nil { + return nil + } + return s.workspaces.Close() +} + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { basePath := s.requestBasePath(r) if basePath == "" { @@ -58,28 +70,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (s *Server) routes() { - if repo, ok := s.store.(billing.Repository); ok { - var webhookService *webhooks.Service - if webhookRepo, ok := s.store.(webhooks.Repository); ok { - webhookService = webhooks.NewServiceWithOptions(webhookRepo, webhooks.ServiceOptions{ - StoreRawPayloads: s.cfg.RawPayloadStorage != config.RawPayloadMetadataOnly, - RetentionDays: s.cfg.RetentionDays, - SignatureHeaderName: s.cfg.WebhookSignatureHeader, - APIVersion: s.cfg.WebhookAPIVersion, - }) - } - var diagnosticsService *diagnostics.Service - if diagnosticsRepo, ok := s.store.(diagnostics.Repository); ok { - diagnosticsService = diagnostics.NewService(diagnosticsRepo) - } - handler := api.New(api.Options{ - Billing: billing.NewService(repo), - Webhooks: webhookService, - Diagnostics: diagnosticsService, - PublicBaseURL: publicBaseURLWithPath(s.cfg.PublicBaseURL, s.cfg.PublicBasePath), - }) - s.mux.Handle("/v1/", handler) - s.mux.Handle("/api/", handler) + if s.workspaces.apiEnabled { + apiHandler := s.workspaces.handler() + s.mux.Handle("/v1/", apiHandler) + s.mux.Handle("/api/", apiHandler) + s.mux.HandleFunc("/workspaces", s.handleWorkspaces) } s.mux.HandleFunc("/", s.handleRoot) s.mux.HandleFunc("/health", s.handleHealth) @@ -93,6 +88,54 @@ func (s *Server) routes() { s.mux.HandleFunc("/assets/", s.handleAssets) } +// buildAPIHandler assembles the Stripe-like API handler for a single +// workspace store. It is invoked once per workspace by the workspace manager. +func (s *Server) buildAPIHandler(store storage.Store) (http.Handler, error) { + repo, ok := store.(billing.Repository) + if !ok { + return nil, errors.New("storage backend does not implement the billing repository") + } + var webhookService *webhooks.Service + if webhookRepo, ok := store.(webhooks.Repository); ok { + webhookService = webhooks.NewServiceWithOptions(webhookRepo, webhooks.ServiceOptions{ + StoreRawPayloads: s.cfg.RawPayloadStorage != config.RawPayloadMetadataOnly, + RetentionDays: s.cfg.RetentionDays, + SignatureHeaderName: s.cfg.WebhookSignatureHeader, + APIVersion: s.cfg.WebhookAPIVersion, + }) + } + var diagnosticsService *diagnostics.Service + if diagnosticsRepo, ok := store.(diagnostics.Repository); ok { + diagnosticsService = diagnostics.NewService(diagnosticsRepo) + } + return api.New(api.Options{ + Billing: billing.NewService(repo), + Webhooks: webhookService, + Diagnostics: diagnosticsService, + PublicBaseURL: publicBaseURLWithPath(s.cfg.PublicBaseURL, s.cfg.PublicBasePath), + }), nil +} + +func (s *Server) handleWorkspaces(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + methodNotAllowed(w) + return + } + names := s.workspaces.list() + data := make([]map[string]any, 0, len(names)) + for _, name := range names { + data = append(data, map[string]any{ + "object": "workspace", + "name": name, + "is_default": name == DefaultWorkspace, + }) + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "object": "list", + "data": data, + }) +} + func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) diff --git a/internal/server/workspace.go b/internal/server/workspace.go new file mode 100644 index 0000000..3e40e6d --- /dev/null +++ b/internal/server/workspace.go @@ -0,0 +1,291 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + + "github.com/hckim/billtap/internal/config" + "github.com/hckim/billtap/internal/storage" +) + +const ( + // DefaultWorkspace is the implicit workspace used when a request does not + // name one. It is backed by the configured DatabaseURL so existing + // integrations keep working unchanged. + DefaultWorkspace = "default" + + // WorkspaceHeader carries the target workspace name on a request and is + // echoed back on the response so callers can confirm the resolved value. + WorkspaceHeader = "X-Billtap-Workspace" + + // WorkspaceQueryParam is an alternative to WorkspaceHeader for callers + // that cannot easily set headers. + WorkspaceQueryParam = "workspace" + + maxWorkspaceNameLength = 63 +) + +// workspaceNamePattern keeps names safe to use as SQLite filenames: an +// alphanumeric lead character followed by alphanumerics, dot, dash, or +// underscore. The leading-character rule rejects "" and dotted paths (".."). +var workspaceNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// apiHandlerBuilder constructs the Stripe-like API handler for one store. +type apiHandlerBuilder func(storage.Store) (http.Handler, error) + +// workspaceManager owns one isolated billing store (and API handler) per +// workspace name. The default workspace reuses the externally-owned store; +// named workspaces open their own SQLite database lazily on first use. +type workspaceManager struct { + cfg config.Config + build apiHandlerBuilder + + mu sync.Mutex + handlers map[string]http.Handler // name -> API handler + stores map[string]storage.Store // name -> store (lazily opened only) + + // apiEnabled is false when the default store cannot back the API (for + // example a non-billing store). It preserves the previous behaviour of + // not mounting /v1/ at all in that case. + apiEnabled bool +} + +func newWorkspaceManager(cfg config.Config, defaultStore storage.Store, build apiHandlerBuilder) *workspaceManager { + m := &workspaceManager{ + cfg: cfg, + build: build, + handlers: make(map[string]http.Handler), + stores: make(map[string]storage.Store), + } + if defaultStore == nil { + return m + } + handler, err := build(defaultStore) + if err != nil { + return m + } + m.handlers[DefaultWorkspace] = handler + m.apiEnabled = true + return m +} + +// handler returns the dispatcher mounted on /v1/ and /api/. It resolves the +// workspace for each request, lazily provisioning isolated storage as needed. +func (m *workspaceManager) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name, err := resolveWorkspace(r) + if err != nil { + writeWorkspaceError(w, r, http.StatusBadRequest, err.Error()) + return + } + apiHandler, err := m.get(r.Context(), name) + if err != nil { + writeWorkspaceError(w, r, http.StatusInternalServerError, + fmt.Sprintf("could not open workspace %q: %v", name, err)) + return + } + w.Header().Set(WorkspaceHeader, name) + // The workspace selector is consumed here; strip it so the strict + // API parameter validation downstream never sees it. + apiHandler.ServeHTTP(w, stripWorkspaceQuery(r)) + }) +} + +// stripWorkspaceQuery returns a request with the workspace query parameter +// removed, leaving the original untouched when it carries no such parameter. +func stripWorkspaceQuery(r *http.Request) *http.Request { + query := r.URL.Query() + if _, ok := query[WorkspaceQueryParam]; !ok { + return r + } + query.Del(WorkspaceQueryParam) + clone := r.Clone(r.Context()) + cloned := *r.URL + cloned.RawQuery = query.Encode() + cloned.RawPath = "" + clone.URL = &cloned + return clone +} + +// get returns the API handler for name, opening its store on first use. +func (m *workspaceManager) get(ctx context.Context, name string) (http.Handler, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if handler, ok := m.handlers[name]; ok { + return handler, nil + } + if !m.apiEnabled { + return nil, fmt.Errorf("billing API is not available for this storage backend") + } + + // Decouple the store lifetime from the triggering request: the store is + // reused for every later request, so a cancelled first request must not + // tear it down. + store, err := storage.OpenSQLite(context.WithoutCancel(ctx), workspaceDSN(m.cfg.DatabaseURL, name)) + if err != nil { + return nil, err + } + handler, err := m.build(store) + if err != nil { + _ = store.Close() + return nil, err + } + m.handlers[name] = handler + m.stores[name] = store + return handler, nil +} + +// list reports the known workspaces: the default, any opened this session, +// and any whose database file already exists on disk. +func (m *workspaceManager) list() []string { + set := map[string]bool{DefaultWorkspace: true} + + m.mu.Lock() + for name := range m.handlers { + set[name] = true + } + m.mu.Unlock() + + if dir := workspacesDir(m.cfg.DatabaseURL); dir != "" { + ext := workspaceDBExt(m.cfg.DatabaseURL) + if entries, err := os.ReadDir(dir); err == nil { + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ext) { + continue + } + set[strings.TrimSuffix(entry.Name(), ext)] = true + } + } + } + + names := make([]string, 0, len(set)) + for name := range set { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// Close releases every lazily-opened workspace store. The default store is +// owned by the caller and is left untouched. +func (m *workspaceManager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + var firstErr error + for name, store := range m.stores { + if err := store.Close(); err != nil && firstErr == nil { + firstErr = err + } + delete(m.stores, name) + delete(m.handlers, name) + } + return firstErr +} + +// resolveWorkspace extracts and validates the workspace name from a request, +// falling back to DefaultWorkspace when none is supplied. +func resolveWorkspace(r *http.Request) (string, error) { + raw := strings.TrimSpace(r.Header.Get(WorkspaceHeader)) + if raw == "" { + raw = strings.TrimSpace(r.URL.Query().Get(WorkspaceQueryParam)) + } + if raw == "" { + return DefaultWorkspace, nil + } + + // Filenames on macOS/Windows are case-insensitive; normalise so "Foo" + // and "foo" cannot resolve to two handlers over one file. + name := strings.ToLower(raw) + if name == DefaultWorkspace { + return DefaultWorkspace, nil + } + if len(name) > maxWorkspaceNameLength { + return "", fmt.Errorf("workspace name must be at most %d characters", maxWorkspaceNameLength) + } + if !workspaceNamePattern.MatchString(name) { + return "", fmt.Errorf("workspace name %q is invalid: use letters, digits, '.', '-', '_' and a leading alphanumeric", raw) + } + return name, nil +} + +// workspaceDSN derives the SQLite DSN for a named workspace from the base +// (default) DSN. The default workspace returns the base DSN unchanged. +func workspaceDSN(baseDSN, name string) string { + if name == "" || name == DefaultWorkspace { + return baseDSN + } + if isMemoryDSN(baseDSN) { + // Each in-memory workspace needs a distinct shared-cache name so it + // stays isolated yet survives across pooled connections. + return fmt.Sprintf("file:billtap_ws_%s?mode=memory&cache=shared", name) + } + + path, query := splitDSN(baseDSN) + ext := filepath.Ext(path) + if ext == "" { + ext = ".db" + } + wsPath := filepath.Join(filepath.Dir(path), "workspaces", name+ext) + if query == "" { + return wsPath + } + return "file:" + wsPath + query +} + +// workspacesDir returns the directory that holds named workspace databases, +// or "" when the base DSN is in-memory. +func workspacesDir(baseDSN string) string { + if isMemoryDSN(baseDSN) { + return "" + } + path, _ := splitDSN(baseDSN) + return filepath.Join(filepath.Dir(path), "workspaces") +} + +func workspaceDBExt(baseDSN string) string { + path, _ := splitDSN(baseDSN) + if ext := filepath.Ext(path); ext != "" { + return ext + } + return ".db" +} + +// splitDSN separates a SQLite DSN into its filesystem path and trailing +// query/fragment, dropping any leading "file:" scheme. +func splitDSN(dsn string) (path string, query string) { + path = strings.TrimPrefix(dsn, "file:") + if idx := strings.IndexAny(path, "?#"); idx >= 0 { + return path[:idx], path[idx:] + } + return path, "" +} + +func isMemoryDSN(dsn string) bool { + return dsn == ":memory:" || + strings.HasPrefix(dsn, "file::memory:") || + strings.Contains(dsn, "mode=memory") +} + +func writeWorkspaceError(w http.ResponseWriter, r *http.Request, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if r.Method == http.MethodHead { + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "type": "invalid_request_error", + "message": message, + }, + }) +} diff --git a/internal/server/workspace_test.go b/internal/server/workspace_test.go new file mode 100644 index 0000000..9e13ac3 --- /dev/null +++ b/internal/server/workspace_test.go @@ -0,0 +1,183 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/hckim/billtap/internal/config" + "github.com/hckim/billtap/internal/storage" +) + +// newWorkspaceServer builds a SQLite-backed server whose configured +// DatabaseURL matches the default store, so named workspaces resolve to +// sibling files under /workspaces. +func newWorkspaceServer(t *testing.T) (*Server, string) { + t.Helper() + dir := t.TempDir() + dbPath := filepath.Join(dir, "billtap.db") + store, err := storage.OpenSQLite(context.Background(), dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + cfg := config.Config{ + Addr: ":0", + DatabaseURL: dbPath, + StaticDir: "web/dist", + Environment: "test", + } + srv := New(Options{Config: cfg, Store: store}) + t.Cleanup(func() { + _ = srv.Close() + _ = store.Close() + }) + return srv, dir +} + +func countCustomers(t *testing.T, handler http.Handler, workspace string) int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) + if workspace != "" { + req.Header.Set(WorkspaceHeader, workspace) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list customers (workspace=%q) status = %d body = %s", workspace, rec.Code, rec.Body.String()) + } + var out struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode customer list: %v body=%s", err, rec.Body.String()) + } + return len(out.Data) +} + +func TestWorkspacesIsolateBillingData(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + // Two customers in the default workspace, one in a named workspace. + postForm[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "default-1@example.test"}) + postForm[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "default-2@example.test"}) + postFormWithHeaders[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "alt@example.test"}, + map[string]string{WorkspaceHeader: "test-a"}) + + if got := countCustomers(t, srv, ""); got != 2 { + t.Fatalf("default workspace customer count = %d, want 2", got) + } + if got := countCustomers(t, srv, "test-a"); got != 1 { + t.Fatalf("test-a workspace customer count = %d, want 1", got) + } + if got := countCustomers(t, srv, "default"); got != 2 { + t.Fatalf("explicit default workspace customer count = %d, want 2", got) + } + if got := countCustomers(t, srv, "test-b"); got != 0 { + t.Fatalf("fresh workspace customer count = %d, want 0", got) + } +} + +func TestWorkspaceResolvedFromQueryParam(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + postFormWithHeaders[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers?workspace=via-query", map[string]string{"email": "q@example.test"}, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/customers?workspace=via-query", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get(WorkspaceHeader); got != "via-query" { + t.Fatalf("response %s = %q, want %q", WorkspaceHeader, got, "via-query") + } + if got := countCustomers(t, srv, ""); got != 0 { + t.Fatalf("default workspace should stay empty, got %d", got) + } +} + +func TestWorkspaceHeaderEchoedAndInvalidRejected(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) + req.Header.Set(WorkspaceHeader, "Mixed-Case") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get(WorkspaceHeader); got != "mixed-case" { + t.Fatalf("resolved workspace = %q, want lowercased %q", got, "mixed-case") + } + + for _, bad := range []string{"bad/name", "../escape", ".hidden", "with space"} { + req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) + req.Header.Set(WorkspaceHeader, bad) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("workspace %q status = %d, want 400", bad, rec.Code) + } + } +} + +func TestWorkspacesListingEndpoint(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + postFormWithHeaders[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "x@example.test"}, + map[string]string{WorkspaceHeader: "scenario-1"}) + + req := httptest.NewRequest(http.MethodGet, "/workspaces", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + var out struct { + Data []struct { + Name string `json:"name"` + IsDefault bool `json:"is_default"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode workspace list: %v body=%s", err, rec.Body.String()) + } + seen := make(map[string]bool) + for _, ws := range out.Data { + seen[ws.Name] = true + } + if !seen[DefaultWorkspace] || !seen["scenario-1"] { + t.Fatalf("workspace list = %#v, want default and scenario-1", out.Data) + } +} + +func TestWorkspaceDSN(t *testing.T) { + cases := []struct { + base string + name string + want string + }{ + {".billtap/billtap.db", "default", ".billtap/billtap.db"}, + {".billtap/billtap.db", "test-a", filepath.Join(".billtap", "workspaces", "test-a.db")}, + {"/data/billtap.db", "ci", filepath.Join("/data", "workspaces", "ci.db")}, + {":memory:", "iso", "file:billtap_ws_iso?mode=memory&cache=shared"}, + } + for _, tc := range cases { + if got := workspaceDSN(tc.base, tc.name); got != tc.want { + t.Fatalf("workspaceDSN(%q, %q) = %q, want %q", tc.base, tc.name, got, tc.want) + } + } +} From 107dbfb1d5ecd4e0019b4aa856a7decf31877fc5 Mon Sep 17 00:00:00 2001 From: midagedev Date: Fri, 22 May 2026 17:13:56 +0900 Subject: [PATCH 2/3] Document billing workspaces in architecture and specs Reflect the new isolated-workspace feature across the design docs and the product spec: testing isolation guidance, the storage architecture note, functional requirement FR-010, non-functional requirement NFR-008, the API contract Workspaces section, the data-model scoping note, and tasks T147. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ARCHITECTURE.md | 9 ++++++++- docs/TESTING.md | 5 +++++ specs/000-product/contracts/api.md | 20 ++++++++++++++++++++ specs/000-product/data-model.md | 6 ++++++ specs/000-product/spec.md | 7 +++++++ specs/000-product/tasks.md | 1 + 6 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5444510..a0773fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,7 +67,14 @@ Initial default: - SQLite for local app state - in-memory option for unit tests -Tables: +One running server can host several isolated billing datasets. The implicit +`default` workspace is backed by the configured `database_url`; each named +workspace (selected per request via the `X-Billtap-Workspace` header or +`workspace` query parameter) opens its own SQLite database lazily under a +sibling `workspaces/` directory and gets an independent API handler, so its +billing state, webhooks, idempotency keys, and test clocks stay isolated. + +Tables (per workspace): - customers - products diff --git a/docs/TESTING.md b/docs/TESTING.md index 6f5def1..342a393 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -81,6 +81,11 @@ Fixture ergonomics for integration tests: - assert expected objects through `POST /api/fixtures/assert` - keep fixture IDs stable for customer, product, and price setup - use fixture `runId`, `namespace`, `tenantId`, and `ref` metadata to isolate repeated local/CI runs +- for stronger isolation, run parallel suites against separate Billtap + workspaces instead of restarting the server between sets: send + `X-Billtap-Workspace: ` (or `?workspace=`) so each suite gets an + independent dataset, while unselected requests keep using the `default` + workspace; `GET /workspaces` lists what exists Integration diagnostics for failed app runs: diff --git a/specs/000-product/contracts/api.md b/specs/000-product/contracts/api.md index 81eaf82..dfc8331 100644 --- a/specs/000-product/contracts/api.md +++ b/specs/000-product/contracts/api.md @@ -16,6 +16,26 @@ Process health. Storage and worker readiness. +## Workspaces + +One running server can host several isolated billing datasets. Every `/v1` +and `/api` request resolves a workspace before dispatch: + +- A request with no selector uses the `default` workspace, backed by the + configured `database_url`. This keeps existing integrations unchanged. +- A request may select a named workspace with the `X-Billtap-Workspace` + request header or the `workspace` query parameter. Named workspaces are + created on first use, have their own storage, and are isolated from each + other and from `default`. +- The resolved workspace name is returned on the `X-Billtap-Workspace` + response header. An invalid workspace name returns `400`. + +### `GET /workspaces` + +Lists known workspaces (the default, any opened this session, and any whose +database file already exists). Returns a `list` envelope of `workspace` +objects with `name` and `is_default`. + ## Stripe-like API ### Customers diff --git a/specs/000-product/data-model.md b/specs/000-product/data-model.md index d4a17c6..5c7002d 100644 --- a/specs/000-product/data-model.md +++ b/specs/000-product/data-model.md @@ -1,5 +1,11 @@ # Data Model +All entities below are scoped to a single workspace. A server hosts the +implicit `default` workspace plus any named workspaces; each workspace has its +own isolated store, so the same entity id may exist independently in different +workspaces. Workspaces are an instance-level partition and are not themselves +persisted rows — see `contracts/api.md` for selection and listing. + ## Customer - id diff --git a/specs/000-product/spec.md b/specs/000-product/spec.md index fd8a651..fb3cf09 100644 --- a/specs/000-product/spec.md +++ b/specs/000-product/spec.md @@ -117,6 +117,11 @@ Acceptance criteria: - FR-007: Create, retrieve, confirm, fail, and list payment intents. - FR-008: Create, retrieve, update, delete, and list webhook endpoints. - FR-009: Create, retrieve, and list events. +- FR-010: Serve isolated billing workspaces from one running server. Requests + with no workspace selector use the backward-compatible `default` workspace; + a request may select a named workspace via the `X-Billtap-Workspace` header + or `workspace` query parameter to get an independent dataset, and the known + workspaces are listable. ### Hosted UI @@ -177,6 +182,8 @@ Acceptance criteria: - NFR-005: No real card data is stored. - NFR-006: Contract behavior is fixture-backed. - NFR-007: Profile-specific behavior is fixture-backed and does not require production payment credentials. +- NFR-008: Named workspaces are isolated at the storage boundary so parallel + test suites do not need a server restart or shared-state reset between runs. ## Non-Goals diff --git a/specs/000-product/tasks.md b/specs/000-product/tasks.md index 41c4838..12ad17c 100644 --- a/specs/000-product/tasks.md +++ b/specs/000-product/tasks.md @@ -269,6 +269,7 @@ Gate: - [x] T144 Capture the public simulation capacity backlog for regression-driven fixture and scenario expansion - [x] T145 Expand customer history, subscription pause/resume, and payment-method attach/detach simulation routes - [x] T146 Add browser-facing public base path and forwarded-prefix support +- [x] T147 Add isolated billing workspaces selectable per request so parallel test suites share one server Suggested agents: From f1ccb0459c53a017bcda20aed2c02d32a8d21e30 Mon Sep 17 00:00:00 2001 From: midagedev Date: Tue, 26 May 2026 12:44:48 +0900 Subject: [PATCH 3/3] Add run-scoped isolation --- README.md | 49 +++-- cmd/billtap/main.go | 122 ++++++++++++ cmd/billtap/main_test.go | 35 ++++ docs/COMPATIBILITY.md | 18 +- docs/decisions/0004-run-scoped-isolation.md | 40 ++++ internal/api/api.go | 45 ++++- internal/server/server.go | 110 +++++++++++ internal/server/workspace.go | 184 ++++++++++++++++-- internal/server/workspace_test.go | 203 ++++++++++++++++++++ internal/storage/admin.go | 108 +++++++++++ specs/000-product/data-model.md | 13 +- specs/000-product/spec.md | 13 +- specs/000-product/tasks.md | 1 + 13 files changed, 901 insertions(+), 40 deletions(-) create mode 100644 docs/decisions/0004-run-scoped-isolation.md create mode 100644 internal/storage/admin.go diff --git a/README.md b/README.md index deea31e..c45bc13 100644 --- a/README.md +++ b/README.md @@ -167,37 +167,64 @@ calls are prefix-aware: The published GHCR image is runtime-prefix safe. You do not need to rebuild the frontend for each mount path. -## Workspaces +## Run-Scoped Isolation Billtap can hold several fully isolated billing datasets in one running server, so parallel test suites do not have to restart Billtap or reset shared state -between runs. +between runs. The Stripe-compatible service URL can include a run scope: + +```text +http://billtap:8080/runs/ +``` + +Stripe SDKs can keep their normal `/v1/...` paths because the SDK appends them +under that base URL. - Requests with no workspace selector use the `default` workspace, backed by the configured `database_url`. Existing integrations keep working unchanged. -- Name a workspace to get an independent dataset (its own customers, invoices, +- Name a run to get an independent dataset (its own customers, invoices, webhooks, idempotency keys, and test clocks). It is created on first use. -- Select a workspace with the `X-Billtap-Workspace` request header or the - `workspace` query parameter. The resolved name is echoed on the - `X-Billtap-Workspace` response header. +- Select a run with `/runs//...`. The resolved name is echoed on + `X-Billtap-Run-Id` and `X-Billtap-Workspace`. +- For backward compatibility, `X-Billtap-Workspace` and the `workspace` query + parameter still select an isolated dataset on unprefixed requests. +- `DELETE /runs/` removes that run's dataset. `GET /admin/runs` lists + known runs and row-count summaries. ```bash -# default workspace (backward compatible) +# default run (backward compatible) curl http://localhost:8080/v1/customers # isolated dataset for one test suite +curl http://localhost:8080/runs/suite-a/v1/customers +curl http://localhost:8080/runs/suite-a/v1/webhook_endpoints + +# legacy workspace selectors curl -H 'X-Billtap-Workspace: suite-a' http://localhost:8080/v1/customers curl 'http://localhost:8080/v1/customers?workspace=suite-a' -# list known workspaces +# list and clean up runs +curl http://localhost:8080/admin/runs +curl -X DELETE http://localhost:8080/runs/suite-a + +# list known legacy workspaces curl http://localhost:8080/workspaces ``` -Workspace names accept letters, digits, `.`, `-`, and `_`, must start with a -letter or digit, and are case-insensitive. Each named workspace is stored next -to the default database under a `workspaces/` directory (for example +Run IDs accept letters, digits, `.`, `-`, and `_`, must start with a letter or +digit, and are case-insensitive. Each named run is stored next to the default +database under a `workspaces/` directory (for example `.billtap/workspaces/suite-a.db`). +Fixture packs can also be applied directly to a run: + +```bash +go run ./cmd/billtap seed --run-id suite-a --pack seed/sample-basic.yml +``` + +When the fixture pack has a top-level `runId`, that value is used for the run +scope and fixture metadata. + ## Fixture And Assertion APIs Billtap includes local integration-test helpers: diff --git a/cmd/billtap/main.go b/cmd/billtap/main.go index 0e79f6b..5900d04 100644 --- a/cmd/billtap/main.go +++ b/cmd/billtap/main.go @@ -16,6 +16,7 @@ import ( "github.com/hckim/billtap/internal/billing" "github.com/hckim/billtap/internal/compatibility" "github.com/hckim/billtap/internal/config" + "github.com/hckim/billtap/internal/fixtures" "github.com/hckim/billtap/internal/scenarios" "github.com/hckim/billtap/internal/server" "github.com/hckim/billtap/internal/storage" @@ -33,6 +34,8 @@ func main() { os.Exit(runScenario(args[1:])) case "compatibility": os.Exit(runCompatibility(args[1:])) + case "seed": + os.Exit(runSeed(args[1:])) } } @@ -88,6 +91,125 @@ func main() { } } +func runSeed(args []string) int { + packPath, runIDFlag, databaseURL, configPath, err := parseSeedArgs(args) + if err != nil { + fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, "usage: billtap seed --pack path [--run-id id] [--database-url dsn] [--config path]") + return scenarios.ExitInvalidConfig + } + cfg, err := config.Load(configPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitInvalidConfig + } + if strings.TrimSpace(databaseURL) != "" { + cfg.DatabaseURL = strings.TrimSpace(databaseURL) + } + body, err := os.ReadFile(packPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitInvalidConfig + } + pack, err := fixtures.LoadPack(body, fixtureContentType(packPath)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitInvalidConfig + } + runID, err := server.NormalizeRunID(firstSeedValue(pack.RunID, runIDFlag, server.DefaultWorkspace)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitInvalidConfig + } + pack.RunID = runID + + ctx := context.Background() + store, err := storage.OpenSQLite(ctx, server.RunDSN(cfg.DatabaseURL, runID)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitRuntimeFailure + } + defer func() { + if err := store.Close(); err != nil { + slog.Warn("close seed store", "error", err) + } + }() + result, err := fixtures.NewService(billing.NewService(store)).Apply(ctx, pack) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitRuntimeFailure + } + fmt.Fprintf(os.Stdout, "seeded fixture %s runId=%s customers=%d products=%d prices=%d subscriptions=%d test_clocks=%d\n", + result.Name, + result.RunID, + result.Summary["customers"], + result.Summary["products"], + result.Summary["prices"], + result.Summary["subscriptions"], + result.Summary["test_clocks"], + ) + return scenarios.ExitPass +} + +func parseSeedArgs(args []string) (packPath string, runID string, databaseURL string, configPath string, err error) { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--pack" || arg == "--run-id" || arg == "--database-url" || arg == "--config": + if i+1 >= len(args) { + return "", "", "", "", fmt.Errorf("%s requires a value", arg) + } + i++ + switch arg { + case "--pack": + packPath = args[i] + case "--run-id": + runID = args[i] + case "--database-url": + databaseURL = args[i] + case "--config": + configPath = args[i] + } + case strings.HasPrefix(arg, "--pack="): + packPath = strings.TrimPrefix(arg, "--pack=") + case strings.HasPrefix(arg, "--run-id="): + runID = strings.TrimPrefix(arg, "--run-id=") + case strings.HasPrefix(arg, "--database-url="): + databaseURL = strings.TrimPrefix(arg, "--database-url=") + case strings.HasPrefix(arg, "--config="): + configPath = strings.TrimPrefix(arg, "--config=") + case strings.HasPrefix(arg, "-"): + return "", "", "", "", fmt.Errorf("unknown flag %s", arg) + default: + if packPath != "" { + return "", "", "", "", fmt.Errorf("multiple fixture packs provided") + } + packPath = arg + } + } + if strings.TrimSpace(packPath) == "" { + return "", "", "", "", fmt.Errorf("fixture pack path is required") + } + return packPath, runID, databaseURL, configPath, nil +} + +func fixtureContentType(path string) string { + lower := strings.ToLower(path) + if strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml") { + return "application/yaml" + } + return "application/json" +} + +func firstSeedValue(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + func runCompatibility(args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "usage: billtap compatibility [flags]") diff --git a/cmd/billtap/main_test.go b/cmd/billtap/main_test.go index 8679d81..7187f70 100644 --- a/cmd/billtap/main_test.go +++ b/cmd/billtap/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "net/http" "net/http/httptest" "os" @@ -8,7 +9,10 @@ import ( "strings" "testing" + "github.com/hckim/billtap/internal/billing" "github.com/hckim/billtap/internal/scenarios" + "github.com/hckim/billtap/internal/server" + "github.com/hckim/billtap/internal/storage" ) func TestParseScenarioRunArgsAllowsFlagsAfterFile(t *testing.T) { @@ -120,6 +124,37 @@ func TestRunCompatibilityInventoryRequiresOpenAPIPath(t *testing.T) { } } +func TestRunSeedUsesFixtureRunIDBeforeFlag(t *testing.T) { + dir := t.TempDir() + packPath := filepath.Join(dir, "seed.yml") + dbPath := filepath.Join(dir, "billtap.db") + writeFile(t, packPath, ` +name: seed-pack +runId: yaml-run +customers: + - id: cus_seeded + email: seeded@example.test +`) + code := runSeed([]string{"--pack", packPath, "--run-id", "flag-run", "--database-url", dbPath}) + if code != scenarios.ExitPass { + t.Fatalf("exit code = %d, want %d", code, scenarios.ExitPass) + } + + ctx := context.Background() + store, err := storage.OpenSQLite(ctx, server.RunDSN(dbPath, "yaml-run")) + if err != nil { + t.Fatalf("open seeded run store: %v", err) + } + defer store.Close() + customers, err := billing.NewService(store).ListCustomers(ctx) + if err != nil { + t.Fatalf("list customers: %v", err) + } + if len(customers) != 1 || customers[0].ID != "cus_seeded" || customers[0].Metadata["billtap_fixture_run_id"] != "yaml-run" { + t.Fatalf("customers = %#v, want fixture run customer", customers) + } +} + func TestRunScenarioReturnsInvalidConfigExitCode(t *testing.T) { dir := t.TempDir() scenarioPath := filepath.Join(dir, "bad.yml") diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 82103dc..5a424a2 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -87,10 +87,12 @@ documentation before it counts as implemented. ## Supported Stripe-Like API Subset -Base path: `/v1`. When `PUBLIC_BASE_PATH` or `BILLTAP_PUBLIC_BASE_PATH` is set, -or a proxy sends `X-Forwarded-Prefix`, the same API is available below that -browser-facing prefix, such as `/billtap/v1`. Internal service traffic can keep -using the unprefixed service URL. +Base path: `/v1`. A parallel test run can scope the same Stripe-like API under +`/runs//v1`; unscoped requests use the backward-compatible `default` +run. When `PUBLIC_BASE_PATH` or `BILLTAP_PUBLIC_BASE_PATH` is set, or a proxy +sends `X-Forwarded-Prefix`, the same API is available below that browser-facing +prefix, such as `/billtap/v1` or `/billtap/runs//v1`. Internal service +traffic can keep using the unprefixed service URL. | Resource | Endpoints | Level | Scope | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -120,6 +122,10 @@ using the unprefixed service URL. | Webhook endpoints | `POST /v1/webhook_endpoints`, `GET /v1/webhook_endpoints`, `GET /v1/webhook_endpoints/{id}`, `POST /v1/webhook_endpoints/{id}`, `PATCH /v1/webhook_endpoints/{id}`, `DELETE /v1/webhook_endpoints/{id}`, `GET /v1/webhook_endpoints/{id}/attempts` | Supported | Manage local webhook endpoints and inspect endpoint-scoped delivery attempts. Secrets are generated when omitted and masked in API responses. `enabled_events` supports exact event names, `*`, and prefix wildcards such as `invoice.*`. `PATCH` accepts the same local mutable fields as `POST`, including the `enabled` alias for `active`. | | Events | `GET /v1/events`, `GET /v1/events/{id}` | Supported | List and retrieve Billtap-created events. Filters include `type`, `scenarioRunId`, `created[gte]`, `created[gt]`, `created[lte]`, `created[lt]`, `data.object.customer`, and `data.object.metadata[key]`. | +All list and search endpoints are scoped by the selected run. Webhook endpoints +registered through `/runs//v1/webhook_endpoints` receive only events +emitted in that run, and local test clocks are isolated the same way. + ## Billtap APIs Base path: `/api` @@ -136,6 +142,10 @@ Base path: `/api` | Scenarios | `POST /api/scenarios/run` | Runs a scenario JSON object or YAML payload and returns the scenario report. | | Boundary controls | `GET /api/audit-log`, `POST /api/retention/apply` | Audit and retention controls for replay, delivery overrides, and raw evidence redaction. | +Billtap-only `/api` endpoints are also available under `/runs//api` for +run-scoped checkout completion, fixture apply/snapshot/assert, diagnostics, and +webhook replay workflows. + ## Webhook Compatibility Claim Billtap emits Stripe-style event envelopes for the supported checkout sequence diff --git a/docs/decisions/0004-run-scoped-isolation.md b/docs/decisions/0004-run-scoped-isolation.md new file mode 100644 index 0000000..11ba9c3 --- /dev/null +++ b/docs/decisions/0004-run-scoped-isolation.md @@ -0,0 +1,40 @@ +# 0004 Run-Scoped Isolation + +## Status + +Accepted + +## Context + +Parallel billing test suites need to share one Billtap server without leaking +Stripe-compatible objects, webhook endpoint registrations, idempotency state, or +test clocks across jobs. Existing integrations also need unprefixed `/v1/...` +requests to keep using the default dataset. + +## Decision + +Billtap exposes run-scoped routing at `/runs//v1/...` and +`/runs//api/...`. Unscoped requests use the `default` run. + +The current implementation maps each named run to the existing isolated-store +workspace mechanism. The default run uses the configured `database_url`; named +runs use sibling SQLite databases under `workspaces/`. This preserves duplicate +Stripe object IDs across runs and isolates webhook fan-out, test clocks, traces, +and fixture state without forcing a risky all-table primary-key migration in the +same change. + +`GET /admin/runs` reports known runs and row-count summaries. `DELETE +/runs/` removes a named run store; `DELETE /runs/default` clears user data +from the default store while retaining schema metadata. + +## Consequences + +- Stripe SDK users can set the API base to `http://billtap:8080/runs/` + and keep normal SDK paths. +- Hosted checkout and portal URLs generated from a run-scoped API request retain + the `/runs/` prefix. +- Existing `X-Billtap-Workspace` and `workspace` query selectors remain + supported as compatibility aliases. +- A future row-level `run_id` schema can be added if a single physical SQLite + file becomes required, but the public isolation contract does not depend on + that migration. diff --git a/internal/api/api.go b/internal/api/api.go index c99f262..890f7db 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -125,6 +125,8 @@ func (h *Handler) routes() { h.mux.HandleFunc("/v1/test_helpers/customers/", h.handleTestHelperCustomer) h.mux.HandleFunc("/v1/test_helpers/test_clocks", h.handleTestClocks) h.mux.HandleFunc("/v1/test_helpers/test_clocks/", h.handleTestClock) + h.mux.HandleFunc("/v1/test_clocks", h.handleTestClocks) + h.mux.HandleFunc("/v1/test_clocks/", h.handleTestClockAlias) h.mux.HandleFunc("/v1/payment_methods", h.handlePaymentMethods) h.mux.HandleFunc("/v1/payment_methods/", h.handlePaymentMethod) h.mux.HandleFunc("/v1/webhook_endpoints", h.handleWebhookEndpoints) @@ -3151,6 +3153,15 @@ func (h *Handler) handleTestClock(w http.ResponseWriter, r *http.Request) { writeResult(w, response, err) } +func (h *Handler) handleTestClockAlias(w http.ResponseWriter, r *http.Request) { + clone := r.Clone(r.Context()) + u := *r.URL + u.Path = "/v1/test_helpers/test_clocks/" + strings.TrimPrefix(r.URL.Path, "/v1/test_clocks/") + u.RawPath = "" + clone.URL = &u + h.handleTestClock(w, clone) +} + func (h *Handler) handlePaymentMethods(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -3740,6 +3751,7 @@ func (h *Handler) handleFixtureApply(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, fmt.Errorf("%w: %v", billing.ErrInvalidInput, err)) return } + pack = applyRequestRunID(r, pack) result, err := fixtures.NewService(h.billing).Apply(r.Context(), pack) if err != nil { if errors.Is(err, fixtures.ErrAssertionFailed) { @@ -3775,6 +3787,7 @@ func (h *Handler) handleFixtureValidate(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, fmt.Errorf("%w: %v", billing.ErrInvalidInput, err)) return } + pack = applyRequestRunID(r, pack) if err := fixtures.NewService(h.billing).Validate(pack); err != nil { writeResult(w, nil, err) return @@ -3872,7 +3885,7 @@ func (h *Handler) handleFixtureResolve(w http.ResponseWriter, r *http.Request) { } result, err := fixtures.NewService(h.billing).Resolve(r.Context(), fixtures.ResolveFilter{ Ref: firstQuery(r, "ref", "id", "lookup_key", "lookupKey"), - RunID: firstQuery(r, "runId", "run_id"), + RunID: firstNonEmptyString(firstQuery(r, "runId", "run_id"), requestRunID(r)), FixtureName: firstQuery(r, "fixtureName", "fixture_name", "name"), Namespace: firstQuery(r, "namespace"), TenantID: firstQuery(r, "tenantId", "tenant_id"), @@ -3895,6 +3908,9 @@ func (h *Handler) handleFixtureAssert(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, fmt.Errorf("%w: %v", billing.ErrInvalidInput, err)) return } + if req.Filter.RunID == "" { + req.Filter.RunID = requestRunID(r) + } report, err := fixtures.NewService(h.billing).Assert(r.Context(), req) if err != nil { if errors.Is(err, fixtures.ErrAssertionFailed) { @@ -3932,13 +3948,24 @@ func decodeLooseBody(body []byte, contentType string) any { func fixtureSnapshotFilter(r *http.Request) fixtures.SnapshotFilter { return fixtures.SnapshotFilter{ CustomerID: firstQuery(r, "customer", "customerId", "customer_id"), - RunID: firstQuery(r, "runId", "run_id"), + RunID: firstNonEmptyString(firstQuery(r, "runId", "run_id"), requestRunID(r)), TenantID: firstQuery(r, "tenantId", "tenant_id"), FixtureName: firstQuery(r, "fixture", "fixtureName", "fixture_name", "name"), Namespace: firstQuery(r, "namespace", "ns"), } } +func applyRequestRunID(r *http.Request, pack fixtures.Pack) fixtures.Pack { + if pack.RunID == "" { + pack.RunID = requestRunID(r) + } + return pack +} + +func requestRunID(r *http.Request) string { + return strings.TrimSpace(r.Header.Get("X-Billtap-Run-Id")) +} + func debugBundleTimelineFilter(p params) billing.TimelineFilter { objectType := dashboardObjectType(p.first("objectType", "object_type", "targetType", "target_type", "type")) objectID := p.first("objectId", "object_id", "targetId", "target_id", "id") @@ -6738,6 +6765,9 @@ func absoluteURL(r *http.Request, path string, publicBase string) string { path = "/" + path } if publicBase != "" { + if runPrefix := requestRunPrefix(r); runPrefix != "" { + path = runPrefix + path + } return publicBase + path } scheme := "http" @@ -6765,6 +6795,17 @@ func requestForwardedPrefix(r *http.Request) string { return raw } +func requestRunPrefix(r *http.Request) string { + raw := strings.TrimSpace(strings.Split(r.Header.Get("X-Billtap-Run-Prefix"), ",")[0]) + if raw == "" || raw == "/" || strings.Contains(raw, "://") || strings.ContainsAny(raw, "?#") { + return "" + } + if !strings.HasPrefix(raw, "/") { + raw = "/" + raw + } + return strings.TrimRight(raw, "/") +} + func (h *Handler) emitCheckoutWebhooks(r *http.Request, result map[string]any) []webhooks.Event { if h.webhooks == nil { return nil diff --git a/internal/server/server.go b/internal/server/server.go index ef28b61..d951c3b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -75,6 +75,8 @@ func (s *Server) routes() { s.mux.Handle("/v1/", apiHandler) s.mux.Handle("/api/", apiHandler) s.mux.HandleFunc("/workspaces", s.handleWorkspaces) + s.mux.HandleFunc("/admin/runs", s.handleAdminRuns) + s.mux.HandleFunc("/runs/", s.handleRun) } s.mux.HandleFunc("/", s.handleRoot) s.mux.HandleFunc("/health", s.handleHealth) @@ -88,6 +90,114 @@ func (s *Server) routes() { s.mux.HandleFunc("/assets/", s.handleAssets) } +func (s *Server) handleAdminRuns(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + methodNotAllowed(w) + return + } + summaries := s.workspaces.summaries(r.Context()) + data := make([]map[string]any, 0, len(summaries)) + for _, run := range summaries { + item := map[string]any{ + "object": "run", + "run_id": run.Name, + "runId": run.Name, + "is_default": run.IsDefault, + "open": run.Open, + "storage": run.Storage, + "summary": run.Summary, + } + if run.Error != "" { + item["error"] = run.Error + } + data = append(data, item) + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "object": "list", + "data": data, + }) +} + +func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { + runID, rest, err := parseRunPath(r.URL.Path) + if err != nil { + writeWorkspaceError(w, r, http.StatusBadRequest, err.Error()) + return + } + if rest == "" { + if r.Method != http.MethodDelete { + w.Header().Set("Allow", "DELETE") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := s.workspaces.delete(r.Context(), runID); err != nil { + writeWorkspaceError(w, r, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "object": "run_cleanup", + "run_id": runID, + "runId": runID, + "deleted": true, + }) + return + } + + runRequest := s.requestForRun(r, runID, rest) + switch { + case rest == "/v1" || strings.HasPrefix(rest, "/v1/") || rest == "/api" || strings.HasPrefix(rest, "/api/"): + apiHandler, err := s.workspaces.get(r.Context(), runID) + if err != nil { + writeWorkspaceError(w, r, http.StatusInternalServerError, err.Error()) + return + } + w.Header().Set(WorkspaceHeader, runID) + w.Header().Set(RunHeader, runID) + apiHandler.ServeHTTP(w, stripWorkspaceQuery(runRequest)) + case rest == "/checkout" || strings.HasPrefix(rest, "/checkout/"): + s.handleHostedCheckout(w, runRequest) + case rest == "/portal" || strings.HasPrefix(rest, "/portal/"): + s.handleHostedPortal(w, runRequest) + case rest == "/app" || strings.HasPrefix(rest, "/app/"): + s.handleApp(w, runRequest) + case strings.HasPrefix(rest, "/assets/"): + s.handleAssets(w, runRequest) + default: + http.NotFound(w, r) + } +} + +func parseRunPath(path string) (string, string, error) { + rest := strings.TrimPrefix(path, "/runs/") + if rest == path || rest == "" { + return "", "", errors.New("run id is required") + } + rawRunID, suffix, _ := strings.Cut(rest, "/") + runID, err := resolveWorkspaceName(rawRunID) + if err != nil { + return "", "", err + } + if suffix == "" { + return runID, "", nil + } + return runID, "/" + suffix, nil +} + +func (s *Server) requestForRun(r *http.Request, runID string, path string) *http.Request { + clone := r.Clone(r.Context()) + clone.Header = r.Header.Clone() + clone.Header.Set(WorkspaceHeader, runID) + clone.Header.Set(RunHeader, runID) + clone.Header.Set(RunPrefixHeader, "/runs/"+runID) + clone.Header.Set("X-Forwarded-Prefix", joinURLPath(forwardedPrefix(r), "/runs/"+runID)) + + u := *r.URL + u.Path = path + u.RawPath = "" + clone.URL = &u + return clone +} + // buildAPIHandler assembles the Stripe-like API handler for a single // workspace store. It is invoked once per workspace by the workspace manager. func (s *Server) buildAPIHandler(store storage.Store) (http.Handler, error) { diff --git a/internal/server/workspace.go b/internal/server/workspace.go index 3e40e6d..a369d39 100644 --- a/internal/server/workspace.go +++ b/internal/server/workspace.go @@ -30,6 +30,12 @@ const ( // that cannot easily set headers. WorkspaceQueryParam = "workspace" + // RunHeader carries the path-scoped run ID resolved from /runs/. + RunHeader = "X-Billtap-Run-Id" + + // RunPrefixHeader carries the browser path prefix for a path-scoped run. + RunPrefixHeader = "X-Billtap-Run-Prefix" + maxWorkspaceNameLength = 63 ) @@ -48,9 +54,10 @@ type workspaceManager struct { cfg config.Config build apiHandlerBuilder - mu sync.Mutex - handlers map[string]http.Handler // name -> API handler - stores map[string]storage.Store // name -> store (lazily opened only) + mu sync.Mutex + handlers map[string]http.Handler // name -> API handler + stores map[string]storage.Store // name -> store (lazily opened only) + defaultStore storage.Store // apiEnabled is false when the default store cannot back the API (for // example a non-billing store). It preserves the previous behaviour of @@ -60,10 +67,11 @@ type workspaceManager struct { func newWorkspaceManager(cfg config.Config, defaultStore storage.Store, build apiHandlerBuilder) *workspaceManager { m := &workspaceManager{ - cfg: cfg, - build: build, - handlers: make(map[string]http.Handler), - stores: make(map[string]storage.Store), + cfg: cfg, + build: build, + handlers: make(map[string]http.Handler), + stores: make(map[string]storage.Store), + defaultStore: defaultStore, } if defaultStore == nil { return m @@ -77,6 +85,15 @@ func newWorkspaceManager(cfg config.Config, defaultStore storage.Store, build ap return m } +type runSummary struct { + Name string + IsDefault bool + Open bool + Storage string + Summary map[string]int + Error string +} + // handler returns the dispatcher mounted on /v1/ and /api/. It resolves the // workspace for each request, lazily provisioning isolated storage as needed. func (m *workspaceManager) handler() http.Handler { @@ -175,6 +192,115 @@ func (m *workspaceManager) list() []string { return names } +func (m *workspaceManager) summaries(ctx context.Context) []runSummary { + names := m.list() + out := make([]runSummary, 0, len(names)) + for _, name := range names { + summary := runSummary{ + Name: name, + IsDefault: name == DefaultWorkspace, + Open: m.isOpen(name), + Storage: workspaceDSN(m.cfg.DatabaseURL, name), + Summary: map[string]int{}, + } + store, closeStore, err := m.storeForSummary(ctx, name) + if err != nil { + summary.Error = err.Error() + out = append(out, summary) + continue + } + counts, err := storage.SQLiteTableCounts(ctx, store) + if closeStore != nil { + closeStore() + } + if err != nil { + summary.Error = err.Error() + } else { + summary.Summary = counts + } + out = append(out, summary) + } + return out +} + +func (m *workspaceManager) isOpen(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + if name == DefaultWorkspace { + return m.defaultStore != nil + } + _, ok := m.handlers[name] + return ok +} + +func (m *workspaceManager) storeForSummary(ctx context.Context, name string) (storage.Store, func(), error) { + m.mu.Lock() + if name == DefaultWorkspace { + store := m.defaultStore + m.mu.Unlock() + if store == nil { + return nil, nil, fmt.Errorf("default run storage is not open") + } + return store, nil, nil + } + if store, ok := m.stores[name]; ok { + m.mu.Unlock() + return store, nil, nil + } + m.mu.Unlock() + + if !workspaceDBExists(m.cfg.DatabaseURL, name) { + return nil, nil, fmt.Errorf("run storage does not exist") + } + store, err := storage.OpenSQLite(context.WithoutCancel(ctx), workspaceDSN(m.cfg.DatabaseURL, name)) + if err != nil { + return nil, nil, err + } + return store, func() { _ = store.Close() }, nil +} + +func (m *workspaceManager) delete(ctx context.Context, name string) error { + if name == "" { + return fmt.Errorf("run id is required") + } + if name == DefaultWorkspace { + m.mu.Lock() + store := m.defaultStore + m.mu.Unlock() + if store == nil { + return fmt.Errorf("default run storage is not open") + } + return storage.ResetSQLiteData(ctx, store) + } + + var store storage.Store + m.mu.Lock() + if existing, ok := m.stores[name]; ok { + store = existing + delete(m.stores, name) + } + delete(m.handlers, name) + m.mu.Unlock() + if store != nil { + if err := store.Close(); err != nil { + return err + } + } + if isMemoryDSN(m.cfg.DatabaseURL) { + return nil + } + path := workspaceDBPath(m.cfg.DatabaseURL, name) + for _, candidate := range []string{path, path + "-wal", path + "-shm"} { + if candidate == "" { + continue + } + if err := os.Remove(candidate); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + // Close releases every lazily-opened workspace store. The default store is // owned by the caller and is left untouched. func (m *workspaceManager) Close() error { @@ -199,6 +325,11 @@ func resolveWorkspace(r *http.Request) (string, error) { if raw == "" { raw = strings.TrimSpace(r.URL.Query().Get(WorkspaceQueryParam)) } + return resolveWorkspaceName(raw) +} + +func resolveWorkspaceName(raw string) (string, error) { + raw = strings.TrimSpace(raw) if raw == "" { return DefaultWorkspace, nil } @@ -218,6 +349,12 @@ func resolveWorkspace(r *http.Request) (string, error) { return name, nil } +// NormalizeRunID validates a user-supplied run ID using the same rules as +// /runs/ routing and workspace selectors. +func NormalizeRunID(raw string) (string, error) { + return resolveWorkspaceName(raw) +} + // workspaceDSN derives the SQLite DSN for a named workspace from the base // (default) DSN. The default workspace returns the base DSN unchanged. func workspaceDSN(baseDSN, name string) string { @@ -230,16 +367,39 @@ func workspaceDSN(baseDSN, name string) string { return fmt.Sprintf("file:billtap_ws_%s?mode=memory&cache=shared", name) } - path, query := splitDSN(baseDSN) + _, query := splitDSN(baseDSN) + wsPath := workspaceDBPath(baseDSN, name) + if query == "" { + return wsPath + } + return "file:" + wsPath + query +} + +// RunDSN returns the SQLite DSN used for a path-scoped run ID. +func RunDSN(baseDSN, runID string) string { + return workspaceDSN(baseDSN, runID) +} + +func workspaceDBPath(baseDSN string, name string) string { + path, _ := splitDSN(baseDSN) ext := filepath.Ext(path) if ext == "" { ext = ".db" } - wsPath := filepath.Join(filepath.Dir(path), "workspaces", name+ext) - if query == "" { - return wsPath + return filepath.Join(filepath.Dir(path), "workspaces", name+ext) +} + +func workspaceDBExists(baseDSN string, name string) bool { + if name == "" || name == DefaultWorkspace { + return true } - return "file:" + wsPath + query + if isMemoryDSN(baseDSN) { + return false + } + if _, err := os.Stat(workspaceDBPath(baseDSN, name)); err == nil { + return true + } + return false } // workspacesDir returns the directory that holds named workspace databases, diff --git a/internal/server/workspace_test.go b/internal/server/workspace_test.go index 9e13ac3..741210e 100644 --- a/internal/server/workspace_test.go +++ b/internal/server/workspace_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "github.com/hckim/billtap/internal/config" @@ -57,6 +58,23 @@ func countCustomers(t *testing.T, handler http.Handler, workspace string) int { return len(out.Data) } +func countCustomersPath(t *testing.T, handler http.Handler, path string) int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list customers %s status = %d body = %s", path, rec.Code, rec.Body.String()) + } + var out struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode customer list: %v body=%s", err, rec.Body.String()) + } + return len(out.Data) +} + func TestWorkspacesIsolateBillingData(t *testing.T) { srv, _ := newWorkspaceServer(t) @@ -181,3 +199,188 @@ func TestWorkspaceDSN(t *testing.T) { } } } + +func TestRunPathPrefixIsolatesBillingData(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/run-a/v1/customers", map[string]string{"id": "cus_shared", "email": "a@example.test"}) + postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/run-b/v1/customers", map[string]string{"id": "cus_shared", "email": "b@example.test"}) + postForm[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"id": "cus_shared", "email": "default@example.test"}) + + if got := countCustomersPath(t, srv, "/runs/run-a/v1/customers"); got != 1 { + t.Fatalf("run-a customer count = %d, want 1", got) + } + if got := countCustomersPath(t, srv, "/runs/run-b/v1/customers"); got != 1 { + t.Fatalf("run-b customer count = %d, want 1", got) + } + if got := countCustomers(t, srv, ""); got != 1 { + t.Fatalf("default customer count = %d, want 1", got) + } + + req := httptest.NewRequest(http.MethodGet, "/runs/run-a/v1/customers", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if got := rec.Header().Get(RunHeader); got != "run-a" { + t.Fatalf("%s = %q, want run-a", RunHeader, got) + } +} + +func TestRunPathPrefixesHostedCheckoutURL(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + customer := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/checkout-run/v1/customers", map[string]string{"email": "buyer@example.test"}) + product := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/checkout-run/v1/products", map[string]string{"name": "Team"}) + price := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/checkout-run/v1/prices", map[string]string{ + "product": product.ID, + "currency": "usd", + "unit_amount": "9900", + "recurring[interval]": "month", + }) + session := postForm[struct { + URL string `json:"url"` + }](t, srv, "/runs/checkout-run/v1/checkout/sessions", map[string]string{ + "customer": customer.ID, + "line_items[0][price]": price.ID, + "line_items[0][quantity]": "1", + }) + if want := "http://example.com/runs/checkout-run/checkout/"; len(session.URL) < len(want) || session.URL[:len(want)] != want { + t.Fatalf("checkout URL = %q, want run prefix %q", session.URL, want) + } + + sessionID := session.URL[strings.LastIndex(session.URL, "/")+1:] + req := httptest.NewRequest(http.MethodGet, "/runs/checkout-run/checkout/"+sessionID, nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("run checkout redirect status = %d, want 302", rec.Code) + } + if got := rec.Header().Get("Location"); got != "/runs/checkout-run/app/checkout/?session_id="+sessionID { + t.Fatalf("run checkout redirect = %q", got) + } +} + +func TestRunWebhookEndpointsOnlyReceiveRunEvents(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + endpointA := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/webhook_endpoints", map[string]string{ + "url": "https://app-a.example.test/webhook", + "enabled_events": "checkout.session.completed", + }) + endpointB := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-b/v1/webhook_endpoints", map[string]string{ + "url": "https://app-b.example.test/webhook", + "enabled_events": "checkout.session.completed", + }) + + customer := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/customers", map[string]string{"email": "buyer@example.test"}) + product := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/products", map[string]string{"name": "Team"}) + price := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/prices", map[string]string{ + "product": product.ID, + "currency": "usd", + "unit_amount": "9900", + "recurring[interval]": "month", + }) + session := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/checkout/sessions", map[string]string{ + "customer": customer.ID, + "line_items[0][price]": price.ID, + "line_items[0][quantity]": "1", + }) + _ = postForm[map[string]any](t, srv, "/runs/webhook-a/v1/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + + attemptsA := getRunList(t, srv, "/runs/webhook-a/v1/webhook_endpoints/"+endpointA.ID+"/attempts") + if len(attemptsA.Data) == 0 { + t.Fatalf("run-a endpoint attempts = 0, want checkout delivery attempts") + } + attemptsB := getRunList(t, srv, "/runs/webhook-b/v1/webhook_endpoints/"+endpointB.ID+"/attempts") + if len(attemptsB.Data) != 0 { + t.Fatalf("run-b endpoint attempts = %d, want 0", len(attemptsB.Data)) + } +} + +func TestRunAdminAndCleanup(t *testing.T) { + srv, _ := newWorkspaceServer(t) + postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/cleanup-run/v1/customers", map[string]string{"email": "cleanup@example.test"}) + + before := getRunSummaries(t, srv) + if got := before["cleanup-run"]["customers"]; got != 1 { + t.Fatalf("cleanup-run customers before cleanup = %d, want 1; summaries=%#v", got, before) + } + + req := httptest.NewRequest(http.MethodDelete, "/runs/cleanup-run", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cleanup status = %d body = %s", rec.Code, rec.Body.String()) + } + if got := countCustomersPath(t, srv, "/runs/cleanup-run/v1/customers"); got != 0 { + t.Fatalf("cleanup-run customers after cleanup = %d, want 0", got) + } +} + +func getRunList(t *testing.T, handler http.Handler, path string) struct { + Data []json.RawMessage `json:"data"` +} { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s status = %d body = %s", path, rec.Code, rec.Body.String()) + } + var out struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %s: %v body=%s", path, err, rec.Body.String()) + } + return out +} + +func getRunSummaries(t *testing.T, handler http.Handler) map[string]map[string]int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/admin/runs", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("admin runs status = %d body = %s", rec.Code, rec.Body.String()) + } + var out struct { + Data []struct { + RunID string `json:"runId"` + Summary map[string]int `json:"summary"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode admin runs: %v body=%s", err, rec.Body.String()) + } + summaries := map[string]map[string]int{} + for _, item := range out.Data { + summaries[item.RunID] = item.Summary + } + return summaries +} diff --git a/internal/storage/admin.go b/internal/storage/admin.go new file mode 100644 index 0000000..89feb86 --- /dev/null +++ b/internal/storage/admin.go @@ -0,0 +1,108 @@ +package storage + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" +) + +var retainedSQLiteTables = map[string]bool{ + "schema_migrations": true, + "runtime_metadata": true, +} + +// SQLiteTableCounts returns row counts for user-data tables in a SQLite-backed +// store. Migration and runtime metadata are intentionally omitted. +func SQLiteTableCounts(ctx context.Context, store Store) (map[string]int, error) { + db, err := sqliteDB(store) + if err != nil { + return nil, err + } + tables, err := sqliteUserTables(ctx, db) + if err != nil { + return nil, err + } + counts := make(map[string]int, len(tables)) + for _, table := range tables { + var count int + if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+quoteSQLiteIdentifier(table)).Scan(&count); err != nil { + return nil, fmt.Errorf("count %s: %w", table, err) + } + counts[table] = count + } + return counts, nil +} + +// ResetSQLiteData deletes all persisted user data from a SQLite-backed store. +// It keeps schema_migrations and runtime_metadata so the database remains ready +// for immediate reuse. +func ResetSQLiteData(ctx context.Context, store Store) error { + db, err := sqliteDB(store) + if err != nil { + return err + } + tables, err := sqliteUserTables(ctx, db) + if err != nil { + return err + } + if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil { + return err + } + defer func() { + _, _ = db.ExecContext(context.Background(), "PRAGMA foreign_keys = ON") + }() + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + for _, table := range tables { + if _, err := tx.ExecContext(ctx, "DELETE FROM "+quoteSQLiteIdentifier(table)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("delete %s: %w", table, err) + } + } + return tx.Commit() +} + +func sqliteDB(store Store) (*sql.DB, error) { + if store == nil { + return nil, fmt.Errorf("sqlite store is not open") + } + withDB, ok := store.(interface{ DB() *sql.DB }) + if !ok || withDB.DB() == nil { + return nil, fmt.Errorf("storage backend is not sqlite-backed") + } + return withDB.DB(), nil +} + +func sqliteUserTables(ctx context.Context, db *sql.DB) ([]string, error) { + rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var tables []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + if retainedSQLiteTables[name] { + continue + } + tables = append(tables, name) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Strings(tables) + return tables, nil +} + +func quoteSQLiteIdentifier(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} diff --git a/specs/000-product/data-model.md b/specs/000-product/data-model.md index 5c7002d..95889e0 100644 --- a/specs/000-product/data-model.md +++ b/specs/000-product/data-model.md @@ -1,10 +1,13 @@ # Data Model -All entities below are scoped to a single workspace. A server hosts the -implicit `default` workspace plus any named workspaces; each workspace has its -own isolated store, so the same entity id may exist independently in different -workspaces. Workspaces are an instance-level partition and are not themselves -persisted rows — see `contracts/api.md` for selection and listing. +All entities below are scoped to a single run. A server hosts the implicit +`default` run plus any named runs selected through `/runs/`; each run has +its own isolated store, so the same entity id may exist independently in +different runs. Runs are an instance-level partition and are listed through +`GET /admin/runs`. + +The earlier workspace selector remains available as a backward-compatible alias +for the same storage partitioning model. ## Customer diff --git a/specs/000-product/spec.md b/specs/000-product/spec.md index fb3cf09..4de3a68 100644 --- a/specs/000-product/spec.md +++ b/specs/000-product/spec.md @@ -117,11 +117,12 @@ Acceptance criteria: - FR-007: Create, retrieve, confirm, fail, and list payment intents. - FR-008: Create, retrieve, update, delete, and list webhook endpoints. - FR-009: Create, retrieve, and list events. -- FR-010: Serve isolated billing workspaces from one running server. Requests - with no workspace selector use the backward-compatible `default` workspace; - a request may select a named workspace via the `X-Billtap-Workspace` header - or `workspace` query parameter to get an independent dataset, and the known - workspaces are listable. +- FR-010: Serve isolated billing runs from one running server. Requests with no + run selector use the backward-compatible `default` run; a request may select + a named run via `/runs//v1/...` or `/runs//api/...` to get an + independent dataset, and the known runs are listable with row-count + summaries. The legacy `X-Billtap-Workspace` header and `workspace` query + parameter remain supported for backward compatibility. ### Hosted UI @@ -182,7 +183,7 @@ Acceptance criteria: - NFR-005: No real card data is stored. - NFR-006: Contract behavior is fixture-backed. - NFR-007: Profile-specific behavior is fixture-backed and does not require production payment credentials. -- NFR-008: Named workspaces are isolated at the storage boundary so parallel +- NFR-008: Named runs are isolated at the storage boundary so parallel test suites do not need a server restart or shared-state reset between runs. ## Non-Goals diff --git a/specs/000-product/tasks.md b/specs/000-product/tasks.md index 12ad17c..31751a8 100644 --- a/specs/000-product/tasks.md +++ b/specs/000-product/tasks.md @@ -270,6 +270,7 @@ Gate: - [x] T145 Expand customer history, subscription pause/resume, and payment-method attach/detach simulation routes - [x] T146 Add browser-facing public base path and forwarded-prefix support - [x] T147 Add isolated billing workspaces selectable per request so parallel test suites share one server +- [x] T148 Add runId URL-scope routing, admin summaries, cleanup, and fixture seed CLI Suggested agents: