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/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/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) + } + } +} 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: