diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32531415..57d3de71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: "1.25" + go-version: "1.26" - name: Create web build placeholder for embed run: mkdir -p web/build && echo "placeholder" > web/build/.gitkeep @@ -99,7 +99,7 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: "1.25" + go-version: "1.26" - name: Create web build placeholder for embed run: mkdir -p web/build && echo "placeholder" > web/build/.gitkeep @@ -157,7 +157,7 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: "1.25" + go-version: "1.26" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9c668a3..8b5deb91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: "1.25" + go-version: "1.26" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb231471..e9b5d0a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thanks for your interest in contributing to Pad! This guide will help you get se ### Prerequisites -- [Go 1.25+](https://go.dev/dl/) +- [Go 1.26+](https://go.dev/dl/) - [Node.js 22+](https://nodejs.org/) - Make diff --git a/Dockerfile b/Dockerfile index b850b772..2046662e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY web/ ./ RUN npm run build # Stage 2: Build Go binary -FROM golang:1.25-alpine AS go-builder +FROM golang:1.26-alpine AS go-builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download diff --git a/README.md b/README.md index 3695903a..7ee70a26 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ make build cp pad ~/.local/bin/ # or /usr/local/bin/ ``` -Requires Go 1.25+ and Node.js 22+. +Requires Go 1.26+ and Node.js 22+. The `go install github.com/xarmian/pad/cmd/pad@latest` path is not supported for the full Pad binary, because the web UI must be built and embedded during the source build. diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 6b716190..401b9042 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -30,9 +30,9 @@ import ( "github.com/xarmian/pad/internal/config" "regexp" + "github.com/redis/go-redis/v9" "github.com/xarmian/pad/internal/billing" "github.com/xarmian/pad/internal/email" - "github.com/redis/go-redis/v9" "github.com/xarmian/pad/internal/events" "github.com/xarmian/pad/internal/logging" "github.com/xarmian/pad/internal/metrics" diff --git a/cmd/pad/server_info.go b/cmd/pad/server_info.go index 973c4bc9..bf364ae4 100644 --- a/cmd/pad/server_info.go +++ b/cmd/pad/server_info.go @@ -57,14 +57,14 @@ type serverInfoWorkspace struct { } type serverInfoLocal struct { - BindAddr string `json:"bind_addr"` - ServerRunning bool `json:"server_running"` - PID *int `json:"pid,omitempty"` - PIDFile string `json:"pid_file"` - LogFile string `json:"log_file"` - DBPath string `json:"db_path"` - DBExists bool `json:"db_exists"` - DBSizeBytes int64 `json:"db_size_bytes"` + BindAddr string `json:"bind_addr"` + ServerRunning bool `json:"server_running"` + PID *int `json:"pid,omitempty"` + PIDFile string `json:"pid_file"` + LogFile string `json:"log_file"` + DBPath string `json:"db_path"` + DBExists bool `json:"db_exists"` + DBSizeBytes int64 `json:"db_size_bytes"` } func infoCmd() *cobra.Command { @@ -171,11 +171,11 @@ func includeLocalRuntime(cfg *config.Config) bool { func collectLocalInfo(cfg *config.Config) *serverInfoLocal { info := &serverInfoLocal{ - BindAddr: cfg.Addr(), + BindAddr: cfg.Addr(), ServerRunning: cli.IsServerRunning(cfg), - PIDFile: cfg.PIDFile(), - LogFile: cfg.LogFile(), - DBPath: cfg.DBPath, + PIDFile: cfg.PIDFile(), + LogFile: cfg.LogFile(), + DBPath: cfg.DBPath, } if pid, ok := readPID(cfg.PIDFile()); ok { diff --git a/cmd/pad/server_info_test.go b/cmd/pad/server_info_test.go index 89d177be..727defaa 100644 --- a/cmd/pad/server_info_test.go +++ b/cmd/pad/server_info_test.go @@ -39,9 +39,9 @@ func TestCollectServerInfoRemoteAuthenticated(t *testing.T) { t.Fatalf("unexpected auth header %q", got) } _ = json.NewEncoder(w).Encode(map[string]any{ - "authenticated": true, + "authenticated": true, "setup_required": false, - "auth_method": "password", + "auth_method": "password", "user": map[string]any{ "id": "user-1", "email": "dave@example.com", diff --git a/go.mod b/go.mod index 01682822..fcb8f3bb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/xarmian/pad -go 1.25.0 +go 1.26.0 require ( github.com/BurntSushi/toml v1.6.0 diff --git a/internal/billing/cloud_client_test.go b/internal/billing/cloud_client_test.go index f77bdc5f..41f33800 100644 --- a/internal/billing/cloud_client_test.go +++ b/internal/billing/cloud_client_test.go @@ -64,6 +64,7 @@ func TestCancelCustomer_HappyPath_SendsCorrectRequest(t *testing.T) { // - 403 on cloud_secret mismatch // - 500 on internal / Stripe failure // - 503 when Stripe is not configured +// // All of them must produce a SidecarError — the handler treats every one // as "abort the delete", regardless of bucket. func TestCancelCustomer_NonOK_ReturnsSidecarError(t *testing.T) { @@ -159,11 +160,11 @@ func TestCancelCustomer_UnconfiguredClient_ReturnsError(t *testing.T) { func TestResolveOutboundSecret(t *testing.T) { cases := []struct { - name string - explicit string - inboundList string - want string - wantEmpty bool + name string + explicit string + inboundList string + want string + wantEmpty bool }{ { name: "explicit wins over inbound", diff --git a/internal/cli/format.go b/internal/cli/format.go index ad8a9a1e..34ea6509 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -15,8 +15,8 @@ import ( // Color definitions for reuse across the CLI. var ( - Bold = color.New(color.Bold) - Dim = color.New(color.Faint) + Bold = color.New(color.Bold) + Dim = color.New(color.Faint) BoldCyan = color.New(color.Bold, color.FgCyan) ) diff --git a/internal/collections/defaults.go b/internal/collections/defaults.go index c5af6376..5368d687 100644 --- a/internal/collections/defaults.go +++ b/internal/collections/defaults.go @@ -54,7 +54,7 @@ func Defaults() []DefaultCollection { Type: "select", Options: []string{"xs", "s", "m", "l", "xl"}, }, - }, + }, }, Settings: models.CollectionSettings{ Layout: "fields-primary", diff --git a/internal/config/config.go b/internal/config/config.go index 67e21019..82c31956 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,9 +41,9 @@ type Config struct { EmailFromName string `toml:"email_from_name"` // Sender display name (e.g. Pad) // Cloud mode - CloudSecret string `toml:"cloud_secret"` // Inbound shared secret(s) accepted from pad-cloud. Comma-separated list supports rotation. - CloudSidecarURL string `toml:"cloud_sidecar_url"` // Base URL pad uses to call the pad-cloud sidecar (reverse direction, e.g. Stripe cancel-customer on account delete) - CloudOutboundSecret string `toml:"cloud_outbound_secret"` // Optional: exact secret to send when calling pad-cloud. Falls back to the LAST entry of CloudSecret (the older rotation value, which is what pad-cloud is usually running). See DEPLOY.md "Cloud secret rotation". + CloudSecret string `toml:"cloud_secret"` // Inbound shared secret(s) accepted from pad-cloud. Comma-separated list supports rotation. + CloudSidecarURL string `toml:"cloud_sidecar_url"` // Base URL pad uses to call the pad-cloud sidecar (reverse direction, e.g. Stripe cancel-customer on account delete) + CloudOutboundSecret string `toml:"cloud_outbound_secret"` // Optional: exact secret to send when calling pad-cloud. Falls back to the LAST entry of CloudSecret (the older rotation value, which is what pad-cloud is usually running). See DEPLOY.md "Cloud secret rotation". // Encryption EncryptionKey string `toml:"encryption_key"` // 32-byte hex-encoded AES-256 key for encrypting sensitive fields diff --git a/internal/events/bus.go b/internal/events/bus.go index 010f489b..985b8c90 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -39,7 +39,7 @@ const ( // Default replay buffer settings. const ( - DefaultReplayBufferSize = 1024 // max events to retain per workspace + DefaultReplayBufferSize = 1024 // max events to retain per workspace DefaultReplayMaxAge = 5 * time.Minute // discard events older than this ) diff --git a/internal/events/redis_bus.go b/internal/events/redis_bus.go index 149f8af8..c0b2b1ea 100644 --- a/internal/events/redis_bus.go +++ b/internal/events/redis_bus.go @@ -35,8 +35,8 @@ type RedisBus struct { // Track which workspace channels we're subscribed to in Redis, // so we subscribe/unsubscribe as local SSE clients come and go. - wsCounts map[string]int // workspace → local subscriber count - wsSubs map[string]*redisSub // workspace → active Redis subscription + wsCounts map[string]int // workspace → local subscriber count + wsSubs map[string]*redisSub // workspace → active Redis subscription // Monotonic sequence counter for event IDs (local to this instance). seq atomic.Int64 diff --git a/internal/metrics/instrumented_bus.go b/internal/metrics/instrumented_bus.go index c3ceb6a1..e9d405c2 100644 --- a/internal/metrics/instrumented_bus.go +++ b/internal/metrics/instrumented_bus.go @@ -14,8 +14,8 @@ type InstrumentedBus struct { inner events.EventBus metrics *Metrics - mu sync.Mutex - workspaces map[chan events.Event]string // channel → workspaceID for gauge decrement + mu sync.Mutex + workspaces map[chan events.Event]string // channel → workspaceID for gauge decrement } // NewInstrumentedBus wraps an EventBus with Prometheus instrumentation. diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index 649c1370..20fcb473 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -72,11 +72,11 @@ func TestRegisterDBCollector(t *testing.T) { } expected := map[string]bool{ - "pad_db_open_connections": false, - "pad_db_idle_connections": false, - "pad_db_in_use_connections": false, - "pad_db_wait_count_total": false, - "pad_db_wait_duration_seconds_total": false, + "pad_db_open_connections": false, + "pad_db_idle_connections": false, + "pad_db_in_use_connections": false, + "pad_db_wait_count_total": false, + "pad_db_wait_duration_seconds_total": false, } for _, f := range families { diff --git a/internal/models/activity.go b/internal/models/activity.go index be3bdf51..557035fc 100644 --- a/internal/models/activity.go +++ b/internal/models/activity.go @@ -9,25 +9,25 @@ var ValidActions = []string{ // Audit action constants for auth/admin events const ( - ActionLogin = "login" - ActionLoginFailed = "login_failed" - ActionLogout = "logout" - ActionBootstrap = "bootstrap" - ActionRegister = "register" - ActionPasswordChanged = "password_changed" - ActionPasswordReset = "password_reset" - ActionTokenCreated = "token_created" - ActionTokenRevoked = "token_revoked" - ActionTokenRotated = "token_rotated" - ActionTOTPEnabled = "totp_enabled" - ActionTOTPDisabled = "totp_disabled" - ActionMemberInvited = "member_invited" - ActionMemberRemoved = "member_removed" - ActionRoleChanged = "role_changed" - ActionSettingsChanged = "settings_changed" - ActionOAuthLogin = "oauth_login" - ActionOAuthLoginFailed = "oauth_login_failed" - ActionPlanChanged = "plan_changed" + ActionLogin = "login" + ActionLoginFailed = "login_failed" + ActionLogout = "logout" + ActionBootstrap = "bootstrap" + ActionRegister = "register" + ActionPasswordChanged = "password_changed" + ActionPasswordReset = "password_reset" + ActionTokenCreated = "token_created" + ActionTokenRevoked = "token_revoked" + ActionTokenRotated = "token_rotated" + ActionTOTPEnabled = "totp_enabled" + ActionTOTPDisabled = "totp_disabled" + ActionMemberInvited = "member_invited" + ActionMemberRemoved = "member_removed" + ActionRoleChanged = "role_changed" + ActionSettingsChanged = "settings_changed" + ActionOAuthLogin = "oauth_login" + ActionOAuthLoginFailed = "oauth_login_failed" + ActionPlanChanged = "plan_changed" ActionPasswordResetByAdmin = "password_reset_by_admin" ActionUserDisabled = "user_disabled" ActionUserEnabled = "user_enabled" diff --git a/internal/models/agent_role.go b/internal/models/agent_role.go index 944da755..61aae091 100644 --- a/internal/models/agent_role.go +++ b/internal/models/agent_role.go @@ -24,7 +24,7 @@ type AgentRole struct { // AgentRoleCreate is the input for creating a new agent role. type AgentRoleCreate struct { Name string `json:"name"` - Slug string `json:"slug,omitempty"` // auto-generated from name if empty + Slug string `json:"slug,omitempty"` // auto-generated from name if empty Description string `json:"description,omitempty"` Icon string `json:"icon,omitempty"` Tools string `json:"tools,omitempty"` diff --git a/internal/models/collection.go b/internal/models/collection.go index ef4f6800..47437f7c 100644 --- a/internal/models/collection.go +++ b/internal/models/collection.go @@ -5,14 +5,14 @@ import "time" type FieldDef struct { Key string `json:"key"` Label string `json:"label"` - Type string `json:"type"` // text, number, select, multi_select, date, checkbox, url, relation + Type string `json:"type"` // text, number, select, multi_select, date, checkbox, url, relation Options []string `json:"options,omitempty"` TerminalOptions []string `json:"terminal_options,omitempty"` // for select fields: which options represent a terminal/finalized state Default any `json:"default,omitempty"` Required bool `json:"required,omitempty"` Computed bool `json:"computed,omitempty"` - Collection string `json:"collection,omitempty"` // for relation type - Suffix string `json:"suffix,omitempty"` // for number type display + Collection string `json:"collection,omitempty"` // for relation type + Suffix string `json:"suffix,omitempty"` // for number type display } type CollectionSchema struct { @@ -21,15 +21,15 @@ type CollectionSchema struct { // QuickAction defines a prompt template that can be triggered from the UI. type QuickAction struct { - Label string `json:"label"` // display label for the button - Prompt string `json:"prompt"` // prompt template with {ref}, {title}, {status}, etc. - Scope string `json:"scope"` // "item" or "collection" - Icon string `json:"icon,omitempty"` // optional emoji/icon + Label string `json:"label"` // display label for the button + Prompt string `json:"prompt"` // prompt template with {ref}, {title}, {status}, etc. + Scope string `json:"scope"` // "item" or "collection" + Icon string `json:"icon,omitempty"` // optional emoji/icon } type CollectionSettings struct { - Layout string `json:"layout,omitempty"` // fields-primary, content-primary, balanced - DefaultView string `json:"default_view,omitempty"` // list, board, table + Layout string `json:"layout,omitempty"` // fields-primary, content-primary, balanced + DefaultView string `json:"default_view,omitempty"` // list, board, table BoardGroupBy string `json:"board_group_by,omitempty"` ListSortBy string `json:"list_sort_by,omitempty"` ListGroupBy string `json:"list_group_by,omitempty"` @@ -45,7 +45,7 @@ type Collection struct { Icon string `json:"icon"` Description string `json:"description"` Schema string `json:"schema"` // JSON string in DB, parsed via methods - Settings string `json:"settings"` // JSON string in DB + Settings string `json:"settings"` // JSON string in DB Prefix string `json:"prefix"` SortOrder int `json:"sort_order"` IsDefault bool `json:"is_default"` diff --git a/internal/models/document.go b/internal/models/document.go index 6ddfdabc..d8932e08 100644 --- a/internal/models/document.go +++ b/internal/models/document.go @@ -39,14 +39,14 @@ type Document struct { } type DocumentCreate struct { - Title string `json:"title"` - Content string `json:"content,omitempty"` - DocType string `json:"doc_type,omitempty"` - Status string `json:"status,omitempty"` - Tags string `json:"tags,omitempty"` - Pinned bool `json:"pinned,omitempty"` - CreatedBy string `json:"created_by,omitempty"` - Source string `json:"source,omitempty"` + Title string `json:"title"` + Content string `json:"content,omitempty"` + DocType string `json:"doc_type,omitempty"` + Status string `json:"status,omitempty"` + Tags string `json:"tags,omitempty"` + Pinned bool `json:"pinned,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Source string `json:"source,omitempty"` } type DocumentUpdate struct { @@ -63,14 +63,14 @@ type DocumentUpdate struct { } type QuickSave struct { - Title string `json:"title"` - Content string `json:"content"` - DocType string `json:"doc_type,omitempty"` - Status string `json:"status,omitempty"` - Tags string `json:"tags,omitempty"` - CreatedBy string `json:"created_by,omitempty"` - Source string `json:"source,omitempty"` - ChangeSummary string `json:"change_summary,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + DocType string `json:"doc_type,omitempty"` + Status string `json:"status,omitempty"` + Tags string `json:"tags,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Source string `json:"source,omitempty"` + ChangeSummary string `json:"change_summary,omitempty"` } type DocumentListParams struct { diff --git a/internal/models/export.go b/internal/models/export.go index 32fa86f3..8d81f7c2 100644 --- a/internal/models/export.go +++ b/internal/models/export.go @@ -2,7 +2,7 @@ package models // WorkspaceExport is the complete portable representation of a workspace. type WorkspaceExport struct { - Version int `json:"version"` // Export format version (1) + Version int `json:"version"` // Export format version (1) ExportedAt string `json:"exported_at"` Workspace WorkspaceExportMeta `json:"workspace"` Collections []CollectionExport `json:"collections"` diff --git a/internal/models/item.go b/internal/models/item.go index 0f4ad76f..785c01e7 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -47,17 +47,17 @@ type Item struct { AgentRoleName string `json:"agent_role_name,omitempty"` AgentRoleSlug string `json:"agent_role_slug,omitempty"` AgentRoleIcon string `json:"agent_role_icon,omitempty"` - CollectionSlug string `json:"collection_slug,omitempty"` - CollectionName string `json:"collection_name,omitempty"` - CollectionIcon string `json:"collection_icon,omitempty"` - CollectionPrefix string `json:"collection_prefix,omitempty"` + CollectionSlug string `json:"collection_slug,omitempty"` + CollectionName string `json:"collection_name,omitempty"` + CollectionIcon string `json:"collection_icon,omitempty"` + CollectionPrefix string `json:"collection_prefix,omitempty"` // Parent link (populated by enrichItemForResponse / enrichItemsWithParent) - ParentLinkID string `json:"parent_link_id,omitempty"` - ParentRef string `json:"parent_ref,omitempty"` - ParentTitle string `json:"parent_title,omitempty"` - ParentSlug string `json:"parent_slug,omitempty"` - ParentCollectionSlug string `json:"parent_collection_slug,omitempty"` + ParentLinkID string `json:"parent_link_id,omitempty"` + ParentRef string `json:"parent_ref,omitempty"` + ParentTitle string `json:"parent_title,omitempty"` + ParentSlug string `json:"parent_slug,omitempty"` + ParentCollectionSlug string `json:"parent_collection_slug,omitempty"` // HasChildren is true if this item has child items linked to it. // Populated by enrichment, not stored in the DB. diff --git a/internal/models/item_links.go b/internal/models/item_links.go index f6818cc0..b4cd5b6f 100644 --- a/internal/models/item_links.go +++ b/internal/models/item_links.go @@ -12,7 +12,7 @@ const ( ItemLinkTypeSplitFrom = "split_from" ItemLinkTypeSupersedes = "supersedes" ItemLinkTypeImplements = "implements" - ItemLinkTypeParent = "parent" + ItemLinkTypeParent = "parent" ) var itemLinkTypeAliases = map[string]string{ diff --git a/internal/models/share_link.go b/internal/models/share_link.go index d7b6b16e..7f09f57e 100644 --- a/internal/models/share_link.go +++ b/internal/models/share_link.go @@ -6,15 +6,15 @@ import "time" // Tokens are hashed at rest; the raw token is returned only once on creation. type ShareLink struct { ID string `json:"id"` - TokenHash string `json:"-"` // Never serialized - Token string `json:"token,omitempty"` // Only set on creation response - TargetType string `json:"target_type"` // "item" or "collection" + TokenHash string `json:"-"` // Never serialized + Token string `json:"token,omitempty"` // Only set on creation response + TargetType string `json:"target_type"` // "item" or "collection" TargetID string `json:"target_id"` WorkspaceID string `json:"workspace_id"` - Permission string `json:"permission"` // "view" or "edit" + Permission string `json:"permission"` // "view" or "edit" CreatedBy string `json:"created_by"` - PasswordHash *string `json:"-"` // Never serialized - HasPassword bool `json:"has_password"` // Derived: password_hash IS NOT NULL + PasswordHash *string `json:"-"` // Never serialized + HasPassword bool `json:"has_password"` // Derived: password_hash IS NOT NULL ExpiresAt *time.Time `json:"expires_at,omitempty"` MaxViews *int `json:"max_views,omitempty"` RequireAuth bool `json:"require_auth"` diff --git a/internal/models/user.go b/internal/models/user.go index 661c2a6b..da928709 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -9,21 +9,21 @@ import ( type User struct { ID string `json:"id"` Email string `json:"email"` - Username string `json:"username"` // Unique handle; empty until set + Username string `json:"username"` // Unique handle; empty until set Name string `json:"name"` - PasswordHash string `json:"-"` // Never serialized - Role string `json:"role"` // "admin" or "member" + PasswordHash string `json:"-"` // Never serialized + Role string `json:"role"` // "admin" or "member" AvatarURL string `json:"avatar_url,omitempty"` - TOTPSecret string `json:"-"` // Never serialized + TOTPSecret string `json:"-"` // Never serialized TOTPEnabled bool `json:"totp_enabled"` - RecoveryCodes string `json:"-"` // Never serialized - Plan string `json:"plan"` // "free", "pro", or "self-hosted" + RecoveryCodes string `json:"-"` // Never serialized + Plan string `json:"plan"` // "free", "pro", or "self-hosted" PlanExpiresAt string `json:"plan_expires_at,omitempty"` - StripeCustomerID string `json:"-"` // Never serialized + StripeCustomerID string `json:"-"` // Never serialized PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits - OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"] - PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash) - DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled + OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"] + PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash) + DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -69,8 +69,8 @@ type UserCreate struct { Email string `json:"email"` Username string `json:"username,omitempty"` // Optional; auto-generated if empty Name string `json:"name"` - Password string `json:"password"` // Plaintext, will be hashed - Role string `json:"role,omitempty"` // Defaults to "member" + Password string `json:"password"` // Plaintext, will be hashed + Role string `json:"role,omitempty"` // Defaults to "member" } // UserUpdate is the input for updating user profile fields. diff --git a/internal/models/workspace.go b/internal/models/workspace.go index f08d5b83..9fb48763 100644 --- a/internal/models/workspace.go +++ b/internal/models/workspace.go @@ -3,19 +3,19 @@ package models import "time" type Workspace struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` OwnerID string `json:"owner_id,omitempty"` // User ID of workspace owner OwnerUsername string `json:"owner_username,omitempty"` // Populated by JOIN (not stored) IsGuest bool `json:"is_guest,omitempty"` // True when user has grants but no membership - Description string `json:"description"` - Settings string `json:"settings"` // JSON - SortOrder int `json:"sort_order"` - Context *WorkspaceContext `json:"context,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + Description string `json:"description"` + Settings string `json:"settings"` // JSON + SortOrder int `json:"sort_order"` + Context *WorkspaceContext `json:"context,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } type WorkspaceCreate struct { diff --git a/internal/server/decode_json_test.go b/internal/server/decode_json_test.go index 98057604..283c1e3f 100644 --- a/internal/server/decode_json_test.go +++ b/internal/server/decode_json_test.go @@ -60,4 +60,3 @@ func TestDecodeJSONWithLimit_CustomCap(t *testing.T) { t.Fatal("expected 256 KiB cap to reject 1 MiB body, got nil") } } - diff --git a/internal/server/handlers_2fa_recovery_test.go b/internal/server/handlers_2fa_recovery_test.go index ac86984a..4461c6f7 100644 --- a/internal/server/handlers_2fa_recovery_test.go +++ b/internal/server/handlers_2fa_recovery_test.go @@ -9,11 +9,11 @@ func TestNormalizeRecoveryCode(t *testing.T) { tests := []struct { in, want string }{ - {"ABCDEFGHIJKLMNOP", "ABCDEFGHIJKLMNOP"}, // already normalized - {"abcdefghijklmnop", "ABCDEFGHIJKLMNOP"}, // lowercase → upper - {"ABCD-EFGH-IJKL-MNOP", "ABCDEFGHIJKLMNOP"}, // dashes stripped + {"ABCDEFGHIJKLMNOP", "ABCDEFGHIJKLMNOP"}, // already normalized + {"abcdefghijklmnop", "ABCDEFGHIJKLMNOP"}, // lowercase → upper + {"ABCD-EFGH-IJKL-MNOP", "ABCDEFGHIJKLMNOP"}, // dashes stripped {" abcd efgh ijkl mnop ", "ABCDEFGHIJKLMNOP"}, // whitespace stripped - {"ABcd-EFgh\nIJkl", "ABCDEFGHIJKL"}, // mixed + newline + {"ABcd-EFgh\nIJkl", "ABCDEFGHIJKL"}, // mixed + newline {"", ""}, } for _, tt := range tests { diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go index de83e9a0..d30fcc2f 100644 --- a/internal/server/handlers_admin.go +++ b/internal/server/handlers_admin.go @@ -10,11 +10,11 @@ import ( // Known platform setting keys. Values are stored in the platform_settings table. const ( - settingEmailProvider = "email_provider" // "maileroo" or empty + settingEmailProvider = "email_provider" // "maileroo" or empty settingMailerooAPIKey = "maileroo_api_key" - settingEmailFrom = "email_from" // Sender address - settingEmailFromName = "email_from_name" // Sender display name - settingPlatformName = "platform_name" // Instance name (default: "Pad") + settingEmailFrom = "email_from" // Sender address + settingEmailFromName = "email_from_name" // Sender display name + settingPlatformName = "platform_name" // Instance name (default: "Pad") // Token policy settings settingTokenDefaultExpiryDays = "token_default_expiry_days" // Default: 90 diff --git a/internal/server/handlers_admin_users.go b/internal/server/handlers_admin_users.go index 3e9af79c..e0df6071 100644 --- a/internal/server/handlers_admin_users.go +++ b/internal/server/handlers_admin_users.go @@ -343,10 +343,10 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request })) writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "method": "temporary_password", - "temp_password": tempPassword, - "message": "Temporary password generated. The user's existing sessions have been invalidated.", + "ok": true, + "method": "temporary_password", + "temp_password": tempPassword, + "message": "Temporary password generated. The user's existing sessions have been invalidated.", }) } @@ -550,10 +550,10 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]interface{}{ - "users": userCount, - "users_by_plan": planCounts, - "workspaces": len(workspaces), - "cloud_mode": s.cloudMode, + "users": userCount, + "users_by_plan": planCounts, + "workspaces": len(workspaces), + "cloud_mode": s.cloudMode, }) } diff --git a/internal/server/handlers_dashboard.go b/internal/server/handlers_dashboard.go index be390589..090074f3 100644 --- a/internal/server/handlers_dashboard.go +++ b/internal/server/handlers_dashboard.go @@ -15,14 +15,14 @@ import ( // Dashboard response types type DashboardResponse struct { - Summary DashboardSummary `json:"summary"` - ActiveItems []DashboardActiveItem `json:"active_items"` - ActivePlans []DashboardPlan `json:"active_plans"` - StarredItems []DashboardActiveItem `json:"starred_items,omitempty"` - ByRole []store.RoleBreakdown `json:"by_role,omitempty"` - Attention []DashboardAttention `json:"attention"` - RecentActivity []DashboardActivity `json:"recent_activity"` - SuggestedNext []DashboardSuggestion `json:"suggested_next"` + Summary DashboardSummary `json:"summary"` + ActiveItems []DashboardActiveItem `json:"active_items"` + ActivePlans []DashboardPlan `json:"active_plans"` + StarredItems []DashboardActiveItem `json:"starred_items,omitempty"` + ByRole []store.RoleBreakdown `json:"by_role,omitempty"` + Attention []DashboardAttention `json:"attention"` + RecentActivity []DashboardActivity `json:"recent_activity"` + SuggestedNext []DashboardSuggestion `json:"suggested_next"` } type DashboardActiveItem struct { diff --git a/internal/server/handlers_rbac_test.go b/internal/server/handlers_rbac_test.go index cf7d5009..47c849b0 100644 --- a/internal/server/handlers_rbac_test.go +++ b/internal/server/handlers_rbac_test.go @@ -14,9 +14,9 @@ import ( // rbacTestEnv holds everything needed for RBAC tests: // a server with an admin, workspace, and users with different roles. type rbacTestEnv struct { - srv *Server - wsSlug string - ownerToken string + srv *Server + wsSlug string + ownerToken string editorToken string viewerToken string } diff --git a/internal/server/middleware_realip_test.go b/internal/server/middleware_realip_test.go index 52fefcbe..85a5d957 100644 --- a/internal/server/middleware_realip_test.go +++ b/internal/server/middleware_realip_test.go @@ -122,7 +122,7 @@ func TestTrustedProxyRealIP_TrustedPeer_XRealIPUsed(t *testing.T) { mw := TrustedProxyRealIP(cidrs)(next) req := httptest.NewRequest("GET", "/", nil) - req.RemoteAddr = "10.0.0.5:12345" // in trusted CIDR + req.RemoteAddr = "10.0.0.5:12345" // in trusted CIDR req.Header.Set("X-Real-IP", "198.51.100.7") mw.ServeHTTP(httptest.NewRecorder(), req) diff --git a/internal/server/middleware_security_test.go b/internal/server/middleware_security_test.go index 284f5204..69d34afd 100644 --- a/internal/server/middleware_security_test.go +++ b/internal/server/middleware_security_test.go @@ -16,9 +16,9 @@ func TestSecurityHeaders(t *testing.T) { headers := map[string]string{ "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "strict-origin-when-cross-origin", - "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", } for name, expected := range headers { diff --git a/internal/store/agent_roles.go b/internal/store/agent_roles.go index 07ed78b8..52b01b71 100644 --- a/internal/store/agent_roles.go +++ b/internal/store/agent_roles.go @@ -463,4 +463,3 @@ func (s *Store) ResolveAgentRoleID(workspaceID, idOrSlug string) (string, error) } return role.ID, nil } - diff --git a/internal/store/bench_concurrent_test.go b/internal/store/bench_concurrent_test.go index fef4bfd7..6265b674 100644 --- a/internal/store/bench_concurrent_test.go +++ b/internal/store/bench_concurrent_test.go @@ -197,9 +197,9 @@ func TestConcurrentMixedReadWrite(t *testing.T) { ) var ( - readOps, writeOps atomic.Int64 - readErrs, writeErrs atomic.Int64 - wg sync.WaitGroup + readOps, writeOps atomic.Int64 + readErrs, writeErrs atomic.Int64 + wg sync.WaitGroup ) deadline := time.Now().Add(duration) diff --git a/internal/store/documents.go b/internal/store/documents.go index 7f461008..06bef85a 100644 --- a/internal/store/documents.go +++ b/internal/store/documents.go @@ -593,4 +593,3 @@ func scanDocuments(rows *sql.Rows) ([]models.Document, error) { } return docs, rows.Err() } - diff --git a/internal/store/item_stars.go b/internal/store/item_stars.go index 07d7c696..f4121ae9 100644 --- a/internal/store/item_stars.go +++ b/internal/store/item_stars.go @@ -235,4 +235,3 @@ func (s *Store) DeleteStarsForItem(itemID string) error { } return nil } - diff --git a/internal/store/items.go b/internal/store/items.go index 8a1c93c7..028de3ca 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -1451,8 +1451,9 @@ func scanCollectionDoneFilters(rows *sql.Rows) []collectionDoneFilter { // for GetAllItemProgress). // // Expression shape: -// ((.collection_id = ? AND LOWER(COALESCE(, '')) IN (?,?)) OR -// (.collection_id = ? AND LOWER(COALESCE(, '')) IN (?,?))) +// +// ((.collection_id = ? AND LOWER(COALESCE(, '')) IN (?,?)) OR +// (.collection_id = ? AND LOWER(COALESCE(, '')) IN (?,?))) // // The `` JSON extract uses scalar text extraction; this works // because DoneFieldKey in the models package only resolves done fields to diff --git a/internal/store/limits.go b/internal/store/limits.go index af3948dd..ed48bd0b 100644 --- a/internal/store/limits.go +++ b/internal/store/limits.go @@ -50,7 +50,7 @@ var DefaultProLimits = PlanLimits{ type LimitResult struct { Allowed bool `json:"allowed"` Feature string `json:"feature"` - Limit int `json:"limit"` // -1 means unlimited + Limit int `json:"limit"` // -1 means unlimited Current int `json:"current"` Plan string `json:"plan"` } diff --git a/internal/store/search.go b/internal/store/search.go index f1377def..afce792e 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -355,7 +355,7 @@ func (s *Store) Search(params SearchParams) (*SearchResponse, error) { // Direct ref hits (e.g. searching "TASK-5") are prepended to results // and always appear first. They occupy slots on page 0; on subsequent // pages we exclude them and adjust the FTS offset accordingly. - refHits := results // save ref results before FTS + refHits := results // save ref results before FTS refCount := len(refHits) refIDs := make(map[string]bool, refCount) for _, r := range refHits { diff --git a/internal/store/search_quality_test.go b/internal/store/search_quality_test.go index 5d0fdd6f..a53374b8 100644 --- a/internal/store/search_quality_test.go +++ b/internal/store/search_quality_test.go @@ -70,7 +70,7 @@ func TestSearchQualityComparison(t *testing.T) { wsIDs[name] = ws.ID coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{ - Name: "Tasks", + Name: "Tasks", Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true},{"key":"priority","label":"Priority","type":"select","options":["low","medium","high"]}]}`, }) if err != nil { @@ -193,7 +193,7 @@ func TestSearchEdgeCases(t *testing.T) { ws := createTestWorkspace(t, s, "search-edge") coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{ - Name: "Items", + Name: "Items", Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`, }) if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 64735ef0..66932bcc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -41,9 +41,11 @@ func (s *Store) DB() *sql.DB { return s.db } // locked, retry for up to 5 seconds before returning SQLITE_BUSY. The // pragma is applied per-connection by the driver, so every pool member // inherits it. +// // - `_pragma=foreign_keys(on)`: foreign-key enforcement is per-connection // in SQLite. Setting it via the DSN guarantees ALL pool members enforce // them, not just the one that received a `db.Exec("PRAGMA ...")` call. +// // - `_txlock=immediate`: makes every `db.Begin()` issue `BEGIN IMMEDIATE` // instead of the default `BEGIN DEFERRED`. With deferred mode, a // transaction starts holding only a SHARED lock and tries to upgrade @@ -79,6 +81,7 @@ func (s *Store) DB() *sql.DB { return s.db } // model the realistic fallout is small (link tables already cascade), // but the integrity check `PRAGMA foreign_key_check` can surface // any pre-existing offenders. +// // - `journal_mode=WAL` is set via Exec below; WAL is a database-level // setting (stored in the file header), so it persists across // connections after the first one applies it. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 38526701..551d8be3 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -238,8 +238,8 @@ func TestSQLiteConcurrentWritersNoBusy(t *testing.T) { done.Add(1) go func() { defer done.Done() - ready.Done() // signal "I'm at the gate" - release.Wait() // park here until main thread releases + ready.Done() // signal "I'm at the gate" + release.Wait() // park here until main thread releases for op := 0; op < opsPerWorker; op++ { _, err := s.CreateItem(ws.ID, coll.ID, models.ItemCreate{ Title: fmt.Sprintf("concurrent-%d-%d", idx, op), @@ -251,8 +251,8 @@ func TestSQLiteConcurrentWritersNoBusy(t *testing.T) { } }() } - ready.Wait() // every worker has called ready.Done() (best-effort gate) - release.Done() // release them all at once + ready.Wait() // every worker has called ready.Done() (best-effort gate) + release.Done() // release them all at once done.Wait() close(errCh) diff --git a/internal/webhooks/dispatcher.go b/internal/webhooks/dispatcher.go index 6b5b2970..8384ad74 100644 --- a/internal/webhooks/dispatcher.go +++ b/internal/webhooks/dispatcher.go @@ -30,9 +30,9 @@ type WebhookPayload struct { // Dispatcher sends webhook HTTP POST notifications for workspace events. type Dispatcher struct { - store WebhookStore - client *http.Client - SkipSSRF bool // Skip SSRF validation (for tests only) + store WebhookStore + client *http.Client + SkipSSRF bool // Skip SSRF validation (for tests only) } // NewDispatcher creates a Dispatcher with the given store. diff --git a/internal/webhooks/dispatcher_test.go b/internal/webhooks/dispatcher_test.go index c5d781db..411b71d3 100644 --- a/internal/webhooks/dispatcher_test.go +++ b/internal/webhooks/dispatcher_test.go @@ -16,7 +16,7 @@ type mockStore struct { mu sync.Mutex hooks []models.Webhook failures map[string]bool // id -> last failed state - updated chan string // signals when UpdateWebhookFailure is called + updated chan string // signals when UpdateWebhookFailure is called } func newMockStore(hooks []models.Webhook) *mockStore {