├── services ├── puppeteer-worker │ ├── .nvmrc │ ├── .npmrc │ ├── src │ │ ├── types-raw.d.ts │ │ ├── logger.ts │ │ ├── file-capture.unit.test.js │ │ ├── file-capture.content-type.test.js │ │ ├── index.ts │ │ └── job-client.abort.test.js │ ├── .gitignore │ ├── .dockerignore │ ├── .env.example │ ├── docker-compose.test.yml │ ├── package.json │ ├── config │ │ └── worker.example.yaml │ ├── tsconfig.json │ └── scripts │ │ └── esbuild.mjs └── merrymaker-go │ ├── frontend │ ├── .npmrc │ ├── bunfig.toml │ ├── templates │ │ ├── partials │ │ │ ├── error_banner.tmpl │ │ │ ├── job_event_details_fragment.tmpl │ │ │ ├── events │ │ │ │ ├── other_event.tmpl │ │ │ │ ├── security_event.tmpl │ │ │ │ ├── error_event.tmpl │ │ │ │ ├── console_event.tmpl │ │ │ │ ├── page_event.tmpl │ │ │ │ ├── worker_log_event.tmpl │ │ │ │ ├── event_item.tmpl │ │ │ │ └── screenshot_event.tmpl │ │ │ ├── source_test_events.tmpl │ │ │ ├── list_table.tmpl │ │ │ ├── list_view.tmpl │ │ │ ├── job_events_fragment.tmpl │ │ │ ├── list_header.tmpl │ │ │ ├── pagination.tmpl │ │ │ ├── job_events_pagination.tmpl │ │ │ └── user_toggle.tmpl │ │ ├── pages │ │ │ └── alert_sink_view.tmpl │ │ ├── layout.tmpl │ │ ├── auth.tmpl │ │ └── error.tmpl │ ├── styles │ │ ├── components │ │ │ ├── spinner.css │ │ │ ├── toolbar.css │ │ │ ├── pagination.css │ │ │ ├── job-status.css │ │ │ ├── ioc-mode-toggle.css │ │ │ └── cards.css │ │ ├── base │ │ │ ├── variables.css │ │ │ ├── reset.css │ │ │ └── fonts.css │ │ ├── utilities │ │ │ ├── width.css │ │ │ ├── flex.css │ │ │ ├── spacing.css │ │ │ ├── layout.css │ │ │ └── text.css │ │ ├── pages │ │ │ ├── sites.css │ │ │ └── auth.css │ │ └── index-modular.css │ ├── README.md │ ├── vendor │ │ ├── ui_jmespath.ts │ │ └── ui_core.ts │ ├── .gitignore │ ├── test-setup.js │ ├── tsconfig.json │ ├── package.json │ └── public │ │ └── js │ │ ├── features │ │ ├── icons.js │ │ ├── network.js │ │ ├── sidebar.js │ │ ├── row-nav.js │ │ ├── htmx-history.js │ │ ├── htmx-fragments.js │ │ ├── user-menu.js │ │ └── screenshot.js │ │ ├── app.js │ │ └── components │ │ ├── screenshot-modal.js │ │ └── row-delete.js │ ├── internal │ ├── bootstrap │ │ ├── db_driver.go │ │ ├── auth_test.go │ │ ├── encryption.go │ │ └── config.go │ ├── migrate │ │ └── migrations │ │ │ ├── 004_jobs_ui_list_index.sql │ │ │ ├── 007_events_metadata.sql │ │ │ ├── 004_site_alert_mode.sql │ │ │ ├── 005_alert_delivery_status.sql │ │ │ ├── 002_job_results.sql │ │ │ ├── 006_perf_indexes.sql │ │ │ ├── 001_scheduled_jobs_overrun.sql │ │ │ ├── 008_job_meta.sql │ │ │ └── 003_job_results_retention.sql │ ├── http │ │ ├── ui │ │ │ └── viewmodel │ │ │ │ ├── pagination.go │ │ │ │ └── layout.go │ │ ├── handlers_health.go │ │ ├── templates │ │ │ └── assets │ │ │ │ └── funcs.go │ │ ├── handlers_ui_test.go │ │ ├── template_allowlist_test.go │ │ ├── context_helpers_test.go │ │ ├── handlers_sources.go │ │ ├── ui_allowlist.go │ │ ├── handlers_health_test.go │ │ ├── template_test.go │ │ ├── htmx_response.go │ │ ├── json.go │ │ ├── context_helpers.go │ │ ├── uiutil │ │ │ └── uiutil.go │ │ ├── filters.go │ │ ├── util.go │ │ ├── htmx_test.go │ │ └── htmx.go │ ├── domain │ │ ├── model │ │ │ ├── source_counts.go │ │ │ ├── site_test.go │ │ │ ├── job_result.go │ │ │ ├── event_query.go │ │ │ └── job_query.go │ │ ├── auth │ │ │ ├── types_test.go │ │ │ └── types.go │ │ ├── scheduler_test.go │ │ ├── rules │ │ │ ├── engine_test.go │ │ │ ├── request_test.go │ │ │ ├── engine.go │ │ │ └── helpers.go │ │ └── job │ │ │ └── lease_policy_test.go │ ├── data │ │ ├── migrations.go │ │ ├── testhelpers │ │ │ └── job_repo_factory.go │ │ ├── errors.go │ │ ├── time_provider.go │ │ ├── job_repo.go │ │ ├── export_contract_test.go │ │ └── event_repo_bench_test.go │ ├── core │ │ ├── alert_dispatcher.go │ │ ├── jobs.go │ │ ├── alert_adapter.go │ │ ├── secrets.go │ │ ├── secrets_test.go │ │ └── secret_repository_stub_test.go │ ├── ports │ │ ├── auth_interfaces_test.go │ │ └── auth.go │ ├── service │ │ ├── secret_errors_test.go │ │ ├── rules │ │ │ ├── metrics.go │ │ │ └── ioc_test.go │ │ ├── scheduler_job_type_test.go │ │ └── failurenotifier │ │ │ ├── notifier_test.go │ │ │ └── notifier.go │ ├── util │ │ └── format.go │ ├── adapters │ │ ├── authroles │ │ │ └── static_mapper.go │ │ ├── jobrunner │ │ │ └── secret_refresh_handler.go │ │ └── devauth │ │ │ └── provider_test.go │ ├── observability │ │ ├── errors │ │ │ └── classify.go │ │ ├── notify │ │ │ ├── event.go │ │ │ ├── pagerduty │ │ │ │ └── pagerduty_test.go │ │ │ └── slack │ │ │ │ └── slack_test.go │ │ └── metrics │ │ │ └── jobs.go │ └── testutil │ │ └── workflowtest │ │ └── workflow_test.go │ ├── .gitignore │ ├── assets.go │ ├── .dockerignore │ ├── tools │ └── tools.go │ ├── revive.toml │ ├── cmd │ └── merrymaker-admin │ │ └── main_test.go │ ├── config │ ├── http.go │ ├── database.go │ └── auth.go │ ├── go.mod │ ├── docker-compose.yml │ ├── Dockerfile │ └── scripts │ └── install-dev-tools.sh ├── .envrc ├── .editorconfig └── .gitignore /services/puppeteer-worker/.nvmrc: -------------------------------------------------------------------------------- 1 | 24.11.1 2 | -------------------------------------------------------------------------------- /services/puppeteer-worker/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = ["./test-setup.js"] 3 | 4 | -------------------------------------------------------------------------------- /services/puppeteer-worker/src/types-raw.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*?raw" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/bootstrap/db_driver.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | // Import pgx driver for database/sql. 5 | _ "github.com/jackc/pgx/v5/stdlib" 6 | ) 7 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export DB_HOST=localhost 2 | export DB_PORT=5432 3 | export DB_USER=merrymaker 4 | export DB_PASSWORD=merrymaker 5 | export DB_NAME=merrymaker 6 | export DB_SSL_MODE=disable 7 | export ENVIRONMENT=development 8 | -------------------------------------------------------------------------------- /services/puppeteer-worker/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | dist-dev/ 4 | *.log 5 | *.report.json 6 | security-report.json 7 | src/client-side-monitoring-bundled.js 8 | src/client-side-monitoring-dev.js 9 | .tsbuildinfo 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [Makefile] indent_style = tab 4 | 5 | [*.go] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/error_banner.tmpl: -------------------------------------------------------------------------------- 1 | {{define "error-banner"}} 2 | {{if or .Error .ErrorMessage}} 3 |
4 | {{if .ErrorMessage}}{{.ErrorMessage}}{{else}}Something went wrong. Please try again.{{end}} 5 |
6 | {{end}} 7 | {{end}} 8 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/migrate/migrations/004_jobs_ui_list_index.sql: -------------------------------------------------------------------------------- 1 | -- Composite index to optimize UI jobs list queries 2 | -- Test/CI-safe index creation (runs inside transaction) 3 | CREATE INDEX IF NOT EXISTS idx_jobs_ui_list 4 | ON jobs (site_id, status, type, is_test, created_at, id); 5 | 6 | 7 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/components/spinner.css: -------------------------------------------------------------------------------- 1 | /* Component: Spinner */ 2 | 3 | .spinner { 4 | animation: spin 1s linear infinite; 5 | display: inline-block; 6 | } 7 | @keyframes spin { 8 | from { 9 | transform: rotate(0deg); 10 | } 11 | to { 12 | transform: rotate(360deg); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.2.22. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/components/toolbar.css: -------------------------------------------------------------------------------- 1 | /* Component: Legacy toolbar compatibility */ 2 | 3 | .toolbar .input-sub, 4 | .toolbar .input-sub-xs, 5 | .toolbar .btn.btn-sm, 6 | .toolbar .btn.btn-tertiary.btn-sm { 7 | height: 32px; 8 | } 9 | .toolbar i[data-lucide] { 10 | width: 14px; 11 | height: 14px; 12 | } 13 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/job_event_details_fragment.tmpl: -------------------------------------------------------------------------------- 1 | {{define "job-event-details-fragment"}} 2 | {{$category := .Event.EventType | eventTypeCategory}} 3 | {{$detailsTpl := eventTemplateName $category "details"}} 4 |
5 | {{renderEventPartial $detailsTpl .Event}} 6 |
7 | {{end}} 8 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/base/variables.css: -------------------------------------------------------------------------------- 1 | /** 2 | * DESIGN TOKENS - CSS Custom Properties 3 | * 4 | * Design tokens now live in ./tokens.css so they can be shared between the 5 | * main bundle and the critical CSS bundle. This wrapper keeps the original 6 | * import path stable for existing imports. 7 | */ 8 | 9 | @import "./tokens.css"; 10 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/utilities/width.css: -------------------------------------------------------------------------------- 1 | /* Utilities: Max width */ 2 | 3 | .max-w-xs { 4 | max-width: 120px; 5 | } 6 | .max-w-sm { 7 | max-width: 200px; 8 | } 9 | .max-w-md { 10 | max-width: 360px; 11 | } 12 | .max-w-lg { 13 | max-width: 480px; 14 | } 15 | .max-w-xl { 16 | max-width: 640px; 17 | } 18 | .max-w-2xs { 19 | max-width: 140px; 20 | } 21 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/ui/viewmodel/pagination.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | // Pagination contains pagination metadata for list views. 4 | type Pagination struct { 5 | Page int 6 | PageSize int 7 | HasPrev bool 8 | HasNext bool 9 | StartIndex int 10 | EndIndex int 11 | TotalCount int 12 | PrevURL string 13 | NextURL string 14 | } 15 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/migrate/migrations/007_events_metadata.sql: -------------------------------------------------------------------------------- 1 | -- Add metadata column to events for storing request attribution and related context 2 | 3 | ALTER TABLE events 4 | ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb; 5 | 6 | -- Backfill existing rows to ensure non-null values 7 | UPDATE events SET metadata = '{}'::jsonb WHERE metadata IS NULL; 8 | -------------------------------------------------------------------------------- /services/puppeteer-worker/.dockerignore: -------------------------------------------------------------------------------- 1 | # Reduce build context size for puppeteer-worker 2 | 3 | # Node/NPM 4 | node_modules 5 | npm-debug.log* 6 | yarn-error.log* 7 | .pnpm-store 8 | 9 | # Builds 10 | .dist 11 | /dist 12 | coverage 13 | 14 | # Env/local 15 | .env 16 | .env.* 17 | .DS_Store 18 | 19 | # Tests and dev files 20 | **/*.test.* 21 | Dockerfile.test 22 | docker-compose.test.yml 23 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/model/source_counts.go: -------------------------------------------------------------------------------- 1 | //revive:disable-next-line:var-naming // legacy package name widely used across the project 2 | package model 3 | 4 | // Package name "types" is shared across core domain models for compatibility. 5 | 6 | // SourceJobCounts holds aggregated job counts for a Source. 7 | type SourceJobCounts struct { 8 | Total int 9 | Browser int 10 | } 11 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/data/migrations.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/target/mmk-ui-api/internal/migrate" 8 | ) 9 | 10 | // RunMigrations executes database migrations to set up the required schema by delegating to the migrate package. 11 | func RunMigrations(ctx context.Context, db *sql.DB) error { 12 | return migrate.Run(ctx, db) 13 | } 14 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/migrate/migrations/004_site_alert_mode.sql: -------------------------------------------------------------------------------- 1 | -- Add alert_mode to sites with default 'active' 2 | ALTER TABLE sites 3 | ADD COLUMN IF NOT EXISTS alert_mode TEXT NOT NULL DEFAULT 'active' 4 | CHECK (alert_mode IN ('active', 'muted')); 5 | 6 | -- Backfill any existing NULLs just in case older rows bypassed defaults 7 | UPDATE sites 8 | SET alert_mode = 'active' 9 | WHERE alert_mode IS NULL; 10 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/utilities/flex.css: -------------------------------------------------------------------------------- 1 | /* Utilities: Flex & Sizing */ 2 | 3 | .w-full { 4 | width: 100%; 5 | } 6 | .flex-1 { 7 | flex: 1; 8 | } 9 | 10 | /* Flex utilities */ 11 | .flex { 12 | display: flex; 13 | } 14 | 15 | .flex-center { 16 | display: flex; 17 | align-items: center; 18 | } 19 | 20 | .flex-between { 21 | display: flex; 22 | align-items: center; 23 | justify-content: space-between; 24 | } 25 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/data/testhelpers/job_repo_factory.go: -------------------------------------------------------------------------------- 1 | package testhelpers 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/target/mmk-ui-api/internal/data" 7 | ) 8 | 9 | // NewJobRepoWithTimeProvider creates a JobRepo with the provided TimeProvider for tests. 10 | func NewJobRepoWithTimeProvider(db *sql.DB, cfg data.RepoConfig, tp data.TimeProvider) *data.JobRepo { 11 | cfg.TimeProvider = tp 12 | return data.NewJobRepo(db, cfg) 13 | } 14 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/migrate/migrations/005_alert_delivery_status.sql: -------------------------------------------------------------------------------- 1 | -- Add delivery_status to alerts with default 'pending' 2 | ALTER TABLE alerts 3 | ADD COLUMN IF NOT EXISTS delivery_status TEXT NOT NULL DEFAULT 'pending' 4 | CHECK (delivery_status IN ('pending', 'muted', 'dispatched', 'failed')); 5 | 6 | -- Backfill existing NULLs if any rows bypassed default during migration 7 | UPDATE alerts 8 | SET delivery_status = 'pending' 9 | WHERE delivery_status IS NULL; 10 | -------------------------------------------------------------------------------- /services/merrymaker-go/.gitignore: -------------------------------------------------------------------------------- 1 | # Frontend build artifacts (keep directory with .gitkeep for go:embed) 2 | frontend/static/* 3 | !frontend/static/.gitkeep 4 | bin 5 | node_modules 6 | merrymaker 7 | docs/*.md 8 | 9 | # Built CSS generated by bun from styles/index.css 10 | frontend/public/css 11 | # Sourcemaps from bun build (if present) 12 | frontend/public/**/*.map 13 | 14 | # Air live-reload temporary files 15 | tmp/ 16 | *.log 17 | 18 | # Test coverage reports 19 | coverage.out 20 | *.coverprofile 21 | -------------------------------------------------------------------------------- /services/merrymaker-go/assets.go: -------------------------------------------------------------------------------- 1 | // Package merrymaker provides embedded assets for production builds. 2 | package merrymaker 3 | 4 | import "embed" 5 | 6 | // Embedded assets for production builds. 7 | // In dev mode (IsDev=true), assets are loaded from disk for hot reloading. 8 | // In production mode (IsDev=false), assets are served from these embedded filesystems. 9 | 10 | //go:embed all:frontend/static 11 | var StaticFS embed.FS 12 | 13 | //go:embed all:frontend/templates 14 | var TemplateFS embed.FS 15 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/core/alert_dispatcher.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/target/mmk-ui-api/internal/domain/model" 7 | ) 8 | 9 | // AlertDispatcher dispatches alerts to configured HTTP alert sinks. 10 | type AlertDispatcher interface { 11 | // Dispatch sends an alert to all configured HTTP alert sinks. 12 | // Returns error if dispatch fails for all sinks, but logs individual failures. 13 | Dispatch(ctx context.Context, alert *model.Alert) error 14 | } 15 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/model/site_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestParseSiteAlertMode(t *testing.T) { 10 | mode, ok := ParseSiteAlertMode("Muted") 11 | assert.True(t, ok) 12 | assert.Equal(t, SiteAlertModeMuted, mode) 13 | 14 | mode, ok = ParseSiteAlertMode(" active ") 15 | assert.True(t, ok) 16 | assert.Equal(t, SiteAlertModeActive, mode) 17 | 18 | _, ok = ParseSiteAlertMode("unknown") 19 | assert.False(t, ok) 20 | } 21 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/events/other_event.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "other-event-summary" -}} 2 | Raw event 3 | {{- end}} 4 | 5 | {{- define "other-event-details" -}} 6 |
7 |
8 |
{{.EventData | formatEventData}}
9 |
10 |
11 | {{- end}} 12 | 13 | {{- define "other-event" -}} 14 | {{template "other-event-details" .}} 15 | {{- end}} 16 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/vendor/ui_jmespath.ts: -------------------------------------------------------------------------------- 1 | // JMESPath vendor bundle 2 | // Exports jmespath for browser use (only needed on alert sink forms) 3 | 4 | // Import and re-export jmespath 5 | import { search } from "@jmespath-community/jmespath"; 6 | 7 | // Attach to window for global access (matching CDN behavior) 8 | declare global { 9 | interface Window { 10 | jmespath: { 11 | search: typeof search; 12 | }; 13 | } 14 | } 15 | 16 | // Make jmespath available globally (matches unpkg behavior) 17 | window.jmespath = { 18 | search, 19 | }; 20 | -------------------------------------------------------------------------------- /services/merrymaker-go/.dockerignore: -------------------------------------------------------------------------------- 1 | # Exclude files not needed for building the Go service 2 | # Keep built static assets and templates for go:embed 3 | 4 | # VCS / editor 5 | .git 6 | .gitignore 7 | .vscode 8 | .DS_Store 9 | 10 | # Binaries / local build outputs 11 | bin 12 | merrymaker 13 | merrymaker-prod 14 | 15 | # Frontend dev-only deps 16 | frontend/node_modules 17 | frontend/vendor 18 | 19 | # Tests and docs not needed in container build context 20 | **/*_test.go 21 | docs 22 | examples 23 | 24 | # Local docker-compose files 25 | docker-compose.yml 26 | 27 | -------------------------------------------------------------------------------- /services/merrymaker-go/tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | // Package tools documents development tool dependencies. 5 | // These tools are installed globally via `go install` and are not tracked in go.mod 6 | // since they are development tools, not runtime dependencies. 7 | package tools 8 | 9 | // Development tools (install via `go install`): 10 | // 11 | // Air - Live reload for Go apps 12 | // Install: go install github.com/air-verse/air@v1.63.0 13 | // Version: v1.63.0 (pinned 2025-01-01) 14 | // Docs: https://github.com/air-verse/air 15 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/migrate/migrations/002_job_results.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS job_results ( 2 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 3 | job_id UUID REFERENCES jobs(id) ON DELETE SET NULL, 4 | job_type TEXT NOT NULL, 5 | result JSONB NOT NULL, 6 | created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 7 | updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 8 | ); 9 | 10 | CREATE UNIQUE INDEX IF NOT EXISTS job_results_job_id_key 11 | ON job_results (job_id); 12 | 13 | CREATE INDEX IF NOT EXISTS job_results_job_type_idx ON job_results (job_type); 14 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/ports/auth_interfaces_test.go: -------------------------------------------------------------------------------- 1 | package ports_test 2 | 3 | import ( 4 | "testing" 5 | 6 | mocks "github.com/target/mmk-ui-api/internal/mocks/auth" 7 | "github.com/target/mmk-ui-api/internal/ports" 8 | ) 9 | 10 | // This test only verifies that our mocks conform to the ports at compile time. 11 | func TestMocksImplementPorts(t *testing.T) { 12 | t.Helper() 13 | 14 | var _ ports.AuthProvider = (*mocks.MockAuthProvider)(nil) 15 | var _ ports.SessionStore = (*mocks.MemorySessionStore)(nil) 16 | var _ ports.RoleMapper = (*mocks.StaticRoleMapper)(nil) 17 | } 18 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/service/secret_errors_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSecretProviderScriptError_UserMessage(t *testing.T) { 11 | err := NewSecretProviderScriptError("/opt/script.sh", errors.New("script failed\nline2")) 12 | 13 | msg := err.UserMessage() 14 | assert.Contains(t, msg, "Refresh script failed during validation") 15 | assert.Contains(t, msg, "/opt/script.sh") 16 | assert.NotContains(t, msg, "\n") 17 | assert.Contains(t, msg, "script failed") 18 | } 19 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | static/ 8 | *.tgz 9 | 10 | # code coverage 11 | coverage 12 | *.lcov 13 | 14 | # logs 15 | logs 16 | _.log 17 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 18 | 19 | # dotenv environment variable files 20 | .env 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | .env.local 25 | 26 | # caches 27 | .eslintcache 28 | .cache 29 | *.tsbuildinfo 30 | 31 | # IntelliJ based IDEs 32 | .idea 33 | 34 | # Finder (MacOS) folder config 35 | .DS_Store 36 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/util/format.go: -------------------------------------------------------------------------------- 1 | package util //nolint:revive // package name util hosts shared formatting helpers used across HTTP templates 2 | 3 | import "time" 4 | 5 | // FormatProcessingDuration formats a time.Duration for display, handling edge cases. 6 | // Returns "—" for zero or negative durations, truncates to milliseconds for readability. 7 | func FormatProcessingDuration(d time.Duration) string { 8 | switch { 9 | case d <= 0: 10 | return "—" 11 | case d < time.Millisecond: 12 | return d.String() 13 | default: 14 | return d.Truncate(time.Millisecond).String() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/auth/types_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestSession_IsGuest(t *testing.T) { 9 | s := Session{Role: RoleGuest} 10 | if !s.IsGuest() { 11 | t.Fatalf("expected guest") 12 | } 13 | if (Session{Role: RoleUser}).IsGuest() { 14 | t.Fatalf("did not expect guest") 15 | } 16 | } 17 | 18 | func TestIdentity_SimpleFields(t *testing.T) { 19 | id := Identity{UserID: "u", Email: "e", ExpiresAt: time.Now().Add(time.Hour)} 20 | if id.UserID != "u" || id.Email != "e" { 21 | t.Fatalf("unexpected identity: %+v", id) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/handlers_health.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | ) 7 | 8 | const healthResponse = `{"status":"ok"}` 9 | 10 | // healthHandler returns a simple 200 OK status for readiness/liveness checks. 11 | func healthHandler(w http.ResponseWriter, r *http.Request) { 12 | w.Header().Set("Content-Type", "application/json") 13 | w.WriteHeader(http.StatusOK) 14 | if r.Method == http.MethodHead { 15 | return 16 | } 17 | if _, err := io.WriteString(w, healthResponse); err != nil { 18 | // Nothing more to do if the client connection is gone. 19 | return 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/data/errors.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "errors" 4 | 5 | // Shared sentinel errors for data-layer repositories. 6 | var ( 7 | // IOC repository sentinels. 8 | ErrIOCNotFound = errors.New("IOC not found") 9 | ErrIOCAlreadyExists = errors.New("IOC already exists") 10 | 11 | // Job result repository sentinels. 12 | ErrJobResultsNotConfigured = errors.New("job results repository not configured") 13 | ErrJobResultsNotFound = errors.New("job results not found") 14 | ErrJobIDRequired = errors.New("job_id is required") 15 | ErrAlertIDRequired = errors.New("alert_id is required") 16 | ) 17 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/core/jobs.go: -------------------------------------------------------------------------------- 1 | // Package core provides the business logic and service layer for the merrymaker job system. 2 | package core 3 | 4 | import ( 5 | "github.com/target/mmk-ui-api/internal/domain/model" 6 | ) 7 | 8 | // JobType represents the type of job to be executed (re-exported from types package). 9 | // This is re-exported here for use in HTTP handlers to avoid direct coupling to the types package. 10 | type JobType = model.JobType 11 | 12 | // CreateJobRequest represents a request to create a new job (re-exported from types package). 13 | // This is re-exported here for use in HTTP handlers to avoid direct coupling to the types package. 14 | type CreateJobRequest = model.CreateJobRequest 15 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/source_test_events.tmpl: -------------------------------------------------------------------------------- 1 | {{define "source-test-events"}} 2 | {{/* Render new events for source test panel - include network events for debugging */}} 3 | {{/* This template is called repeatedly with only NEW events since last poll */}} 4 | {{range .Events}} 5 | {{$category := .EventType | eventTypeCategory}} 6 | {{/* Include all event types in test panel for better debugging */}} 7 | {{template "event-item" .}} 8 | {{end}} 9 | {{/* Update polling state after rendering events */}} 10 | 15 | {{end}} 16 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/ui/viewmodel/layout.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | // User represents the authenticated user context exposed to templates. 4 | type User struct { 5 | Email string 6 | Role string 7 | } 8 | 9 | // Layout captures shared chrome metadata (titles, navigation state, auth flags). 10 | type Layout struct { 11 | Title string 12 | PageTitle string 13 | CurrentPage string 14 | CSRFToken string 15 | IsAuthenticated bool 16 | CanManageAllowlist bool 17 | CanManageJobs bool 18 | User *User 19 | } 20 | 21 | // LayoutProvider exposes layout metadata for renderer utilities. 22 | type LayoutProvider interface { 23 | LayoutData() *Layout 24 | } 25 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/list_table.tmpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | list-table renders a table with headers and body content. 3 | SECURITY NOTE: .TableBody should contain safe HTML or use sub-templates. 4 | If passing raw HTML strings, ensure they are sanitized and typed as template.HTML. 5 | */}} 6 | {{define "list-table"}} 7 |
8 | 9 | 10 | 11 | {{range .Headers}} 12 | 13 | {{end}} 14 | 15 | 16 | 17 | {{.TableBody}} 18 | 19 |
{{.Text}}
20 |
21 | {{end}} 22 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/model/job_result.go: -------------------------------------------------------------------------------- 1 | //revive:disable-next-line:var-naming // legacy package name widely used across the project 2 | package model 3 | 4 | import ( 5 | "encoding/json" 6 | "time" 7 | ) 8 | 9 | // JobResult represents persisted job execution details. 10 | // JobID may be nil if the parent job has been reaped while preserving delivery history. 11 | type JobResult struct { 12 | JobID *string `json:"job_id" db:"job_id"` 13 | JobType JobType `json:"job_type" db:"job_type"` 14 | Result json.RawMessage `json:"result" db:"result"` 15 | CreatedAt time.Time `json:"created_at" db:"created_at"` 16 | UpdatedAt time.Time `json:"updated_at" db:"updated_at"` 17 | } 18 | -------------------------------------------------------------------------------- /services/merrymaker-go/revive.toml: -------------------------------------------------------------------------------- 1 | ignoreGeneratedHeader = false 2 | 3 | [rule.blank-imports] 4 | [rule.context-as-argument] 5 | [rule.context-keys-type] 6 | [rule.dot-imports] 7 | [rule.empty-block] 8 | [rule.error-naming] 9 | [rule.error-return] 10 | [rule.error-strings] 11 | [rule.errorf] 12 | [rule.exported] 13 | [rule.increment-decrement] 14 | [rule.indent-error-flow] 15 | [rule.package-comments] 16 | [rule.range] 17 | [rule.receiver-naming] 18 | [rule.redefines-builtin-id] 19 | [rule.superfluous-else] 20 | [rule.time-naming] 21 | [rule.unexported-return] 22 | [rule.unreachable-code] 23 | [rule.unused-parameter] 24 | [rule.var-declaration] 25 | [rule.var-naming] 26 | 27 | [rule.argument-limit] 28 | arguments = [3] 29 | exclude = ["_test.go$"] 30 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/utilities/spacing.css: -------------------------------------------------------------------------------- 1 | /* Utilities: Spacing */ 2 | 3 | .mt-2 { 4 | margin-top: var(--space-2); 5 | } 6 | .mt-3 { 7 | margin-top: var(--space-3); 8 | } 9 | .mt-4 { 10 | margin-top: var(--space-4); 11 | } 12 | .mt-6 { 13 | margin-top: var(--space-6); 14 | } 15 | .mb-1 { 16 | margin-bottom: var(--space-1); 17 | } 18 | .mt-1 { 19 | margin-top: var(--space-1); 20 | } 21 | .ml-1 { 22 | margin-left: var(--space-1); 23 | } 24 | .ml-2 { 25 | margin-left: var(--space-2); 26 | } 27 | 28 | .gap-2 { 29 | gap: var(--space-2); 30 | } 31 | .gap-4 { 32 | gap: var(--space-4); 33 | } 34 | 35 | .mb-2 { 36 | margin-bottom: var(--space-2); 37 | } 38 | .mb-3 { 39 | margin-bottom: var(--space-3); 40 | } 41 | .mb-4 { 42 | margin-bottom: var(--space-4); 43 | } 44 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/utilities/layout.css: -------------------------------------------------------------------------------- 1 | /* Utilities: Layout helpers */ 2 | 3 | .row { 4 | display: flex; 5 | align-items: center; 6 | flex-wrap: wrap; 7 | } 8 | .row-between { 9 | display: flex; 10 | align-items: center; 11 | justify-content: space-between; 12 | gap: var(--space-4); 13 | min-width: 0; 14 | } 15 | 16 | /* Icon sizing utilities */ 17 | .icon-sm { 18 | width: 14px; 19 | height: 14px; 20 | } 21 | 22 | .icon-md { 23 | width: 16px; 24 | height: 16px; 25 | } 26 | 27 | .icon-lg { 28 | width: 20px; 29 | height: 20px; 30 | } 31 | 32 | .icon-xl { 33 | width: 22px; 34 | height: 22px; 35 | } 36 | 37 | .icon-xxl { 38 | width: 24px; 39 | height: 24px; 40 | } 41 | 42 | /* Vertical alignment */ 43 | .v-middle { 44 | vertical-align: middle; 45 | } 46 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/list_view.tmpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | list-view renders a complete list view with header, content, and pagination. 3 | SECURITY NOTE: .Content field should contain safe HTML or use sub-templates. 4 | If passing raw HTML strings, ensure they are sanitized and typed as template.HTML. 5 | */}} 6 | {{define "list-view"}} 7 |
8 |
9 | {{template "list-header" .}} 10 |
11 | {{template "error-banner" .}} 12 | {{if not .Error}} 13 | {{.Content}} 14 | {{if .ShowPagination}} 15 | {{template "pagination" .}} 16 | {{end}} 17 | {{end}} 18 |
19 |
20 |
21 | {{end}} 22 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/job_events_fragment.tmpl: -------------------------------------------------------------------------------- 1 | {{define "job-events-fragment"}} 2 |
5 | 6 |
{{ template "job-events-filter-bar" . }}
7 | {{ if .Events }} 8 |
9 | {{range .Events}} 10 | {{template "event-item" .}} 11 | {{end}} 12 |
13 | 14 |
{{ template "job-events-pagination" . }}
15 | {{ else }} 16 |
No events found{{ if hasEventFilters .Filters }} matching the current filters{{ end }}.
17 | {{ end }} 18 |
19 | {{end}} 20 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/test-setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test setup for Bun test runner 3 | * Configures happy-dom for DOM testing 4 | */ 5 | 6 | import { Window } from 'happy-dom'; 7 | 8 | // Create a window instance 9 | const window = new Window(); 10 | const document = window.document; 11 | 12 | // Set globals 13 | global.window = window; 14 | global.document = document; 15 | global.navigator = window.navigator; 16 | global.HTMLElement = window.HTMLElement; 17 | global.Element = window.Element; 18 | global.Node = window.Node; 19 | global.KeyboardEvent = window.KeyboardEvent; 20 | global.MouseEvent = window.MouseEvent; 21 | global.CustomEvent = window.CustomEvent; 22 | 23 | // Ensure window has SyntaxError constructor 24 | if (!window.SyntaxError) { 25 | window.SyntaxError = SyntaxError; 26 | } 27 | 28 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/adapters/authroles/static_mapper.go: -------------------------------------------------------------------------------- 1 | package authroles 2 | 3 | import ( 4 | domainauth "github.com/target/mmk-ui-api/internal/domain/auth" 5 | ) 6 | 7 | // StaticRoleMapper maps groups by simple string membership rules. 8 | // Move of logic from internal/mocks/auth to a concrete adapter for production wiring. 9 | type StaticRoleMapper struct { 10 | AdminGroup string 11 | UserGroup string 12 | } 13 | 14 | func (m StaticRoleMapper) Map(groups []string) domainauth.Role { 15 | for _, g := range groups { 16 | if m.AdminGroup != "" && g == m.AdminGroup { 17 | return domainauth.RoleAdmin 18 | } 19 | } 20 | for _, g := range groups { 21 | if m.UserGroup != "" && g == m.UserGroup { 22 | return domainauth.RoleUser 23 | } 24 | } 25 | return domainauth.RoleGuest 26 | } 27 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/pages/sites.css: -------------------------------------------------------------------------------- 1 | /* Page: Sites header layout */ 2 | 3 | .sites-header-layout { 4 | display: grid; 5 | grid-template-columns: minmax(0, 1fr); 6 | align-items: start; 7 | gap: var(--space-3) var(--space-4); 8 | width: 100%; 9 | } 10 | 11 | .sites-filters { 12 | min-width: 0; 13 | } 14 | 15 | .sites-filters .filter-bar { 16 | width: 100%; 17 | } 18 | 19 | /* Normalize the size of header action buttons to match filter controls */ 20 | .sites-header-layout .btn { 21 | height: 36px; 22 | padding: 0 var(--space-4); 23 | display: inline-flex; 24 | align-items: center; 25 | align-self: start; 26 | justify-self: start; 27 | } 28 | 29 | @media (min-width: 1100px) { 30 | .sites-header-layout { 31 | grid-template-columns: auto minmax(0, 1fr); 32 | gap: 0 var(--space-4); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/core/alert_adapter.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/target/mmk-ui-api/internal/domain/model" 7 | ) 8 | 9 | // AlertService is a minimal adapter for creating alerts. 10 | // This exists solely to support the rules package tests without creating import cycles. 11 | // Production code should use service.AlertService instead. 12 | // 13 | // Deprecated: This is only for internal/service/rules tests. Use service.AlertService in production. 14 | type AlertService struct { 15 | Repo AlertRepository 16 | } 17 | 18 | // Create creates a new alert with the given request parameters. 19 | // This is a minimal implementation that just delegates to the repository. 20 | func (s *AlertService) Create(ctx context.Context, req *model.CreateAlertRequest) (*model.Alert, error) { 21 | return s.Repo.Create(ctx, req) 22 | } 23 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/components/pagination.css: -------------------------------------------------------------------------------- 1 | /* Component: Pagination */ 2 | 3 | .pagination-container { 4 | display: flex; 5 | align-items: center; 6 | justify-content: space-between; 7 | margin-top: var(--space-4); 8 | gap: var(--space-4); 9 | } 10 | 11 | .pagination-info { 12 | flex: 0 0 auto; 13 | } 14 | 15 | .pagination-text { 16 | font-size: var(--font-size-sm); 17 | color: var(--color-text-muted); 18 | white-space: nowrap; 19 | } 20 | 21 | .pagination-controls { 22 | display: flex; 23 | align-items: center; 24 | gap: var(--space-2); 25 | } 26 | 27 | /* Responsive behavior */ 28 | @media (max-width: 600px) { 29 | .pagination-container { 30 | flex-direction: column; 31 | align-items: stretch; 32 | gap: var(--space-3); 33 | } 34 | 35 | .pagination-info { 36 | text-align: center; 37 | } 38 | 39 | .pagination-controls { 40 | justify-content: center; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/migrate/migrations/006_perf_indexes.sql: -------------------------------------------------------------------------------- 1 | -- Performance indexes for slow/expensive queries observed via pg_stat_statements. 2 | -- Note: Do not use CREATE INDEX CONCURRENTLY; migrations run inside a transaction in tests. 3 | 4 | -- Speeds up job event lookups by source_job_id (used in ListByJob/ListWithFilters). 5 | CREATE INDEX IF NOT EXISTS idx_events_source_job_created ON events (source_job_id, created_at, id); 6 | -- Variant with event_type first helps when filtering/sorting by event_type for a job. 7 | CREATE INDEX IF NOT EXISTS idx_events_source_job_event_type_created ON events (source_job_id, event_type, created_at, id); 8 | 9 | -- Helps reaper DELETE of completed/failed jobs ordered by completion/update time. 10 | CREATE INDEX IF NOT EXISTS idx_jobs_completed_failed_coalesce_ts 11 | ON jobs (status, COALESCE(completed_at, updated_at)) 12 | WHERE status IN ('completed', 'failed'); 13 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/list_header.tmpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | list-header renders a card header with optional New button and custom content. 3 | SECURITY NOTE: .CustomHeaderContent should contain safe HTML or use sub-templates. 4 | If passing raw HTML strings, ensure they are sanitized and typed as template.HTML. 5 | */}} 6 | {{define "list-header"}} 7 |
8 | {{if .NewButtonURL}} 9 | 15 | 16 | {{if .NewButtonText}}{{.NewButtonText}}{{else}}New{{end}} 17 | 18 | {{end}} 19 | {{if .CustomHeaderContent}} 20 | {{.CustomHeaderContent}} 21 | {{end}} 22 |
23 | {{end}} 24 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "merrymaker", 3 | "type": "module", 4 | "private": true, 5 | "scripts": { 6 | "build": "bun run build.ts", 7 | "build:dev": "DEV=true bun run build.ts", 8 | "build:prod": "NODE_ENV=production bun run build.ts", 9 | "dev": "DEV=true bun run build.ts", 10 | "watch": "DEV=true bun run --watch build.ts", 11 | "test": "bun test --preload ./test-setup.js", 12 | "lint": "biome lint", 13 | "lint:fix": "biome lint --write", 14 | "format": "biome format --write" 15 | }, 16 | "devDependencies": { 17 | "@biomejs/biome": "2.3.8", 18 | "@types/bun": "latest", 19 | "happy-dom": "^20.0.0" 20 | }, 21 | "peerDependencies": { 22 | "typescript": "^5" 23 | }, 24 | "dependencies": { 25 | "@fontsource/inter": "^5.2.8", 26 | "@jmespath-community/jmespath": "1.3.0", 27 | "htmx.org": "2.0.8", 28 | "lucide": "0.555.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/events/security_event.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "security-event-summary" -}} 2 | {{$data := .EventData | parseEventData}} 3 | {{$message := index $data "message"}} 4 | {{$source := index $data "source"}} 5 | {{if $source}}{{$source}}{{end}} 6 | {{if $message}} 7 | {{truncateText $message 60}} 8 | {{else}} 9 | Security alert 10 | {{end}} 11 | {{- end}} 12 | 13 | {{- define "security-event-details" -}} 14 |
15 |
16 |
{{.EventData | formatEventData}}
17 |
18 |
19 | {{- end}} 20 | 21 | {{- define "security-event" -}} 22 | {{template "security-event-details" .}} 23 | {{- end}} 24 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/migrate/migrations/001_scheduled_jobs_overrun.sql: -------------------------------------------------------------------------------- 1 | -- Migration: add per-task overrun configuration and outstanding fire key tracking. 2 | ALTER TABLE scheduled_jobs 3 | ADD COLUMN IF NOT EXISTS overrun_policy TEXT, 4 | ADD COLUMN IF NOT EXISTS overrun_state_mask SMALLINT, 5 | ADD COLUMN IF NOT EXISTS active_fire_key TEXT, 6 | ADD COLUMN IF NOT EXISTS active_fire_key_set_at TIMESTAMPTZ; 7 | 8 | ALTER TABLE scheduled_jobs 9 | ADD CONSTRAINT scheduled_jobs_overrun_policy_check 10 | CHECK ( 11 | overrun_policy IS NULL OR 12 | overrun_policy IN ('skip', 'queue', 'reschedule') 13 | ); 14 | 15 | CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_active_fire_key 16 | ON scheduled_jobs (task_name) 17 | WHERE active_fire_key IS NOT NULL; 18 | 19 | CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_task_fire_key 20 | ON scheduled_jobs (task_name, active_fire_key) 21 | WHERE active_fire_key IS NOT NULL; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | test_events 25 | test_events/ 26 | 27 | .DS_Store 28 | 29 | # Ignore binary files named main in */cmd/* directories 30 | */cmd/main 31 | */cmd/worker 32 | 33 | # IDEs 34 | .idea 35 | .vscode 36 | 37 | .env 38 | .env.development 39 | 40 | # Build artifacts 41 | /bin/* 42 | 43 | # Development tools 44 | tmp/ 45 | build-errors.log 46 | 47 | # Ignore all files in certs directory 48 | certs/* 49 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/observability/errors/classify.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | goerrors "errors" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | // Classify returns a normalized error type name suitable for tagging metrics/logs. 10 | // It unwraps errors until the innermost concrete type is found and converts it to snake_case-ish. 11 | func Classify(err error) string { 12 | if err == nil { 13 | return "" 14 | } 15 | 16 | // Unwrap to the innermost error for better signal. 17 | for { 18 | unwrapped := goerrors.Unwrap(err) 19 | if unwrapped == nil { 20 | break 21 | } 22 | err = unwrapped 23 | } 24 | 25 | t := reflect.TypeOf(err) 26 | for t != nil && t.Kind() == reflect.Pointer { 27 | t = t.Elem() 28 | } 29 | if t == nil { 30 | return "unknown" 31 | } 32 | 33 | name := strings.ToLower(strings.ReplaceAll(t.String(), "*", "")) 34 | name = strings.ReplaceAll(name, ".", "_") 35 | if name == "" { 36 | return "unknown" 37 | } 38 | return name 39 | } 40 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/templates/assets/funcs.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "html/template" 5 | 6 | httpassets "github.com/target/mmk-ui-api/internal/http/assets" 7 | ) 8 | 9 | // Options configures asset-related template helpers. 10 | type Options struct { 11 | Resolver *httpassets.AssetResolver 12 | DevMode bool 13 | CriticalCSS func() string 14 | } 15 | 16 | // Funcs returns template helpers for asset resolution and critical CSS embedding. 17 | func Funcs(opts Options) template.FuncMap { 18 | funcs := template.FuncMap{ 19 | "asset": func(logicalName string) string { 20 | return httpassets.ResolveAsset(opts.Resolver, logicalName, opts.DevMode) 21 | }, 22 | } 23 | 24 | funcs["criticalCSS"] = func() template.CSS { 25 | if opts.CriticalCSS == nil { 26 | return "" 27 | } 28 | // #nosec G203 - Critical CSS is loaded from our own trusted source files at build time 29 | return template.CSS(opts.CriticalCSS()) 30 | } 31 | 32 | return funcs 33 | } 34 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/handlers_ui_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestUI_Index_RendersLayout(t *testing.T) { 11 | h := CreateUIHandlersForTest(t) 12 | if h == nil { 13 | return 14 | } 15 | r := httptest.NewRequest(http.MethodGet, "/", nil) 16 | rr := httptest.NewRecorder() 17 | 18 | h.Index(rr, r) 19 | 20 | res := rr.Result() 21 | t.Cleanup(func() { _ = res.Body.Close() }) 22 | 23 | if got := res.StatusCode; got != http.StatusOK { 24 | // Default status is 200 if WriteHeader isn't called; enforce it. 25 | t.Fatalf("expected status 200, got %d", got) 26 | } 27 | if ct := res.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { 28 | t.Fatalf("expected text/html content type, got %q", ct) 29 | } 30 | body := rr.Body.String() 31 | if !strings.Contains(body, "
") { 32 | t.Fatalf("expected body to contain main container, got: %s", body) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/migrate/migrations/008_job_meta.sql: -------------------------------------------------------------------------------- 1 | -- Precomputed job metadata to avoid expensive event scans on UI/job views. 2 | 3 | CREATE TABLE IF NOT EXISTS job_meta ( 4 | job_id UUID PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE, 5 | event_count INTEGER NOT NULL DEFAULT 0, 6 | last_status TEXT, 7 | updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 8 | ); 9 | 10 | -- Backfill existing rows so historical jobs have precomputed event counts. 11 | INSERT INTO job_meta (job_id, event_count, last_status) 12 | SELECT j.id, 13 | COALESCE(ec.event_count, 0), 14 | j.status 15 | FROM jobs j 16 | LEFT JOIN ( 17 | SELECT source_job_id, COUNT(*) AS event_count 18 | FROM events 19 | GROUP BY source_job_id 20 | ) ec ON ec.source_job_id = j.id 21 | ON CONFLICT (job_id) DO UPDATE 22 | SET event_count = EXCLUDED.event_count, 23 | last_status = EXCLUDED.last_status, 24 | updated_at = now(); 25 | 26 | CREATE INDEX IF NOT EXISTS idx_job_meta_updated_at ON job_meta (updated_at); 27 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/adapters/jobrunner/secret_refresh_handler.go: -------------------------------------------------------------------------------- 1 | package jobrunner 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | 9 | "github.com/target/mmk-ui-api/internal/domain/model" 10 | ) 11 | 12 | // handleSecretRefreshJob processes a secret refresh job by executing the provider script. 13 | func (r *Runner) handleSecretRefreshJob(ctx context.Context, job *model.Job) error { 14 | if r.secretRefreshSvc == nil { 15 | return errors.New("secret refresh service not configured") 16 | } 17 | 18 | // Decode job payload 19 | var p struct { 20 | SecretID string `json:"secret_id"` 21 | } 22 | if err := json.Unmarshal(job.Payload, &p); err != nil { 23 | return fmt.Errorf("decode payload: %w", err) 24 | } 25 | if p.SecretID == "" { 26 | return errors.New("missing secret_id in job payload") 27 | } 28 | 29 | // Execute secret refresh 30 | if err := r.secretRefreshSvc.ExecuteRefresh(ctx, p.SecretID); err != nil { 31 | return fmt.Errorf("execute secret refresh: %w", err) 32 | } 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /services/puppeteer-worker/.env.example: -------------------------------------------------------------------------------- 1 | # Example Environment Configuration 2 | # Copy this file to .env and customize for your environment 3 | 4 | # Required: Job queue API base URL 5 | MERRYMAKER_API_BASE=http://localhost:8080 6 | 7 | # Worker configuration 8 | WORKER_JOB_TYPE=browser 9 | WORKER_LEASE_SECONDS=30 10 | WORKER_WAIT_SECONDS=25 11 | WORKER_HEARTBEAT_SECONDS=10 12 | 13 | # Puppeteer settings 14 | PUPPETEER_HEADLESS=true 15 | PUPPETEER_TIMEOUT=30000 16 | 17 | # File capture 18 | FILE_CAPTURE_ENABLED=true 19 | FILE_CAPTURE_TYPES=script,document,stylesheet 20 | FILE_CAPTURE_MAX_SIZE=1048576 21 | FILE_CAPTURE_STORAGE=memory 22 | FILE_CAPTURE_CT_MATCHERS=application/javascript,application/json,application/xml 23 | 24 | # Event shipping 25 | SHIPPING_ENDPOINT=http://localhost:8080/api/events/bulk 26 | SHIPPING_BATCH_SIZE=100 27 | SHIPPING_MAX_BATCH_AGE=5000 28 | 29 | # Client monitoring 30 | CLIENT_MONITORING_ENABLED=true 31 | CLIENT_MONITORING_EVENTS=storage,dynamicCode 32 | 33 | # Optional: Custom Chrome path 34 | # PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium 35 | -------------------------------------------------------------------------------- /services/merrymaker-go/cmd/merrymaker-admin/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | "github.com/target/mmk-ui-api/internal/service" 10 | ) 11 | 12 | func TestPrintRulesJobResultsIncludesFailureBanner(t *testing.T) { 13 | oldStdout := os.Stdout 14 | r, w, err := os.Pipe() 15 | require.NoError(t, err) 16 | 17 | defer func() { 18 | os.Stdout = oldStdout 19 | }() 20 | 21 | os.Stdout = w 22 | 23 | results := &service.RulesProcessingResults{ErrorsEncountered: 2} 24 | err = printRulesJobResults(&printRulesJobResultsRequest{ 25 | JobID: "job-123", 26 | Key: "", 27 | Results: results, 28 | }) 29 | require.NoError(t, err) 30 | 31 | require.NoError(t, w.Close()) 32 | os.Stdout = oldStdout 33 | 34 | output, err := io.ReadAll(r) 35 | require.NoError(t, err) 36 | require.NoError(t, r.Close()) 37 | 38 | outStr := string(output) 39 | require.Contains(t, outStr, "Status: failed (rule evaluation errors: 2)") 40 | require.Contains(t, outStr, "results may be incomplete") 41 | } 42 | -------------------------------------------------------------------------------- /services/merrymaker-go/config/http.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // HTTPConfig contains HTTP server configuration. 4 | type HTTPConfig struct { 5 | // Addr is the address to bind the HTTP server to. 6 | Addr string `env:"HTTP_ADDR" envDefault:":8080"` 7 | 8 | // CookieDomain is the domain for session cookies. 9 | // Leave empty to use the request domain. 10 | CookieDomain string `env:"APP_COOKIE_DOMAIN" envDefault:""` 11 | 12 | // CompressionEnabled enables gzip compression for text-based assets. 13 | CompressionEnabled bool `env:"HTTP_COMPRESSION_ENABLED" envDefault:"false"` 14 | 15 | // CompressionLevel is the gzip compression level (1-9). 16 | // Default is 6 (standard gzip default). 17 | CompressionLevel int `env:"HTTP_COMPRESSION_LEVEL" envDefault:"6"` 18 | } 19 | 20 | // Sanitize applies guardrails to HTTP configuration values. 21 | func (h *HTTPConfig) Sanitize() { 22 | // Clamp compression level to valid gzip range (1-9) 23 | if h.CompressionLevel < 1 { 24 | h.CompressionLevel = 1 25 | } 26 | if h.CompressionLevel > 9 { 27 | h.CompressionLevel = 9 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/events/error_event.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "error-event-summary" -}} 2 | {{$data := .EventData | parseEventData}} 3 | {{$name := index $data "name"}} 4 | {{$message := index $data "message"}} 5 | {{$value := index $data "value"}} 6 | {{if $name}}{{$name}}{{end}} 7 | {{if $message}} 8 | {{truncateText $message 64}} 9 | {{else if $value}} 10 | {{truncateText $value 64}} 11 | {{else}} 12 | Error event 13 | {{end}} 14 | {{- end}} 15 | 16 | {{- define "error-event-details" -}} 17 |
18 |
19 |
{{.EventData | formatEventData}}
20 |
21 |
22 | {{- end}} 23 | 24 | {{- define "error-event" -}} 25 | {{template "error-event-details" .}} 26 | {{- end}} 27 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/template_allowlist_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | // Verifies allowlist pages map to the expected content partials via sectionTmpl. 9 | func TestTemplateHelpers_Allowlist_Mapping(t *testing.T) { 10 | tr := RequireTemplateRenderer(t) 11 | if tr == nil { 12 | return 13 | } 14 | cloned, err := tr.t.Clone() 15 | if err != nil { 16 | t.Fatalf("clone: %v", err) 17 | } 18 | cloned, err = cloned.Parse(`{{define "probe"}}{{ sectionTmpl . }}{{end}}`) 19 | if err != nil { 20 | t.Fatalf("parse probe: %v", err) 21 | } 22 | 23 | cases := map[string]string{ 24 | PageAllowlist: "allowlist-content", 25 | PageAllowlistForm: "allowlist-form-content", 26 | } 27 | for page, want := range cases { 28 | var buf bytes.Buffer 29 | if err := cloned.ExecuteTemplate(&buf, "probe", page); err != nil { 30 | t.Fatalf("execute probe(%s): %v", page, err) 31 | } 32 | got := buf.String() 33 | if got != want { 34 | t.Fatalf("sectionTmpl(%s) => %q, want %q", page, got, want) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/events/console_event.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "console-event-summary" -}} 2 | {{$data := .EventData | parseEventData}} 3 | {{$level := index $data "type"}} 4 | {{$text := index $data "text"}} 5 | {{if $level}}{{$level}}{{end}} 6 | {{if $text}} 7 | {{truncateText $text 64}} 8 | {{else}} 9 | Console output 10 | {{end}} 11 | {{- end}} 12 | 13 | {{- define "console-event-details" -}} 14 | {{$data := .EventData | parseEventData}} 15 | {{$text := index $data "text"}} 16 |
17 | {{if $text}} 18 |
{{$text}}
19 | {{end}} 20 |
21 |
{{.EventData | formatEventData}}
22 |
23 |
24 | {{- end}} 25 | 26 | {{- define "console-event" -}} 27 | {{template "console-event-details" .}} 28 | {{- end}} 29 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/utilities/text.css: -------------------------------------------------------------------------------- 1 | /* Utilities: Text & Typography */ 2 | 3 | .text-secondary { 4 | color: var(--color-text-secondary); 5 | } 6 | .text-muted { 7 | color: var(--color-text-muted); 8 | } 9 | .text-success { 10 | color: var(--color-success); 11 | } 12 | .text-danger { 13 | color: var(--color-danger); 14 | } 15 | .text-warning { 16 | color: var(--color-warning); 17 | } 18 | 19 | .small { 20 | font-size: var(--font-size-sm); 21 | } 22 | .mono { 23 | font-family: 24 | ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", 25 | monospace; 26 | } 27 | .nowrap { 28 | white-space: nowrap; 29 | } 30 | 31 | /* Backwards compatibility aliases */ 32 | .text-sm { 33 | font-size: var(--font-size-sm); 34 | } 35 | .fw-medium { 36 | font-weight: var(--font-weight-medium); 37 | } 38 | 39 | .text-center { 40 | text-align: center; 41 | } 42 | 43 | .fw-semibold { 44 | font-weight: var(--font-weight-semibold); 45 | } 46 | 47 | .text-xs { 48 | font-size: var(--font-size-xs); 49 | } 50 | 51 | .text-danger-conditional { 52 | color: var(--color-danger); 53 | } 54 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/adapters/devauth/provider_test.go: -------------------------------------------------------------------------------- 1 | package devauth 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/target/mmk-ui-api/internal/ports" 9 | ) 10 | 11 | func TestProvider_BeginAndExchange(t *testing.T) { 12 | prov, err := NewProvider(Config{UserID: "dev-user", Email: "dev@example.com", Groups: []string{"users"}}) 13 | if err != nil { 14 | t.Fatalf("NewProvider error: %v", err) 15 | } 16 | url, state, nonce, err := prov.Begin(context.Background(), ports.BeginInput{RedirectURL: "/"}) 17 | if err != nil { 18 | t.Fatalf("Begin error: %v", err) 19 | } 20 | if !strings.HasPrefix(url, "/auth/callback?") { 21 | t.Fatalf("unexpected authURL: %s", url) 22 | } 23 | if state == "" || nonce == "" { 24 | t.Fatal("state and nonce should be generated") 25 | } 26 | id, err := prov.Exchange(context.Background(), ports.ExchangeInput{Code: "dev", State: state, Nonce: nonce}) 27 | if err != nil { 28 | t.Fatalf("Exchange error: %v", err) 29 | } 30 | if id.UserID != "dev-user" || id.Email != "dev@example.com" { 31 | t.Fatalf("unexpected identity: %+v", id) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/features/icons.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Lucide icon feature module 3 | * 4 | * Initializes Lucide icons on initial load and after HTMX swaps to ensure newly 5 | * injected fragments receive icon treatment. 6 | */ 7 | import { on } from "../core/htmx-bridge.js"; 8 | 9 | let initialized = false; 10 | 11 | function createIcons() { 12 | try { 13 | if (!window.lucide || typeof window.lucide.createIcons !== "function") return; 14 | 15 | window.lucide.createIcons( 16 | { icons: window.lucide.icons }, 17 | { attrs: {}, nameAttr: "data-lucide" }, 18 | ); 19 | } catch (_) { 20 | /* noop */ 21 | } 22 | } 23 | 24 | function registerEventHandlers() { 25 | const refresh = () => { 26 | createIcons(); 27 | }; 28 | 29 | if (document.readyState === "loading") { 30 | document.addEventListener("DOMContentLoaded", refresh, { once: true }); 31 | } else { 32 | refresh(); 33 | } 34 | 35 | on("htmx:afterSwap", refresh); 36 | on("htmx:historyRestore", refresh); 37 | } 38 | 39 | export function initIcons() { 40 | if (initialized) return; 41 | initialized = true; 42 | registerEventHandlers(); 43 | } 44 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/components/job-status.css: -------------------------------------------------------------------------------- 1 | /* Component: Job Status Pills */ 2 | 3 | .job-status { 4 | font-size: var(--font-size-sm); 5 | font-weight: var(--font-weight-medium); 6 | text-decoration: none; 7 | padding: var(--space-1) var(--space-2); 8 | border-radius: var(--border-radius-sm); 9 | display: inline-flex; 10 | align-items: center; 11 | gap: var(--space-1); 12 | } 13 | .job-status.completed { 14 | color: var(--color-success); 15 | background-color: var(--color-success-subtle, hsl(150, 60%, 95%)); 16 | } 17 | .job-status.failed { 18 | color: var(--color-danger); 19 | background-color: var(--color-danger-subtle, hsl(0, 70%, 95%)); 20 | } 21 | .job-status.running { 22 | color: var(--color-warning); 23 | background-color: var(--color-warning-subtle, hsl(38, 92%, 95%)); 24 | } 25 | .job-status.pending { 26 | color: var(--color-text-secondary); 27 | background-color: var(--color-background); 28 | } 29 | .job-status.timeout, 30 | .job-status.error { 31 | color: var(--color-text-muted); 32 | background-color: var(--color-background); 33 | } 34 | .job-status:hover { 35 | text-decoration: underline; 36 | } 37 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/scheduler_test.go: -------------------------------------------------------------------------------- 1 | package domain_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/target/mmk-ui-api/internal/domain" 8 | ) 9 | 10 | func TestParseOverrunStateMask(t *testing.T) { 11 | mask, err := domain.ParseOverrunStateMask("running, pending") 12 | require.NoError(t, err) 13 | require.True(t, mask.Has(domain.OverrunStateRunning)) 14 | require.True(t, mask.Has(domain.OverrunStatePending)) 15 | require.False(t, mask.Has(domain.OverrunStateRetrying)) 16 | require.Equal(t, "running,pending", mask.String()) 17 | } 18 | 19 | func TestParseOverrunStateMaskInvalid(t *testing.T) { 20 | _, err := domain.ParseOverrunStateMask("unknown") 21 | require.Error(t, err) 22 | } 23 | 24 | func TestOverrunStateMaskMarshal(t *testing.T) { 25 | mask := domain.OverrunStatePending | domain.OverrunStateRetrying 26 | text, err := mask.MarshalText() 27 | require.NoError(t, err) 28 | require.Equal(t, "pending,retrying", string(text)) 29 | 30 | var roundTrip domain.OverrunStateMask 31 | require.NoError(t, roundTrip.UnmarshalText(text)) 32 | require.Equal(t, mask, roundTrip) 33 | } 34 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/observability/notify/event.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Severity constants recognised by downstream sinks. 9 | const ( 10 | SeverityCritical = "critical" 11 | ) 12 | 13 | // JobFailurePayload captures the canonical data we emit for job failure notifications. 14 | type JobFailurePayload struct { 15 | JobID string 16 | JobType string 17 | SiteID string 18 | Scope string 19 | IsTest bool 20 | Error string 21 | ErrorClass string 22 | Severity string 23 | OccurredAt time.Time 24 | Metadata map[string]string 25 | } 26 | 27 | // Sink describes a destination capable of consuming job failure notifications. 28 | type Sink interface { 29 | SendJobFailure(ctx context.Context, payload JobFailurePayload) error 30 | } 31 | 32 | // SinkFunc adapts a function to the Sink interface (useful for tests). 33 | type SinkFunc func(ctx context.Context, payload JobFailurePayload) error 34 | 35 | // SendJobFailure implements the Sink interface. 36 | func (f SinkFunc) SendJobFailure(ctx context.Context, payload JobFailurePayload) error { 37 | if f == nil { 38 | return nil 39 | } 40 | return f(ctx, payload) 41 | } 42 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/events/page_event.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "page-event-summary" -}} 2 | {{$data := .EventData | parseEventData}} 3 | {{$url := index $data "url"}} 4 | {{$selector := index $data "selector"}} 5 | {{$value := index $data "value"}} 6 | {{$text := index $data "text"}} 7 | {{if $url}} 8 | {{truncateURL120 $url}} 9 | {{else if $selector}} 10 | {{$selector}} 11 | {{else if $text}} 12 | {{truncateText $text 60}} 13 | {{else if $value}} 14 | {{truncateText $value 60}} 15 | {{else}} 16 | Event details 17 | {{end}} 18 | {{- end}} 19 | 20 | {{- define "page-event-details" -}} 21 |
22 |
23 |
{{.EventData | formatEventData}}
24 |
25 |
26 | {{- end}} 27 | 28 | {{- define "page-event" -}} 29 | {{template "page-event-details" .}} 30 | {{- end}} 31 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/base/reset.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS RESET AND BASE STYLES 3 | * 4 | * This file contains the CSS reset and base element styles. 5 | * It provides a consistent foundation across all browsers. 6 | */ 7 | 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: border-box; 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | /* Fallback focus styles for accessibility - ensures all inputs have focus indicators */ 17 | input:focus-visible:not(.input):not(.filter-input), 18 | select:focus-visible:not(.filter-select), 19 | textarea:focus-visible:not(.input) { 20 | outline: 2px solid var(--color-brand-primary); 21 | outline-offset: 2px; 22 | } 23 | 24 | html { 25 | -webkit-font-smoothing: antialiased; 26 | -moz-osx-font-smoothing: grayscale; 27 | } 28 | 29 | body { 30 | font-family: var(--font-family-sans); 31 | font-size: var(--font-size-base); 32 | color: var(--color-text-primary); 33 | background-color: var(--color-background); 34 | line-height: 1.6; 35 | } 36 | 37 | /* Help text styling */ 38 | small { 39 | display: block; 40 | margin-top: var(--space-2); 41 | font-size: var(--font-size-sm); 42 | color: var(--color-text-muted); 43 | line-height: 1.4; 44 | } 45 | 46 | /* Simple unstyled, divided list */ 47 | .list { 48 | list-style: none; 49 | padding-left: 0; 50 | } 51 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/context_helpers_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | domainauth "github.com/target/mmk-ui-api/internal/domain/auth" 9 | ) 10 | 11 | func TestGetUserSessionFromContext(t *testing.T) { 12 | // No session 13 | if s, ok := GetUserSessionFromContext(context.Background()); assert.False(t, ok) { 14 | assert.Nil(t, s) 15 | } 16 | 17 | // With session 18 | sess := &domainauth.Session{ID: "abc", Role: domainauth.RoleUser} 19 | ctx := SetSessionInContext(context.Background(), sess) 20 | s, ok := GetUserSessionFromContext(ctx) 21 | assert.True(t, ok) 22 | assert.Equal(t, sess, s) 23 | } 24 | 25 | func TestIsGuestUser(t *testing.T) { 26 | // No session => guest 27 | assert.True(t, IsGuestUser(context.Background())) 28 | 29 | // Guest role => guest 30 | guest := &domainauth.Session{ID: "g", Role: domainauth.RoleGuest} 31 | assert.True(t, IsGuestUser(SetSessionInContext(context.Background(), guest))) 32 | 33 | // User/Admin => not guest 34 | user := &domainauth.Session{ID: "u", Role: domainauth.RoleUser} 35 | admin := &domainauth.Session{ID: "a", Role: domainauth.RoleAdmin} 36 | assert.False(t, IsGuestUser(SetSessionInContext(context.Background(), user))) 37 | assert.False(t, IsGuestUser(SetSessionInContext(context.Background(), admin))) 38 | } 39 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/pages/alert_sink_view.tmpl: -------------------------------------------------------------------------------- 1 | {{define "alert-sink-view-content"}} 2 |
3 | {{with .AlertSink}} 4 |

{{.Name}}

5 |
6 |
7 |
8 |
9 |
Method
10 |
{{ .Method }}
11 |
12 |
13 |
URI
14 |
{{ .URI }}
15 |
16 |
17 |
OK Status
18 |
{{ .OkStatus }}
19 |
20 |
21 |
Retry
22 |
{{ .Retry }}
23 |
24 |
25 |
Secrets
26 |
{{ if .Secrets }}{{ range $i, $s := .Secrets }}{{ if $i }}, {{ end }}{{ $s }}{{ end }}{{ else }}None{{ end }}
27 |
28 |
29 |
30 |
31 | {{else}} 32 |

Alert sink not found.

33 | {{end}} 34 |
35 | {{end}} 36 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * App initialization for the browser UI 3 | * - Boots feature modules registered with the app shell 4 | * - Boots component lifecycle integrations 5 | */ 6 | import { Lifecycle } from "./core/lifecycle.js"; 7 | import "./alert_sink_form.js"; 8 | import "./components/filter-pills.js"; 9 | import "./components/toast.js"; 10 | import "./components/row-delete.js"; 11 | import "./components/ioc-form.js"; 12 | import "./job_status.js"; 13 | import "./source_form.js"; 14 | import { bootFeatures } from "./features/index.js"; 15 | import { closeSidebar } from "./features/sidebar.js"; 16 | import { closeAllUserMenus } from "./features/user-menu.js"; 17 | 18 | function setupLifecycle() { 19 | const boot = () => { 20 | Lifecycle.init(); 21 | }; 22 | 23 | if (document.readyState === "loading") { 24 | document.addEventListener("DOMContentLoaded", boot, { once: true }); 25 | } else { 26 | boot(); 27 | } 28 | } 29 | 30 | function setupEscapeKeyListeners() { 31 | document.addEventListener("keydown", (event) => { 32 | if (event.key !== "Escape") return; 33 | 34 | closeSidebar(); 35 | 36 | const toggles = closeAllUserMenus(); 37 | const lastToggle = toggles[toggles.length - 1]; 38 | if (lastToggle) lastToggle.focus(); 39 | }); 40 | } 41 | 42 | bootFeatures(); 43 | setupLifecycle(); 44 | setupEscapeKeyListeners(); 45 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/features/network.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Network event helpers feature module 3 | * 4 | * Provides toggle support for expanding and collapsing long network URLs in 5 | * event detail views. 6 | */ 7 | import { Events } from "../core/events.js"; 8 | 9 | let initialized = false; 10 | 11 | function registerEventHandlers() { 12 | Events.on('[data-action="toggle-url"]', "click", (_event, button) => { 13 | const span = button.previousElementSibling; 14 | if (!span?.dataset.fullUrl) return; 15 | 16 | const isExpanded = button.dataset.expanded === "true"; 17 | const nextExpanded = !isExpanded; 18 | const nextUrl = nextExpanded 19 | ? span.dataset.fullUrl 20 | : (span.dataset.truncatedUrl ?? span.dataset.fullUrl ?? ""); 21 | const nextLabel = nextExpanded ? "Show less" : "Show full"; 22 | 23 | const schedule = window.requestAnimationFrame?.bind(window) ?? ((fn) => setTimeout(fn, 16)); 24 | schedule(() => { 25 | if (!(span.isConnected && button.isConnected)) return; 26 | 27 | span.textContent = nextUrl; 28 | button.dataset.expanded = String(nextExpanded); 29 | button.setAttribute("aria-pressed", String(nextExpanded)); 30 | button.textContent = nextLabel; 31 | }); 32 | }); 33 | } 34 | 35 | export function initNetworkDetails() { 36 | if (initialized) return; 37 | initialized = true; 38 | registerEventHandlers(); 39 | } 40 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/model/event_query.go: -------------------------------------------------------------------------------- 1 | //revive:disable-next-line:var-naming // legacy package name widely used across the project 2 | package model 3 | 4 | // EventListByJobOptions groups parameters for listing/counting events by job with optional filters. 5 | type EventListByJobOptions struct { 6 | JobID string 7 | Limit int 8 | Offset int 9 | // CursorAfter and CursorBefore enable keyset pagination. When provided, they take precedence over Offset. 10 | CursorAfter *string 11 | CursorBefore *string 12 | // Optional filters (when nil/empty, no filter is applied) 13 | EventType *string // Optional filter by exact event_type (e.g., "Network.requestWillBeSent") 14 | Category *string // Optional filter by event category (network, console, security, page, action, error, other) 15 | SearchQuery *string // Optional text search in event_data JSON 16 | SortBy *string // Optional sort field (timestamp, event_type) 17 | SortDir *string // Optional sort direction (asc, desc) 18 | } 19 | 20 | // EventListPage contains a page of events with pagination cursors. 21 | type EventListPage struct { 22 | Events []*Event 23 | NextCursor *string 24 | PrevCursor *string 25 | } 26 | 27 | // EventListOptions is an alias for EventListByJobOptions for backward compatibility. 28 | // 29 | // Deprecated: Use EventListByJobOptions instead. 30 | type EventListOptions = EventListByJobOptions 31 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/migrate/migrations/003_job_results_retention.sql: -------------------------------------------------------------------------------- 1 | -- Reshape job_results so we can keep delivery history after reaping jobs. 2 | ALTER TABLE job_results 3 | DROP CONSTRAINT IF EXISTS job_results_job_id_fkey; 4 | 5 | ALTER TABLE job_results 6 | DROP CONSTRAINT IF EXISTS job_results_pkey; 7 | 8 | ALTER TABLE job_results 9 | ADD COLUMN IF NOT EXISTS id UUID; 10 | 11 | UPDATE job_results 12 | SET id = gen_random_uuid() 13 | WHERE id IS NULL; 14 | 15 | ALTER TABLE job_results 16 | ALTER COLUMN id SET DEFAULT gen_random_uuid(); 17 | 18 | ALTER TABLE job_results 19 | ALTER COLUMN id SET NOT NULL; 20 | 21 | ALTER TABLE job_results 22 | ALTER COLUMN job_id DROP NOT NULL; 23 | 24 | ALTER TABLE job_results 25 | ADD CONSTRAINT job_results_pkey PRIMARY KEY (id); 26 | 27 | ALTER TABLE job_results 28 | ADD CONSTRAINT job_results_job_id_fkey 29 | FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE SET NULL; 30 | 31 | CREATE UNIQUE INDEX IF NOT EXISTS job_results_job_id_key 32 | ON job_results (job_id); 33 | 34 | -- Improve lookup performance when listing results by alert id. 35 | CREATE INDEX IF NOT EXISTS job_results_alert_id_idx 36 | ON job_results ((result ->> 'alert_id')); 37 | 38 | -- Support batched retention cleanup queries. 39 | CREATE INDEX IF NOT EXISTS job_results_job_type_updated_at_idx 40 | ON job_results (job_type, updated_at, job_id); 41 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/components/ioc-mode-toggle.css: -------------------------------------------------------------------------------- 1 | /* Component: IOC Mode Toggle */ 2 | 3 | .ioc-mode-toggle { 4 | display: inline-flex; 5 | background-color: var(--color-background); 6 | border: var(--border-width) solid var(--color-border); 7 | border-radius: var(--border-radius-md); 8 | padding: var(--space-1); 9 | gap: var(--space-1); 10 | } 11 | 12 | .ioc-mode-btn { 13 | display: inline-flex; 14 | align-items: center; 15 | justify-content: center; 16 | padding: var(--space-2) var(--space-4); 17 | border: var(--border-width) solid transparent; 18 | background-color: transparent; 19 | color: var(--color-text-secondary); 20 | border-radius: calc(var(--border-radius-md) - var(--space-1)); 21 | font-size: var(--font-size-sm); 22 | font-weight: var(--font-weight-medium); 23 | cursor: pointer; 24 | transition: 25 | background-color 0.2s ease, 26 | color 0.2s ease, 27 | border-color 0.2s ease; 28 | white-space: nowrap; 29 | } 30 | 31 | .ioc-mode-btn:hover { 32 | color: var(--color-text-primary); 33 | background-color: var(--color-surface); 34 | } 35 | 36 | .ioc-mode-btn.active { 37 | background-color: var(--color-surface); 38 | color: var(--color-text-primary); 39 | border-color: var(--color-border); 40 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 41 | } 42 | 43 | .ioc-mode-btn:focus-visible { 44 | outline: 2px solid var(--color-brand-primary); 45 | outline-offset: 2px; 46 | } 47 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/events/worker_log_event.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "worker-log-event-summary" -}} 2 | {{$data := .EventData | parseEventData}} 3 | {{$message := index $data "message"}} 4 | {{if not $message}} 5 | {{$message = index $data "text"}} 6 | {{end}} 7 | {{if not $message}} 8 | {{$message = index $data "msg"}} 9 | {{end}} 10 | {{if $message}} 11 | {{truncateText $message 64}} 12 | {{else}} 13 | Worker log entry 14 | {{end}} 15 | {{- end}} 16 | 17 | {{- define "worker-log-event-details" -}} 18 | {{$data := .EventData | parseEventData}} 19 | {{$message := index $data "message"}} 20 | {{if not $message}} 21 | {{$message = index $data "text"}} 22 | {{end}} 23 | {{if not $message}} 24 | {{$message = index $data "msg"}} 25 | {{end}} 26 |
27 |
{{if $message}}{{$message}}{{else}}(empty log message){{end}}
33 |
34 | {{- end}} 35 | 36 | {{- define "worker-log-event" -}} 37 | {{template "worker-log-event-details" .}} 38 | {{- end}} 39 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/handlers_sources.go: -------------------------------------------------------------------------------- 1 | // Package httpx provides HTTP handlers and utilities for the merrymaker job system API. 2 | package httpx 3 | 4 | import ( 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/target/mmk-ui-api/internal/data" 9 | "github.com/target/mmk-ui-api/internal/domain/model" 10 | "github.com/target/mmk-ui-api/internal/service" 11 | ) 12 | 13 | // SourceHandlers provides HTTP handlers for source-related operations using the orchestration service layer. 14 | type SourceHandlers struct { 15 | Svc *service.SourceService 16 | } 17 | 18 | // Create handles HTTP requests to create a new source. If Test=true, a job is auto-enqueued. 19 | func (h *SourceHandlers) Create(w http.ResponseWriter, r *http.Request) { 20 | var req *model.CreateSourceRequest 21 | if !DecodeJSON(w, r, &req) { 22 | return 23 | } 24 | 25 | src, err := h.Svc.Create(r.Context(), req) 26 | if err != nil { 27 | switch { 28 | case errors.Is(err, data.ErrSourceNameExists): 29 | WriteError(w, ErrorParams{Code: http.StatusConflict, ErrCode: "name_conflict", Err: err}) 30 | case isValidationError(err): 31 | WriteError(w, ErrorParams{Code: http.StatusBadRequest, ErrCode: "validation_failed", Err: err}) 32 | default: 33 | WriteError(w, ErrorParams{Code: http.StatusInternalServerError, ErrCode: "create_failed", Err: err}) 34 | } 35 | return 36 | } 37 | 38 | WriteJSON(w, http.StatusCreated, src) 39 | } 40 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/layout.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "layout" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{.Title}} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | {{ template "content" . }} 23 |
24 | 25 |
29 | 30 | 31 | {{ end }} 32 | -------------------------------------------------------------------------------- /services/puppeteer-worker/docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | redis: 5 | image: redis:7-alpine 6 | ports: 7 | - "6380:6379" # Use different port to avoid conflicts 8 | command: redis-server --appendonly yes 9 | healthcheck: 10 | test: ["CMD", "redis-cli", "ping"] 11 | interval: 5s 12 | timeout: 3s 13 | retries: 5 14 | volumes: 15 | - redis_data:/data 16 | 17 | minio: 18 | image: minio/minio:latest 19 | ports: 20 | - "9000:9000" 21 | - "9001:9001" 22 | environment: 23 | MINIO_ROOT_USER: minioadmin 24 | MINIO_ROOT_PASSWORD: minioadmin123 25 | command: server /data --console-address ":9001" 26 | healthcheck: 27 | test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] 28 | interval: 10s 29 | timeout: 5s 30 | retries: 5 31 | volumes: 32 | - minio_data:/data 33 | 34 | # Create test bucket on startup 35 | minio-setup: 36 | image: minio/mc:latest 37 | depends_on: 38 | minio: 39 | condition: service_healthy 40 | entrypoint: > 41 | /bin/sh -c " 42 | mc alias set minio http://minio:9000 minioadmin minioadmin123; 43 | mc mb minio/puppeteer-files-test --ignore-existing; 44 | mc policy set public minio/puppeteer-files-test; 45 | echo 'MinIO setup complete'; 46 | " 47 | 48 | volumes: 49 | redis_data: 50 | minio_data: 51 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/core/secrets.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | // ResolveSecretPlaceholders fetches the provided secret names and replaces their 11 | // __NAME__ placeholders within content. If repo is nil, secrets is empty, or 12 | // content lacks placeholders, the original content is returned unchanged. 13 | func ResolveSecretPlaceholders( 14 | ctx context.Context, 15 | repo SecretRepository, 16 | secretNames []string, 17 | content string, 18 | ) (string, error) { 19 | if len(secretNames) == 0 || strings.TrimSpace(content) == "" { 20 | return content, nil 21 | } 22 | if repo == nil { 23 | return "", errors.New("secret repository not configured") 24 | } 25 | 26 | seen := make(map[string]struct{}, len(secretNames)) 27 | resolved := content 28 | 29 | for _, name := range secretNames { 30 | name = strings.TrimSpace(name) 31 | if name == "" { 32 | continue 33 | } 34 | if _, dup := seen[name]; dup { 35 | continue 36 | } 37 | seen[name] = struct{}{} 38 | 39 | placeholder := "__" + name + "__" 40 | if !strings.Contains(resolved, placeholder) { 41 | continue 42 | } 43 | 44 | secret, err := repo.GetByName(ctx, name) 45 | if err != nil { 46 | return "", fmt.Errorf("resolve secret %q: %w", name, err) 47 | } 48 | resolved = strings.ReplaceAll(resolved, placeholder, secret.Value) 49 | } 50 | 51 | return resolved, nil 52 | } 53 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/ui_allowlist.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/target/mmk-ui-api/internal/domain/model" 8 | ) 9 | 10 | // Allowlist serves the Domain Allow List page (list view). HTMX-aware. 11 | func (h *UIHandlers) Allowlist(w http.ResponseWriter, r *http.Request) { 12 | // Use generic list handler - no filtering needed for allowlist 13 | HandleList(ListHandlerOpts[*model.DomainAllowlist, struct{}]{ 14 | Handler: h, 15 | W: w, 16 | R: r, 17 | Fetcher: func(ctx context.Context, pg pageOpts) ([]*model.DomainAllowlist, error) { 18 | // Fetch pageSize+1 to detect hasNext 19 | limit, offset := pg.LimitAndOffset() 20 | listOpts := model.DomainAllowlistListOptions{Limit: limit, Offset: offset} 21 | items, err := h.AllowlistSvc.List(ctx, listOpts) 22 | if err != nil { 23 | h.logger().Error("failed to load allowlist for UI", 24 | "error", err, 25 | "page", pg.Page, 26 | "page_size", pg.PageSize, 27 | ) 28 | } 29 | return items, err 30 | }, 31 | BasePath: "/allowlist", 32 | PageMeta: PageMeta{Title: "Merrymaker - Allow List", PageTitle: "Allow List", CurrentPage: PageAllowlist}, 33 | ItemsKey: "Allowlist", 34 | ErrorMessage: "Unable to load allow list.", 35 | ServiceAvailable: func() bool { 36 | return h.AllowlistSvc != nil 37 | }, 38 | UnavailableMessage: "Unable to load allow list.", 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/vendor/ui_core.ts: -------------------------------------------------------------------------------- 1 | // Core UI Libraries vendor bundle 2 | // Exports htmx and lucide for browser use (needed on every page) 3 | 4 | // Import and re-export htmx 5 | import htmx from "htmx.org"; 6 | 7 | // Import and re-export lucide 8 | import { createIcons, icons } from "lucide"; 9 | 10 | // Attach to window for global access (matching CDN behavior) 11 | declare global { 12 | interface Window { 13 | htmx: typeof htmx; 14 | lucide: { 15 | createIcons: typeof createIcons; 16 | icons: typeof icons; 17 | }; 18 | } 19 | } 20 | 21 | // Make htmx available globally (matches unpkg behavior) 22 | window.htmx = htmx; 23 | 24 | // Configure htmx to include CSRF token in all requests 25 | document.addEventListener("DOMContentLoaded", () => { 26 | // Get CSRF token from cookie 27 | const getCsrfToken = (): string => { 28 | const match = document.cookie.match(/csrf_token=([^;]+)/); 29 | return match ? match[1] : ""; 30 | }; 31 | 32 | // Add CSRF token to all htmx requests 33 | document.body.addEventListener("htmx:configRequest", (event: Event) => { 34 | const customEvent = event as CustomEvent<{ 35 | headers: Record; 36 | }>; 37 | const token = getCsrfToken(); 38 | if (token) { 39 | customEvent.detail.headers["X-Csrf-Token"] = token; 40 | } 41 | }); 42 | }); 43 | 44 | // Make lucide available globally (matches unpkg behavior) 45 | window.lucide = { 46 | createIcons, 47 | icons, 48 | }; 49 | -------------------------------------------------------------------------------- /services/puppeteer-worker/src/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, type LoggerOptions } from "./lib/logger.js"; 2 | 3 | const isProd = process.env.NODE_ENV === "production"; 4 | const level = process.env.LOG_LEVEL || (isProd ? "info" : "debug"); 5 | const prettyEnv = process.env.LOG_PRETTY; 6 | const prettyFlag = prettyEnv?.toLowerCase(); 7 | const disablePretty = new Set(["false", "0", "no", "off", "disabled"]); 8 | const prettyEnabled = 9 | !isProd && (prettyFlag == null || !disablePretty.has(prettyFlag)); 10 | 11 | const redactPaths = [ 12 | // generic secrets 13 | "authorization", 14 | "password", 15 | "token", 16 | "apiKey", 17 | "clientSecret", 18 | "secret", 19 | // common locations 20 | "headers.authorization", 21 | "headers.cookie", 22 | "cookies", 23 | // PII-ish 24 | "ssn", 25 | "creditCard.number", 26 | "cvv", 27 | ]; 28 | 29 | const baseOptions: LoggerOptions = { 30 | level: level as LoggerOptions["level"], 31 | redact: redactPaths, 32 | splitErrorStream: true, 33 | }; 34 | 35 | const logger = (() => { 36 | if (prettyEnabled) { 37 | baseOptions.pretty = true; 38 | } 39 | return createLogger(baseOptions); 40 | })(); 41 | 42 | const pageLogger = (() => { 43 | const opts: LoggerOptions = { 44 | ...baseOptions, 45 | name: "browser", 46 | // ensure browser console logs do not mix with app errors 47 | splitErrorStream: false, 48 | stream: process.stdout, 49 | }; 50 | return createLogger(opts); 51 | })(); 52 | 53 | export { logger, pageLogger }; 54 | -------------------------------------------------------------------------------- /services/merrymaker-go/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/target/mmk-ui-api 2 | 3 | go 1.24.6 4 | 5 | require ( 6 | github.com/caarlos0/env/v11 v11.3.1 7 | github.com/coreos/go-oidc/v3 v3.15.0 8 | github.com/google/uuid v1.6.0 9 | github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 10 | github.com/jmespath-community/go-jmespath v1.1.1 11 | github.com/joho/godotenv v1.5.1 12 | github.com/redis/go-redis/v9 v9.14.0 13 | github.com/stretchr/testify v1.11.1 14 | go.uber.org/mock v0.6.0 15 | golang.org/x/net v0.47.0 16 | golang.org/x/oauth2 v0.31.0 17 | golang.org/x/sync v0.18.0 18 | ) 19 | 20 | require ( 21 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 24 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 25 | github.com/jackc/pgpassfile v1.0.0 // indirect 26 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 27 | github.com/jackc/puddle/v2 v2.2.2 // indirect 28 | github.com/kr/pretty v0.3.1 // indirect 29 | github.com/pmezard/go-difflib v1.0.0 // indirect 30 | github.com/stretchr/objx v0.5.2 // indirect 31 | golang.org/x/crypto v0.45.0 // indirect 32 | golang.org/x/exp v0.0.0-20230314191032-db074128a8ec // indirect 33 | ) 34 | 35 | require ( 36 | github.com/jackc/pgx/v5 v5.7.6 37 | github.com/rogpeppe/go-internal v1.13.1 // indirect 38 | golang.org/x/text v0.31.0 // indirect 39 | gopkg.in/yaml.v3 v3.0.1 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/events/event_item.tmpl: -------------------------------------------------------------------------------- 1 | {{define "event-item"}} 2 | {{$category := .EventType | eventTypeCategory}} 3 | {{$typeTpl := eventTemplateName $category "type"}} 4 | {{$summaryTpl := eventTemplateName $category "summary"}} 5 | {{$detailsTpl := eventTemplateName $category "details"}} 6 |
7 | 8 | 11 | 12 | {{renderEventPartial $typeTpl .}} 13 | 14 | 15 | {{renderEventPartial $summaryTpl .}} 16 | 17 | 18 | 19 | {{.CreatedAt | timeTag}} 20 | 21 |
26 | {{if .DataTrimmed}} 27 |
Loading full details on demand…
28 | {{else}} 29 |
Loading event details…
30 | {{end}} 31 |
32 |
33 | {{end}} 34 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/handlers_health_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | func TestHealthHandlerGET(t *testing.T) { 10 | req := httptest.NewRequest(http.MethodGet, "/healthz", nil) 11 | rec := httptest.NewRecorder() 12 | 13 | healthHandler(rec, req) 14 | 15 | resp := rec.Result() 16 | t.Cleanup(func() { _ = resp.Body.Close() }) 17 | 18 | if resp.StatusCode != http.StatusOK { 19 | t.Fatalf("expected status %d, got %d", http.StatusOK, resp.StatusCode) 20 | } 21 | 22 | if ct := resp.Header.Get("Content-Type"); ct != "application/json" { 23 | t.Fatalf("expected content-type application/json, got %q", ct) 24 | } 25 | 26 | body := rec.Body.String() 27 | if body != `{"status":"ok"}` { 28 | t.Fatalf("unexpected body: %q", body) 29 | } 30 | } 31 | 32 | func TestHealthHandlerHEAD(t *testing.T) { 33 | req := httptest.NewRequest(http.MethodHead, "/healthz", nil) 34 | rec := httptest.NewRecorder() 35 | 36 | healthHandler(rec, req) 37 | 38 | resp := rec.Result() 39 | t.Cleanup(func() { _ = resp.Body.Close() }) 40 | 41 | if resp.StatusCode != http.StatusOK { 42 | t.Fatalf("expected status %d, got %d", http.StatusOK, resp.StatusCode) 43 | } 44 | 45 | if ct := resp.Header.Get("Content-Type"); ct != "application/json" { 46 | t.Fatalf("expected content-type application/json, got %q", ct) 47 | } 48 | 49 | if bodyLen := rec.Body.Len(); bodyLen != 0 { 50 | t.Fatalf("expected empty body for HEAD request, got %d bytes", bodyLen) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/template_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestTemplateRenderer_LoadTemplates(t *testing.T) { 11 | // Test loading templates from the correct path 12 | tr := RequireTemplateRenderer(t) 13 | require.NotNil(t, tr, "Template renderer should not be nil") 14 | 15 | // Test that the templates are loaded correctly 16 | assert.NotNil(t, tr.t, "Template should be loaded") 17 | 18 | // Test that we can find the expected templates 19 | templates := tr.t.Templates() 20 | templateNames := make([]string, len(templates)) 21 | for i, tmpl := range templates { 22 | templateNames[i] = tmpl.Name() 23 | } 24 | 25 | // Check for expected template names 26 | expectedTemplates := []string{"layout", "content", "error-layout", "dashboard-content"} 27 | for _, expected := range expectedTemplates { 28 | found := false 29 | for _, name := range templateNames { 30 | if name == expected { 31 | found = true 32 | break 33 | } 34 | } 35 | assert.True(t, found, "Template %s should be loaded", expected) 36 | } 37 | } 38 | 39 | func TestTemplateRenderer_FromCurrentDir(t *testing.T) { 40 | // Test loading templates from the current directory (as the router does) 41 | tr := RequireTemplateRendererFromRoot(t) 42 | if tr == nil { 43 | return 44 | } 45 | 46 | assert.NotNil(t, tr, "Template renderer should not be nil") 47 | assert.NotNil(t, tr.t, "Template should be loaded") 48 | } 49 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/auth/types.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | // Package auth contains domain-level types for authentication and sessions. 4 | // It is pure and free of framework/adapter concerns. 5 | 6 | import "time" 7 | 8 | // Role represents an application's authorization role. 9 | // Keep string form for easy persistence and cookies. 10 | // Valid values are defined as constants below. 11 | type Role string 12 | 13 | const ( 14 | RoleAdmin Role = "admin" 15 | RoleUser Role = "user" 16 | RoleGuest Role = "guest" 17 | ) 18 | 19 | // Identity represents the authenticated principal returned by an IdP. 20 | // Adapters map provider-specific claims into this shape. 21 | type Identity struct { 22 | UserID string // stable user identifier (e.g., samAccountName or sub) 23 | FirstName string 24 | LastName string 25 | Email string 26 | Groups []string 27 | ExpiresAt time.Time // absolute expiry from IdP token 28 | } 29 | 30 | // Session is the server-side record we persist for an authenticated user. 31 | // ID is an opaque session identifier (e.g., random URL-safe string). 32 | type Session struct { 33 | ID string `json:"id"` 34 | UserID string `json:"user_id"` 35 | FirstName string `json:"first_name"` 36 | LastName string `json:"last_name"` 37 | Email string `json:"email"` 38 | Role Role `json:"role"` 39 | ExpiresAt time.Time `json:"expires_at"` 40 | } 41 | 42 | // IsGuest returns true if the session role is guest. 43 | func (s Session) IsGuest() bool { return s.Role == RoleGuest } 44 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/bootstrap/auth_test.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | "testing" 7 | 8 | "github.com/target/mmk-ui-api/config" 9 | ) 10 | 11 | func TestBuildAuthServiceReturnsNilWithoutRedis(t *testing.T) { 12 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 13 | 14 | tests := []struct { 15 | name string 16 | auth config.AuthConfig 17 | }{ 18 | { 19 | name: "dev auth mode", 20 | auth: config.AuthConfig{ 21 | Mode: config.AuthModeMock, 22 | AdminGroup: "admins", 23 | UserGroup: "users", 24 | DevAuth: config.DevAuthConfig{ 25 | UserID: "dev", 26 | Email: "dev@example.com", 27 | Groups: []string{"admins"}, 28 | }, 29 | }, 30 | }, 31 | { 32 | name: "oauth mode", 33 | auth: config.AuthConfig{ 34 | Mode: config.AuthModeOAuth, 35 | AdminGroup: "admins", 36 | UserGroup: "users", 37 | OAuth: config.OAuthConfig{ 38 | ClientID: "client-id", 39 | ClientSecret: "client-secret", 40 | DiscoveryURL: "https://issuer.example.com", 41 | RedirectURL: "https://app.example.com/auth/callback", 42 | Scope: "openid", 43 | }, 44 | }, 45 | }, 46 | } 47 | 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | cfg := AuthConfig{ 51 | Auth: tt.auth, 52 | RedisClient: nil, 53 | Logger: logger, 54 | } 55 | 56 | if svc := BuildAuthService(cfg); svc != nil { 57 | t.Fatalf("BuildAuthService() = %v, want nil", svc) 58 | } 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/ports/auth.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | // Package ports defines interfaces (hexagonal ports) for auth-related behavior. 4 | // Implementations live in internal/adapters; orchestration in internal/service. 5 | 6 | import ( 7 | "context" 8 | 9 | domainauth "github.com/target/mmk-ui-api/internal/domain/auth" 10 | ) 11 | 12 | // BeginInput carries inputs for initiating an auth flow. 13 | type BeginInput struct { 14 | RedirectURL string 15 | } 16 | 17 | // AuthProvider initiates and completes an authentication flow against an IdP. 18 | type AuthProvider interface { 19 | // Begin starts the login flow and returns the provider auth URL, an opaque state, and a nonce. 20 | Begin(ctx context.Context, in BeginInput) (authURL, state, nonce string, err error) 21 | 22 | // Exchange completes the login flow, verifying state and nonce, and returns the authenticated identity. 23 | Exchange(ctx context.Context, in ExchangeInput) (domainauth.Identity, error) 24 | } 25 | 26 | // ExchangeInput groups parameters for the code/token exchange. 27 | type ExchangeInput struct { 28 | Code string 29 | State string 30 | Nonce string 31 | } 32 | 33 | // SessionStore persists and retrieves user sessions. 34 | type SessionStore interface { 35 | Save(ctx context.Context, sess domainauth.Session) error 36 | Get(ctx context.Context, id string) (domainauth.Session, error) 37 | Delete(ctx context.Context, id string) error 38 | } 39 | 40 | // RoleMapper maps provider groups to application roles. 41 | type RoleMapper interface { 42 | Map(groups []string) domainauth.Role 43 | } 44 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/testutil/workflowtest/workflow_test.go: -------------------------------------------------------------------------------- 1 | package workflowtest 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // TestCreateSimpleEventBatch tests the event batch creation utility. 11 | func TestCreateSimpleEventBatch(t *testing.T) { 12 | batchID := "test-batch-1" 13 | sessionID := "" 14 | jobID := "job-123" 15 | 16 | batch := CreateSimpleEventBatch(batchID, sessionID, jobID) 17 | 18 | assert.Equal(t, batchID, batch.BatchID) 19 | assert.NotEmpty(t, batch.SessionID) // Should generate a UUID 20 | assert.Len(t, batch.Events, 1) 21 | assert.Equal(t, "event-1", batch.Events[0].ID) 22 | assert.Equal(t, "Network.requestWillBeSent", batch.Events[0].Method) 23 | assert.Equal(t, jobID, batch.BatchMetadata.JobID) 24 | 25 | // Test with provided sessionID 26 | providedSessionID := "550e8400-e29b-41d4-a716-446655440000" 27 | batch2 := CreateSimpleEventBatch("batch-2", providedSessionID, jobID) 28 | assert.Equal(t, providedSessionID, batch2.SessionID) 29 | } 30 | 31 | // TestWorkflowTestOptions tests the option builders. 32 | func TestWorkflowTestOptions(t *testing.T) { 33 | // Test default options 34 | opts := DefaultWorkflowOptions() 35 | assert.False(t, opts.EnableRedis) 36 | assert.Equal(t, 30*time.Second, opts.JobLease) 37 | assert.Equal(t, 1000, opts.EventMaxBatch) 38 | 39 | // Test Redis options 40 | redisOpts := RedisWorkflowOptions() 41 | assert.True(t, redisOpts.EnableRedis) 42 | assert.Equal(t, 30*time.Second, redisOpts.JobLease) 43 | assert.Equal(t, 1000, redisOpts.EventMaxBatch) 44 | } 45 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/observability/metrics/jobs.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "time" 5 | 6 | obserrors "github.com/target/mmk-ui-api/internal/observability/errors" 7 | "github.com/target/mmk-ui-api/internal/observability/statsd" 8 | ) 9 | 10 | // Result constants for metric tagging. 11 | const ( 12 | ResultSuccess = "success" 13 | ResultError = "error" 14 | ResultNoop = "noop" 15 | ) 16 | 17 | // JobMetric captures details about a job lifecycle event for metric emission. 18 | type JobMetric struct { 19 | JobType string 20 | Transition string 21 | Result string 22 | Duration time.Duration 23 | Err error 24 | } 25 | 26 | // EmitJobLifecycle emits standardised job lifecycle metrics. 27 | func EmitJobLifecycle(sink statsd.Sink, in JobMetric) { 28 | if sink == nil { 29 | return 30 | } 31 | 32 | tags := map[string]string{ 33 | "job_type": in.JobType, 34 | "transition": in.Transition, 35 | "result": in.Result, 36 | } 37 | 38 | if in.Err != nil && in.Result == ResultError { 39 | if class := obserrors.Classify(in.Err); class != "" { 40 | tags["error_class"] = class 41 | } 42 | } 43 | 44 | sink.Count("job.transition", 1, tags) 45 | 46 | if in.Duration > 0 { 47 | sink.Timing("job.duration", in.Duration, CloneTags(tags)) 48 | } 49 | } 50 | 51 | // CloneTags creates a shallow copy of a tag map, filtering out empty keys. 52 | func CloneTags(src map[string]string) map[string]string { 53 | if len(src) == 0 { 54 | return nil 55 | } 56 | out := make(map[string]string, len(src)) 57 | for k, v := range src { 58 | out[k] = v 59 | } 60 | return out 61 | } 62 | -------------------------------------------------------------------------------- /services/puppeteer-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "puppeteer-worker", 3 | "version": "1.0.0", 4 | "description": "merrymaker puppeteer worker", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "npm run build && node --test src/*.test.js", 8 | "test:watch": "npm run build:watch & node --test --watch src/*.test.js src/*-test.js", 9 | "lint": "biome lint", 10 | "lint:fix": "biome lint --write", 11 | "format": "biome format --write", 12 | "build": "tsc --emitDeclarationOnly && node scripts/esbuild.mjs", 13 | "build:watch": "node scripts/esbuild.mjs --watch", 14 | "start": "tsx src/index.ts", 15 | "start:dist": "node dist/index.js", 16 | "start:env": "node --env-file=.env dist/index.js", 17 | "worker": "tsx src/worker-main.ts", 18 | "worker:dist": "node dist/worker-main.js", 19 | "worker:env": "node --env-file=.env dist/worker-main.js", 20 | "dev": "tsx --watch src/index.ts", 21 | "examples": "node dist/examples.js", 22 | "demo": "tsx src/index.ts" 23 | }, 24 | "keywords": [], 25 | "author": "", 26 | "license": "ISC", 27 | "type": "module", 28 | "engines": { 29 | "node": ">=24.0.0" 30 | }, 31 | "devDependencies": { 32 | "@biomejs/biome": "2.3.8", 33 | "esbuild": "^0.27.0", 34 | "tsx": "^4.20.4", 35 | "typescript": "^5.9.2" 36 | }, 37 | "dependencies": { 38 | "@aws-sdk/client-s3": "^3.873.0", 39 | "@aws-sdk/s3-request-presigner": "^3.873.0", 40 | "@azure/storage-blob": "^12.28.0", 41 | "@google-cloud/storage": "^7.17.0", 42 | "ioredis": "^5.7.0", 43 | "minio": "^8.0.5", 44 | "puppeteer": "^24.17.0", 45 | "undici": "^7.14.0", 46 | "yaml": "^2.8.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /services/puppeteer-worker/src/file-capture.unit.test.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import { describe, test } from "node:test"; 3 | 4 | // NOTE: Tests import the built artifact. Run `npm run build` before these tests. 5 | import { FileCapture } from "../dist/file-capture.js"; 6 | 7 | function makeMemoryConfig(overrides = {}) { 8 | return { 9 | enabled: true, 10 | types: ["script", "document", "stylesheet"], 11 | maxFileSize: 1024 * 1024, 12 | storage: "memory", 13 | storageConfig: { ...overrides }, 14 | }; 15 | } 16 | 17 | describe("FileCapture in-run dedupe (memory storage)", () => { 18 | test("same-session duplicate uses same fileId and is retrievable", async () => { 19 | const sessionId = `sess-${Math.random().toString(36).slice(2)}`; 20 | const fc = new FileCapture(makeMemoryConfig(), sessionId); 21 | 22 | const content = Buffer.from("same content"); 23 | 24 | const first = await fc.captureFile({ 25 | url: "https://example.com/a.js", 26 | content, 27 | contentType: "application/javascript; charset=utf-8", 28 | sessionId, 29 | }); 30 | 31 | assert.ok(first); 32 | 33 | const second = await fc.captureFile({ 34 | url: "https://example.com/a.js", 35 | content, 36 | contentType: "application/javascript; charset=utf-8", 37 | sessionId, 38 | }); 39 | 40 | assert.ok(second); 41 | assert.equal(second.fileId, first.fileId); 42 | assert.equal(second.metadata.captureReason, "duplicate"); 43 | 44 | const roundTrip = await fc.retrieveFile(first.fileId); 45 | assert.ok(roundTrip); 46 | assert.equal(roundTrip.toString("utf8"), "same content"); 47 | 48 | await fc.cleanup(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/bootstrap/encryption.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "errors" 7 | "log/slog" 8 | 9 | "github.com/target/mmk-ui-api/internal/data/cryptoutil" 10 | ) 11 | 12 | // CreateEncryptor creates an AES-GCM encryptor from the provided key. 13 | // If the key is a hex string, it decodes it. Otherwise, it hashes the key to get a 32-byte key. 14 | // Returns a noop encryptor if the key is empty or invalid (with warning log). 15 | // 16 | //nolint:ireturn // Returning interface is intentional for encryptor abstraction 17 | func CreateEncryptor(key string, logger *slog.Logger) cryptoutil.Encryptor { 18 | if key == "" { 19 | if logger != nil { 20 | logger.Warn("encryption key is empty, using noop encryptor") 21 | } 22 | return &cryptoutil.NoopEncryptor{} 23 | } 24 | 25 | enc, err := createAESGCMEncryptor(key) 26 | if err != nil { 27 | if logger != nil { 28 | logger.Warn("failed to create encryptor, using noop encryptor", "error", err) 29 | } 30 | return &cryptoutil.NoopEncryptor{} 31 | } 32 | 33 | return enc 34 | } 35 | 36 | func createAESGCMEncryptor(key string) (*cryptoutil.AESGCMEncryptor, error) { 37 | if key == "" { 38 | return nil, errors.New("encryption key is required") 39 | } 40 | 41 | // If the key is a hex string, decode it 42 | var keyBytes []byte 43 | if decoded, err := hex.DecodeString(key); err == nil && len(decoded) == 32 { 44 | keyBytes = decoded 45 | } else { 46 | // Otherwise, hash the key to get a 32-byte key 47 | hash := sha256.Sum256([]byte(key)) 48 | keyBytes = hash[:] 49 | } 50 | 51 | return cryptoutil.NewAESGCMEncryptor(keyBytes) 52 | } 53 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/htmx_response.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // HTMXResponse provides a fluent API for building HTMX responses. 8 | type HTMXResponse struct { 9 | w http.ResponseWriter 10 | } 11 | 12 | // HTMX creates a new HTMXResponse for fluent response building. 13 | func HTMX(w http.ResponseWriter) *HTMXResponse { 14 | return &HTMXResponse{w: w} 15 | } 16 | 17 | // Redirect instructs htmx to redirect the browser to the given URL. 18 | // It sets the HX-Redirect header and returns a 204 No Content status. 19 | // The handler should return immediately after calling this method to avoid 20 | // accidental writes that would be ignored. 21 | func (h *HTMXResponse) Redirect(url string) { 22 | SetHXRedirect(h.w, url) 23 | h.w.WriteHeader(http.StatusNoContent) 24 | } 25 | 26 | // Trigger triggers a client-side event after swap with optional payload. 27 | // This method is chainable. 28 | func (h *HTMXResponse) Trigger(event string, payload any) *HTMXResponse { 29 | SetHXTrigger(h.w, event, payload) 30 | return h 31 | } 32 | 33 | // PushURL pushes the given URL into the browser history for the new content. 34 | // This method is chainable. 35 | func (h *HTMXResponse) PushURL(url string) *HTMXResponse { 36 | SetHXPushURL(h.w, url) 37 | return h 38 | } 39 | 40 | // Refresh forces a full page refresh. 41 | // It sets the HX-Refresh header and returns a 204 No Content status. 42 | // The handler should return immediately after calling this method to avoid 43 | // accidental writes that would be ignored. 44 | func (h *HTMXResponse) Refresh() { 45 | SetHXRefresh(h.w, true) 46 | h.w.WriteHeader(http.StatusNoContent) 47 | } 48 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/rules/engine_test.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | type stubRule struct { 12 | id string 13 | apply func(*ProcessingResults) 14 | err error 15 | } 16 | 17 | func (s stubRule) ID() string { 18 | return s.id 19 | } 20 | 21 | func (s stubRule) Evaluate(_ context.Context, _ RuleWorkItem) RuleEvaluation { 22 | eval := RuleEvaluation{RuleID: s.id} 23 | if s.apply != nil { 24 | eval.ApplyFn = s.apply 25 | } 26 | eval.Err = s.err 27 | return eval 28 | } 29 | 30 | func TestDefaultRuleEngineEvaluate(t *testing.T) { 31 | engine := NewRuleEngine([]Rule{ 32 | stubRule{ 33 | id: "ok", 34 | apply: func(results *ProcessingResults) { 35 | results.AlertsCreated += 2 36 | }, 37 | }, 38 | stubRule{ 39 | id: "err", 40 | err: assert.AnError, 41 | }, 42 | nil, 43 | }) 44 | 45 | item := RuleWorkItem{SiteID: "site-1", Scope: "default"} 46 | 47 | evals := engine.Evaluate(context.Background(), item) 48 | require.Len(t, evals, 2) 49 | 50 | require.Equal(t, "ok", evals[0].RuleID) 51 | require.NoError(t, evals[0].Err) 52 | require.NotNil(t, evals[0].ApplyFn) 53 | 54 | require.Equal(t, "err", evals[1].RuleID) 55 | require.Error(t, evals[1].Err) 56 | require.Nil(t, evals[1].ApplyFn) 57 | 58 | results := &ProcessingResults{} 59 | for _, eval := range evals { 60 | if eval.Err != nil { 61 | results.ErrorsEncountered++ 62 | continue 63 | } 64 | eval.Apply(results) 65 | } 66 | 67 | assert.Equal(t, 2, results.AlertsCreated) 68 | assert.Equal(t, 1, results.ErrorsEncountered) 69 | } 70 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/json.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | ) 8 | 9 | // DecodeJSON decodes JSON from the request body into the destination and handles errors. 10 | // Returns true if successful, false if there was an error (error response already written). 11 | func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { 12 | dec := json.NewDecoder(r.Body) 13 | dec.DisallowUnknownFields() 14 | 15 | if err := dec.Decode(dst); err != nil { 16 | WriteError(w, ErrorParams{Code: http.StatusBadRequest, ErrCode: "invalid_json", Err: err}) 17 | return false 18 | } 19 | 20 | return true 21 | } 22 | 23 | // WriteJSON writes a JSON response with the given status code and data. 24 | func WriteJSON(w http.ResponseWriter, code int, v any) { 25 | var buf bytes.Buffer 26 | if err := json.NewEncoder(&buf).Encode(v); err != nil { 27 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 28 | return 29 | } 30 | 31 | w.Header().Set("Content-Type", "application/json") 32 | w.WriteHeader(code) 33 | if _, err := buf.WriteTo(w); err != nil { 34 | // Response writer errors (e.g., client disconnect) can't be recovered from here. 35 | return 36 | } 37 | } 38 | 39 | // ErrorParams groups parameters for WriteError to adhere to the ≤3 params guideline. 40 | type ErrorParams struct { 41 | Code int 42 | ErrCode string 43 | Err error 44 | } 45 | 46 | // WriteError writes a JSON error response using ErrorParams. 47 | func WriteError(w http.ResponseWriter, p ErrorParams) { 48 | WriteJSON(w, p.Code, map[string]string{"error": p.ErrCode, "message": p.Err.Error()}) 49 | } 50 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/service/rules/metrics.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | // Minimal cache metrics hooks for rules caches. 4 | // Keep interface compact and avoid external deps; callers can adapt to Prometheus or other systems. 5 | // Functions must have ≤3 parameters; use a struct for event details. 6 | 7 | type CacheName string 8 | 9 | type CacheTier string 10 | 11 | type CacheOp string 12 | 13 | const ( 14 | CacheSeen CacheName = "seen" 15 | CacheIOC CacheName = "ioc" 16 | CacheFiles CacheName = "files" 17 | ) 18 | 19 | const ( 20 | TierLocal CacheTier = "local" 21 | TierRedis CacheTier = "redis" 22 | TierRepo CacheTier = "repo" 23 | ) 24 | 25 | const ( 26 | OpHit CacheOp = "hit" 27 | OpMiss CacheOp = "miss" 28 | OpWrite CacheOp = "write" 29 | ) 30 | 31 | // CacheEvent is a compact event describing a cache metric occurrence. 32 | // Add fields cautiously to keep it small and general. 33 | // Name: which typed cache (seen/ioc/files) 34 | // Tier: which tier (local/redis/repo) 35 | // Op: hit/miss/write 36 | // Ok: whether operation succeeded (for writes/lookups) 37 | // Note: Not every combination is used by all caches; unused are fine. 38 | 39 | type CacheEvent struct { 40 | Name CacheName 41 | Tier CacheTier 42 | Op CacheOp 43 | Ok bool 44 | } 45 | 46 | // CacheMetrics is an optional hook; implementations may aggregate counters. 47 | // A Noop implementation is provided for convenience. 48 | 49 | type CacheMetrics interface { 50 | RecordCacheEvent(e CacheEvent) 51 | } 52 | 53 | // NoopCacheMetrics is the default when no metrics are provided. 54 | 55 | type NoopCacheMetrics struct{} 56 | 57 | func (NoopCacheMetrics) RecordCacheEvent(_ CacheEvent) {} 58 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/auth.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "signed-out-page" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{.Title}} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 |
23 |

Signed out

24 |

You have been signed out.

25 |
26 | Sign In 28 | Go Home 29 |
30 |
31 |
32 |
33 | 34 | 35 | {{ end }} 36 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/features/sidebar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sidebar controls feature module 3 | * 4 | * Handles sidebar open/close state, accessibility attributes, and responsive 5 | * behaviors such as auto-closing on viewport changes or HTMX swaps. 6 | */ 7 | import { qsa } from "../core/dom.js"; 8 | import { Events } from "../core/events.js"; 9 | import { on } from "../core/htmx-bridge.js"; 10 | 11 | const BREAKPOINT = 1024; 12 | let initialized = false; 13 | 14 | function syncToggleButtons(isOpen) { 15 | qsa("[data-sidebar-toggle]").forEach((btn) => { 16 | btn.setAttribute("aria-expanded", isOpen ? "true" : "false"); 17 | }); 18 | } 19 | 20 | function toggleSidebar() { 21 | document.body.classList.toggle("sidebar-open"); 22 | const isOpen = document.body.classList.contains("sidebar-open"); 23 | syncToggleButtons(isOpen); 24 | } 25 | 26 | function closeSidebar() { 27 | if (!document.body.classList.contains("sidebar-open")) return; 28 | document.body.classList.remove("sidebar-open"); 29 | syncToggleButtons(false); 30 | } 31 | 32 | function registerEventHandlers() { 33 | Events.on("[data-sidebar-toggle]", "click", () => { 34 | toggleSidebar(); 35 | }); 36 | 37 | Events.on("[data-sidebar-close], .sidebar-overlay", "click", () => { 38 | closeSidebar(); 39 | }); 40 | 41 | window.addEventListener("resize", () => { 42 | if (window.innerWidth > BREAKPOINT) { 43 | closeSidebar(); 44 | } 45 | }); 46 | 47 | on("htmx:afterSwap", closeSidebar); 48 | on("htmx:historyRestore", closeSidebar); 49 | } 50 | 51 | export function initSidebar() { 52 | if (initialized) return; 53 | initialized = true; 54 | registerEventHandlers(); 55 | } 56 | 57 | export { closeSidebar }; 58 | -------------------------------------------------------------------------------- /services/merrymaker-go/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:15.14 4 | profiles: ["dev"] 5 | environment: 6 | POSTGRES_DB: merrymaker 7 | POSTGRES_USER: merrymaker 8 | POSTGRES_PASSWORD: merrymaker 9 | ports: 10 | - "5432:5432" 11 | healthcheck: 12 | test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] 13 | interval: 2s 14 | timeout: 5s 15 | retries: 30 16 | volumes: 17 | - pgdata_dev:/var/lib/postgresql/data 18 | 19 | postgres_test: 20 | image: postgres:15.14 21 | profiles: ["test"] 22 | environment: 23 | POSTGRES_DB: merrymaker 24 | POSTGRES_USER: merrymaker 25 | POSTGRES_PASSWORD: merrymaker 26 | ports: 27 | - "55432:5432" 28 | healthcheck: 29 | test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] 30 | interval: 1s 31 | timeout: 5s 32 | retries: 60 33 | volumes: 34 | - pgdata_test:/var/lib/postgresql/data 35 | 36 | redis: 37 | image: redis:7-alpine 38 | profiles: ["dev"] 39 | command: ["redis-server", "--appendonly", "no"] 40 | ports: 41 | - "6379:6379" 42 | healthcheck: 43 | test: ["CMD", "redis-cli", "ping"] 44 | interval: 2s 45 | timeout: 5s 46 | retries: 30 47 | 48 | redis_test: 49 | image: redis:7-alpine 50 | profiles: ["test"] 51 | command: ["redis-server", "--appendonly", "no"] 52 | ports: 53 | - "56379:6379" 54 | healthcheck: 55 | test: ["CMD", "redis-cli", "-h", "127.0.0.1", "ping"] 56 | interval: 1s 57 | timeout: 5s 58 | retries: 60 59 | 60 | 61 | volumes: 62 | pgdata_dev: 63 | pgdata_test: 64 | -------------------------------------------------------------------------------- /services/puppeteer-worker/config/worker.example.yaml: -------------------------------------------------------------------------------- 1 | # Example Worker Configuration 2 | # Copy this file to worker.yaml and customize for your environment 3 | 4 | # Basic Puppeteer settings 5 | headless: true 6 | timeout: 30000 7 | 8 | # Worker configuration for job processing 9 | worker: 10 | apiBaseUrl: "http://localhost:8080" # Base URL for the job queue API 11 | jobType: "browser" # Type of jobs to process: browser or rules 12 | leaseSeconds: 30 # How long to lease jobs (seconds) 13 | waitSeconds: 25 # Long-poll timeout for job reservation (seconds) 14 | heartbeatSeconds: 10 # Interval for sending heartbeats (seconds) 15 | 16 | # File capture settings 17 | fileCapture: 18 | enabled: true 19 | types: [script, document, stylesheet] # Valid types: script, document, stylesheet, other 20 | maxFileSize: 1048576 # 1MB (note: property name is maxFileSize, not maxSize) 21 | storage: memory # Options: memory, redis, cloud 22 | contentTypeMatchers: [ 23 | "application/javascript", 24 | "application/json", 25 | "application/xml" 26 | ] 27 | 28 | # Event shipping configuration 29 | shipping: 30 | endpoint: "http://localhost:8080/api/events/bulk" 31 | batchSize: 100 # Default is 100, not 50 32 | maxBatchAge: 5000 33 | 34 | # Client-side monitoring 35 | clientMonitoring: 36 | enabled: true 37 | events: [storage, dynamicCode] # Valid events: storage, dynamicCode (network is not a valid option) 38 | 39 | # Puppeteer launch options 40 | launch: 41 | args: ["--disable-web-security", "--no-sandbox", "--disable-dev-shm-usage"] 42 | # executablePath: "/usr/bin/chromium" # Uncomment to specify custom Chrome path 43 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/context_helpers.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "context" 5 | 6 | domainauth "github.com/target/mmk-ui-api/internal/domain/auth" 7 | ) 8 | 9 | // sessionKey is an unexported context key type to avoid collisions across packages. 10 | // Centralized in this file so all handlers/middleware use the same key. 11 | type sessionKey struct{} 12 | 13 | // SetSessionInContext returns a child context that carries the given session. 14 | // If session is nil, the original ctx is returned unchanged. 15 | func SetSessionInContext(ctx context.Context, session *domainauth.Session) context.Context { 16 | if session == nil { 17 | return ctx 18 | } 19 | return context.WithValue(ctx, sessionKey{}, session) 20 | } 21 | 22 | // GetUserSessionFromContext returns the user session from context and a boolean indicating presence. 23 | func GetUserSessionFromContext(ctx context.Context) (*domainauth.Session, bool) { 24 | if session, ok := ctx.Value(sessionKey{}).(*domainauth.Session); ok && session != nil { 25 | return session, true 26 | } 27 | return nil, false 28 | } 29 | 30 | // GetSessionFromContext retrieves the session from the request context. 31 | // Maintained for convenience; prefer GetUserSessionFromContext when you need presence info. 32 | func GetSessionFromContext(ctx context.Context) *domainauth.Session { 33 | if s, ok := GetUserSessionFromContext(ctx); ok { 34 | return s 35 | } 36 | return nil 37 | } 38 | 39 | // IsGuestUser reports whether the current request context is unauthenticated or a guest session. 40 | func IsGuestUser(ctx context.Context) bool { 41 | s, ok := GetUserSessionFromContext(ctx) 42 | if !ok || s == nil { 43 | return true 44 | } 45 | return s.IsGuest() 46 | } 47 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/components/screenshot-modal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Screenshot Modal Helper 3 | * 4 | * Specialized modal for displaying screenshots in full size. 5 | * Uses the Modal component for consistent behavior and accessibility. 6 | * 7 | * @example 8 | * ```js 9 | * import { showScreenshotModal } from './components/screenshot-modal.js'; 10 | * 11 | * showScreenshotModal('data:image/png;base64,...', 'Screenshot caption'); 12 | * ``` 13 | */ 14 | 15 | import { h } from "../core/dom.js"; 16 | import { Modal } from "./modal.js"; 17 | 18 | /** 19 | * Show a screenshot in a modal dialog 20 | * @param {string} src - Image source URL or data URI 21 | * @param {string} caption - Optional image caption 22 | * @returns {Modal} The modal instance 23 | */ 24 | export function showScreenshotModal(src, caption = "") { 25 | // Use title for proper ARIA labeling (creates h3 with id="screenshot-modal-title") 26 | return Modal.show({ 27 | id: "screenshot-modal", 28 | title: caption || "Screenshot", 29 | content: h("img", { 30 | src, 31 | alt: caption || "Screenshot", 32 | style: { 33 | maxWidth: "100%", 34 | maxHeight: "70vh", 35 | display: "block", 36 | margin: "0 auto", 37 | }, 38 | onError: (e) => { 39 | const errorMsg = h("p", { 40 | text: "Failed to load image", 41 | style: { 42 | color: "#dc3545", 43 | textAlign: "center", 44 | padding: "20px", 45 | }, 46 | }); 47 | e.target.replaceWith(errorMsg); 48 | }, 49 | }), 50 | buttons: [ 51 | { 52 | text: "Close", 53 | className: "btn btn-secondary", 54 | onClick: (modal) => modal.hide(), 55 | }, 56 | ], 57 | }); 58 | } 59 | 60 | export default showScreenshotModal; 61 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/model/job_query.go: -------------------------------------------------------------------------------- 1 | //revive:disable-next-line:var-naming // legacy package name widely used across the project 2 | package model 3 | 4 | // JobListBySourceOptions groups parameters for listing jobs by source. 5 | type JobListBySourceOptions struct { 6 | SourceID string 7 | Limit int 8 | Offset int 9 | } 10 | 11 | // JobListBySiteOptions groups parameters for listing jobs by site with optional filters. 12 | type JobListBySiteOptions struct { 13 | SiteID *string // Optional filter by site_id 14 | Status *string // Optional filter by status (pending, running, completed, failed) 15 | Type *string // Optional filter by type (browser, rules, alert) 16 | Limit int // Pagination limit 17 | Offset int // Pagination offset 18 | } 19 | 20 | // JobListOptions groups parameters for listing all jobs with optional filters (admin view). 21 | type JobListOptions struct { 22 | Status *JobStatus // Optional filter by status (pending, running, completed, failed) 23 | Type *JobType // Optional filter by type (browser, rules, alert) 24 | SiteID *string // Optional filter by site_id 25 | IsTest *bool // Optional filter by is_test flag 26 | SortBy string // Sort field: "created_at", "status", "type" (default: "created_at") 27 | SortOrder string // Sort order: "asc", "desc" (default: "desc") 28 | Limit int // Pagination limit 29 | Offset int // Pagination offset 30 | } 31 | 32 | // JobWithEventCount represents a job with its associated event count for UI display. 33 | type JobWithEventCount struct { 34 | Job 35 | EventCount int `json:"event_count" db:"event_count"` 36 | SiteName string `json:"site_name,omitempty" db:"site_name"` 37 | } 38 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/base/fonts.css: -------------------------------------------------------------------------------- 1 | /** 2 | * SELF-HOSTED FONTS 3 | * 4 | * Inter font family with font-display: swap to prevent render-blocking. 5 | * Only includes the weights actually used in the application (400, 500, 600). 6 | * 7 | * Note: Using @fontsource package which provides optimized font files. 8 | * The build process copies these to /static/fonts/. 9 | */ 10 | 11 | /* Inter Regular (400) */ 12 | @font-face { 13 | font-family: "Inter"; 14 | src: url("@fontsource/inter/files/inter-latin-400-normal.woff2") format("woff2"); 15 | font-weight: 400; 16 | font-style: normal; 17 | font-display: swap; 18 | unicode-range: 19 | U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, 20 | U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, 21 | U+2215, U+FEFF, U+FFFD; 22 | } 23 | 24 | /* Inter Medium (500) */ 25 | @font-face { 26 | font-family: "Inter"; 27 | src: url("@fontsource/inter/files/inter-latin-500-normal.woff2") format("woff2"); 28 | font-weight: 500; 29 | font-style: normal; 30 | font-display: swap; 31 | unicode-range: 32 | U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, 33 | U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, 34 | U+2215, U+FEFF, U+FFFD; 35 | } 36 | 37 | /* Inter SemiBold (600) */ 38 | @font-face { 39 | font-family: "Inter"; 40 | src: url("@fontsource/inter/files/inter-latin-600-normal.woff2") format("woff2"); 41 | font-weight: 600; 42 | font-style: normal; 43 | font-display: swap; 44 | unicode-range: 45 | U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, 46 | U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, 47 | U+2215, U+FEFF, U+FFFD; 48 | } 49 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/pages/auth.css: -------------------------------------------------------------------------------- 1 | /* Page: Auth */ 2 | 3 | .auth-page { 4 | max-width: 720px; 5 | margin: var(--space-12) auto; 6 | padding: 0 var(--space-6); 7 | } 8 | .page-title { 9 | font-size: 2rem; 10 | font-weight: var(--font-weight-semibold); 11 | } 12 | .page-subtitle { 13 | margin-top: var(--space-3); 14 | color: var(--color-text-secondary); 15 | } 16 | .auth-page .actions, 17 | .auth-actions { 18 | margin-top: var(--space-5); 19 | display: flex; 20 | gap: var(--space-3); 21 | align-items: center; 22 | } 23 | 24 | /* Connection status indicators for polling */ 25 | .connection-status { 26 | font-size: var(--font-size-sm); 27 | font-weight: var(--font-weight-medium); 28 | padding: var(--space-1) var(--space-2); 29 | border-radius: var(--border-radius); 30 | display: inline-block; 31 | } 32 | .connection-status.connecting { 33 | color: var(--color-brand-primary); 34 | background-color: var(--color-brand-primary-subtle); 35 | } 36 | .connection-status.connected { 37 | color: var(--color-success); 38 | background-color: var(--color-success-subtle, hsl(150, 60%, 95%)); 39 | } 40 | .connection-status.error { 41 | color: var(--color-danger); 42 | background-color: var(--color-danger-subtle, hsl(0, 70%, 95%)); 43 | } 44 | .connection-status.waiting { 45 | color: var(--color-text-muted); 46 | background-color: var(--color-background); 47 | } 48 | .connection-status.stopped { 49 | color: var(--color-warning); 50 | background-color: hsl(38, 92%, 95%); 51 | } 52 | 53 | /* Polling control buttons */ 54 | .stop-polling-btn, 55 | .retry-btn { 56 | font-size: var(--font-size-sm); 57 | padding: var(--space-1) var(--space-2); 58 | margin-left: var(--space-2); 59 | } 60 | .status-text { 61 | font-weight: var(--font-weight-medium); 62 | } 63 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/components/cards.css: -------------------------------------------------------------------------------- 1 | /* Component: Card */ 2 | 3 | .card { 4 | background-color: var(--color-surface); 5 | border: var(--border-width) solid var(--color-border); 6 | border-radius: var(--border-radius-lg); 7 | padding: var(--space-6); 8 | box-shadow: var(--shadow-sm); 9 | transition: box-shadow 0.2s ease; 10 | } 11 | .card:hover { 12 | box-shadow: var(--shadow-md); 13 | } 14 | 15 | .card h3, 16 | .card-title { 17 | margin: 0 0 var(--space-3); 18 | font-weight: var(--font-weight-semibold); 19 | font-size: var(--font-size-md); 20 | line-height: 1.2; 21 | padding: 0; 22 | } 23 | .card-subtitle { 24 | margin-top: var(--space-1); 25 | color: var(--color-text-secondary); 26 | font-size: var(--font-size-sm); 27 | } 28 | .card-body > * + * { 29 | margin-top: var(--space-3); 30 | } 31 | 32 | /* Card header with icon and title */ 33 | .card-header { 34 | display: flex; 35 | align-items: center; 36 | gap: var(--space-2); 37 | margin-bottom: var(--space-2); 38 | } 39 | 40 | .card-header .card-title { 41 | margin: 0; 42 | } 43 | 44 | .card-header-icon { 45 | width: var(--icon-size-sm); 46 | height: var(--icon-size-sm); 47 | color: var(--color-brand-primary); 48 | flex-shrink: 0; 49 | } 50 | 51 | /* Card header with space-between layout (e.g., title on left, action on right) */ 52 | .card-header-between { 53 | display: flex; 54 | align-items: center; 55 | justify-content: space-between; 56 | margin-bottom: var(--space-3); 57 | } 58 | 59 | /* When .card-header is nested inside .card-header-between, remove its bottom margin */ 60 | .card-header-between .card-header { 61 | margin-bottom: 0; 62 | } 63 | 64 | /* Button group with gap */ 65 | .btn-group { 66 | margin-top: var(--space-4); 67 | display: flex; 68 | gap: var(--space-2); 69 | } 70 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/data/time_provider.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "time" 4 | 5 | // TimeProvider provides time-related functionality that can be mocked for testing. 6 | type TimeProvider interface { 7 | // Now returns the current time 8 | Now() time.Time 9 | // FormatForDB formats a time for database insertion 10 | FormatForDB(t time.Time) string 11 | } 12 | 13 | // RealTimeProvider implements TimeProvider using real system time. 14 | type RealTimeProvider struct{} 15 | 16 | // Now returns the current system time. 17 | func (r *RealTimeProvider) Now() time.Time { 18 | return time.Now() 19 | } 20 | 21 | // FormatForDB formats a time for PostgreSQL insertion. 22 | func (r *RealTimeProvider) FormatForDB(t time.Time) string { 23 | return t.UTC().Format(time.RFC3339) 24 | } 25 | 26 | // FixedTimeProvider implements TimeProvider with a fixed time for testing. 27 | type FixedTimeProvider struct { 28 | fixedTime time.Time 29 | } 30 | 31 | // NewFixedTimeProvider creates a new FixedTimeProvider with the given time. 32 | func NewFixedTimeProvider(t time.Time) *FixedTimeProvider { 33 | return &FixedTimeProvider{fixedTime: t} 34 | } 35 | 36 | // Now returns the fixed time. 37 | func (f *FixedTimeProvider) Now() time.Time { 38 | return f.fixedTime 39 | } 40 | 41 | // FormatForDB formats the fixed time for PostgreSQL insertion. 42 | func (f *FixedTimeProvider) FormatForDB(t time.Time) string { 43 | return t.UTC().Format(time.RFC3339) 44 | } 45 | 46 | // SetTime updates the fixed time (useful for testing time progression). 47 | func (f *FixedTimeProvider) SetTime(t time.Time) { 48 | f.fixedTime = t 49 | } 50 | 51 | // AddTime adds a duration to the current fixed time. 52 | func (f *FixedTimeProvider) AddTime(d time.Duration) { 53 | f.fixedTime = f.fixedTime.Add(d) 54 | } 55 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/core/secrets_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/target/mmk-ui-api/internal/domain/model" 11 | ) 12 | 13 | func TestResolveSecretPlaceholders(t *testing.T) { 14 | ctx := context.Background() 15 | 16 | t.Run("no repo or secrets returns content", func(t *testing.T) { 17 | out, err := ResolveSecretPlaceholders(ctx, nil, nil, "hello") 18 | require.NoError(t, err) 19 | assert.Equal(t, "hello", out) 20 | }) 21 | 22 | t.Run("replaces placeholders", func(t *testing.T) { 23 | repo := newStubSecretRepo(map[string]*model.Secret{ 24 | "TOKEN": {Name: "TOKEN", Value: "abc123"}, 25 | }, nil) 26 | out, err := ResolveSecretPlaceholders(ctx, repo, []string{"TOKEN"}, "Bearer __TOKEN__") 27 | require.NoError(t, err) 28 | assert.Equal(t, "Bearer abc123", out) 29 | }) 30 | 31 | t.Run("skips missing placeholders", func(t *testing.T) { 32 | repo := newStubSecretRepo(map[string]*model.Secret{ 33 | "TOKEN": {Name: "TOKEN", Value: "abc123"}, 34 | }, nil) 35 | out, err := ResolveSecretPlaceholders(ctx, repo, []string{"TOKEN"}, "No secrets here") 36 | require.NoError(t, err) 37 | assert.Equal(t, "No secrets here", out) 38 | }) 39 | 40 | t.Run("propagates repo error", func(t *testing.T) { 41 | repo := newStubSecretRepo(nil, errors.New("boom")) 42 | out, err := ResolveSecretPlaceholders(ctx, repo, []string{"TOKEN"}, "__TOKEN__") 43 | require.Error(t, err) 44 | assert.Empty(t, out) 45 | }) 46 | 47 | t.Run("error when repo missing", func(t *testing.T) { 48 | out, err := ResolveSecretPlaceholders(ctx, nil, []string{"TOKEN"}, "__TOKEN__") 49 | require.Error(t, err) 50 | assert.Empty(t, out) 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/uiutil/uiutil.go: -------------------------------------------------------------------------------- 1 | package uiutil 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | const FriendlyDateTimeLayout = "Jan 2, 2006 3:04 PM" 10 | 11 | // FriendlyRelativeTime returns a human-friendly description of how long ago t occurred. 12 | // Times in the future are treated as "just now" to avoid confusing negative durations. 13 | func FriendlyRelativeTime(t time.Time) string { 14 | diff := time.Since(t) 15 | if diff < 0 { 16 | return "just now" 17 | } 18 | 19 | switch { 20 | case diff < time.Minute: 21 | return "just now" 22 | case diff < time.Hour: 23 | mins := int(diff.Minutes()) 24 | if mins == 1 { 25 | return "1 minute ago" 26 | } 27 | return strconv.Itoa(mins) + " minutes ago" 28 | case diff < 24*time.Hour: 29 | hours := int(diff.Hours()) 30 | if hours == 1 { 31 | return "1 hour ago" 32 | } 33 | return strconv.Itoa(hours) + " hours ago" 34 | case diff < 7*24*time.Hour: 35 | days := int(diff.Hours() / 24) 36 | if days == 1 { 37 | return "1 day ago" 38 | } 39 | return strconv.Itoa(days) + " days ago" 40 | default: 41 | return FormatFriendlyDateTime(t) 42 | } 43 | } 44 | 45 | // FormatFriendlyDateTime returns a consistent, user-friendly local timestamp representation. 46 | func FormatFriendlyDateTime(t time.Time) string { 47 | if t.IsZero() { 48 | return "" 49 | } 50 | return t.Local().Format(FriendlyDateTimeLayout) 51 | } 52 | 53 | // TruncateWithEllipsis shortens text to the provided rune limit and appends an ellipsis when truncated. 54 | func TruncateWithEllipsis(text string, limit int) string { 55 | runes := []rune(text) 56 | if len(runes) <= limit { 57 | return text 58 | } 59 | if limit <= 1 { 60 | return "…" 61 | } 62 | return strings.TrimSpace(string(runes[:limit-1])) + "…" 63 | } 64 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/rules/request_test.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import "testing" 4 | 5 | func TestEnqueueJobRequestValidate(t *testing.T) { 6 | t.Parallel() 7 | 8 | testCases := []struct { 9 | name string 10 | req EnqueueJobRequest 11 | wantErr bool 12 | }{ 13 | { 14 | name: "valid request", 15 | req: EnqueueJobRequest{ 16 | EventIDs: []string{"1", "2"}, 17 | SiteID: "site", 18 | Scope: "scope", 19 | Priority: 10, 20 | }, 21 | }, 22 | { 23 | name: "missing event ids", 24 | req: EnqueueJobRequest{ 25 | SiteID: "site", 26 | Scope: "scope", 27 | }, 28 | wantErr: true, 29 | }, 30 | { 31 | name: "missing site id", 32 | req: EnqueueJobRequest{ 33 | EventIDs: []string{"1"}, 34 | Scope: "scope", 35 | }, 36 | wantErr: true, 37 | }, 38 | { 39 | name: "missing scope", 40 | req: EnqueueJobRequest{ 41 | EventIDs: []string{"1"}, 42 | SiteID: "site", 43 | }, 44 | wantErr: true, 45 | }, 46 | { 47 | name: "priority too low", 48 | req: EnqueueJobRequest{ 49 | EventIDs: []string{"1"}, 50 | SiteID: "site", 51 | Scope: "scope", 52 | Priority: -1, 53 | }, 54 | wantErr: true, 55 | }, 56 | { 57 | name: "priority too high", 58 | req: EnqueueJobRequest{ 59 | EventIDs: []string{"1"}, 60 | SiteID: "site", 61 | Scope: "scope", 62 | Priority: 101, 63 | }, 64 | wantErr: true, 65 | }, 66 | } 67 | 68 | for _, tc := range testCases { 69 | t.Run(tc.name, func(t *testing.T) { 70 | t.Parallel() 71 | err := tc.req.Validate() 72 | if tc.wantErr && err == nil { 73 | t.Fatalf("expected error, got nil") 74 | } 75 | if !tc.wantErr && err != nil { 76 | t.Fatalf("unexpected error: %v", err) 77 | } 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/service/scheduler_job_type_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/target/mmk-ui-api/internal/domain/model" 8 | ) 9 | 10 | func TestDetermineJobTypeFromTaskName(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | taskName string 14 | expectedType model.JobType 15 | expectedOk bool 16 | }{ 17 | { 18 | name: "secret refresh task", 19 | taskName: "secret-refresh:abc123", 20 | expectedType: model.JobTypeSecretRefresh, 21 | expectedOk: true, 22 | }, 23 | { 24 | name: "secret refresh task with UUID", 25 | taskName: "secret-refresh:550e8400-e29b-41d4-a716-446655440000", 26 | expectedType: model.JobTypeSecretRefresh, 27 | expectedOk: true, 28 | }, 29 | { 30 | name: "site task (no specific type)", 31 | taskName: "site:abc123", 32 | expectedType: "", 33 | expectedOk: false, 34 | }, 35 | { 36 | name: "generic task (no specific type)", 37 | taskName: "some-other-task", 38 | expectedType: "", 39 | expectedOk: false, 40 | }, 41 | { 42 | name: "empty task name", 43 | taskName: "", 44 | expectedType: "", 45 | expectedOk: false, 46 | }, 47 | { 48 | name: "task name too short to be secret-refresh", 49 | taskName: "secret-refresh", 50 | expectedType: "", 51 | expectedOk: false, 52 | }, 53 | } 54 | 55 | for _, tt := range tests { 56 | t.Run(tt.name, func(t *testing.T) { 57 | gotType, gotOk := determineJobTypeFromTaskName(tt.taskName) 58 | assert.Equal(t, tt.expectedOk, gotOk, "ok value mismatch") 59 | if tt.expectedOk { 60 | assert.Equal(t, tt.expectedType, gotType, "job type mismatch") 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/rules/engine.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import "context" 4 | 5 | // Rule evaluates a work item and returns a RuleEvaluation describing the outcome. 6 | type Rule interface { 7 | ID() string 8 | Evaluate(ctx context.Context, item RuleWorkItem) RuleEvaluation 9 | } 10 | 11 | // RuleFunc adapts a simple function to the Rule interface. 12 | type RuleFunc func(ctx context.Context, item RuleWorkItem) RuleEvaluation 13 | 14 | // Evaluate executes f(ctx, item). 15 | func (f RuleFunc) Evaluate(ctx context.Context, item RuleWorkItem) RuleEvaluation { 16 | if f == nil { 17 | return RuleEvaluation{} 18 | } 19 | return f(ctx, item) 20 | } 21 | 22 | // DefaultRuleEngine executes a collection of rules for a given work item. 23 | type DefaultRuleEngine struct { 24 | rules []Rule 25 | } 26 | 27 | // NewRuleEngine constructs a DefaultRuleEngine from the supplied rules, filtering nil entries. 28 | func NewRuleEngine(rules []Rule) *DefaultRuleEngine { 29 | filtered := make([]Rule, 0, len(rules)) 30 | for _, rule := range rules { 31 | if rule != nil { 32 | filtered = append(filtered, rule) 33 | } 34 | } 35 | return &DefaultRuleEngine{rules: filtered} 36 | } 37 | 38 | // Evaluate runs each configured rule and returns their individual evaluations. 39 | func (e *DefaultRuleEngine) Evaluate(ctx context.Context, item RuleWorkItem) []RuleEvaluation { 40 | if e == nil || len(e.rules) == 0 { 41 | return nil 42 | } 43 | 44 | evaluations := make([]RuleEvaluation, 0, len(e.rules)) 45 | for _, rule := range e.rules { 46 | if rule == nil { 47 | continue 48 | } 49 | eval := rule.Evaluate(ctx, item) 50 | if eval.RuleID == "" { 51 | eval.RuleID = rule.ID() 52 | } 53 | evaluations = append(evaluations, eval) 54 | } 55 | return evaluations 56 | } 57 | 58 | var _ RuleEngine = (*DefaultRuleEngine)(nil) 59 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/data/job_repo.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "log/slog" 7 | ) 8 | 9 | var ( 10 | // ErrJobNotFound is returned when a job is not found. 11 | ErrJobNotFound = errors.New("job not found") 12 | // ErrJobNotDeletable is returned when attempting to delete a job that is not in a deletable state. 13 | ErrJobNotDeletable = errors.New("job cannot be deleted (must be in pending, completed, or failed status)") 14 | // ErrJobReserved is returned when attempting to delete a job that has an active lease. 15 | ErrJobReserved = errors.New("job is reserved and cannot be deleted") 16 | ) 17 | 18 | // RepoConfig holds configuration options for the job repository. 19 | type RepoConfig struct { 20 | RetryDelaySeconds int 21 | Logger *slog.Logger 22 | TimeProvider TimeProvider 23 | } 24 | 25 | // JobRepo provides database operations for job management. 26 | type JobRepo struct { 27 | DB *sql.DB 28 | cfg RepoConfig 29 | timeProvider TimeProvider 30 | logger *slog.Logger 31 | } 32 | 33 | // NewJobRepo creates a new JobRepo instance with the given database connection and configuration. 34 | func NewJobRepo(db *sql.DB, cfg RepoConfig) *JobRepo { 35 | tp := cfg.TimeProvider 36 | if tp == nil { 37 | tp = &RealTimeProvider{} 38 | } 39 | 40 | return &JobRepo{ 41 | DB: db, 42 | cfg: cfg, 43 | timeProvider: tp, 44 | logger: cfg.Logger, 45 | } 46 | } 47 | 48 | const jobColumns = ` 49 | id, 50 | type, 51 | status, 52 | priority, 53 | payload, 54 | metadata, 55 | session_id, 56 | site_id, 57 | source_id, 58 | is_test, 59 | scheduled_at, 60 | started_at, 61 | completed_at, 62 | retry_count, 63 | max_retries, 64 | last_error, 65 | lease_expires_at, 66 | created_at, 67 | updated_at 68 | ` 69 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/pagination.tmpl: -------------------------------------------------------------------------------- 1 | {{define "pagination"}} 2 |
3 |
4 | {{if and .StartIndex .EndIndex}} 5 | Showing {{formatNumber .StartIndex}}–{{formatNumber .EndIndex}}{{if .TotalCount}} of {{formatNumber .TotalCount}}{{end}} 6 | {{else}} 7 |   8 | {{end}} 9 |
10 |
11 | {{if .HasPrev}} 12 | 19 | 20 | Prev 21 | 22 | {{else}} 23 | 24 | 25 | Prev 26 | 27 | {{end}} 28 | {{if .HasNext}} 29 | 36 | Next 37 | 38 | 39 | {{else}} 40 | 41 | Next 42 | 43 | 44 | {{end}} 45 |
46 |
47 | {{end}} 48 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/events/screenshot_event.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "screenshot-event-summary" -}} 2 | {{$data := .EventData | parseEventData}} 3 | {{$caption := index $data "caption"}} 4 | {{if $caption}} 5 | {{truncateText $caption 48}} 6 | {{else}} 7 | Screenshot captured 8 | {{end}} 9 | {{- end}} 10 | 11 | {{- define "screenshot-event-details" -}} 12 | {{$data := .EventData | parseEventData}} 13 | {{$image := index $data "image"}} 14 | {{$mime := index $data "mime"}} 15 | {{$caption := index $data "caption"}} 16 |
17 | {{if $image}} 18 | {{/* Render screenshot thumbnail with modal functionality */}} 19 |
20 | {{if $caption}} 21 |
{{$caption}}
22 | {{end}} 23 | Screenshot 35 | 36 |
37 | {{else}} 38 |
Screenshot (no image data)
39 | {{end}} 40 |
41 | {{- end}} 42 | 43 | {{- define "screenshot-event" -}} 44 | {{template "screenshot-event-details" .}} 45 | {{- end}} 46 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/rules/helpers.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import "strings" 4 | 5 | func appendSample(samples *[]string, domain string) { 6 | if samples == nil { 7 | return 8 | } 9 | domain = strings.TrimSpace(domain) 10 | if domain == "" { 11 | return 12 | } 13 | for _, existing := range *samples { 14 | if strings.EqualFold(existing, domain) { 15 | return 16 | } 17 | } 18 | if len(*samples) < MetricsSampleLimit { 19 | *samples = append(*samples, domain) 20 | } 21 | } 22 | 23 | // MergeUnknownDomainMetrics merges src into dst, preserving sample limits. 24 | func MergeUnknownDomainMetrics(dst *UnknownDomainMetrics, src UnknownDomainMetrics) { 25 | if dst == nil { 26 | return 27 | } 28 | dst.Alerted.Merge(src.Alerted) 29 | dst.AlertedDryRun.Merge(src.AlertedDryRun) 30 | dst.AlertedMuted.Merge(src.AlertedMuted) 31 | dst.SuppressedAllowlist.Merge(src.SuppressedAllowlist) 32 | dst.SuppressedSeen.Merge(src.SuppressedSeen) 33 | dst.SuppressedDedupe.Merge(src.SuppressedDedupe) 34 | dst.NormalizationFailed.Merge(src.NormalizationFailed) 35 | dst.Errors.Merge(src.Errors) 36 | } 37 | 38 | // MergeIOCMetrics merges src into dst, preserving sample limits. 39 | func MergeIOCMetrics(dst *IOCMetrics, src IOCMetrics) { 40 | if dst == nil { 41 | return 42 | } 43 | dst.Matches.Merge(src.Matches) 44 | dst.MatchesDryRun.Merge(src.MatchesDryRun) 45 | dst.Alerts.Merge(src.Alerts) 46 | dst.AlertsMuted.Merge(src.AlertsMuted) 47 | } 48 | 49 | // AppendUniqueLower appends a lower-cased value when not already present. 50 | func AppendUniqueLower(list *[]string, value string) { 51 | if list == nil { 52 | return 53 | } 54 | v := strings.ToLower(strings.TrimSpace(value)) 55 | if v == "" { 56 | return 57 | } 58 | for _, existing := range *list { 59 | if existing == v { 60 | return 61 | } 62 | } 63 | *list = append(*list, v) 64 | } 65 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/core/secret_repository_stub_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/target/mmk-ui-api/internal/domain/model" 8 | ) 9 | 10 | // stubSecretRepo provides a minimal SecretRepository implementation for tests. 11 | type stubSecretRepo struct { 12 | values map[string]*model.Secret 13 | err error 14 | } 15 | 16 | func newStubSecretRepo(values map[string]*model.Secret, err error) *stubSecretRepo { 17 | return &stubSecretRepo{values: values, err: err} 18 | } 19 | 20 | func (s *stubSecretRepo) Create(context.Context, model.CreateSecretRequest) (*model.Secret, error) { 21 | return nil, errors.New("not implemented") 22 | } 23 | 24 | func (s *stubSecretRepo) GetByID(context.Context, string) (*model.Secret, error) { 25 | return nil, errors.New("not implemented") 26 | } 27 | 28 | func (s *stubSecretRepo) GetByName(_ context.Context, name string) (*model.Secret, error) { 29 | if s.err != nil { 30 | return nil, s.err 31 | } 32 | if secret, ok := s.values[name]; ok { 33 | return secret, nil 34 | } 35 | return nil, errors.New("secret not found") 36 | } 37 | 38 | func (s *stubSecretRepo) List(context.Context, int, int) ([]*model.Secret, error) { 39 | return nil, errors.New("not implemented") 40 | } 41 | 42 | func (s *stubSecretRepo) Update(context.Context, string, model.UpdateSecretRequest) (*model.Secret, error) { 43 | return nil, errors.New("not implemented") 44 | } 45 | 46 | func (s *stubSecretRepo) Delete(context.Context, string) (bool, error) { 47 | return false, errors.New("not implemented") 48 | } 49 | 50 | func (s *stubSecretRepo) FindDueForRefresh(context.Context, int) ([]*model.Secret, error) { 51 | return nil, errors.New("not implemented") 52 | } 53 | 54 | func (s *stubSecretRepo) UpdateRefreshStatus(context.Context, UpdateSecretRefreshStatusParams) error { 55 | return errors.New("not implemented") 56 | } 57 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/domain/job/lease_policy_test.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestNewLeasePolicy(t *testing.T) { 12 | t.Run("success", func(t *testing.T) { 13 | policy, err := NewLeasePolicy(30 * time.Second) 14 | require.NoError(t, err) 15 | assert.Equal(t, 30*time.Second, policy.Default()) 16 | }) 17 | 18 | t.Run("invalid default lease", func(t *testing.T) { 19 | policy, err := NewLeasePolicy(0) 20 | require.ErrorIs(t, err, ErrInvalidDefaultLease) 21 | assert.Nil(t, policy) 22 | }) 23 | } 24 | 25 | func TestLeasePolicy_Resolve(t *testing.T) { 26 | policy, err := NewLeasePolicy(30 * time.Second) 27 | require.NoError(t, err) 28 | 29 | t.Run("explicit duration uses whole seconds", func(t *testing.T) { 30 | decision := policy.Resolve(45 * time.Second) 31 | assert.Equal(t, 45, decision.Seconds) 32 | assert.Equal(t, LeaseSourceExplicit, decision.Source) 33 | assert.False(t, decision.Clamped()) 34 | }) 35 | 36 | t.Run("default duration when request is zero", func(t *testing.T) { 37 | decision := policy.Resolve(0) 38 | assert.Equal(t, 30, decision.Seconds) 39 | assert.Equal(t, LeaseSourceDefault, decision.Source) 40 | assert.True(t, decision.UsedDefault()) 41 | }) 42 | 43 | t.Run("sub-second duration clamps to minimum", func(t *testing.T) { 44 | decision := policy.Resolve(500 * time.Millisecond) 45 | assert.Equal(t, 1, decision.Seconds) 46 | assert.Equal(t, LeaseSourceClamped, decision.Source) 47 | assert.True(t, decision.Clamped()) 48 | }) 49 | 50 | t.Run("negative duration clamps to minimum", func(t *testing.T) { 51 | decision := policy.Resolve(-5 * time.Second) 52 | assert.Equal(t, 1, decision.Seconds) 53 | assert.Equal(t, LeaseSourceClamped, decision.Source) 54 | assert.True(t, decision.Clamped()) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /services/puppeteer-worker/src/file-capture.content-type.test.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import { describe, test } from "node:test"; 3 | 4 | // NOTE: Tests import the built artifact. Run `npm run build` before these tests. 5 | import { FileCapture } from "../dist/file-capture.js"; 6 | 7 | function makeMemoryConfig(types = ["document"]) { 8 | return { 9 | enabled: true, 10 | types, 11 | maxFileSize: 1024 * 1024, 12 | storage: "memory", 13 | storageConfig: {}, 14 | }; 15 | } 16 | 17 | describe("FileCapture content-type coverage", () => { 18 | test("captures JSON as document", async () => { 19 | const sessionId = `sess-json-${Math.random().toString(36).slice(2)}`; 20 | const fc = new FileCapture(makeMemoryConfig(["document"]), sessionId); 21 | 22 | const content = Buffer.from('{"ok":true}'); 23 | 24 | const ctx = await fc.captureFile({ 25 | url: "https://example.com/data.json", 26 | content, 27 | contentType: "application/json; charset=utf-8", 28 | sessionId, 29 | }); 30 | 31 | assert.ok(ctx, "expected JSON to be captured"); 32 | assert.equal(ctx.contentType.includes("application/json"), true); 33 | 34 | await fc.cleanup(); 35 | }); 36 | 37 | test("captures XML (+xml) as document", async () => { 38 | const sessionId = `sess-xml-${Math.random().toString(36).slice(2)}`; 39 | const fc = new FileCapture(makeMemoryConfig(["document"]), sessionId); 40 | 41 | const content = Buffer.from(''); 42 | 43 | const ctx = await fc.captureFile({ 44 | url: "https://example.com/feed", 45 | content, 46 | contentType: "application/rss+xml; charset=utf-8", 47 | sessionId, 48 | }); 49 | 50 | assert.ok(ctx, "expected XML to be captured"); 51 | assert.equal( 52 | ctx.contentType.includes("+xml") || ctx.contentType.includes("xml"), 53 | true, 54 | ); 55 | 56 | await fc.cleanup(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/job_events_pagination.tmpl: -------------------------------------------------------------------------------- 1 | {{define "job-events-pagination"}} 2 |
3 |
4 | {{if and .StartIndex .EndIndex}} 5 | Showing {{formatNumber .StartIndex}}–{{formatNumber .EndIndex}}{{if .TotalCount}} of {{formatNumber .TotalCount}}{{end}} 6 | {{else}} 7 |   8 | {{end}} 9 |
10 |
11 | {{if .HasPrev}} 12 | 19 | 20 | Prev 21 | 22 | {{else}} 23 | 24 | 25 | Prev 26 | 27 | {{end}} 28 | {{if .HasNext}} 29 | 36 | Next 37 | 38 | 39 | {{else}} 40 | 41 | Next 42 | 43 | 44 | {{end}} 45 |
46 |
47 | {{end}} 48 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/features/row-nav.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Row navigation feature module 3 | * 4 | * Provides delegated handling for clickable table/list rows. Elements marked 5 | * with `.row-link` will trigger navigation when activated via click or keyboard, 6 | * unless an ancestor has `data-stop-row-nav` set. 7 | * 8 | * Supported attributes: 9 | * - data-row-nav-target: optional selector defining the element to activate. 10 | * Defaults to the row element itself. 11 | */ 12 | import { Events } from "../core/events.js"; 13 | 14 | let initialized = false; 15 | 16 | function getNavTarget(row) { 17 | const selector = row.getAttribute("data-row-nav-target"); 18 | if (!selector) return row; 19 | return row.querySelector(selector) || row; 20 | } 21 | 22 | function triggerNavigation(target) { 23 | if (!target) return; 24 | if (window.htmx && typeof window.htmx.trigger === "function") { 25 | window.htmx.trigger(target, "row-nav"); 26 | return; 27 | } 28 | 29 | if (typeof target.click === "function") { 30 | target.click(); 31 | } 32 | } 33 | 34 | function isInteractiveControl(event) { 35 | const control = event.target?.closest("[data-stop-row-nav], button, a, input, select, textarea"); 36 | return Boolean(control); 37 | } 38 | 39 | function registerHandlers() { 40 | Events.on(".row-link", "click", (event, row) => { 41 | // Ignore clicks on interactive controls inside the row 42 | if (isInteractiveControl(event)) return; 43 | 44 | triggerNavigation(getNavTarget(row)); 45 | }); 46 | 47 | Events.on(".row-link", "keydown", (event, row) => { 48 | if (event.key !== "Enter" && event.key !== " ") return; 49 | 50 | // Allow space/enter to activate interactive children normally 51 | if (isInteractiveControl(event)) return; 52 | 53 | event.preventDefault(); 54 | triggerNavigation(getNavTarget(row)); 55 | }); 56 | } 57 | 58 | export function initRowNav() { 59 | if (initialized) return; 60 | initialized = true; 61 | registerHandlers(); 62 | } 63 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/styles/index-modular.css: -------------------------------------------------------------------------------- 1 | /** 2 | * MERRYMAKER FRONTEND STYLES - MODULAR ARCHITECTURE 3 | * 4 | * This is the main entry point for all CSS styles. 5 | * It imports all modular CSS files in the correct order. 6 | * 7 | * Order matters: 8 | * 1. Fonts (self-hosted with font-display: swap) 9 | * 2. Base styles (variables, reset, typography) 10 | * 3. Layout styles (grid, header, responsive) 11 | * 4. Components (buttons, forms, tables, etc.) 12 | * 5. Pages (specific page styles) 13 | * 6. Utilities (helper classes) 14 | */ 15 | 16 | /* === FONTS === */ 17 | @import "./base/fonts.css"; 18 | 19 | /* === BASE STYLES === */ 20 | @import "./base/variables.css"; 21 | @import "./base/reset.css"; 22 | 23 | /* === LAYOUT === */ 24 | /* Dashboard layout will be created separately */ 25 | 26 | /* === COMPONENTS === */ 27 | @import "./components/buttons.css"; 28 | @import "./components/forms.css"; 29 | @import "./components/filters.css"; 30 | 31 | /* === MODULAR EXTENSIONS (override legacy block) === */ 32 | @import "./layout/dashboard.css"; 33 | @import "./components/cards.css"; 34 | @import "./components/dashboard-stats.css"; 35 | @import "./components/tables.css"; 36 | @import "./components/pagination.css"; 37 | @import "./components/alerts.css"; 38 | @import "./components/badges.css"; 39 | @import "./components/job-status.css"; 40 | @import "./components/job-detail.css"; 41 | @import "./components/spinner.css"; 42 | @import "./components/lists.css"; 43 | @import "./components/toolbar.css"; 44 | @import "./components/events.css"; 45 | @import "./components/failure-event.css"; 46 | @import "./components/user-toggle.css"; 47 | @import "./components/toast.css"; 48 | @import "./components/ioc-mode-toggle.css"; 49 | @import "./pages/auth.css"; 50 | @import "./pages/sites.css"; 51 | @import "./utilities/width.css"; 52 | @import "./utilities/text.css"; 53 | @import "./utilities/spacing.css"; 54 | @import "./utilities/flex.css"; 55 | @import "./utilities/layout.css"; 56 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/data/export_contract_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/target/mmk-ui-api/internal/core" 8 | ) 9 | 10 | var ( 11 | _ core.JobRepository = (*JobRepo)(nil) 12 | _ core.JobRepositoryTx = (*JobRepo)(nil) 13 | ) 14 | 15 | func TestJobRepoExportedMethodsMatchAllowlist(t *testing.T) { 16 | allowed := map[string]struct{}{ 17 | "Complete": {}, 18 | "CountAggregatesBySources": {}, 19 | "CountBrowserBySource": {}, 20 | "CountBySource": {}, 21 | "Create": {}, 22 | "CreateInTx": {}, 23 | "Delete": {}, 24 | "DeleteByPayloadField": {}, 25 | "DeleteOldJobResults": {}, 26 | "DeleteOldJobs": {}, 27 | "Fail": {}, 28 | "FailStalePendingJobs": {}, 29 | "GetByID": {}, 30 | "Heartbeat": {}, 31 | "JobStatesByTaskName": {}, 32 | "List": {}, 33 | "ListBySiteWithFilters": {}, 34 | "ListBySource": {}, 35 | "ListRecentByType": {}, 36 | "ListRecentByTypeWithSiteNames": {}, 37 | "ReserveNext": {}, 38 | "RunningJobExistsByTaskName": {}, 39 | "Stats": {}, 40 | "WaitForNotification": {}, 41 | } 42 | 43 | methods := reflect.TypeOf(&JobRepo{}) 44 | seen := make(map[string]struct{}) 45 | 46 | for i := range methods.NumMethod() { 47 | m := methods.Method(i) 48 | if !m.IsExported() { 49 | continue 50 | } 51 | name := m.Name 52 | if _, ok := allowed[name]; !ok { 53 | t.Fatalf("unexpected exported method on JobRepo: %s", name) 54 | } 55 | seen[name] = struct{}{} 56 | } 57 | 58 | for name := range allowed { 59 | if _, ok := seen[name]; !ok { 60 | t.Fatalf("expected JobRepo to export method %s", name) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/observability/notify/pagerduty/pagerduty_test.go: -------------------------------------------------------------------------------- 1 | package pagerduty 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/target/mmk-ui-api/internal/observability/notify" 9 | ) 10 | 11 | func TestNewClientValidation(t *testing.T) { 12 | if _, err := NewClient(Config{}); err == nil { 13 | t.Fatal("expected error when routing key missing") 14 | } 15 | } 16 | 17 | func TestBuildEventDefaults(t *testing.T) { 18 | client, err := NewClient(Config{ 19 | RoutingKey: "key", 20 | Source: "", 21 | Component: "", 22 | Timeout: time.Second, 23 | }) 24 | if err != nil { 25 | t.Fatalf("unexpected error: %v", err) 26 | } 27 | 28 | payload := notify.JobFailurePayload{ 29 | JobID: "123", 30 | JobType: "rules", 31 | Error: "boom", 32 | ErrorClass: "err_class", 33 | } 34 | event := client.buildEvent(payload) 35 | 36 | payloadSection, ok := event["payload"].(map[string]any) 37 | if !ok { 38 | t.Fatalf("expected payload section") 39 | } 40 | if payloadSection["severity"] != notify.SeverityCritical { 41 | t.Fatalf("expected default severity, got %v", payloadSection["severity"]) 42 | } 43 | if payloadSection["source"] != "merrymaker" { 44 | t.Fatalf("expected default source, got %v", payloadSection["source"]) 45 | } 46 | if payloadSection["component"] != "merrymaker" { 47 | t.Fatalf("expected default component, got %v", payloadSection["component"]) 48 | } 49 | 50 | custom, ok := payloadSection["custom_details"].(map[string]any) 51 | if !ok { 52 | t.Fatalf("expected custom details") 53 | } 54 | 55 | required := []string{"job_id", "job_type", "error", "error_class"} 56 | for _, key := range required { 57 | if _, exists := custom[key]; !exists { 58 | t.Fatalf("expected key %s in custom details", key) 59 | } 60 | } 61 | 62 | dedup, _ := event["dedup_key"].(string) 63 | if !strings.Contains(dedup, "123") { 64 | t.Fatalf("expected dedup key to reference job id, got %s", dedup) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/data/event_repo_bench_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "fmt" 8 | "testing" 9 | 10 | "github.com/target/mmk-ui-api/internal/domain/model" 11 | "github.com/target/mmk-ui-api/internal/testutil" 12 | ) 13 | 14 | func BenchmarkEventRepo_BulkInsert(b *testing.B) { 15 | testutil.SkipIfNoTestDB(b) 16 | 17 | testutil.WithAutoDB(b, func(db *sql.DB) { 18 | ctx := context.Background() 19 | eventRepo := &EventRepo{DB: db} 20 | 21 | // Create test events 22 | events := make([]model.RawEvent, 100) 23 | for i := range events { 24 | data := fmt.Sprintf(`{"index":%d}`, i) 25 | events[i] = model.RawEvent{ 26 | Type: "benchmark_event", 27 | Data: json.RawMessage(data), 28 | Priority: intPtr(1), 29 | } 30 | } 31 | 32 | req := model.BulkEventRequest{ 33 | SessionID: "550e8400-e29b-41d4-a716-446655440005", 34 | Events: events, 35 | } 36 | 37 | b.ResetTimer() 38 | for b.Loop() { 39 | _, err := eventRepo.BulkInsert(ctx, req, false) 40 | if err != nil { 41 | b.Fatal(err) 42 | } 43 | } 44 | }) 45 | } 46 | 47 | func BenchmarkEventRepo_BulkInsertCopy(b *testing.B) { 48 | testutil.SkipIfNoTestDB(b) 49 | 50 | testutil.WithAutoDB(b, func(db *sql.DB) { 51 | ctx := context.Background() 52 | eventRepo := &EventRepo{DB: db} 53 | 54 | // Create test events 55 | events := make([]model.RawEvent, 100) 56 | for i := range 100 { 57 | data := fmt.Sprintf(`{"index":%d}`, i) 58 | events[i] = model.RawEvent{ 59 | Type: "benchmark_event_copy", 60 | Data: json.RawMessage(data), 61 | Priority: intPtr(1), 62 | } 63 | } 64 | 65 | req := model.BulkEventRequest{ 66 | SessionID: "550e8400-e29b-41d4-a716-446655440006", 67 | Events: events, 68 | } 69 | 70 | b.ResetTimer() 71 | for b.Loop() { 72 | _, err := eventRepo.BulkInsertCopy(ctx, req, false) 73 | if err != nil { 74 | b.Fatal(err) 75 | } 76 | } 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/features/htmx-history.js: -------------------------------------------------------------------------------- 1 | /** 2 | * HTMX history helpers 3 | * 4 | * Ensures forms can opt into pushState updates on submit-driven HTMX requests 5 | * without embedding inline `hx-on` handlers in templates. 6 | */ 7 | import { on } from "../core/htmx-bridge.js"; 8 | 9 | const PUSH_ON_SUBMIT_ATTR = "data-hx-push-url-on-submit"; 10 | let initialized = false; 11 | 12 | function isSubmitRequest(detail) { 13 | return detail?.requestConfig?.triggeringEvent?.type === "submit"; 14 | } 15 | 16 | function elementWithOptIn(detail, fallbackTarget) { 17 | const elt = detail?.elt ?? detail?.target ?? fallbackTarget; 18 | if (!(elt instanceof Element)) { 19 | return null; 20 | } 21 | return elt.closest(`[${PUSH_ON_SUBMIT_ATTR}]`); 22 | } 23 | 24 | function getFinalPath(detail) { 25 | const pathInfo = detail?.pathInfo; 26 | if (pathInfo?.finalPath) { 27 | return pathInfo.finalPath; 28 | } 29 | if (pathInfo?.requestPath) { 30 | return pathInfo.requestPath; 31 | } 32 | return detail?.requestConfig?.path ?? null; 33 | } 34 | 35 | function pushStateIfPossible(path) { 36 | if (!path) return; 37 | if (typeof window === "undefined") return; 38 | if (!window.history || typeof window.history.pushState !== "function") { 39 | return; 40 | } 41 | 42 | try { 43 | window.history.pushState({}, "", path); 44 | } catch (error) { 45 | console.warn("htmx-history: failed to push state", error); 46 | } 47 | } 48 | 49 | function handleAfterRequest(event) { 50 | const { detail } = event; 51 | if (!detail) return; 52 | if (!isSubmitRequest(detail)) return; 53 | 54 | const target = elementWithOptIn(detail, event.target); 55 | if (!target) return; 56 | 57 | const finalPath = getFinalPath(detail); 58 | pushStateIfPossible(finalPath); 59 | } 60 | 61 | function registerHandlers() { 62 | on("htmx:afterRequest", handleAfterRequest); 63 | } 64 | 65 | export function initHtmxHistory() { 66 | if (initialized) return; 67 | initialized = true; 68 | registerHandlers(); 69 | } 70 | -------------------------------------------------------------------------------- /services/merrymaker-go/Dockerfile: -------------------------------------------------------------------------------- 1 | # ---------- Builder ---------- 2 | FROM golang:1.25.4-bookworm AS builder 3 | 4 | 5 | ENV CGO_ENABLED=0 \ 6 | GOFLAGS=-trimpath 7 | 8 | WORKDIR /src 9 | 10 | # Leverage Docker layer cache: copy go.mod/go.sum first and download deps 11 | COPY go.mod go.sum ./ 12 | RUN --mount=type=cache,target=/go/pkg/mod \ 13 | go mod download 14 | 15 | # Copy the rest of the source 16 | COPY . ./ 17 | 18 | # Build the binaries (embed assets via go:embed) 19 | RUN --mount=type=cache,target=/go/pkg/mod \ 20 | --mount=type=cache,target=/root/.cache/go-build \ 21 | go build -buildvcs=false -ldflags "-s -w" -o /out/merrymaker ./cmd/merrymaker 22 | 23 | # ---------- Runtime ---------- 24 | FROM debian:bookworm-slim AS runtime 25 | 26 | # Install minimal runtime deps and wget for HEALTHCHECK 27 | RUN apt-get update && apt-get install -y --no-install-recommends \ 28 | ca-certificates wget \ 29 | && rm -rf /var/lib/apt/lists/* 30 | 31 | # Create non-root user 32 | RUN groupadd -r app && useradd -r -g app app 33 | 34 | WORKDIR /app 35 | COPY --chown=app:app --from=builder /out/merrymaker /app/merrymaker 36 | COPY --chown=app:app env_secrets_expand.sh /app/env_secrets_expand.sh 37 | 38 | RUN chmod +x /app/env_secrets_expand.sh 39 | 40 | USER app 41 | EXPOSE 8080 42 | 43 | # OCI labels (optionally overridden via build args) 44 | ARG VERSION="dev" 45 | ARG VCS_REF="unknown" 46 | LABEL org.opencontainers.image.title="merrymaker-go" \ 47 | org.opencontainers.image.description="Merrymaker Go service" \ 48 | org.opencontainers.image.version=$VERSION \ 49 | org.opencontainers.image.revision=$VCS_REF \ 50 | org.opencontainers.image.source="https://github.com/target/mmk-ui-api" 51 | 52 | # Healthcheck: HTTP reachability of dedicated health endpoint 53 | HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ 54 | CMD wget -q --spider http://localhost:8080/healthz || exit 1 55 | 56 | ENTRYPOINT ["/bin/bash", "/app/env_secrets_expand.sh"] 57 | CMD ["/app/merrymaker"] 58 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/features/htmx-fragments.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility helpers for HTMX-driven fragments. 3 | * Provides a declarative pattern for showing/hiding error states 4 | * without relying on verbose inline hx-on attributes. 5 | */ 6 | import { on } from "../core/htmx-bridge.js"; 7 | 8 | const PANEL_SELECTOR = "[data-fragment-panel]"; 9 | const ERROR_SELECTOR = "[data-fragment-error]"; 10 | 11 | /** 12 | * Hide all error elements within a fragment panel. 13 | * @param {Element} panel 14 | */ 15 | function hideErrors(panel) { 16 | panel.querySelectorAll(ERROR_SELECTOR).forEach((el) => { 17 | el.setAttribute("hidden", ""); 18 | }); 19 | } 20 | 21 | /** 22 | * Show all error elements within a fragment panel. 23 | * @param {Element} panel 24 | */ 25 | function showErrors(panel) { 26 | panel.querySelectorAll(ERROR_SELECTOR).forEach((el) => { 27 | el.removeAttribute("hidden"); 28 | }); 29 | } 30 | 31 | /** 32 | * Extract the fragment panel associated with an HTMX event. 33 | * @param {CustomEvent} event 34 | * @returns {Element|null} 35 | */ 36 | function panelFromEvent(event) { 37 | const elt = event.detail?.elt ?? event.detail?.target ?? event.target; 38 | if (!(elt instanceof Element)) { 39 | return null; 40 | } 41 | return elt.closest(PANEL_SELECTOR); 42 | } 43 | 44 | /** 45 | * Initialize fragment helpers. 46 | * Wires HTMX lifecycle events to toggle error states on panels. 47 | */ 48 | let helpersInitialized = false; 49 | 50 | export function initFragmentHelpers() { 51 | if (helpersInitialized || typeof document === "undefined") { 52 | return; 53 | } 54 | helpersInitialized = true; 55 | 56 | on("htmx:beforeRequest", (event) => { 57 | const panel = panelFromEvent(event); 58 | if (!panel) return; 59 | hideErrors(panel); 60 | }); 61 | 62 | const showErrorHandler = (event) => { 63 | const panel = panelFromEvent(event); 64 | if (!panel) return; 65 | showErrors(panel); 66 | }; 67 | 68 | on("htmx:responseError", showErrorHandler); 69 | on("htmx:sendError", showErrorHandler); 70 | on("htmx:timeout", showErrorHandler); 71 | } 72 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/features/user-menu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User menu feature module 3 | * 4 | * Manages dropdown menu toggling, closing on outside clicks, and accessibility 5 | * attributes for user navigation controls. 6 | */ 7 | import { qsa } from "../core/dom.js"; 8 | import { Events } from "../core/events.js"; 9 | 10 | let initialized = false; 11 | 12 | function closeAllUserMenus() { 13 | const toggles = []; 14 | 15 | qsa(".user-toggle-menu.is-open").forEach((menu) => { 16 | menu.classList.remove("is-open"); 17 | const btn = menu.parentElement?.querySelector("[data-user-toggle]"); 18 | if (btn) { 19 | btn.setAttribute("aria-expanded", "false"); 20 | toggles.push(btn); 21 | } 22 | }); 23 | 24 | return toggles; 25 | } 26 | 27 | function toggleUserMenu(toggle, menu) { 28 | const isOpen = menu.classList.contains("is-open"); 29 | 30 | qsa(".user-toggle-menu.is-open").forEach((otherMenu) => { 31 | if (otherMenu === menu) return; 32 | 33 | otherMenu.classList.remove("is-open"); 34 | const otherBtn = otherMenu.parentElement?.querySelector("[data-user-toggle]"); 35 | if (otherBtn) otherBtn.setAttribute("aria-expanded", "false"); 36 | }); 37 | 38 | menu.classList.toggle("is-open", !isOpen); 39 | toggle.setAttribute("aria-expanded", !isOpen ? "true" : "false"); 40 | } 41 | 42 | function registerEventHandlers() { 43 | Events.on("[data-user-toggle]", "click", (event, toggle) => { 44 | event.preventDefault(); 45 | event.stopPropagation(); 46 | 47 | const menu = toggle.parentElement?.querySelector("[data-user-menu]"); 48 | if (!menu) return; 49 | 50 | toggleUserMenu(toggle, menu); 51 | }); 52 | 53 | document.addEventListener("click", (event) => { 54 | const toggle = event.target?.closest("[data-user-toggle]"); 55 | const inMenu = event.target?.closest("[data-user-menu]"); 56 | 57 | if (!(toggle || inMenu)) { 58 | closeAllUserMenus(); 59 | } 60 | }); 61 | } 62 | 63 | export function initUserMenu() { 64 | if (initialized) return; 65 | initialized = true; 66 | registerEventHandlers(); 67 | } 68 | 69 | export { closeAllUserMenus }; 70 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/partials/user_toggle.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "user-toggle" }} 2 | {{ if .IsAuthenticated }} 3 |
4 | 12 |
13 |
14 | 17 |
18 |
19 |
20 | 21 | Appearance 22 |
23 | 31 |
32 |
33 |
34 |
35 | 39 |
40 |
41 |
42 |
43 | {{ else }} 44 | 50 | {{ end }} 51 | {{ end }} 52 | -------------------------------------------------------------------------------- /services/puppeteer-worker/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Puppeteer Worker - Main Library Entry Point 3 | * 4 | * Exports core functionality for browser automation and event monitoring. 5 | * When run directly, executes a simple demo script. 6 | */ 7 | 8 | import { loadConfig } from "./config-loader.js"; 9 | import { logger } from "./logger.js"; 10 | import { PuppeteerRunner } from "./puppeteer-runner.js"; 11 | 12 | /** 13 | * Simple demo for development/testing 14 | * Run with: npm run start, npm run dev, or npm run demo 15 | */ 16 | async function runDemo(): Promise { 17 | const runner = new PuppeteerRunner(); 18 | const config = await loadConfig(); 19 | 20 | logger.info("Starting Puppeteer Worker demo..."); 21 | logger.info( 22 | `Config: headless=${config.headless}, clientMonitoring=${config.clientMonitoring?.enabled}`, 23 | ); 24 | 25 | const demoScript = ` 26 | console.log('Demo: navigating to test page...'); 27 | await page.goto('about:blank'); 28 | await page.setContent('

Demo Page

'); 29 | console.log('Demo: page loaded'); 30 | `; 31 | 32 | try { 33 | const result = await runner.runScript(demoScript, config); 34 | logger.info(`Demo completed in ${result.executionTime.toFixed(2)}ms`); 35 | logger.info( 36 | `Captured ${result.eventCount} events, ${result.fileCount} files`, 37 | ); 38 | } catch (error) { 39 | logger.error(error, "Demo failed"); 40 | process.exit(1); 41 | } 42 | } 43 | 44 | // Run demo if executed directly 45 | if (import.meta.url === `file://${process.argv[1]}`) { 46 | runDemo().catch(console.error); 47 | } 48 | 49 | export { loadConfig } from "./config-loader.js"; 50 | export { EventMonitor } from "./event-monitor.js"; 51 | export { EventShipper } from "./event-shipper.js"; 52 | export * as Examples from "./examples.js"; 53 | export { FileCapture } from "./file-capture.js"; 54 | export { JobClient } from "./job-client.js"; 55 | // Export main components for use as a library 56 | export { PuppeteerRunner } from "./puppeteer-runner.js"; 57 | export * from "./types.js"; 58 | export { WorkerLoop } from "./worker-loop.js"; 59 | -------------------------------------------------------------------------------- /services/puppeteer-worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // Visit https://aka.ms/tsconfig to read more about this file 3 | "compilerOptions": { 4 | /* Basic Options */ 5 | "target": "ES2024", // Target Node.js v24 with latest ES features 6 | "module": "NodeNext", // Use the modern Node.js ES module system 7 | "outDir": "./dist", // Redirect output structure to the dist folder 8 | "rootDir": "./src", // Specify the root directory of input files 9 | "sourceMap": true, // Generate corresponding .map files for debugging 10 | 11 | /* Module Resolution Options */ 12 | "moduleResolution": "NodeNext", // Use Node.js-style module resolution 13 | "allowImportingTsExtensions": false, // Keep false for Node.js compatibility 14 | "resolveJsonModule": true, // Allow importing JSON files 15 | 16 | /* Strict Type-Checking Options */ 17 | "strict": false, // Disable strict checking for simplified version 18 | "noUncheckedIndexedAccess": false, 19 | "exactOptionalPropertyTypes": false, 20 | 21 | /* Additional Checks */ 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "noImplicitReturns": false, 25 | "noFallthroughCasesInSwitch": false, 26 | 27 | /* Module Interop and Output */ 28 | "esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules 29 | "verbatimModuleSyntax": true, // Enforces 'import type' and ensures module specifiers are not elided 30 | "declaration": true, // Generate corresponding .d.ts files 31 | "declarationMap": true, // Create sourcemaps for d.ts files 32 | 33 | /* Performance and Completeness */ 34 | "skipLibCheck": true, // Skip type checking of all declaration files (*.d.ts) 35 | "forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file 36 | "incremental": true, // Enable incremental compilation for faster builds 37 | "tsBuildInfoFile": "./dist/.tsbuildinfo" // Store build info for incremental compilation 38 | }, 39 | "include": ["src/**/*"], // Specifies which files to include in compilation 40 | "exclude": ["node_modules", "dist", "src/*.js"] // Specifies which files to exclude 41 | } 42 | -------------------------------------------------------------------------------- /services/merrymaker-go/config/database.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | // DBConfig contains PostgreSQL database configuration. 6 | type DBConfig struct { 7 | Host string `env:"HOST" envDefault:"localhost"` 8 | Port int `env:"PORT" envDefault:"5432"` 9 | User string `env:"USER" envDefault:"merrymaker"` 10 | Password string `env:"PASSWORD" envDefault:"merrymaker"` 11 | Name string `env:"NAME" envDefault:"merrymaker"` 12 | SSLMode string `env:"SSL_MODE" envDefault:"disable"` // Use 'disable' for local dev, 'require' for production 13 | // RunMigrationsOnStart controls whether the application automatically applies migrations during startup. 14 | RunMigrationsOnStart bool `env:"RUN_MIGRATIONS_ON_START" envDefault:"true"` 15 | } 16 | 17 | // RedisConfig contains Redis configuration. 18 | type RedisConfig struct { 19 | URI string `env:"URI" envDefault:"localhost:6379"` 20 | Password string `env:"PASSWORD" envDefault:""` 21 | SentinelPort string `env:"SENTINEL_PORT" envDefault:"26379"` 22 | SentinelNodes []string `env:"SENTINEL_NODES" envDefault:"localhost:26379"` 23 | SentinelMasterName string `env:"SENTINEL_MASTER_NAME" envDefault:"mymaster"` 24 | SentinelPassword string `env:"SENTINEL_PASSWORD" envDefault:""` 25 | UseSentinel bool `env:"USE_SENTINEL" envDefault:"false"` 26 | ClusterNodes []string `env:"CLUSTER_NODES" envDefault:""` 27 | UseCluster bool `env:"USE_CLUSTER" envDefault:"false"` 28 | } 29 | 30 | // CacheConfig contains cache configuration (Redis-based). 31 | type CacheConfig struct { 32 | // Redis connection settings for cache. 33 | RedisAddr string `env:"CACHE_REDIS_ADDR" envDefault:"localhost:6379"` 34 | RedisPassword string `env:"CACHE_REDIS_PASSWORD" envDefault:""` 35 | RedisDB int `env:"CACHE_REDIS_DB" envDefault:"0"` 36 | 37 | // SourceTTL is the TTL for cached source content. 38 | SourceTTL time.Duration `env:"CACHE_SOURCE_TTL" envDefault:"30m"` 39 | } 40 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/filters.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | // StrTrue represents the string "true" for boolean query parameters. 10 | StrTrue = "true" 11 | // StrFalse represents the string "false" for boolean query parameters. 12 | StrFalse = "false" 13 | // SortDirAsc represents ascending sort direction. 14 | SortDirAsc = "asc" 15 | // SortDirDesc represents descending sort direction. 16 | SortDirDesc = "desc" 17 | ) 18 | 19 | // ParseSortParam extracts and validates sort field and direction from URL query parameters. 20 | // It supports two formats: 21 | // 1. Combined format: ?sort=field:dir (e.g., ?sort=created_at:desc) 22 | // 2. Separate format: ?sort=field&dir=direction (e.g., ?sort=created_at&dir=desc) 23 | // 24 | // The direction is normalized to lowercase and validated (must be "asc" or "desc"). 25 | // If the direction is invalid, it returns an empty string for dir. 26 | // 27 | // Parameters: 28 | // - q: URL query values 29 | // - sortKey: the query parameter key for the sort field (typically "sort") 30 | // - dirKey: the query parameter key for the direction (typically "dir") 31 | // 32 | // Returns the sort field name (trimmed) and the sort direction ("asc", "desc", or empty string if invalid). 33 | func ParseSortParam(q url.Values, sortKey, dirKey string) (string, string) { 34 | sortParam := strings.TrimSpace(q.Get(sortKey)) 35 | dirParam := strings.ToLower(strings.TrimSpace(q.Get(dirKey))) 36 | 37 | // Try to split on ":" first (avoids double allocation) 38 | parts := strings.SplitN(sortParam, ":", 2) 39 | if len(parts) == 2 { 40 | fieldPart := strings.TrimSpace(parts[0]) 41 | dirPart := strings.ToLower(strings.TrimSpace(parts[1])) 42 | // Only accept known directions 43 | if dirPart == SortDirAsc || dirPart == SortDirDesc { 44 | return fieldPart, dirPart 45 | } 46 | // Invalid direction in colon syntax, return field only 47 | return fieldPart, "" 48 | } 49 | 50 | // Validate separate direction parameter 51 | if dirParam == SortDirAsc || dirParam == SortDirDesc { 52 | return sortParam, dirParam 53 | } 54 | 55 | // No valid direction found 56 | return sortParam, "" 57 | } 58 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/templates/error.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "error-layout" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{.Title}} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 |

{{.Code}}

25 |

{{.Title}}

26 |

{{.Message}}

27 | {{ if .ShowLogin }} 28 |
29 | Sign In 31 | Go Home 32 |
33 | {{ else }} 34 |
35 | Go Home 36 | {{ if .IsAuthenticated }} 37 | Go Back 38 | {{ end }} 39 |
40 | {{ end }} 41 |
42 |
43 |
44 | 45 | 46 | {{ end }} 47 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/service/rules/ioc_test.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/target/mmk-ui-api/internal/core" 13 | "github.com/target/mmk-ui-api/internal/domain/model" 14 | ) 15 | 16 | type stubIOCCache struct { 17 | hosts map[string]*model.IOC 18 | } 19 | 20 | func (s stubIOCCache) LookupHost(_ context.Context, host string) (*model.IOC, error) { 21 | if ioc, ok := s.hosts[strings.ToLower(strings.TrimSpace(host))]; ok { 22 | return ioc, nil 23 | } 24 | return nil, ErrNotFound 25 | } 26 | 27 | func TestIOCEvaluator_AlertContextIncludesAttribution(t *testing.T) { 28 | ctx := context.Background() 29 | ioc := &model.IOC{ 30 | ID: "ioc-1", 31 | Type: model.IOCTypeFQDN, 32 | Value: "example.com", 33 | Enabled: true, 34 | CreatedAt: time.Now(), 35 | UpdatedAt: time.Now(), 36 | } 37 | 38 | fa := &fakeAlertRepo{} 39 | eval := &IOCEvaluator{ 40 | Caches: Caches{ 41 | IOCs: stubIOCCache{hosts: map[string]*model.IOC{ 42 | "example.com": ioc, 43 | }}, 44 | }, 45 | Alerter: &core.AlertService{Repo: fa}, 46 | AlertTTL: 0, 47 | } 48 | 49 | req := IOCRequest{ 50 | Scope: ScopeKey{SiteID: "site-1", Scope: "default"}, 51 | Host: "example.com", 52 | SiteID: "site-1", 53 | JobID: "job-1", 54 | RequestURL: "https://example.com/api", 55 | PageURL: "https://example.com/page", 56 | Referrer: "https://example.com/", 57 | UserAgent: "UA", 58 | EventID: "evt-1", 59 | } 60 | 61 | alerted, err := eval.Evaluate(ctx, req) 62 | require.NoError(t, err) 63 | require.True(t, alerted) 64 | require.Len(t, fa.created, 1) 65 | 66 | var ctxMap map[string]any 67 | require.NoError(t, json.Unmarshal(fa.created[0].EventContext, &ctxMap)) 68 | 69 | assert.Equal(t, "job-1", ctxMap["job_id"]) 70 | assert.Equal(t, "evt-1", ctxMap["event_id"]) 71 | assert.Equal(t, "https://example.com/api", ctxMap["request_url"]) 72 | assert.Equal(t, "https://example.com/page", ctxMap["page_url"]) 73 | assert.Equal(t, "https://example.com/", ctxMap["referrer"]) 74 | assert.Equal(t, "UA", ctxMap["user_agent"]) 75 | assert.Equal(t, "ioc-1", ctxMap["ioc_id"]) 76 | } 77 | -------------------------------------------------------------------------------- /services/merrymaker-go/config/auth.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // AuthMode represents the authentication mode for the application. 9 | type AuthMode string 10 | 11 | const ( 12 | // AuthModeOAuth uses OAuth/OIDC for authentication. 13 | AuthModeOAuth AuthMode = "oauth" 14 | // AuthModeMock uses mock/dev authentication (for development only). 15 | AuthModeMock AuthMode = "mock" 16 | ) 17 | 18 | // UnmarshalText implements encoding.TextUnmarshaler for AuthMode. 19 | func (a *AuthMode) UnmarshalText(text []byte) error { 20 | v := strings.ToLower(string(text)) 21 | switch v { 22 | case "oauth", "mock": 23 | *a = AuthMode(v) 24 | return nil 25 | default: 26 | return fmt.Errorf("invalid AuthMode: %q (valid options: oauth, mock)", v) 27 | } 28 | } 29 | 30 | // OAuthConfig contains OAuth/OIDC configuration. 31 | type OAuthConfig struct { 32 | ClientID string `env:"CLIENT_ID" envDefault:"merrymaker"` 33 | ClientSecret string `env:"CLIENT_SECRET" envDefault:"merrymaker"` 34 | RedirectURL string `env:"REDIRECT_URL" envDefault:"http://localhost:8080/auth/callback"` 35 | Scope string `env:"SCOPE" envDefault:"openid profile email groups"` 36 | DiscoveryURL string `env:"DISCOVERY_URL"` 37 | LogoutURL string `env:"LOGOUT_URL"` 38 | } 39 | 40 | // DevAuthConfig controls mock/dev authentication identity. 41 | // Used when AUTH_MODE=mock for development and testing. 42 | type DevAuthConfig struct { 43 | UserID string `env:"USER_ID" envDefault:"dev-user"` 44 | Email string `env:"EMAIL" envDefault:"dev@example.com"` 45 | Groups []string `env:"GROUPS" envDefault:"admins" envSeparator:";"` 46 | } 47 | 48 | // AuthConfig groups all authentication-related configuration. 49 | type AuthConfig struct { 50 | // Mode determines which authentication provider to use. 51 | Mode AuthMode `env:"AUTH_MODE" envDefault:"oauth"` 52 | 53 | // OAuth configuration (used when Mode=oauth). 54 | OAuth OAuthConfig `envPrefix:"OAUTH_"` 55 | 56 | // DevAuth configuration (used when Mode=mock). 57 | DevAuth DevAuthConfig `envPrefix:"DEV_AUTH_"` 58 | 59 | // AdminGroup is the LDAP/AD group DN for admin users. 60 | AdminGroup string `env:"ADMIN_GROUP,required"` 61 | 62 | // UserGroup is the LDAP/AD group DN for regular users. 63 | UserGroup string `env:"USER_GROUP,required"` 64 | } 65 | -------------------------------------------------------------------------------- /services/merrymaker-go/scripts/install-dev-tools.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Install development tools for merrymaker-go 3 | # This script installs Air (live-reload) and other dev dependencies 4 | 5 | set -Eeuo pipefail 6 | 7 | echo "[INFO] Installing development tools..." 8 | 9 | # Check if Go is installed 10 | if ! command -v go &> /dev/null; then 11 | echo "[ERROR] Go is not installed. Please install Go 1.24+ first." 12 | exit 1 13 | fi 14 | 15 | # Check Go version 16 | GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//') 17 | echo "[INFO] Go version: $GO_VERSION" 18 | 19 | # Install Air (pinned version for consistency) 20 | AIR_VERSION="v1.63.0" 21 | echo "[INFO] Installing Air $AIR_VERSION..." 22 | go install github.com/air-verse/air@${AIR_VERSION} 23 | 24 | # Verify Air installation 25 | if command -v air &> /dev/null; then 26 | INSTALLED_VERSION=$(air -v 2>&1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1) 27 | echo "[SUCCESS] Air installed successfully: $INSTALLED_VERSION" 28 | else 29 | echo "[ERROR] Air installation failed. Make sure \$GOPATH/bin is in your \$PATH" 30 | echo "[INFO] Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):" 31 | echo " export PATH=\$PATH:\$(go env GOPATH)/bin" 32 | exit 1 33 | fi 34 | 35 | # Check if Bun is installed (for frontend builds) 36 | if ! command -v bun &> /dev/null; then 37 | echo "[WARNING] Bun is not installed. Frontend builds require Bun." 38 | echo "[INFO] Install Bun: curl -fsSL https://bun.sh/install | bash" 39 | else 40 | BUN_VERSION=$(bun --version) 41 | echo "[SUCCESS] Bun is installed: v$BUN_VERSION" 42 | fi 43 | 44 | # Install frontend dependencies 45 | if [ -d "frontend" ]; then 46 | echo "[INFO] Installing frontend dependencies..." 47 | cd frontend 48 | if command -v bun &> /dev/null; then 49 | bun install 50 | echo "[SUCCESS] Frontend dependencies installed" 51 | else 52 | echo "[WARNING] Skipping frontend dependencies (Bun not installed)" 53 | fi 54 | cd .. 55 | fi 56 | 57 | echo "" 58 | echo "[SUCCESS] Development tools installed successfully!" 59 | echo "" 60 | echo "Quick start:" 61 | echo " make dev-full # Start full dev environment (DB + live-reload)" 62 | echo " make dev # Start live-reload only (no DB)" 63 | echo "" 64 | echo "See docs/development-workflow.md for more details." 65 | 66 | -------------------------------------------------------------------------------- /services/puppeteer-worker/src/job-client.abort.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * JobClient abort behavior tests 3 | */ 4 | 5 | import { describe, test, beforeEach, afterEach } from "node:test"; 6 | import assert from "node:assert"; 7 | import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from "undici"; 8 | import { JobClient } from "../dist/job-client.js"; 9 | 10 | describe("JobClient aborts", () => { 11 | let mockAgent; 12 | let originalDispatcher; 13 | let mockPool; 14 | 15 | beforeEach(() => { 16 | originalDispatcher = getGlobalDispatcher(); 17 | mockAgent = new MockAgent(); 18 | setGlobalDispatcher(mockAgent); 19 | mockPool = mockAgent.get("http://localhost:8080"); 20 | }); 21 | 22 | afterEach(async () => { 23 | setGlobalDispatcher(originalDispatcher); 24 | await mockAgent.close(); 25 | }); 26 | 27 | test("complete should reject with AbortError when external signal is aborted", async () => { 28 | // Arrange: slow complete endpoint so we can abort while in-flight 29 | mockPool 30 | .intercept({ 31 | path: "/api/jobs/job-abc/complete", 32 | method: "POST", 33 | }) 34 | .reply(204) 35 | .delay(500); 36 | 37 | const client = new JobClient("http://localhost:8080", { 38 | timeoutMs: 1000, 39 | retries: 0, 40 | }); 41 | const controller = new AbortController(); 42 | 43 | // Act: start request then abort shortly after 44 | const promise = client.complete("job-abc", controller.signal); 45 | setTimeout(() => controller.abort(), 50); 46 | 47 | // Assert 48 | await assert.rejects(promise, (err) => err && err.name === "AbortError"); 49 | }); 50 | 51 | test("fail should reject with AbortError when external signal is aborted", async () => { 52 | // Arrange: slow fail endpoint so we can abort while in-flight 53 | mockPool 54 | .intercept({ 55 | path: "/api/jobs/job-abc/fail", 56 | method: "POST", 57 | }) 58 | .reply(204) 59 | .delay(500); 60 | 61 | const client = new JobClient("http://localhost:8080", { 62 | timeoutMs: 1000, 63 | retries: 0, 64 | }); 65 | const controller = new AbortController(); 66 | 67 | // Act: start request then abort shortly after 68 | const promise = client.fail("job-abc", "boom", controller.signal); 69 | setTimeout(() => controller.abort(), 50); 70 | 71 | // Assert 72 | await assert.rejects(promise, (err) => err && err.name === "AbortError"); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/util.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // validationErrorPatterns holds common validation error substrings to classify 400 vs 5xx. 10 | // Keeping this at package scope avoids per-call allocations in isValidationError. 11 | 12 | var validationErrorPatterns = []string{ //nolint:gochecknoglobals // read-only cache of patterns to avoid per-call allocations 13 | "is required and cannot be empty", 14 | "value is required and cannot be empty", 15 | "cannot be empty", 16 | "cannot exceed", 17 | "at least one field must be updated", 18 | "cannot contain empty", 19 | "must be a valid URL", 20 | "must use http or https scheme", 21 | "must have a valid host", 22 | "must be one of:", 23 | "must be between", 24 | "must be non-negative", 25 | "must be at least", 26 | "must start with", 27 | "contain only", 28 | } 29 | 30 | // parseIntQuery returns the integer value of a query param or a default. 31 | // It is tolerant of missing/invalid values. 32 | func parseIntQuery(r *http.Request, key string, def int) int { 33 | if v := r.URL.Query().Get(key); v != "" { 34 | if i, err := strconv.Atoi(v); err == nil { 35 | return i 36 | } 37 | } 38 | return def 39 | } 40 | 41 | // ParseLimitOffset parses common pagination params and clamps to sane bounds. 42 | // - defLimit: default limit when not specified 43 | // - maxLimit: maximum allowed limit (values > maxLimit are clamped to maxLimit). 44 | func ParseLimitOffset(r *http.Request, defLimit, maxLimit int) (int, int) { 45 | // Defensive: ensure maxLimit is at least 1 to avoid clamping to 0 or negatives 46 | if maxLimit < 1 { 47 | maxLimit = 1 48 | } 49 | 50 | lim := parseIntQuery(r, "limit", defLimit) 51 | off := parseIntQuery(r, "offset", 0) 52 | if lim < 1 { 53 | lim = 1 54 | } 55 | if lim > maxLimit { 56 | lim = maxLimit 57 | } 58 | if off < 0 { 59 | off = 0 60 | } 61 | return lim, off 62 | } 63 | 64 | // isValidationError checks for common validation error patterns to decide 400 vs 5xx. 65 | // This is a stopgap until typed validation errors are adopted across services. 66 | func isValidationError(err error) bool { 67 | if err == nil { 68 | return false 69 | } 70 | msg := err.Error() 71 | for _, p := range validationErrorPatterns { 72 | if strings.Contains(msg, p) { 73 | return true 74 | } 75 | } 76 | return false 77 | } 78 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/observability/notify/slack/slack_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/target/mmk-ui-api/internal/observability/notify" 9 | ) 10 | 11 | func TestNewClientValidation(t *testing.T) { 12 | if _, err := NewClient(Config{}); err == nil { 13 | t.Fatal("expected error when webhook url missing") 14 | } 15 | } 16 | 17 | func TestFormatMessageIncludesFields(t *testing.T) { 18 | client, err := NewClient(Config{ 19 | WebhookURL: "https://hooks.slack.com/services/test", 20 | Channel: "#alerts", 21 | Username: "bot", 22 | Timeout: time.Second, 23 | }) 24 | if err != nil { 25 | t.Fatalf("unexpected error: %v", err) 26 | } 27 | 28 | msg := client.formatMessage(notify.JobFailurePayload{ 29 | JobID: "123", 30 | JobType: "rules", 31 | SiteID: "site-1", 32 | Scope: "global", 33 | Error: "boom", 34 | ErrorClass: "test_error", 35 | }) 36 | 37 | if msg["username"] != "bot" { 38 | t.Fatalf("expected username to be preserved, got %v", msg["username"]) 39 | } 40 | if msg["channel"] != "#alerts" { 41 | t.Fatalf("expected channel to be set, got %v", msg["channel"]) 42 | } 43 | 44 | text, ok := msg["text"].(string) 45 | if !ok { 46 | t.Fatalf("expected text field") 47 | } 48 | if !containsAll(text, []string{"Job failure alert", "123", "rules", "site-1", "global", "boom", "test_error"}) { 49 | t.Fatalf("message text missing fields: %s", text) 50 | } 51 | } 52 | 53 | func TestFormatMessageSiteLink(t *testing.T) { 54 | client, err := NewClient(Config{ 55 | WebhookURL: "https://hooks.slack.com/services/test", 56 | SiteURLPrefix: "https://app.merrymaker.local/sites", 57 | }) 58 | if err != nil { 59 | t.Fatalf("unexpected error: %v", err) 60 | } 61 | 62 | msg := client.formatMessage(notify.JobFailurePayload{ 63 | SiteID: "site-123", 64 | }) 65 | 66 | text, ok := msg["text"].(string) 67 | if !ok { 68 | t.Fatalf("expected text field") 69 | } 70 | 71 | expected := "" 72 | if !strings.Contains(text, expected) { 73 | t.Fatalf("expected site link %q in text: %s", expected, text) 74 | } 75 | } 76 | 77 | func containsAll(text string, substrs []string) bool { 78 | for _, s := range substrs { 79 | if !strings.Contains(text, s) { 80 | return false 81 | } 82 | } 83 | return true 84 | } 85 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/htmx_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestHTMX_RequestDetection(t *testing.T) { 11 | r := httptest.NewRequest(http.MethodGet, "/x", nil) 12 | r.Header.Set("Hx-Request", "true") 13 | r.Header.Set("Hx-Boosted", "true") 14 | if !IsHTMX(r) { 15 | t.Fatal("expected IsHTMX true") 16 | } 17 | if !IsBoosted(r) { 18 | t.Fatal("expected IsBoosted true") 19 | } 20 | 21 | r2 := httptest.NewRequest(http.MethodGet, "/x", nil) 22 | if IsHTMX(r2) || IsBoosted(r2) { 23 | t.Fatal("expected defaults to false") 24 | } 25 | } 26 | 27 | func TestHTMX_HistoryRestore_WantsPartial(t *testing.T) { 28 | r := httptest.NewRequest(http.MethodGet, "/x", nil) 29 | r.Header.Set("Hx-Request", "true") 30 | if !WantsPartial(r) { 31 | t.Fatal("htmx request should want partial") 32 | } 33 | r.Header.Set("Hx-History-Restore-Request", "true") 34 | if !WantsPartial(r) { 35 | t.Fatal("history restore should still want partial") 36 | } 37 | } 38 | 39 | func TestHTMX_TargetAndTrigger_Read(t *testing.T) { 40 | r := httptest.NewRequest(http.MethodPost, "/x", nil) 41 | r.Header.Set("Hx-Target", "main") 42 | r.Header.Set("Hx-Trigger", "btn1") 43 | if HXTarget(r) != "main" { 44 | t.Fatalf("HXTarget mismatch: %q", HXTarget(r)) 45 | } 46 | if HXTrigger(r) != "btn1" { 47 | t.Fatalf("HXTrigger mismatch: %q", HXTrigger(r)) 48 | } 49 | } 50 | 51 | func TestHTMX_ResponseHeaders_Setters(t *testing.T) { 52 | rr := httptest.NewRecorder() 53 | SetHXRedirect(rr, "/auth/login") 54 | SetHXPushURL(rr, "/secrets") 55 | SetHXRefresh(rr, true) 56 | SetHXTrigger(rr, "saved", map[string]any{"id": "123"}) 57 | res := rr.Result() 58 | t.Cleanup(func() { _ = res.Body.Close() }) 59 | if got := res.Header.Get("Hx-Redirect"); got != "/auth/login" { 60 | t.Fatalf("HX-Redirect: %q", got) 61 | } 62 | if got := res.Header.Get("Hx-Push-Url"); got != "/secrets" { 63 | t.Fatalf("HX-Push-Url: %q", got) 64 | } 65 | if got := res.Header.Get("Hx-Refresh"); got != "true" { 66 | t.Fatalf("HX-Refresh: %q", got) 67 | } 68 | var payload map[string]any 69 | if err := json.Unmarshal([]byte(res.Header.Get("Hx-Trigger")), &payload); err != nil { 70 | t.Fatalf("unmarshal trigger: %v", err) 71 | } 72 | if _, ok := payload["saved"]; !ok { 73 | t.Fatalf("expected 'saved' key in HX-Trigger: %v", payload) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/service/failurenotifier/notifier_test.go: -------------------------------------------------------------------------------- 1 | package failurenotifier 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/target/mmk-ui-api/internal/domain/model" 9 | "github.com/target/mmk-ui-api/internal/observability/notify" 10 | ) 11 | 12 | func TestServiceNotifyJobFailure(t *testing.T) { 13 | ctx := context.Background() 14 | 15 | var received []notify.JobFailurePayload 16 | svc := NewService(Options{ 17 | Sinks: []SinkRegistration{ 18 | { 19 | Name: "capture", 20 | Sink: notify.SinkFunc(func(ctx context.Context, payload notify.JobFailurePayload) error { 21 | received = append(received, payload) 22 | return nil 23 | }), 24 | }, 25 | }, 26 | }) 27 | 28 | svc.NotifyJobFailure(ctx, notify.JobFailurePayload{ 29 | JobID: "123", 30 | JobType: "rules", 31 | }) 32 | 33 | if len(received) != 1 { 34 | t.Fatalf("expected 1 payload, got %d", len(received)) 35 | } 36 | if received[0].Severity != notify.SeverityCritical { 37 | t.Fatalf("expected severity to default to critical, got %s", received[0].Severity) 38 | } 39 | } 40 | 41 | func TestServiceDisabled(t *testing.T) { 42 | svc := NewService(Options{}) 43 | if svc.Enabled() { 44 | t.Fatal("expected Enabled() to be false when no sinks registered") 45 | } 46 | } 47 | 48 | func TestServiceLogsErrors(t *testing.T) { 49 | // Ensure we don't panic when sink returns an error. 50 | svc := NewService(Options{ 51 | Sinks: []SinkRegistration{ 52 | { 53 | Name: "fail", 54 | Sink: notify.SinkFunc(func(ctx context.Context, payload notify.JobFailurePayload) error { 55 | return errors.New("boom") 56 | }), 57 | }, 58 | }, 59 | }) 60 | 61 | svc.NotifyJobFailure(context.Background(), notify.JobFailurePayload{JobID: "123"}) 62 | } 63 | 64 | func TestServiceSkipsTestBrowserJob(t *testing.T) { 65 | ctx := context.Background() 66 | var called bool 67 | svc := NewService(Options{ 68 | Sinks: []SinkRegistration{ 69 | { 70 | Name: "capture", 71 | Sink: notify.SinkFunc(func(ctx context.Context, payload notify.JobFailurePayload) error { 72 | called = true 73 | return nil 74 | }), 75 | }, 76 | }, 77 | }) 78 | 79 | svc.NotifyJobFailure(ctx, notify.JobFailurePayload{ 80 | JobID: "test-job", 81 | JobType: string(model.JobTypeBrowser), 82 | IsTest: true, 83 | }) 84 | 85 | if called { 86 | t.Fatal("expected sink not to be invoked for test browser job") 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/components/row-delete.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Row Delete Component 3 | * 4 | * Applies row removal animations and cleanup logic to delete triggers marked 5 | * with data-component="row-delete" and data-row-delete. 6 | */ 7 | 8 | import { Lifecycle } from "../core/lifecycle.js"; 9 | 10 | function findRow(element) { 11 | try { 12 | return element?.closest?.("tr") ?? null; 13 | } catch (error) { 14 | console.warn("row-delete: failed to find parent row", error); 15 | return null; 16 | } 17 | } 18 | 19 | function setRowRemoving(element, enabled) { 20 | const row = findRow(element); 21 | if (!row) return; 22 | row.classList.toggle("row-removing", enabled); 23 | } 24 | 25 | Lifecycle.register( 26 | "row-delete", 27 | (element) => { 28 | if (!element.matches?.("[data-row-delete]")) { 29 | console.warn("row-delete: component applied to element without [data-row-delete] attribute"); 30 | } 31 | 32 | const handlers = []; 33 | const addHandler = (type, listener) => { 34 | element.addEventListener(type, listener); 35 | handlers.push({ type, listener }); 36 | }; 37 | 38 | addHandler("htmx:beforeRequest", () => { 39 | setRowRemoving(element, true); 40 | }); 41 | 42 | addHandler("htmx:responseError", () => { 43 | setRowRemoving(element, false); 44 | }); 45 | 46 | addHandler("htmx:sendError", () => { 47 | setRowRemoving(element, false); 48 | }); 49 | 50 | addHandler("htmx:timeout", () => { 51 | setRowRemoving(element, false); 52 | }); 53 | 54 | addHandler("htmx:afterRequest", (event) => { 55 | const status = event?.detail?.xhr?.status; 56 | if (status === 204) { 57 | setRowRemoving(element, false); 58 | } 59 | }); 60 | 61 | addHandler("htmx:beforeSwap", (event) => { 62 | const detail = event?.detail; 63 | if (!detail) { 64 | return; 65 | } 66 | 67 | if (detail.isError) { 68 | setRowRemoving(element, false); 69 | return; 70 | } 71 | 72 | const status = detail?.xhr?.status; 73 | if (typeof status === "number" && status >= 400) { 74 | setRowRemoving(element, false); 75 | } 76 | }); 77 | 78 | element.__rowDeleteHandlers = handlers; 79 | }, 80 | (element) => { 81 | const handlers = element.__rowDeleteHandlers || []; 82 | handlers.forEach(({ type, listener }) => { 83 | element.removeEventListener(type, listener); 84 | }); 85 | setRowRemoving(element, false); 86 | element.__rowDeleteHandlers = undefined; 87 | }, 88 | ); 89 | 90 | export default null; 91 | -------------------------------------------------------------------------------- /services/puppeteer-worker/scripts/esbuild.mjs: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { dirname, resolve } from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import esbuild from "esbuild"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | const projectRoot = resolve(__dirname, ".."); 9 | 10 | const pkg = JSON.parse( 11 | await readFile(resolve(projectRoot, "package.json"), "utf8"), 12 | ); 13 | const externals = Object.keys({ 14 | ...(pkg.dependencies || {}), 15 | ...(pkg.peerDependencies || {}), 16 | ...(pkg.optionalDependencies || {}), 17 | }); 18 | 19 | const rawPlugin = { 20 | name: "raw", 21 | setup(build) { 22 | build.onResolve({ filter: /\?raw$/ }, (args) => { 23 | const pathNoQuery = args.path.replace(/\?raw$/, ""); 24 | return { 25 | path: resolve(args.resolveDir, pathNoQuery), 26 | namespace: "raw", 27 | }; 28 | }); 29 | build.onLoad({ filter: /.*/, namespace: "raw" }, async (args) => { 30 | const contents = await readFile(args.path, "utf8"); 31 | return { contents, loader: "text" }; 32 | }); 33 | }, 34 | }; 35 | 36 | const nodeEntries = [ 37 | "src/index.ts", 38 | "src/examples.ts", 39 | "src/worker-main.ts", 40 | // Include core modules to keep dist in sync for tests 41 | "src/file-capture.ts", 42 | "src/puppeteer-runner.ts", 43 | "src/event-monitor.ts", 44 | "src/event-shipper.ts", 45 | "src/config-loader.ts", 46 | "src/config-schema.ts", 47 | "src/types.ts", 48 | "src/logger.ts", 49 | "src/job-client.ts", 50 | "src/worker-loop.ts", 51 | ]; 52 | 53 | const clientMonitoringEntry = "src/client-monitoring.js"; 54 | 55 | const nodeBuildConfig = { 56 | entryPoints: nodeEntries, 57 | absWorkingDir: projectRoot, 58 | outdir: "dist", 59 | platform: "node", 60 | format: "esm", 61 | bundle: true, 62 | sourcemap: true, 63 | target: "node20", 64 | external: externals, 65 | plugins: [rawPlugin], 66 | }; 67 | 68 | const clientMonitoringConfig = { 69 | entryPoints: [clientMonitoringEntry], 70 | absWorkingDir: projectRoot, 71 | outdir: "dist", 72 | platform: "browser", 73 | format: "iife", 74 | bundle: true, 75 | sourcemap: true, 76 | target: ["es2020"], 77 | }; 78 | 79 | if (process.argv.includes("--watch")) { 80 | const nodeCtx = await esbuild.context(nodeBuildConfig); 81 | const clientCtx = await esbuild.context(clientMonitoringConfig); 82 | await Promise.all([nodeCtx.watch(), clientCtx.watch()]); 83 | console.log("esbuild watching..."); 84 | } else { 85 | await Promise.all([ 86 | esbuild.build(nodeBuildConfig), 87 | esbuild.build(clientMonitoringConfig), 88 | ]); 89 | console.log("esbuild complete"); 90 | } 91 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/http/htmx.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | // IsHTMX reports whether the request was initiated by htmx (Hx-Request: true). 10 | func IsHTMX(r *http.Request) bool { 11 | return strings.EqualFold(r.Header.Get("Hx-Request"), "true") 12 | } 13 | 14 | // IsBoosted reports whether the request was initiated by hx-boost (Hx-Boosted: true). 15 | func IsBoosted(r *http.Request) bool { 16 | return strings.EqualFold(r.Header.Get("Hx-Boosted"), "true") 17 | } 18 | 19 | // IsHistoryRestore reports true when htmx is restoring history (Hx-History-Restore-Request: true). 20 | func IsHistoryRestore(r *http.Request) bool { 21 | return strings.EqualFold(r.Header.Get("Hx-History-Restore-Request"), "true") 22 | } 23 | 24 | // WantsPartial returns true when the handler should return only the main fragment (not full layout). 25 | // Rule: partial for all HTMX requests, including history restores. 26 | func WantsPartial(r *http.Request) bool { 27 | return IsHTMX(r) 28 | } 29 | 30 | // HXTarget returns the id of the target element being updated. 31 | func HXTarget(r *http.Request) string { return r.Header.Get("Hx-Target") } 32 | 33 | // HXTrigger returns the id/name of the element that triggered the request. 34 | func HXTrigger(r *http.Request) string { return r.Header.Get("Hx-Trigger") } 35 | 36 | // SetHXRedirect instructs htmx to redirect the browser to the given URL. 37 | func SetHXRedirect(w http.ResponseWriter, url string) { w.Header().Set("Hx-Redirect", url) } 38 | 39 | // SetHXPushURL pushes the given URL into the browser history for the new content. 40 | func SetHXPushURL(w http.ResponseWriter, url string) { w.Header().Set("Hx-Push-Url", url) } 41 | 42 | // SetHXRefresh forces a full page refresh when true. 43 | func SetHXRefresh(w http.ResponseWriter, refresh bool) { 44 | if refresh { 45 | w.Header().Set("Hx-Refresh", "true") 46 | return 47 | } 48 | w.Header().Set("Hx-Refresh", "false") 49 | } 50 | 51 | // SetHXTrigger triggers a client-side event after swap with optional payload. 52 | // It sets the Hx-Trigger response header as a JSON object: {"": }. 53 | // If payload is nil, the value true is used for the event. 54 | func SetHXTrigger(w http.ResponseWriter, event string, payload any) { 55 | var value any = true 56 | if payload != nil { 57 | value = payload 58 | } 59 | m := map[string]any{event: value} 60 | b, err := json.Marshal(m) 61 | if err != nil { 62 | // Fall back to a boolean trigger if payload cannot be serialized 63 | w.Header().Set("Hx-Trigger", "{\""+event+"\":true}") 64 | return 65 | } 66 | w.Header().Set("Hx-Trigger", string(b)) 67 | } 68 | -------------------------------------------------------------------------------- /services/merrymaker-go/frontend/public/js/features/screenshot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Screenshot modal feature module 3 | * 4 | * Wires up thumbnail triggers and exposes the modal helper globally for areas 5 | * that still access it via `window.showScreenshotModal`. 6 | */ 7 | import { Events } from "../core/events.js"; 8 | import { showScreenshotModal } from "../components/screenshot-modal.js"; 9 | 10 | let initialized = false; 11 | let observer; 12 | 13 | function revealScreenshotFallback(img) { 14 | if (!(img instanceof HTMLImageElement)) return; 15 | if (!img.matches("img[data-onerror-fallback]")) return; 16 | img.hidden = true; 17 | const fallback = img.nextElementSibling; 18 | if (fallback instanceof HTMLElement) { 19 | fallback.hidden = false; 20 | } 21 | } 22 | 23 | function handleExistingFailures(root = document) { 24 | const images = root.querySelectorAll("img[data-onerror-fallback]"); 25 | for (const img of images) { 26 | if (!(img instanceof HTMLImageElement)) continue; 27 | if (img.complete && img.naturalWidth === 0) { 28 | revealScreenshotFallback(img); 29 | } 30 | } 31 | } 32 | 33 | function observeNewImages() { 34 | if (observer || !("MutationObserver" in window) || !document.body) return; 35 | observer = new MutationObserver((mutations) => { 36 | for (const mutation of mutations) { 37 | for (const node of mutation.addedNodes) { 38 | if (!(node instanceof HTMLElement)) continue; 39 | if (node.matches?.("img[data-onerror-fallback]")) { 40 | if (node.complete && node.naturalWidth === 0) { 41 | revealScreenshotFallback(node); 42 | } 43 | } 44 | handleExistingFailures(node); 45 | } 46 | } 47 | }); 48 | observer.observe(document.body, { childList: true, subtree: true }); 49 | } 50 | 51 | function registerEventHandlers() { 52 | window.showScreenshotModal = showScreenshotModal; 53 | 54 | document.addEventListener( 55 | "error", 56 | (event) => { 57 | const target = event.target; 58 | revealScreenshotFallback(target); 59 | }, 60 | true, 61 | ); 62 | 63 | if (document.readyState === "loading") { 64 | document.addEventListener( 65 | "DOMContentLoaded", 66 | () => { 67 | handleExistingFailures(); 68 | observeNewImages(); 69 | }, 70 | { once: true }, 71 | ); 72 | } else { 73 | handleExistingFailures(); 74 | observeNewImages(); 75 | } 76 | 77 | Events.on(".screenshot-thumbnail", "click", (_event, img) => { 78 | const src = img.dataset.src || img.src; 79 | const caption = img.dataset.caption || ""; 80 | showScreenshotModal(src, caption); 81 | }); 82 | } 83 | 84 | export function initScreenshotFeature() { 85 | if (initialized) return; 86 | initialized = true; 87 | registerEventHandlers(); 88 | } 89 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/service/failurenotifier/notifier.go: -------------------------------------------------------------------------------- 1 | package failurenotifier 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "sync" 7 | 8 | "github.com/target/mmk-ui-api/internal/domain/model" 9 | "github.com/target/mmk-ui-api/internal/observability/notify" 10 | ) 11 | 12 | // SinkRegistration pairs a sink implementation with a human-readable name for logging. 13 | type SinkRegistration struct { 14 | Name string 15 | Sink notify.Sink 16 | } 17 | 18 | // Options configures the failure notifier service. 19 | type Options struct { 20 | Logger *slog.Logger 21 | Sinks []SinkRegistration 22 | } 23 | 24 | // Service dispatches failure events to all registered sinks. 25 | type Service struct { 26 | logger *slog.Logger 27 | sinks []SinkRegistration 28 | } 29 | 30 | // NewService constructs a failure notifier. 31 | func NewService(opts Options) *Service { 32 | logger := opts.Logger 33 | if logger == nil { 34 | logger = slog.Default().With("component", "failure_notifier") 35 | } 36 | 37 | var sinks []SinkRegistration 38 | for _, entry := range opts.Sinks { 39 | if entry.Sink == nil { 40 | continue 41 | } 42 | name := entry.Name 43 | if name == "" { 44 | name = "sink" 45 | } 46 | sinks = append(sinks, SinkRegistration{ 47 | Name: name, 48 | Sink: entry.Sink, 49 | }) 50 | } 51 | 52 | return &Service{ 53 | logger: logger, 54 | sinks: sinks, 55 | } 56 | } 57 | 58 | // NotifyJobFailure fan-outs the job failure payload to all sinks. 59 | func (s *Service) NotifyJobFailure(ctx context.Context, payload notify.JobFailurePayload) { 60 | if len(s.sinks) == 0 { 61 | return 62 | } 63 | 64 | if payload.JobType == string(model.JobTypeBrowser) && payload.IsTest { 65 | if s.logger != nil { 66 | s.logger.DebugContext(ctx, "skipping notification for test browser job", 67 | "job_id", payload.JobID, 68 | "job_type", payload.JobType, 69 | ) 70 | } 71 | return 72 | } 73 | 74 | if payload.Severity == "" { 75 | payload.Severity = notify.SeverityCritical 76 | } 77 | 78 | var wg sync.WaitGroup 79 | for _, entry := range s.sinks { 80 | wg.Add(1) 81 | go func() { 82 | defer wg.Done() 83 | if err := entry.Sink.SendJobFailure(ctx, payload); err != nil { 84 | s.logger.Error("failure notifier delivery error", 85 | "sink", entry.Name, 86 | "job_id", payload.JobID, 87 | "job_type", payload.JobType, 88 | "error", err, 89 | ) 90 | } 91 | }() 92 | } 93 | wg.Wait() 94 | } 95 | 96 | // Enabled reports whether the notifier has any active sinks. 97 | func (s *Service) Enabled() bool { 98 | return len(s.sinks) > 0 99 | } 100 | -------------------------------------------------------------------------------- /services/merrymaker-go/internal/bootstrap/config.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/caarlos0/env/v11" 10 | "github.com/joho/godotenv" 11 | "github.com/target/mmk-ui-api/config" 12 | ) 13 | 14 | // InitLogger initializes the structured logger. 15 | func InitLogger() *slog.Logger { 16 | logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 17 | Level: slog.LevelInfo, 18 | })) 19 | slog.SetDefault(logger) 20 | return logger 21 | } 22 | 23 | // LoadConfig loads configuration from environment variables. 24 | func LoadConfig() (config.AppConfig, error) { 25 | // Load .env file if it exists (development) 26 | if err := godotenv.Load(); err != nil { 27 | var pathErr *os.PathError 28 | if !errors.As(err, &pathErr) { 29 | return config.AppConfig{}, fmt.Errorf("load .env file: %w", err) 30 | } 31 | } 32 | 33 | var cfg config.AppConfig 34 | if err := env.Parse(&cfg); err != nil { 35 | return cfg, fmt.Errorf("parse config: %w", err) 36 | } 37 | 38 | cfg.Sanitize() 39 | return cfg, nil 40 | } 41 | 42 | // ValidateServiceConfig validates that at least one service is enabled. 43 | func ValidateServiceConfig(cfg *config.AppConfig) error { 44 | if cfg == nil { 45 | return errors.New("service config is required") 46 | } 47 | services, err := cfg.GetEnabledServices() 48 | if err != nil { 49 | return fmt.Errorf("invalid service configuration: %w", err) 50 | } 51 | 52 | if len(services) == 0 { 53 | return errors.New("no services enabled") 54 | } 55 | 56 | return nil 57 | } 58 | 59 | // GetEnabledServices returns a list of enabled service names. 60 | func GetEnabledServices(cfg *config.AppConfig) []string { 61 | if cfg == nil { 62 | return []string{} 63 | } 64 | services, err := cfg.GetEnabledServices() 65 | if err != nil { 66 | // Return empty list on error - validation will catch this 67 | return []string{} 68 | } 69 | 70 | enabledServices := make([]string, 0, len(services)) 71 | for svc := range services { 72 | switch svc { 73 | case config.ServiceModeHTTP: 74 | enabledServices = append(enabledServices, "http") 75 | case config.ServiceModeRulesEngine: 76 | enabledServices = append(enabledServices, "rules-engine") 77 | case config.ServiceModeScheduler: 78 | enabledServices = append(enabledServices, "scheduler") 79 | case config.ServiceModeAlertRunner: 80 | enabledServices = append(enabledServices, "alert-runner") 81 | case config.ServiceModeSecretRefreshRunner: 82 | enabledServices = append(enabledServices, "secret-refresh-runner") 83 | case config.ServiceModeReaper: 84 | enabledServices = append(enabledServices, "reaper") 85 | } 86 | } 87 | 88 | return enabledServices 89 | } 90 | --------------------------------------------------------------------------------