diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 74a63033..19dea956 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -69,3 +69,8 @@ jobs: path: apps/docs/.next retention-days: 14 if-no-files-found: error + # Next.js writes its build output to `.next/` — a dot-prefixed + # directory which actions/upload-artifact@v4 treats as hidden + # and skips by default. Without this flag the upload step + # reports "No files were found" even when the build succeeded. + include-hidden-files: true diff --git a/apps/admin/src/app/(authenticated)/comments/types.ts b/apps/admin/src/app/(authenticated)/comments/types.ts index 93ef1c77..e7959301 100644 --- a/apps/admin/src/app/(authenticated)/comments/types.ts +++ b/apps/admin/src/app/(authenticated)/comments/types.ts @@ -5,10 +5,18 @@ * (`apps/api/internal/admin/comments`). Keeping them in one place * means the list, detail, status badge, and bulk-action components * compile against a single contract. + * + * The admin endpoint (`/api/v1/admin/comments`) emits a richer row + * shape than the public spec's `Comment` schema (it joins the post + * title, threads, etc.), so the row interfaces below are NOT derived + * from the spec wholesale. The status enum, however, IS shared with + * the public API and is sourced from there — issue #514 follow-up so + * an enum value change shows up as a type error here. */ +import type { components } from '@gonext/api-types'; /** Canonical moderation states. Mirrors the API's Status type. */ -export type CommentStatus = 'pending' | 'approved' | 'spam' | 'trash'; +export type CommentStatus = components['schemas']['Comment']['status']; /** Bulk action verbs accepted by `/api/v1/admin/comments/bulk`. */ export type BulkAction = 'approve' | 'spam' | 'trash'; diff --git a/apps/admin/src/app/(authenticated)/jobs/dlq/types.ts b/apps/admin/src/app/(authenticated)/jobs/dlq/types.ts index 0884b153..ba9e22a6 100644 --- a/apps/admin/src/app/(authenticated)/jobs/dlq/types.ts +++ b/apps/admin/src/app/(authenticated)/jobs/dlq/types.ts @@ -10,7 +10,15 @@ * Field nullability follows the Go side: optional fields use `?` and * map to `omitempty` JSON tags. When the Go contract changes, this * file changes with it. + * + * Issue #514 follow-up: `ArchivedTask` is sourced from the OpenAPI + * spec via `@gonext/api-types` so the wire fields are in lock-step + * with the server's struct tags. The list rendering depends on + * `failed_at` being present (the row's primary timestamp), so we + * tighten that field locally — once the spec marks it required this + * override can go away. */ +import type { components } from '@gonext/api-types'; /** * A single archived (dead-letter) task as returned by the list and @@ -22,21 +30,18 @@ * `redacted_fields` is present only when the task has an active * redaction record — the UI uses its presence to render the masked * badge and the "fields masked: X, Y" hint. + * + * `failed_at` is required client-side because every list row renders + * the relative "5m ago" timestamp — a missing value would degrade to + * "Invalid date". The spec marks it optional; this is a local tighten + * tracked as a spec follow-up. */ -export interface ArchivedTask { - id: string; - queue: string; - type: string; - payload_preview: string; - /** Base64-encoded raw bytes on the detail endpoint; omitted on list. */ - payload?: string; - last_error: string; +export type ArchivedTask = Omit< + components['schemas']['ArchivedTask'], + 'failed_at' +> & { failed_at: string; - retried: number; - max_retry: number; - redacted: boolean; - redacted_fields?: string[]; -} +}; /** * The paginated list response — matches `router.Page[ArchivedTask]` @@ -52,11 +57,15 @@ export interface DLQListResponse { /** * The body shape for `POST /dlq/{id}/redact`. + * + * Extends the spec's `RedactRequest` (which only models `fields`) with + * the admin endpoint's `queue` discriminator. The spec will gain the + * field once the admin DLQ surface is folded into the public schema + * — tracked as #514 follow-up. */ -export interface RedactRequest { +export type RedactRequest = components['schemas']['RedactRequest'] & { queue: string; - fields: string[]; -} +}; /** * Common queue names for the filter chip. We don't fetch the actual diff --git a/apps/admin/src/app/(authenticated)/pages/page.tsx b/apps/admin/src/app/(authenticated)/pages/page.tsx index 52d13a33..115b0851 100644 --- a/apps/admin/src/app/(authenticated)/pages/page.tsx +++ b/apps/admin/src/app/(authenticated)/pages/page.tsx @@ -27,6 +27,7 @@ import Link from 'next/link'; import { Suspense, type ReactElement } from 'react'; import { Plus } from 'lucide-react'; +import type { components } from '@gonext/api-types'; import { serverApiFetch } from '@/lib/server-api'; import { Headline } from '@/components/ui/headline'; import { Button } from '@/components/ui/button'; @@ -35,10 +36,11 @@ import { Badge } from '@/components/ui/badge'; export const dynamic = 'force-dynamic'; /** Status values the page list surfaces. Mirrors the WP-compat set - * the API speaks. The API uses `publish`/`scheduled`/`draft`; we - * normalise scheduled to `future` so the badge logic stays aligned - * with the post detail surface. */ -export type PageStatus = 'publish' | 'draft' | 'future' | 'private' | 'trash'; + * the API speaks. The spec's `Post.status` (Page is a Post alias) + * carries the canonical enum — we source it from there so a status + * alphabet change in `openapi.yaml` shows up as a type error rather + * than as silent UI drift (issue #514 follow-up). */ +export type PageStatus = components['schemas']['Page']['status']; /** Flatter shape the list UI renders. Pulled out of the adapter so * the tests can exercise the field-mapping rules without spinning up @@ -52,15 +54,23 @@ export interface SitePage { updatedAt: string; } -/** Wire shape we expect from `GET /api/v1/posts?post_type=page`. */ -export type ApiPagePost = { +/** + * Wire shape we expect from `GET /api/v1/posts?post_type=page`. + * + * Issue #514 follow-up: derived from the OpenAPI spec's `Page` schema + * (which aliases `Post`). The list endpoint emits a sparse projection + * — most fields the spec marks required are absent on a list row — so + * we treat every field as optional via `Partial<>` and guarantee `id` + * explicitly (the row key MUST be present). `status` is explicitly + * widened back to `string` because the API sometimes returns the + * past-tense `published` / `scheduled` variants which the adapter + * normalises to the canonical enum (and the tests exercise that + * branch with fixture values like `"bogus"`). + */ +export type ApiPagePost = Omit, 'status'> & { id: string; - title?: string; - slug?: string; status?: string; published_at?: string | null; - updated_at?: string; - created_at?: string; }; /** diff --git a/apps/admin/src/app/(authenticated)/posts/[id]/page.tsx b/apps/admin/src/app/(authenticated)/posts/[id]/page.tsx index b61f3753..22e50858 100644 --- a/apps/admin/src/app/(authenticated)/posts/[id]/page.tsx +++ b/apps/admin/src/app/(authenticated)/posts/[id]/page.tsx @@ -39,6 +39,7 @@ import { } from '@gonext/blocks-editor'; import { BlockRegistry } from '@gonext/blocks-sdk'; import type { BlockTree } from '@gonext/blocks-sdk'; +import type { components } from '@gonext/api-types'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -47,14 +48,31 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { api } from '@/lib/api-client'; -type PostStatus = 'draft' | 'publish' | 'future' | 'private'; +/** + * Status alphabet — sourced from the OpenAPI spec (issue #514 follow-up). + * The editor UI only exposes the four user-facing values; `pending` and + * `trash` are valid server states but flow in through the list screen + * (bulk actions) rather than this dropdown. + */ +type PostStatus = components['schemas']['Post']['status']; -interface UpdatePostBody { - title?: string; - slug?: string; +/** + * Body for `PATCH /api/v1/posts/{id}`. + * + * Issue #514 follow-up: based on the spec's `PostUpdate` schema. The + * spec types `status` as `string`; we tighten it here to the strict + * enum so the dropdown handler can't smuggle in a bogus value. The + * spec types `content_blocks` as an opaque `Record` + * placeholder; the editor needs the typed `BlockTree`, so we override + * that field. + */ +type UpdatePostBody = Omit< + components['schemas']['PostUpdate'], + 'status' | 'content_blocks' +> & { status?: PostStatus; content_blocks?: BlockTree; -} +}; export default function PostDetailPage(): ReactElement { const params = useParams<{ id: string }>(); diff --git a/apps/admin/src/app/(authenticated)/posts/columns.tsx b/apps/admin/src/app/(authenticated)/posts/columns.tsx index 71046362..ed3fe897 100644 --- a/apps/admin/src/app/(authenticated)/posts/columns.tsx +++ b/apps/admin/src/app/(authenticated)/posts/columns.tsx @@ -12,16 +12,17 @@ * deferred to the edit screen. */ import type { ReactElement } from 'react'; +import type { components } from '@gonext/api-types'; import styles from './posts.module.css'; -/** Canonical post status set used across the admin. */ -export type PostStatus = - | 'publish' - | 'draft' - | 'pending' - | 'private' - | 'future' - | 'trash'; +/** + * Canonical post status set used across the admin. + * + * Issue #514 follow-up: derived from the OpenAPI spec instead of + * hand-typed so a status alphabet change in `openapi.yaml` shows up + * here as a type error rather than as silent UI drift. + */ +export type PostStatus = components['schemas']['Post']['status']; /** Shape of a single post row as returned by `/api/v1/posts`. */ export interface Post { diff --git a/apps/admin/src/app/(authenticated)/posts/new/page.tsx b/apps/admin/src/app/(authenticated)/posts/new/page.tsx index 5585f166..5386cadb 100644 --- a/apps/admin/src/app/(authenticated)/posts/new/page.tsx +++ b/apps/admin/src/app/(authenticated)/posts/new/page.tsx @@ -29,6 +29,7 @@ import { type ReactElement, } from 'react'; import { ChevronLeft, Loader2, Plus } from 'lucide-react'; +import type { components } from '@gonext/api-types'; import { ApiError, api } from '@/lib/api-client'; import { Button } from '@/components/ui/button'; @@ -36,18 +37,44 @@ import { Headline } from '@/components/ui/headline'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -type PostStatus = 'draft' | 'publish' | 'private' | 'future'; +/** + * Status alphabet — sourced from the spec (issue #514 follow-up). The + * form only exposes the four operator-facing states; `pending` and + * `trash` (also in the spec enum) are workflow transitions that flow + * through other surfaces (review queue, list bulk actions). + */ +type PostStatus = components['schemas']['Post']['status']; -interface CreatePostBody { +/** + * Body posted to `/api/v1/posts`. + * + * Issue #514 follow-up: derived from the spec's `PostCreate` schema. + * Two local overrides: + * • `title`/`slug`/`status` are required by the form (the spec + * marks them optional because the server fills sensible defaults + * when omitted, but the UI always sends them). + * • `content_blocks` is typed as a `Record` placeholder + * in the spec (the spec models block-tree JSON as opaque). The + * create form sends an empty array to seed the post; the spec will + * gain a typed block-tree schema later (issue #514 follow-up). + */ +type CreatePostBody = Omit< + components['schemas']['PostCreate'], + 'title' | 'slug' | 'status' | 'content_blocks' +> & { title: string; slug: string; status: PostStatus; content_blocks: never[]; -} +}; -interface CreatePostResponse { - id: string; -} +/** + * Server response on success. The create endpoint returns the full + * `Post` projection per the spec; the form only reads the `id` to + * route to the editor, so we narrow with `Pick` to avoid coupling to + * fields the form doesn't care about. + */ +type CreatePostResponse = Pick; /** * Derive a URL-safe slug from a free-form title. Mirrors the server's diff --git a/apps/admin/src/app/(authenticated)/settings/tokens/types.ts b/apps/admin/src/app/(authenticated)/settings/tokens/types.ts index 9c364e55..b4e66f75 100644 --- a/apps/admin/src/app/(authenticated)/settings/tokens/types.ts +++ b/apps/admin/src/app/(authenticated)/settings/tokens/types.ts @@ -1,6 +1,6 @@ /** * On-wire shapes for /me/tokens. These mirror the Go IssuedTokenView / - * TokenView structs (apps/api/internal/admin/tokens/handler.go). Keep them + * TokenView structs (apps/api/internal/auth/pat/handler.go). Keep them * in lock-step — a mismatched field is a runtime decode failure, not a * type error. */ diff --git a/apps/admin/src/app/(authenticated)/webhooks/types.ts b/apps/admin/src/app/(authenticated)/webhooks/types.ts index d15fa64d..b91d643c 100644 --- a/apps/admin/src/app/(authenticated)/webhooks/types.ts +++ b/apps/admin/src/app/(authenticated)/webhooks/types.ts @@ -6,23 +6,31 @@ * changes, this file changes with it — there's no separate mapping * step, the fetch layer just asserts the response as one of these * types. + * + * Issue #514 follow-up: the base wire fields (`id`, `url`, `events`, + * `active`, `created_at`) are sourced from the OpenAPI spec via + * `@gonext/api-types`. The admin endpoint emits a richer projection + * (delivery telemetry, name, updated_at) that the spec doesn't model + * yet — those fields stay as a local intersection so a spec change + * still flags drift on the shared fields. */ +import type { components } from '@gonext/api-types'; + +type WebhookSpec = components['schemas']['Webhook']; -export interface Subscription { - id: string; +export type Subscription = Pick< + WebhookSpec, + 'id' | 'url' | 'events' | 'active' | 'created_at' +> & { name: string; - url: string; - events: string[]; - active: boolean; created_by?: string; - created_at: string; updated_at: string; last_delivery_at?: string; last_delivery_status?: 'success' | 'retry' | 'failed' | ''; last_delivery_response_code?: number; consecutive_failures: number; degraded_at?: string; -} +}; /** * Returned by POST /webhooks — includes the raw HMAC secret as a hex @@ -34,19 +42,27 @@ export interface SubscriptionWithSecret extends Subscription { secret: string; } -export interface SubscriptionCreate { +/** + * Body for POST /webhooks. Extends the spec's `WebhookCreate` with the + * admin-only `name` field (the spec doesn't model it yet — tracked as + * follow-up). `active` is required by the spec; we relax it here so + * the create form can omit it and let the server default kick in. + */ +export type SubscriptionCreate = Omit< + components['schemas']['WebhookCreate'], + 'secret' | 'active' +> & { name: string; - url: string; - events: string[]; active?: boolean; -} +}; -export interface SubscriptionUpdate { +/** + * Body for PATCH /webhooks/{id}. The admin form additionally allows + * editing `name`, which the spec doesn't model yet. + */ +export type SubscriptionUpdate = components['schemas']['WebhookUpdate'] & { name?: string; - url?: string; - events?: string[]; - active?: boolean; -} +}; export interface Delivery { id: number; diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index c547acad..0ad1a076 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -12,7 +12,7 @@ # -t ghcr.io/singleton-solution/gonext-api:dev . # # Layer story (see docs/09-deployment-ops.md §2.2): -# 1. golang:1.25-alpine builder with module cache mount +# 1. golang:1.25.10-alpine builder with module cache mount # 2. distroless/static-debian12:nonroot runtime, ~25-30 MB # # The Go workspace at the repo root (go.work) references this module plus @@ -29,7 +29,11 @@ ARG IMAGEPROC_BACKEND=stdlib # ---------- Stage 1: build ---------- -FROM golang:1.25-alpine AS build +# Pinned to a specific patch so govulncheck's stdlib coverage is reproducible +# across runs. Bumped from 1.25 → 1.25.10 to clear the stdlib CVEs that the +# `security-scan` CI job flagged (net/mail, html/template, net, crypto/x509, +# crypto/tls, net/http, os, net/url all have fixes in 1.25.8–1.25.10). +FROM golang:1.25.10-alpine AS build # IMAGEPROC_BACKEND selects between the pure-Go default (`stdlib`) and # the libvips-backed `govips` build of the on-the-fly image proxy diff --git a/apps/api/go.mod b/apps/api/go.mod index 7f7e7ad7..3a626f06 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -14,6 +14,7 @@ require ( github.com/redis/go-redis/v9 v9.19.0 github.com/vektah/gqlparser/v2 v2.5.33 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -124,7 +125,7 @@ require ( golang.org/x/crypto v0.52.0 // indirect golang.org/x/image v0.40.0 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect @@ -134,7 +135,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/Singleton-Solution/GoNext/packages/go => ../../packages/go diff --git a/apps/api/go.sum b/apps/api/go.sum index 632f3bbc..bcccb30e 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -300,8 +300,8 @@ golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/apps/api/internal/admin/tokens/doc.go b/apps/api/internal/admin/tokens/doc.go deleted file mode 100644 index 3bfa4a93..00000000 --- a/apps/api/internal/admin/tokens/doc.go +++ /dev/null @@ -1,35 +0,0 @@ -// Package tokens implements the REST surface for Personal Access Tokens -// — the /me/tokens routes operators use to issue, list, and revoke -// long-lived API credentials. -// -// The routes live under /api/v1/me/tokens (NOT /api/v1/admin/...) for -// two reasons: -// -// 1. PATs are owned by their issuing user, even when that user is an -// operator. The "admin" namespace is for cross-user surfaces (DLQ, -// RUM, status); per-user tokens belong under /me/. -// 2. Routing under /me/ makes the access pattern uniform with the -// existing /me/sessions surface (PR #291) — every operator and -// every regular user lands on the same path. -// -// Auth contract: -// -// - All three routes require an authenticated session OR a PAT with -// the implicit "self" scope. A PAT can manage its sibling PATs of -// the same user; this is intentional, matching the OAuth-app -// posture most CIs expect. -// - Route-level capability checks are explicit: the PAT middleware -// and policy.Require compose with each other; see scopes.go. -// -// Response shape: -// -// - GET /me/tokens — list of TokenView (no hash, no plaintext). -// - POST /me/tokens — single IssuedTokenView, exactly ONCE in this -// response; the plaintext field is the only place the operator -// will ever see the full token. The UI is responsible for -// surfacing the "save now, you won't see it again" warning. -// - DELETE /me/tokens/{id} — 204 No Content. -// -// Errors use the router.ProblemDetails envelope to match the rest of -// the admin REST surface. -package tokens diff --git a/apps/api/internal/admin/tokens/handler.go b/apps/api/internal/admin/tokens/handler.go deleted file mode 100644 index fd6d5e54..00000000 --- a/apps/api/internal/admin/tokens/handler.go +++ /dev/null @@ -1,308 +0,0 @@ -package tokens - -import ( - "encoding/json" - "errors" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router" - "github.com/Singleton-Solution/GoNext/packages/go/auth/pat" - "github.com/Singleton-Solution/GoNext/packages/go/policy" -) - -// Deps is the dependency bag for Mount. Every field is required; -// validate() catches missing fields at boot rather than NPE'ing on the -// first request. -type Deps struct { - // Store is the PAT persistence layer. Required. - Store pat.Store - - // UserCaps resolves the user's effective capability set. Required - // — the issue handler narrows the requested scopes against this - // to surface an "effective" set in the response, and the middleware - // uses the same resolver for the auth path. - UserCaps pat.UserCapsFunc - - // Logger receives structured log lines. nil falls back to - // slog.Default — fine for tests, but production wiring should - // always pass a service logger. - Logger *slog.Logger - - // Now, if set, replaces time.Now. Used by tests to pin expiry. - Now func() time.Time -} - -func (d Deps) validate() error { - if d.Store == nil { - return errors.New("admin/tokens: Store is required") - } - if d.UserCaps == nil { - return errors.New("admin/tokens: UserCaps resolver is required") - } - return nil -} - -// handlers is the resolved-Deps form passed around inside the package. -type handlers struct { - store pat.Store - userCaps pat.UserCapsFunc - logger *slog.Logger - now func() time.Time -} - -// Mount wires the /me/tokens routes onto mux under base (typically -// "/api/v1/me/tokens"). Returns an error rather than panicking if -// Deps is malformed. -// -// The route tree: -// -// GET {base} — list current user's active tokens -// POST {base} — issue; response carries plaintext ONCE -// DELETE {base}/{id} — revoke (idempotent; second call is 204) -// -// Every route requires the user to be authenticated. The caller is -// responsible for mounting the session and/or PAT auth middleware -// upstream so the Principal is on the request context. -func Mount(mux *http.ServeMux, base string, deps Deps) error { - if err := deps.validate(); err != nil { - return err - } - if deps.Logger == nil { - deps.Logger = slog.Default() - } - if deps.Now == nil { - deps.Now = func() time.Time { return time.Now().UTC() } - } - h := &handlers{ - store: deps.Store, - userCaps: deps.UserCaps, - logger: deps.Logger, - now: deps.Now, - } - base = strings.TrimRight(base, "/") - mux.Handle("GET "+base, h.gate(h.list)) - mux.Handle("POST "+base, h.gate(h.issue)) - mux.Handle("DELETE "+base+"/{id}", h.gate(h.revoke)) - return nil -} - -// gate ensures a Principal is on the context. It does NOT do a -// capability check — every authenticated user may manage their own -// tokens. Cross-user surfaces use policy.Require with an explicit cap. -func (h *handlers) gate(next func(http.ResponseWriter, *http.Request, policy.Principal)) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - pr, ok := policy.FromContext(r.Context()) - if !ok || pr.UserID == "" { - router.WriteError(w, http.StatusUnauthorized, "unauthenticated", "authentication required") - return - } - next(w, r, pr) - }) -} - -// TokenView is the on-wire shape returned by list and revoke. It is -// deliberately separate from pat.PAT so the hash never appears in JSON -// even if a future refactor changes the in-memory layout. -type TokenView struct { - ID string `json:"id"` - Name string `json:"name"` - Prefix string `json:"prefix"` - Scopes []string `json:"scopes"` - CreatedAt time.Time `json:"created_at"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` -} - -// IssuedTokenView is the on-wire shape returned by the issue handler. -// The plaintext field is the ONLY place the operator will ever see -// the full token. The UI's TokenReveal component is responsible for -// gating dismissal behind a "save it" confirmation. -type IssuedTokenView struct { - TokenView - Plaintext string `json:"plaintext"` - // EffectiveScopes is the intersection of the requested scopes with - // the user's effective capability set. Surfaced so the UI can warn - // "you asked for posts.write, but your role doesn't grant that; - // the token can only do posts.read". - EffectiveScopes []string `json:"effective_scopes"` - // SaveNow is a UI hint that the plaintext is non-recoverable. - // Clients SHOULD render this prominently; we surface it as a flag - // rather than relying on the UI to know. - SaveNow bool `json:"save_now"` -} - -func toView(p pat.PAT) TokenView { - return TokenView{ - ID: p.ID, - Name: p.Name, - Prefix: p.Prefix, - Scopes: append([]string{}, p.Scopes...), - CreatedAt: p.CreatedAt, - LastUsedAt: p.LastUsedAt, - ExpiresAt: p.ExpiresAt, - } -} - -// list handles GET /me/tokens. -func (h *handlers) list(w http.ResponseWriter, r *http.Request, pr policy.Principal) { - rows, err := h.store.List(r.Context(), pr.UserID) - if err != nil { - h.logger.ErrorContext(r.Context(), "admin/tokens: list failed", - slog.String("user_id", pr.UserID), - slog.Any("err", err), - ) - router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to list tokens") - return - } - out := make([]TokenView, 0, len(rows)) - for _, p := range rows { - out = append(out, toView(p)) - } - router.WriteJSON(w, http.StatusOK, map[string]any{"data": out}) -} - -// IssueRequest is the JSON body for POST /me/tokens. -type IssueRequest struct { - Name string `json:"name"` - Scopes []string `json:"scopes"` - // ExpiresIn is the duration before the token expires, expressed - // as one of: "30d", "90d", "1y", "never". The presets match the - // UI radio group. Empty string means "never". - ExpiresIn string `json:"expires_in"` -} - -// presetExpiry maps the preset string to a duration. Unknown presets -// return ok=false; the handler maps that to a 400. -func presetExpiry(s string, now time.Time) (*time.Time, bool) { - switch strings.TrimSpace(s) { - case "", "never": - return nil, true - case "30d": - t := now.Add(30 * 24 * time.Hour) - return &t, true - case "90d": - t := now.Add(90 * 24 * time.Hour) - return &t, true - case "1y": - t := now.Add(365 * 24 * time.Hour) - return &t, true - default: - return nil, false - } -} - -// issue handles POST /me/tokens. -func (h *handlers) issue(w http.ResponseWriter, r *http.Request, pr policy.Principal) { - var req IssueRequest - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - if err := dec.Decode(&req); err != nil { - router.WriteError(w, http.StatusBadRequest, "invalid_body", "request body is not valid JSON") - return - } - if strings.TrimSpace(req.Name) == "" { - router.WriteError(w, http.StatusBadRequest, "invalid_name", "name is required") - return - } - if len(req.Scopes) == 0 { - router.WriteError(w, http.StatusBadRequest, "invalid_scopes", "at least one scope is required") - return - } - // Dedup scopes — a multi-select UI can submit duplicates after - // a quick double-click; we store the canonical set. - req.Scopes = dedupScopes(req.Scopes) - expiresAt, ok := presetExpiry(req.ExpiresIn, h.now()) - if !ok { - router.WriteError(w, http.StatusBadRequest, "invalid_expires_in", "expires_in must be one of: never, 30d, 90d, 1y") - return - } - - plaintext, row, hash, err := pat.New(pr.UserID, req.Name, req.Scopes, expiresAt) - if err != nil { - h.logger.ErrorContext(r.Context(), "admin/tokens: mint failed", - slog.String("user_id", pr.UserID), - slog.Any("err", err), - ) - router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to mint token") - return - } - stored, err := h.store.Issue(r.Context(), row, hash) - if err != nil { - h.logger.ErrorContext(r.Context(), "admin/tokens: insert failed", - slog.String("user_id", pr.UserID), - slog.Any("err", err), - ) - router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to persist token") - return - } - - caps, err := h.userCaps(r.Context(), pr.UserID) - if err != nil { - // We've already inserted the row; logging the cap-resolution - // failure but returning the token is the right call — the - // operator needs the plaintext NOW and the UI can re-fetch - // effective_scopes later. - h.logger.WarnContext(r.Context(), "admin/tokens: caps lookup failed", - slog.String("user_id", pr.UserID), - slog.Any("err", err), - ) - caps = policy.CapabilitySet{} - } - effective := pat.Intersect(req.Scopes, caps) - effectiveSlugs := make([]string, 0, len(effective)) - for c := range effective { - effectiveSlugs = append(effectiveSlugs, string(c)) - } - - view := IssuedTokenView{ - TokenView: toView(stored), - Plaintext: plaintext, - EffectiveScopes: effectiveSlugs, - SaveNow: true, - } - router.WriteJSON(w, http.StatusCreated, view) -} - -// revoke handles DELETE /me/tokens/{id}. -func (h *handlers) revoke(w http.ResponseWriter, r *http.Request, pr policy.Principal) { - id := r.PathValue("id") - if id == "" { - router.WriteError(w, http.StatusBadRequest, "missing_id", "token id is required") - return - } - err := h.store.Revoke(r.Context(), pr.UserID, id) - switch { - case err == nil: - w.WriteHeader(http.StatusNoContent) - case errors.Is(err, pat.ErrNotFound): - router.WriteError(w, http.StatusNotFound, "not_found", "token not found") - default: - h.logger.ErrorContext(r.Context(), "admin/tokens: revoke failed", - slog.String("user_id", pr.UserID), - slog.String("token_id", id), - slog.Any("err", err), - ) - router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to revoke token") - } -} - -func dedupScopes(in []string) []string { - seen := make(map[string]struct{}, len(in)) - out := make([]string, 0, len(in)) - for _, s := range in { - s = strings.TrimSpace(s) - if s == "" { - continue - } - if _, dup := seen[s]; dup { - continue - } - seen[s] = struct{}{} - out = append(out, s) - } - return out -} - diff --git a/apps/api/internal/admin/tokens/handler_test.go b/apps/api/internal/admin/tokens/handler_test.go deleted file mode 100644 index 6af2cf23..00000000 --- a/apps/api/internal/admin/tokens/handler_test.go +++ /dev/null @@ -1,372 +0,0 @@ -package tokens - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/Singleton-Solution/GoNext/packages/go/auth/pat" - "github.com/Singleton-Solution/GoNext/packages/go/policy" -) - -// withPrincipal wraps a handler with a middleware that injects a fixed -// Principal. Production wires the session/PAT middleware here; tests -// just want a Principal on the context. -func withPrincipal(p policy.Principal, h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r = r.WithContext(policy.WithPrincipal(r.Context(), p)) - h.ServeHTTP(w, r) - }) -} - -// newServer wires Mount + a principal-injecting middleware. Returns -// the server URL and the store (so tests can pre-seed rows). -func newServer(t *testing.T, p policy.Principal, caps policy.CapabilitySet) (string, pat.Store, func()) { - t.Helper() - store := pat.NewMemoryStore() - mux := http.NewServeMux() - err := Mount(mux, "/api/v1/me/tokens", Deps{ - Store: store, - UserCaps: func(_ context.Context, _ string) (policy.CapabilitySet, error) { - return caps, nil - }, - }) - if err != nil { - t.Fatalf("Mount: %v", err) - } - srv := httptest.NewServer(withPrincipal(p, mux)) - return srv.URL, store, srv.Close -} - -func decode(t *testing.T, r *http.Response, v any) { - t.Helper() - body, err := io.ReadAll(r.Body) - if err != nil { - t.Fatalf("read body: %v", err) - } - if err := json.Unmarshal(body, v); err != nil { - t.Fatalf("decode: %v\nbody: %s", err, body) - } -} - -// TestIssue_HappyPath — operator submits valid input; response carries -// the plaintext once, save_now=true, effective_scopes = scopes ∩ caps. -func TestIssue_HappyPath(t *testing.T) { - t.Parallel() - pr := policy.Principal{UserID: "user:1"} - caps := policy.NewCapabilitySet(policy.CapRead, policy.CapEditPosts) - url, store, cleanup := newServer(t, pr, caps) - defer cleanup() - - body, _ := json.Marshal(IssueRequest{ - Name: "ci-token", - Scopes: []string{"read", "edit_posts", "manage_options"}, - ExpiresIn: "30d", - }) - res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusCreated { - raw, _ := io.ReadAll(res.Body) - t.Fatalf("status %d body=%s", res.StatusCode, raw) - } - var view IssuedTokenView - decode(t, res, &view) - if view.Plaintext == "" || !strings.HasPrefix(view.Plaintext, "gnp_") { - t.Fatalf("plaintext missing or wrong shape: %q", view.Plaintext) - } - if !view.SaveNow { - t.Fatal("save_now must be true on the issue response") - } - // effective_scopes is the intersection — manage_options is dropped. - if got := view.EffectiveScopes; !containsAll(got, []string{"read", "edit_posts"}) || contains(got, "manage_options") { - t.Fatalf("effective_scopes wrong: %v", got) - } - // The row landed in the store. - rows, err := store.List(context.Background(), "user:1") - if err != nil { - t.Fatalf("List: %v", err) - } - if got, want := len(rows), 1; got != want { - t.Fatalf("store rows: %d want %d", got, want) - } -} - -// TestIssue_RejectsEmptyName — empty name is a 400 before any DB hit. -func TestIssue_RejectsEmptyName(t *testing.T) { - t.Parallel() - url, _, cleanup := newServer(t, policy.Principal{UserID: "user:1"}, nil) - defer cleanup() - body, _ := json.Marshal(IssueRequest{Name: " ", Scopes: []string{"read"}}) - res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusBadRequest { - t.Fatalf("status %d want 400", res.StatusCode) - } -} - -// TestIssue_RejectsEmptyScopes — empty scope list is a 400. -func TestIssue_RejectsEmptyScopes(t *testing.T) { - t.Parallel() - url, _, cleanup := newServer(t, policy.Principal{UserID: "user:1"}, nil) - defer cleanup() - body, _ := json.Marshal(IssueRequest{Name: "x", Scopes: nil}) - res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusBadRequest { - t.Fatalf("status %d want 400", res.StatusCode) - } -} - -// TestIssue_RejectsUnknownExpiry — only the documented presets are -// accepted; arbitrary durations are 400. -func TestIssue_RejectsUnknownExpiry(t *testing.T) { - t.Parallel() - url, _, cleanup := newServer(t, policy.Principal{UserID: "user:1"}, nil) - defer cleanup() - body, _ := json.Marshal(IssueRequest{Name: "x", Scopes: []string{"read"}, ExpiresIn: "42d"}) - res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusBadRequest { - t.Fatalf("status %d want 400", res.StatusCode) - } -} - -// TestList_NoTokens_ReturnsEmpty — fresh user gets {"data":[]}. -func TestList_NoTokens_ReturnsEmpty(t *testing.T) { - t.Parallel() - url, _, cleanup := newServer(t, policy.Principal{UserID: "user:1"}, nil) - defer cleanup() - res, err := http.Get(url + "/api/v1/me/tokens") - if err != nil { - t.Fatalf("GET: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Fatalf("status %d", res.StatusCode) - } - var out struct { - Data []TokenView `json:"data"` - } - decode(t, res, &out) - if len(out.Data) != 0 { - t.Fatalf("expected empty data, got %v", out.Data) - } -} - -// TestList_DoesNotIncludeHashOrPlaintext — guard against accidental -// leakage if someone adds a Hash/Plaintext field to TokenView later. -func TestList_DoesNotIncludeHashOrPlaintext(t *testing.T) { - t.Parallel() - pr := policy.Principal{UserID: "user:1"} - url, store, cleanup := newServer(t, pr, nil) - defer cleanup() - - _, row, hash, err := pat.New("user:1", "x", []string{"read"}, nil) - if err != nil { - t.Fatalf("New: %v", err) - } - if _, err := store.Issue(context.Background(), row, hash); err != nil { - t.Fatalf("Issue: %v", err) - } - - res, err := http.Get(url + "/api/v1/me/tokens") - if err != nil { - t.Fatalf("GET: %v", err) - } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) - low := strings.ToLower(string(body)) - if strings.Contains(low, "plaintext") { - t.Fatalf("list response leaked plaintext field: %s", body) - } - if strings.Contains(low, "\"hash\"") { - t.Fatalf("list response leaked hash field: %s", body) - } -} - -// TestRevoke_HappyPath — DELETE returns 204; subsequent list excludes -// the row. -func TestRevoke_HappyPath(t *testing.T) { - t.Parallel() - pr := policy.Principal{UserID: "user:1"} - url, store, cleanup := newServer(t, pr, nil) - defer cleanup() - - _, row, hash, err := pat.New("user:1", "x", []string{"read"}, nil) - if err != nil { - t.Fatalf("New: %v", err) - } - stored, err := store.Issue(context.Background(), row, hash) - if err != nil { - t.Fatalf("Issue: %v", err) - } - - req, _ := http.NewRequest("DELETE", url+"/api/v1/me/tokens/"+stored.ID, nil) - res, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("DELETE: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusNoContent { - t.Fatalf("status %d want 204", res.StatusCode) - } - rows, _ := store.List(context.Background(), "user:1") - if len(rows) != 0 { - t.Fatalf("expected 0 active tokens after revoke, got %d", len(rows)) - } -} - -// TestRevoke_Unknown — 404 when the id does not exist. -func TestRevoke_Unknown(t *testing.T) { - t.Parallel() - url, _, cleanup := newServer(t, policy.Principal{UserID: "user:1"}, nil) - defer cleanup() - req, _ := http.NewRequest("DELETE", url+"/api/v1/me/tokens/00000000-0000-7000-8000-000000000999", nil) - res, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("DELETE: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusNotFound { - t.Fatalf("status %d want 404", res.StatusCode) - } -} - -// TestRevoke_OtherUsersToken — user can't revoke another user's -// token. The store returns ErrNotFound; the handler propagates 404 -// rather than 403 to avoid leaking existence. -func TestRevoke_OtherUsersToken(t *testing.T) { - t.Parallel() - pr := policy.Principal{UserID: "user:1"} - url, store, cleanup := newServer(t, pr, nil) - defer cleanup() - - _, row, hash, err := pat.New("user:2", "x", []string{"read"}, nil) - if err != nil { - t.Fatalf("New: %v", err) - } - other, err := store.Issue(context.Background(), row, hash) - if err != nil { - t.Fatalf("Issue: %v", err) - } - - req, _ := http.NewRequest("DELETE", url+"/api/v1/me/tokens/"+other.ID, nil) - res, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("DELETE: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusNotFound { - t.Fatalf("status %d want 404", res.StatusCode) - } -} - -// TestGate_NoPrincipal_401 — without a Principal on the context the -// gate returns 401 instead of a panic. -func TestGate_NoPrincipal_401(t *testing.T) { - t.Parallel() - store := pat.NewMemoryStore() - mux := http.NewServeMux() - if err := Mount(mux, "/api/v1/me/tokens", Deps{ - Store: store, - UserCaps: func(_ context.Context, _ string) (policy.CapabilitySet, error) { - return nil, nil - }, - }); err != nil { - t.Fatalf("Mount: %v", err) - } - srv := httptest.NewServer(mux) - defer srv.Close() - res, err := http.Get(srv.URL + "/api/v1/me/tokens") - if err != nil { - t.Fatalf("GET: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusUnauthorized { - t.Fatalf("status %d want 401", res.StatusCode) - } -} - -// TestIssue_NeverExpiry — "never" preset yields a nil ExpiresAt. -func TestIssue_NeverExpiry(t *testing.T) { - t.Parallel() - pr := policy.Principal{UserID: "user:1"} - caps := policy.NewCapabilitySet(policy.CapRead) - url, _, cleanup := newServer(t, pr, caps) - defer cleanup() - body, _ := json.Marshal(IssueRequest{Name: "x", Scopes: []string{"read"}, ExpiresIn: "never"}) - res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST: %v", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusCreated { - t.Fatalf("status %d", res.StatusCode) - } - var view IssuedTokenView - decode(t, res, &view) - if view.ExpiresAt != nil { - t.Fatalf("expected nil ExpiresAt for never preset, got %v", view.ExpiresAt) - } -} - -// TestIssue_30dExpiry — yields ExpiresAt ≈ now + 30d. -func TestIssue_30dExpiry(t *testing.T) { - t.Parallel() - pr := policy.Principal{UserID: "user:1"} - caps := policy.NewCapabilitySet(policy.CapRead) - url, _, cleanup := newServer(t, pr, caps) - defer cleanup() - body, _ := json.Marshal(IssueRequest{Name: "x", Scopes: []string{"read"}, ExpiresIn: "30d"}) - res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST: %v", err) - } - defer res.Body.Close() - var view IssuedTokenView - decode(t, res, &view) - if view.ExpiresAt == nil { - t.Fatal("expected non-nil ExpiresAt") - } - d := time.Until(*view.ExpiresAt) - const margin = 5 * time.Minute - if d < 30*24*time.Hour-margin || d > 30*24*time.Hour+margin { - t.Fatalf("ExpiresAt out of band: %v", d) - } -} - -func contains(xs []string, want string) bool { - for _, x := range xs { - if x == want { - return true - } - } - return false -} - -func containsAll(xs, wants []string) bool { - for _, w := range wants { - if !contains(xs, w) { - return false - } - } - return true -} diff --git a/apps/api/internal/rest/posts/handlers.go b/apps/api/internal/rest/posts/handlers.go index ea8c2199..c37d562a 100644 --- a/apps/api/internal/rest/posts/handlers.go +++ b/apps/api/internal/rest/posts/handlers.go @@ -299,6 +299,21 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request, pr policy.Prin return } + // post_type body override mirrors the LIST behavior: the admin + // Pages screens POST to /api/v1/posts with body post_type="page" + // rather than depending on a separate /api/v1/pages mount that + // isn't wired yet. Validate against the closed {"post","page"} + // set; anything else is a 400 so a caller can't smuggle a row + // into an unsupported CPT. + postType := h.postType + if in.PostType != nil && *in.PostType != "" { + if *in.PostType != PostTypePost && *in.PostType != PostTypePage { + router.WriteError(w, http.StatusBadRequest, "invalid_post_type", fmt.Sprintf("unknown post_type %q", *in.PostType)) + return + } + postType = *in.PostType + } + // Publishing a post requires the publish_* cap. We do this here // rather than inside the store so a denial doesn't side-effect. if in.Status != nil && *in.Status == "published" { @@ -313,7 +328,7 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request, pr policy.Prin return } - post, err := h.store.Create(r.Context(), h.postType, pr.UserID, in) + post, err := h.store.Create(r.Context(), postType, pr.UserID, in) if err != nil { h.writeStoreError(w, r, err, "posts.create") return diff --git a/apps/api/internal/rest/posts/handlers_test.go b/apps/api/internal/rest/posts/handlers_test.go index f828be8a..073d6599 100644 --- a/apps/api/internal/rest/posts/handlers_test.go +++ b/apps/api/internal/rest/posts/handlers_test.go @@ -195,6 +195,123 @@ func TestCreate_PublishRequiresPublishCap(t *testing.T) { } } +// TestCreate_DefaultsToMountPostType is the regression baseline for the +// /pages/new flow (issue #506 follow-up): when the body omits post_type, +// the create must fall back to the mount's hardcoded discriminator. This +// is what every pre-fix caller expected to happen. +func TestCreate_DefaultsToMountPostType(t *testing.T) { + t.Parallel() + h := newHarness(t, PostTypePost) + pr := authorPrincipal("u1") + + title := "Hello" + req := httptest.NewRequest("POST", h.base, jsonBody(t, CreateInput{Title: &title})) + rec := h.do(req, &pr) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Post + decodeJSON(t, rec, &got) + if got.PostType != PostTypePost { + t.Errorf("post_type = %q, want %q (mount default)", got.PostType, PostTypePost) + } +} + +// TestCreate_BodyPostTypeOverrideToPage is the regression test for the +// admin /pages/new flow (issue #506 follow-up). The admin Pages create +// screen POSTs to /api/v1/posts with body {post_type:"page", ...}; the +// handler must honor that override so the row lands as a page and shows +// up in subsequent ?post_type=page list queries. +func TestCreate_BodyPostTypeOverrideToPage(t *testing.T) { + t.Parallel() + h := newHarness(t, PostTypePost) + pr := editorPrincipal("u1") + + title := "About" + slug := "about" + pageType := PostTypePage + body := CreateInput{ + Title: &title, + Slug: &slug, + PostType: &pageType, + } + req := httptest.NewRequest("POST", h.base, jsonBody(t, body)) + rec := h.do(req, &pr) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Post + decodeJSON(t, rec, &got) + if got.PostType != PostTypePage { + t.Errorf("post_type = %q, want %q (body override)", got.PostType, PostTypePage) + } + + // Round-trip: the row must be retrievable via ?post_type=page on + // the same /api/v1/posts mount (this is the path the admin Pages + // list actually uses). + listReq := httptest.NewRequest("GET", h.base+"?post_type=page", nil) + listRec := h.do(listReq, &pr) + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, body = %s", listRec.Code, listRec.Body.String()) + } + var page router.Page[Post] + decodeJSON(t, listRec, &page) + var found bool + for _, p := range page.Data { + if p.ID == got.ID { + found = true + break + } + } + if !found { + t.Errorf("created page id %q not present in ?post_type=page list: %+v", got.ID, page.Data) + } +} + +// TestCreate_BodyPostTypeInvalidRejected guards the closed-set +// validation on the create path: an unknown post_type is a 400, not a +// silent fall-through to the mount default. Matches the LIST behavior. +func TestCreate_BodyPostTypeInvalidRejected(t *testing.T) { + t.Parallel() + h := newHarness(t, PostTypePost) + pr := authorPrincipal("u1") + + bogus := "attachment" + body := CreateInput{PostType: &bogus} + req := httptest.NewRequest("POST", h.base, jsonBody(t, body)) + rec := h.do(req, &pr) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + var pd router.ProblemDetails + decodeJSON(t, rec, &pd) + if pd.Code != "invalid_post_type" { + t.Errorf("code = %q, want invalid_post_type", pd.Code) + } +} + +// TestCreate_PagesMount_BodyPostTypeOmitted confirms the mount default +// still wins when the body omits post_type, even on a /api/v1/pages +// mount. This is the symmetric case: a Page mount with no body override +// must still produce a page row. +func TestCreate_PagesMount_BodyPostTypeOmitted(t *testing.T) { + t.Parallel() + h := newHarness(t, PostTypePage) + pr := editorPrincipal("u1") // editor has edit_pages + + title := "Mount Default Page" + req := httptest.NewRequest("POST", h.base, jsonBody(t, CreateInput{Title: &title})) + rec := h.do(req, &pr) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Post + decodeJSON(t, rec, &got) + if got.PostType != PostTypePage { + t.Errorf("post_type = %q, want %q (mount default wins when body omits)", got.PostType, PostTypePage) + } +} + // ----------------------------------------------------------------------------- // GET ONE // ----------------------------------------------------------------------------- diff --git a/apps/api/internal/rest/posts/model.go b/apps/api/internal/rest/posts/model.go index 2ea48e56..c4fa23a6 100644 --- a/apps/api/internal/rest/posts/model.go +++ b/apps/api/internal/rest/posts/model.go @@ -61,6 +61,17 @@ func (p *Post) Hash() []byte { return p.hash } // content_blocks, meta, and status which all carry defaults at the // DB level. type CreateInput struct { + // PostType, when non-nil and non-empty, OVERRIDES the mount's + // hardcoded post type discriminator for this single create. Empty + // or omitted means "use the mount default". + // + // This exists so the admin Pages screens (issue #506) can POST to + // /api/v1/posts with body {post_type: "page", ...} rather than + // depending on a separate /api/v1/pages mount. The handler + // validates the value against the closed {"post","page"} set + // before forwarding it to the store, so a malicious request can't + // land a row as a CPT it shouldn't. + PostType *string `json:"post_type,omitempty"` ParentID *string `json:"parent_id"` Status *string `json:"status"` Title *string `json:"title"` diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile index 53257deb..1ce5e791 100644 --- a/apps/worker/Dockerfile +++ b/apps/worker/Dockerfile @@ -18,7 +18,11 @@ # root so go.work, packages/go, and apps/worker are all visible in /src. # ---------- Stage 1: build ---------- -FROM golang:1.25-alpine AS build +# Pinned to a specific patch so govulncheck's stdlib coverage is reproducible +# across runs. Bumped from 1.25 → 1.25.10 to clear the stdlib CVEs that the +# `security-scan` CI job flagged (net/mail, html/template, net, crypto/x509, +# crypto/tls, net/http, os, net/url all have fixes in 1.25.8–1.25.10). +FROM golang:1.25.10-alpine AS build RUN apk add --no-cache git ca-certificates tzdata diff --git a/apps/worker/go.mod b/apps/worker/go.mod index afd5e917..f0ad5f8e 100644 --- a/apps/worker/go.mod +++ b/apps/worker/go.mod @@ -42,7 +42,7 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/apps/worker/go.sum b/apps/worker/go.sum index 26f9a39e..4f39b0e2 100644 --- a/apps/worker/go.sum +++ b/apps/worker/go.sum @@ -200,8 +200,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= diff --git a/cli/gonext/Dockerfile b/cli/gonext/Dockerfile index 3828fbf7..e211c978 100644 --- a/cli/gonext/Dockerfile +++ b/cli/gonext/Dockerfile @@ -19,7 +19,11 @@ # See docs/09-deployment-ops.md §2.2 for the canonical layer story. # ---------- Stage 1: build ---------- -FROM golang:1.25-alpine AS build +# Pinned to a specific patch so govulncheck's stdlib coverage is reproducible +# across runs. Bumped from 1.25 → 1.25.10 to clear the stdlib CVEs that the +# `security-scan` CI job flagged (net/mail, html/template, net, crypto/x509, +# crypto/tls, net/http, os, net/url all have fixes in 1.25.8–1.25.10). +FROM golang:1.25.10-alpine AS build RUN apk add --no-cache git ca-certificates tzdata diff --git a/cli/gonext/cmd/audit/verify.go b/cli/gonext/cmd/audit/verify.go index 073a7082..35200c29 100644 --- a/cli/gonext/cmd/audit/verify.go +++ b/cli/gonext/cmd/audit/verify.go @@ -39,7 +39,10 @@ func runVerify(args []string, stdout, stderr io.Writer) int { return ExitUsage } - cfg, err := config.Load() + // Use LoadMinimal: this CLI subcommand only needs DATABASE_URL and + // GONEXT_AUDIT_HMAC_KEY. Forcing the operator to set the full auth + // secret surface to run an audit-chain verify would be a regression. + cfg, err := config.LoadMinimal() if err != nil { fmt.Fprintf(stderr, "audit verify: load config: %v\n", err) return ExitFail diff --git a/cli/gonext/cmd/jobs/jobs_test.go b/cli/gonext/cmd/jobs/jobs_test.go index 445c8765..6341bd48 100644 --- a/cli/gonext/cmd/jobs/jobs_test.go +++ b/cli/gonext/cmd/jobs/jobs_test.go @@ -52,8 +52,12 @@ func withStub(t *testing.T, stub *stubInspector) func() { return func() { inspectorFactory = prev } } +// Tests that swap `inspectorFactory` or `cronRegistryFactory` must NOT +// run in parallel — those vars are package-level globals and the race +// detector (CI runs with -race) flags concurrent reads/writes. The +// suite is fast enough that serialization costs nothing material. + func TestQueue_Table(t *testing.T) { - t.Parallel() stub := newStub() stub.infos["default"] = &asynq.QueueInfo{Queue: "default", Size: 7, Pending: 5} cleanup := withStub(t, stub) @@ -70,7 +74,6 @@ func TestQueue_Table(t *testing.T) { } func TestFailed_FiltersByQueue(t *testing.T) { - t.Parallel() stub := newStub() stub.archived["default"] = []*asynq.TaskInfo{ {ID: "t1", Type: "post.publish", LastErr: "boom", Retried: 3, LastFailedAt: time.Now()}, @@ -89,7 +92,6 @@ func TestFailed_FiltersByQueue(t *testing.T) { } func TestDrain_RequiresConfirmation(t *testing.T) { - t.Parallel() stub := newStub() stub.archived["default"] = []*asynq.TaskInfo{{ID: "t1"}} cleanup := withStub(t, stub) @@ -107,7 +109,6 @@ func TestDrain_RequiresConfirmation(t *testing.T) { } func TestDrain_HappyPath(t *testing.T) { - t.Parallel() stub := newStub() stub.archived["default"] = []*asynq.TaskInfo{{ID: "t1"}, {ID: "t2"}} cleanup := withStub(t, stub) @@ -125,7 +126,6 @@ func TestDrain_HappyPath(t *testing.T) { } func TestCron_EmptySnapshot(t *testing.T) { - t.Parallel() prev := cronRegistryFactory cronRegistryFactory = func() (CronRegistry, error) { return &staticCronRegistry{entries: []CronEntry{ diff --git a/cli/gonext/go.mod b/cli/gonext/go.mod index 23821e86..126d38db 100644 --- a/cli/gonext/go.mod +++ b/cli/gonext/go.mod @@ -6,6 +6,7 @@ require ( github.com/Singleton-Solution/GoNext/packages/go v0.0.0-00010101000000-000000000000 github.com/fsnotify/fsnotify v1.10.1 github.com/google/uuid v1.6.0 + github.com/hibiken/asynq v0.26.0 github.com/jackc/pgx/v5 v5.9.2 golang.org/x/term v0.43.0 ) @@ -59,8 +60,11 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/redis/go-redis/v9 v9.19.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect github.com/testcontainers/testcontainers-go/modules/minio v0.42.0 // indirect @@ -76,15 +80,18 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/gonext/go.sum b/cli/gonext/go.sum index 06577cd5..31aece8d 100644 --- a/cli/gonext/go.sum +++ b/cli/gonext/go.sum @@ -8,6 +8,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -45,6 +49,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= @@ -65,6 +71,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw= +github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -144,6 +152,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -152,6 +162,8 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= @@ -177,8 +189,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= -github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= @@ -193,16 +205,15 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -211,21 +222,23 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/go.work b/go.work index be3346f6..1f6e3681 100644 --- a/go.work +++ b/go.work @@ -1,11 +1,13 @@ go 1.25.0 +toolchain go1.25.10 + use ( ./apps/api ./apps/worker ./cli/gonext - ./examples/plugins/seo ./examples/plugins/sdk-go-hello + ./examples/plugins/seo ./packages/go ./packages/go/sdk ) diff --git a/migrations/000040_plugins.down.sql b/migrations/000040_plugins.down.sql new file mode 100644 index 00000000..c8add0e7 --- /dev/null +++ b/migrations/000040_plugins.down.sql @@ -0,0 +1,9 @@ +-- 000040_plugins.down.sql +-- +-- Drops the plugins lifecycle table, its state index, and the +-- updated_at trigger. The shared `touch_updated_at()` function stays +-- in place — other tables (posts, comments, …) depend on it. + +DROP TRIGGER IF EXISTS plugins_touch_updated_at ON plugins; +DROP INDEX IF EXISTS plugins_state_idx; +DROP TABLE IF EXISTS plugins; diff --git a/migrations/000040_plugins.up.sql b/migrations/000040_plugins.up.sql new file mode 100644 index 00000000..323786d6 --- /dev/null +++ b/migrations/000040_plugins.up.sql @@ -0,0 +1,137 @@ +-- 000040_plugins.up.sql +-- +-- The `plugins` table: lifecycle row for every plugin known to the +-- platform. One row per installed plugin (`gn-seo`, `gn-redirects`, …) +-- carrying the State the lifecycle Manager has parked it in and the +-- raw manifest the bundle shipped with. +-- +-- The Go implementation has lived in +-- `packages/go/plugins/lifecycle/postgres.go` since Wave N landed; the +-- column contract is documented in that package's `doc.go`. Until this +-- migration the table never actually existed: PR #527 papered over +-- this by making `Storage.List` return an empty slice on SQLSTATE +-- 42P01 (undefined_table) so the admin sidebar's +-- /api/v1/admin/plugin-pages poll didn't 500 on a fresh database, but +-- the other CRUD paths (Get/Insert/UpdateState/Delete) would still +-- explode. PR #529 mounted /api/v1/plugins on top of PostgresStorage, +-- which made the missing table user-visible the moment anyone hit a +-- non-List endpoint. +-- +-- This migration creates the table the Go layer has been writing +-- against in spec, plus the state index documented in doc.go, plus the +-- BEFORE UPDATE trigger that keeps `updated_at` honest without making +-- the application layer remember to set it on every write. +-- +-- Depends on: +-- * 000001_init — for the `citext` extension (used for the slug PK). +-- * 000004_posts — for the shared `touch_updated_at()` trigger +-- helper. The helper is defined in 000004 and we +-- reuse it here rather than declaring a new copy. + +-- ============================================================================= +-- Table +-- ============================================================================= +-- +-- Columns match the Storage interface in +-- packages/go/plugins/lifecycle/postgres.go::insertSQL / selectColumns: +-- +-- slug — natural PK. CITEXT so a future admin renaming via +-- capital letters doesn't sneak a duplicate past the +-- uniqueness check; the lifecycle Manager still +-- validates the manifest-side regex +-- (`^[a-z][a-z0-9-]{2,40}$`) before inserting so the +-- on-disk values stay lower-case in practice. +-- version — SemVer string from the manifest. Stored verbatim; +-- the package does not compare versions. +-- abi_version — host ABI the bundle was compiled against, so the +-- admin UI can render compatibility info without +-- re-parsing the manifest. +-- manifest — raw manifest.json bytes. +-- state — lifecycle State enum. CHECK constraint keeps a +-- forgotten Storage caller from poisoning the column +-- with a value the State type wouldn't accept. +-- capabilities — sandbox capability list parsed out of the manifest's +-- top-level `capabilities` block. Stored as JSONB so +-- policy code can `?` / `@>` against it without +-- re-parsing the manifest. +-- last_error — most recent transition failure, human-readable. +-- Cleared by Manager.Reset. +-- error_at — moment `last_error` was recorded. NULL when there +-- has been no error. +-- installed_at — moment Install succeeded. Never updated after +-- insert. +-- activated_at — moment of the most recent Activate, or NULL if the +-- plugin has never been activated. Not cleared on +-- Deactivate so the admin UI can show "last active 3 +-- days ago". +-- row_version — bumped on every UpdateState. Storage maintains it +-- inside the UPDATE itself (see updateStateSQL in +-- postgres.go), so the trigger here only touches +-- updated_at. +-- updated_at — bumped on every UPDATE by the trigger below. + +CREATE TABLE plugins ( + slug CITEXT PRIMARY KEY + CHECK (length(slug) > 0 AND length(slug) <= 64), + + version TEXT NOT NULL, + + abi_version INTEGER NOT NULL, + + manifest JSONB NOT NULL DEFAULT '{}'::JSONB, + + state TEXT NOT NULL + CHECK (state IN ( + 'installed', + 'active', + 'inactive', + 'pending_uninstall', + 'errored' + )), + + capabilities JSONB NOT NULL DEFAULT '[]'::JSONB, + + last_error TEXT NOT NULL DEFAULT '', + error_at TIMESTAMPTZ, + + installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + activated_at TIMESTAMPTZ, + + row_version BIGINT NOT NULL DEFAULT 1, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE plugins IS + 'Plugin lifecycle rows. One per installed plugin. See packages/go/plugins/lifecycle/doc.go for the column contract.'; +COMMENT ON COLUMN plugins.slug IS + 'Manifest-supplied identifier (regex ^[a-z][a-z0-9-]{2,40}$). CITEXT so case variants collide.'; +COMMENT ON COLUMN plugins.state IS + 'Lifecycle State enum. CHECK enforces the same set the Go State type considers Valid().'; +COMMENT ON COLUMN plugins.row_version IS + 'Optimistic-concurrency counter. Bumped inside Storage.UpdateState (see postgres.go::updateStateSQL).'; + +-- ============================================================================= +-- Index: state lookups +-- ============================================================================= +-- +-- The admin sidebar and the future marketplace UI both filter by state +-- ("show me all Active plugins", "show me anything in Errored"). +-- Without an index that's a sequential scan; the table is small but +-- the queries are on the render path, so an index is cheap insurance. +CREATE INDEX plugins_state_idx ON plugins (state); + +-- ============================================================================= +-- updated_at trigger +-- ============================================================================= +-- +-- Reuses the shared `touch_updated_at()` helper introduced in +-- 000004_posts.up.sql rather than declaring a per-table function — the +-- helper is exactly the body we need (NEW.updated_at := now();) and +-- has been the platform-wide convention for tables that only need +-- updated_at-bumping without an OCC counter (the OCC `row_version` +-- column here is managed by Storage.UpdateState in the UPDATE +-- statement itself, not by a trigger). +CREATE TRIGGER plugins_touch_updated_at + BEFORE UPDATE ON plugins + FOR EACH ROW + EXECUTE FUNCTION touch_updated_at(); diff --git a/migrations/000033_gdpr_anonymization.down.sql b/migrations/000041_gdpr_anonymization.down.sql similarity index 94% rename from migrations/000033_gdpr_anonymization.down.sql rename to migrations/000041_gdpr_anonymization.down.sql index c819e93d..263ff232 100644 --- a/migrations/000033_gdpr_anonymization.down.sql +++ b/migrations/000041_gdpr_anonymization.down.sql @@ -1,4 +1,4 @@ --- 000033_gdpr_anonymization.down.sql +-- 000041_gdpr_anonymization.down.sql -- -- Reverse of the GDPR anonymization columns. We drop the index first -- because it depends on the column; Postgres would refuse to drop the diff --git a/migrations/000033_gdpr_anonymization.up.sql b/migrations/000041_gdpr_anonymization.up.sql similarity index 98% rename from migrations/000033_gdpr_anonymization.up.sql rename to migrations/000041_gdpr_anonymization.up.sql index 1c097105..633fd773 100644 --- a/migrations/000033_gdpr_anonymization.up.sql +++ b/migrations/000041_gdpr_anonymization.up.sql @@ -1,4 +1,4 @@ --- 000033_gdpr_anonymization.up.sql +-- 000041_gdpr_anonymization.up.sql -- -- GDPR "right to erasure" support (issue #216). -- diff --git a/migrations/000033_audit_prev_hash.down.sql b/migrations/000042_audit_prev_hash.down.sql similarity index 65% rename from migrations/000033_audit_prev_hash.down.sql rename to migrations/000042_audit_prev_hash.down.sql index a7b17710..d924bacf 100644 --- a/migrations/000033_audit_prev_hash.down.sql +++ b/migrations/000042_audit_prev_hash.down.sql @@ -1,6 +1,6 @@ --- 000033_audit_prev_hash.down.sql +-- 000042_audit_prev_hash.down.sql -- --- Reverse of 000033_audit_prev_hash.up.sql. Since the up only +-- Reverse of 000042_audit_prev_hash.up.sql. Since the up only -- updated the column comment (the prev_hash column itself was added -- by 000029), the down restores the original placeholder comment. diff --git a/migrations/000033_audit_prev_hash.up.sql b/migrations/000042_audit_prev_hash.up.sql similarity index 98% rename from migrations/000033_audit_prev_hash.up.sql rename to migrations/000042_audit_prev_hash.up.sql index 670922e9..c1276c26 100644 --- a/migrations/000033_audit_prev_hash.up.sql +++ b/migrations/000042_audit_prev_hash.up.sql @@ -1,4 +1,4 @@ --- 000033_audit_prev_hash.up.sql +-- 000042_audit_prev_hash.up.sql -- -- Activate the tamper-evidence chain on audit_log. The `prev_hash` -- column already exists (created by 000029_audit_log.up.sql as a diff --git a/migrations/000035_webauthn_credentials.down.sql b/migrations/000043_webauthn_credentials.down.sql similarity index 100% rename from migrations/000035_webauthn_credentials.down.sql rename to migrations/000043_webauthn_credentials.down.sql diff --git a/migrations/000035_webauthn_credentials.up.sql b/migrations/000043_webauthn_credentials.up.sql similarity index 100% rename from migrations/000035_webauthn_credentials.up.sql rename to migrations/000043_webauthn_credentials.up.sql diff --git a/migrations/000035_media_collections.down.sql b/migrations/000044_media_collections.down.sql similarity index 87% rename from migrations/000035_media_collections.down.sql rename to migrations/000044_media_collections.down.sql index d389af16..ed7aab4f 100644 --- a/migrations/000035_media_collections.down.sql +++ b/migrations/000044_media_collections.down.sql @@ -1,6 +1,6 @@ --- 000035_media_collections.down.sql +-- 000044_media_collections.down.sql -- --- Reverts 000035_media_collections.up.sql. Drops the FK column from +-- Reverts 000044_media_collections.up.sql. Drops the FK column from -- media first (so the collections table can be dropped without -- breaking the reference), then the table itself. We do NOT drop -- the ltree / citext extensions — other future tables may want diff --git a/migrations/000035_media_collections.up.sql b/migrations/000044_media_collections.up.sql similarity index 99% rename from migrations/000035_media_collections.up.sql rename to migrations/000044_media_collections.up.sql index d6e075bc..2b62e4ee 100644 --- a/migrations/000035_media_collections.up.sql +++ b/migrations/000044_media_collections.up.sql @@ -1,4 +1,4 @@ --- 000035_media_collections.up.sql +-- 000044_media_collections.up.sql -- -- Media collections — hierarchical folders that group media assets. -- The hierarchy is stored two ways: a parent_id self-reference (so a diff --git a/migrations/000035_custom_fields.down.sql b/migrations/000045_custom_fields.down.sql similarity index 84% rename from migrations/000035_custom_fields.down.sql rename to migrations/000045_custom_fields.down.sql index 758bca47..c441eb58 100644 --- a/migrations/000035_custom_fields.down.sql +++ b/migrations/000045_custom_fields.down.sql @@ -1,4 +1,4 @@ --- 000035_custom_fields.down.sql +-- 000045_custom_fields.down.sql DROP INDEX IF EXISTS idx_post_meta_values_group; DROP INDEX IF EXISTS idx_field_groups_post_types; diff --git a/migrations/000035_custom_fields.up.sql b/migrations/000045_custom_fields.up.sql similarity index 98% rename from migrations/000035_custom_fields.up.sql rename to migrations/000045_custom_fields.up.sql index eb8139ee..45c8d326 100644 --- a/migrations/000035_custom_fields.up.sql +++ b/migrations/000045_custom_fields.up.sql @@ -1,4 +1,4 @@ --- 000035_custom_fields.up.sql +-- 000045_custom_fields.up.sql -- -- Custom-fields field groups + per-post meta values (issue #162). One -- group describes "what extra fields does this post type gain"; one diff --git a/migrations/000036_menus.down.sql b/migrations/000046_menus.down.sql similarity index 92% rename from migrations/000036_menus.down.sql rename to migrations/000046_menus.down.sql index 840b4944..5714b2e0 100644 --- a/migrations/000036_menus.down.sql +++ b/migrations/000046_menus.down.sql @@ -1,4 +1,4 @@ --- 000036_menus.down.sql +-- 000046_menus.down.sql -- -- Drop order matters: menu_items references menus, so menu_items first. DROP TRIGGER IF EXISTS menu_items_touch ON menu_items; diff --git a/migrations/000036_menus.up.sql b/migrations/000046_menus.up.sql similarity index 99% rename from migrations/000036_menus.up.sql rename to migrations/000046_menus.up.sql index 517556fc..162f0abc 100644 --- a/migrations/000036_menus.up.sql +++ b/migrations/000046_menus.up.sql @@ -1,4 +1,4 @@ --- 000036_menus.up.sql +-- 000046_menus.up.sql -- -- Navigation menus — issue #54. Two tables, modelled after the -- WordPress nav-menus surface but with an ltree-style path column so diff --git a/packages/go/config/loadminimal.go b/packages/go/config/loadminimal.go new file mode 100644 index 00000000..7f2eadea --- /dev/null +++ b/packages/go/config/loadminimal.go @@ -0,0 +1,71 @@ +package config + +import "time" + +// LoadMinimal builds a Config carrying only the fields a CLI subcommand +// typically needs: Database, Redis, and Env. The auth secrets +// (GONEXT_AUTH_PEPPER / GONEXT_AUTH_SESSION_SECRET / GONEXT_AUTH_CSRF_SECRET) +// are intentionally NOT required — short-lived CLI invocations +// (`gonext audit verify`, `gonext jobs drain`, etc.) don't construct +// auth subsystems and should not refuse to boot when an operator runs +// them from a shell that lacks the production secrets. +// +// Production binaries (apps/api, apps/worker) must continue to call +// Load(), which enforces the full env surface. +// +// On error the returned *Config is partial; callers should treat it +// the same way as Load()'s partial return — useful for diagnostics, not +// for running. +func LoadMinimal(opts ...LoadOption) (*Config, error) { + lc := loadConfig{env: osEnv{}} + for _, o := range opts { + o(&lc) + } + e := lc.env + + cfg := &Config{} + var errs []error + + cfg.Env = parseEnv(getString(e, "GONEXT_ENV", "development")) + + // ---- Database ---- + if url, err := getStringRequired(e, "DATABASE_URL"); err != nil { + errs = append(errs, err) + } else { + cfg.Database.URL = url + } + if n, err := getInt(e, "GONEXT_DB_MAX_OPEN_CONNS", 25); err != nil { + errs = append(errs, err) + } else { + cfg.Database.MaxOpenConns = n + } + if n, err := getInt(e, "GONEXT_DB_MAX_IDLE_CONNS", 5); err != nil { + errs = append(errs, err) + } else { + cfg.Database.MaxIdleConns = n + } + if d, err := getDuration(e, "GONEXT_DB_CONN_MAX_LIFETIME", 30*time.Minute); err != nil { + errs = append(errs, err) + } else { + cfg.Database.ConnMaxLifetime = d + } + if d, err := getDuration(e, "GONEXT_DB_CONN_MAX_IDLE_TIME", 5*time.Minute); err != nil { + errs = append(errs, err) + } else { + cfg.Database.ConnMaxIdleTime = d + } + if d, err := getDuration(e, "GONEXT_DB_STATEMENT_TIMEOUT", 30*time.Second); err != nil { + errs = append(errs, err) + } else { + cfg.Database.StatementTimeout = d + } + cfg.Database.MigrationDir = getString(e, "GONEXT_MIGRATION_DIR", "./migrations") + + // ---- Redis ---- + cfg.Redis.URL = getString(e, "REDIS_URL", "redis://localhost:6379/0") + + if len(errs) > 0 { + return cfg, joinErrs(errs) + } + return cfg, nil +} diff --git a/packages/go/go.mod b/packages/go/go.mod index d0887b1e..44064313 100644 --- a/packages/go/go.mod +++ b/packages/go/go.mod @@ -36,7 +36,7 @@ require ( golang.org/x/crypto v0.52.0 golang.org/x/image v0.40.0 golang.org/x/mod v0.36.0 - golang.org/x/net v0.54.0 + golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 ) diff --git a/packages/go/go.sum b/packages/go/go.sum index d0566851..b79459b4 100644 --- a/packages/go/go.sum +++ b/packages/go/go.sum @@ -292,8 +292,8 @@ golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=