Release notes

What's new in Manifold

We change a lot of things at once early on. This page is the canonical record — auto-rendered from CHANGELOG.md in the repo.

Changelog

All notable changes to Manifold are documented in this file.

The format follows Keep a Changelog, and the project (loosely) follows Semantic Versioning — breaking changes bump minor while we're pre-1.0.

[Unreleased]

In flight

  • Slices C + D of STRATEGY Phase 7 (calendar overlay for draft

campaign variants, dedicated /panel/campaigns/new route) — both deferred from the 2026-05-26 session; the inline form on the list page covers the create flow for now.

  • UI walkthrough of the 2026-05-15 sprint still pending.
  • Input-sources "populate handles" workflow — the dedicated Input

sources settings section + general.inputSources enable flags landed this session (2026-05-30), but the bulk job that reads enabled sources → generates social drafts (deduped against Post.publishedTo) is not built yet. The enable flags are forward-looking until it is.

  • Publish-state on postsPost.publishedTo + variants[net]

already record per-network publish history and the scheduler dedups on it (app-scheduler.js:61), but the post-editor UI doesn't surface "posted to X on <date>" yet, and there's no manual "Publish again" action that appends a new publishedTo entry. Deferred.

Recently completed — 2026-05-31 session

Centralized social-OAuth apps + one-click Connect, plus a DB-backed waitlist feature-flag toggle. Committed to main (publisher wiring + integrations); the centralized-OAuth flows are connect-complete but unverified end-to-end (need live platform apps + connected accounts).

  • Centralized OAuth apps — new SocialOAuthApp table (one row per

network, clientSecret encrypted) + /panel/integrations admin page (superadmin) to register Manifold's OAuth app per platform once. Per-site connect becomes a single Connect button instead of pasting a developer-app's clientId/secret. helper/database/socialOAuthApp.js, routes/auth.js, routes/panelApi.js.

  • In-panel OAuth flows — LinkedIn, Pinterest, YouTube migrated to the

central app (fallback to per-site blob); new flows for X (OAuth 2.0 PKCE), Facebook Login (→ Page token + linked Instagram business account), Imgur, and Reddit.

  • Publisher wiringfacebook.js, social/imgur.js, reddit.js,

twitter.js, instagram.js now read creds at call time (was module-load → stale across multi-site runs) so connected-account tokens reach the API; helper/loadSiteEnv.js maps the captured fields (REDDIT_REFRESH_TOKEN, INSTAGRAM_GRAPH_*). Caveat: X still posts via OAuth 1.0a (media upload needs v1.1); Reddit not yet in the scheduler's dispatch map.

  • Waitlist feature flag/panel/waitlist "Waitlist mode" toggle backed

by the FeatureFlag table; the marketing site reads it via GET /v1/flags.json, replacing the deploy-time WAITLIST_ENABLED env var.

Recently completed — 2026-05-30 session

Dashboard onboarding + a full connections-management surface (PR #10, branch dashboard-enhancements). All frontend + routes/panelApi.js + routes/auth.js + helper/connectionTest.js; no migration (new state lives in the SiteSetting.general JSON bag).

  • Dashboard redesign — site hero (logo/name/URL), icon-driven

quick-link cards, and a Connections panel with at-a-glance health chips and worst-first sorting.

  • Setup wizard — 4-step onboarding card on the dashboard (input

sources → social networks → campaign → publishing) with server-computed completion (dashboard.jsonsetup, admin/superadmin only). Hides when complete; deep-links each step.

  • Current-campaign card — running/approved (or latest draft)

campaign with status, platforms, window, and live progress bar.

  • Connection Test (POST /v1/panel/connections/:network/test,

helper/connectionTest.js) — reads the decrypted siteSetting blob directly (bypassing the boot-frozen, env-bound publishers) and verifies each network: read-only identity checks for Facebook / Reddit / LinkedIn / YouTube / Pinterest / Imgur (+ Instagram login); WordPress creates a draft; X posts a real tweet (its Free tier is write-only, so a read-verify 403s) behind a UI confirm. Outcome persisted to general.connectionTests and surfaced as "Tested OK · 5m ago" / "Last test failed" on each card.

  • OAuth / Authorize — Authorize buttons for LinkedIn + Pinterest

(YouTube already had one); redirect URIs now derived from BETTER_AUTH_URL in both authorize + callback halves (no more stale per-site redirectUri); OAuth popup closes even when the dev callback is cross-origin; per-connection Re-authorize button appears on auth failure for the OAuth networks.

  • Input sources section — dedicated panel on the site-settings page

(Google Sheet / existing blog [GitHub connector] / WordPress) with enable toggles + status + "Configure" deep-links, backed by general.inputSources.

  • Per-network credential help — each settings section now has a

"How to get these credentials" box with steps, a developer-console link, and (for OAuth networks) the exact redirect URL to register (from the server's BETTER_AUTH_URL).

  • Sites: inactive + soft-delete — shipped earlier (PR #9, merged) as

the Site.isActive flag + deletedAt soft-delete; migration 20260529120000_add_site_isactive applied to Neon.

  • Panel sidebar footer pinned — full-height sticky sidebar with

internally-scrolling nav so "Signed in as / Subscription / Sign out" stays bottom-left.

Recently completed — 2026-05-26 session

Director agent landed as a real feature, plus a hardening pass.

  • Phase 7 Slices A + B — Director as a real feature (commit

675bb9b). New Campaign model + CampaignStatus enum, Post.campaignId, BlogSource.director; migration 20260526120000_add_campaign applied to Neon. Backend (helper/director/runner.js, helper/database/campaigns.js, routes/campaigns.js) composes analyzer + planner + materializer behind isAuthenticated + authorizeRoles('admin', 'superadmin'). Panel: /panel/campaigns list + inline "Plan campaign" form; /panel/campaigns/[id] detail with weekly grouping and Approve / Archive. Drafts-only approval per STRATEGY — no DIRECTOR_AUTO_APPROVE escape hatch yet. v1 themes restricted to blog-amplify, pull-quote, feature-highlight.

  • Empty-body variant fix. The runner now drafts each variant

via aiDrafts.draftForNetwork() during materialization with bounded concurrency (default 3, capped at 8). Fail-soft per variant: a single rate-limited call doesn't sink a 50-post plan — the variant gets body: '' plus draftError: '<msg>' so the panel can flag "needs regeneration". Plus a belt-and-suspenders gate in approveCampaign(): refuses to flip status if zero variants are publishable (route returns 409). The approve response now returns variantsScheduled + variantsSkipped so the UI can surface partial-approval state.

  • URL sanitization with GCP Safe Browsing

(helper/urlSanitizer.js). Single chokepoint for every user-supplied URL. Structural floor (always on, no API key needed): protocol allowlist (http/https), reject embedded creds, reject SSRF targets (IPv4 in 10/8, 127/8, 169.254/16 incl. AWS metadata, 172.16/12, 192.168/16, 100.64/10 CGNAT, 0.0.0.0, 224/4, 255.x; IPv6 ::1, fc00::/7, fe80::/10, IPv4-mapped; localhost, *.localhost, *.internal, *.local; 2048-char cap). Safe Browsing v4 threatMatches:find for MALWARE / SOCIAL_ENGINEERING / UNWANTED_SOFTWARE / PHA when GOOGLE_SAFE_BROWSING_API_KEY is set; soft-fail on Google outage (Sentry log + allow — structural checks remain the hard floor). 1h in-process cache to stay under the 10k/day free tier. Wired into /import/react-blog, generator /start (bulk URLs), POST /v1/panel/sites + /sites/:id, and re-validated at Director run-time on site.url. See SECURITY.md §"User-supplied URLs" for the threat model.

  • Build optimization (commit f872b94). Dropped 20 unused deps

from package.json — biggest weights were @tensorflow/tfjs-node (~300MB of native binaries on Linux), puppeteer (~50MB npm package even with Chromium download skipped), the duplicated ffmpeg installers (@ffmpeg-installer/ffmpeg, ffmpeg-static, @ffprobe-installer/ffprobe — apt installs ffmpeg in the Dockerfile, so the installers were dead weight), and four legacy-only deps that only app.js / app-{site}.js required (microsoft-cognitiveservices-speech-sdk, natural, cloudmersive-nlp-api-client, google-trends-api). Combined drop: ~500MB off the runtime image, 2–4 min off cold npm install. Dockerfile switches to npm ci when a lockfile is present (web/package-lock.json now committed; commit 5ed8124 regenerated it against a clean tree after the first attempt picked up pnpm symlinks and shipped a partial lockfile that broke the Fly build). Falls back to npm install otherwise (root lockfile is harder to generate locally — --package-lock-only triggers an arborist crash against our bson + nyc transitive tree). .dockerignore drops Markdown docs, docs/, .github/, modal/, scripts/archive, scripts/director-spike.js — smaller context and (more importantly) doc tweaks no longer bust the COPY . . layer cache.

Recently completed — 2026-05-15 sprint (commit c60b598)

Six S-sized features from FUTURE-FEATURES.md "What I'd actually build first." Per-feature breakdown in SESSION-HANDOFF-2026-05-14.md §8.

  • Smart presets per site. Generator compose form hydrates from

per-site defaults — default platforms, editor mode, image mode, and tone-dial values. Stored in siteSetting.general.composePresets with no schema migration. Site switch re-arms hydration; user edits stop hydration to avoid blowing away in-progress work. Edit panel in site settings.

  • Brand kit per site. Per-site primary color, accent color, logo

URL pair, watermark position, font family. Threaded through helper/quote_card.js#renderQuoteCard so every SVG quote card uses the site's palette — gradient derived by HSL-darkening the primary, attribution color by lightening it. Defaults preserve the legacy indigo/violet/amber wash exactly when no kit is set.

  • Tone-and-intent dials. Three 1-5 dials (formality, hook

strength, length) on the generator compose form. The drafter only injects natural-language tone instructions when a dial diverges from neutral 3, so the default 3/3/3 produces identical drafts to pre-dial behavior.

  • Pre-publish lint pass. New helper/publishLint.js with

per-network synchronous checks (X char limit with t.co URL accounting, LinkedIn hook ≤210 chars, Reddit subreddit pattern, Pinterest image required, IG hashtag spam, etc.). Dispatcher (routes/publish.js) blocks on errors with HTTP 422. Two read-only endpoints: GET /v1/panel/posts/:id/lint.json and POST /v1/panel/posts/lint.json. Editor surface UI still pending.

  • RSS-driven generation. New helper/sources/rss.js (RSS 2.0 +

Atom via cheerio, no new deps). Scheduler rssTick runs hourly (AUTO_RSS_POLL, SCHEDULER_RSS_MS), polls each site's configured feeds, dedups against per-feed watermark + existing Post.sourceUrl, inserts new entries as draft Posts the operator approves manually. No auto-publish — RSS is an idea-inbox.

  • Cmd-K command palette. Global ⌘K in the panel layout. Static

navigation, active-site shortcuts, dynamic site-switch entries. Keyboard-first (↑↓/Enter/Esc), substring + keyword filter, role-gated via the same dashboard query the sidebar uses.

Recently completed — 2026-05-14 session (commit c60b598)

  • LinkedIn publish — three-bug cascade fix. Restored the missing

rej parameter in helper/linkedin.js publishPhoto() (was a ReferenceError at load time), fixed getFeaturedImagePath() in helper/utils.js to translate served /panel/images/... URLs to on-disk asset paths before the existence check, and split out postToUpdateData() in helper/database/posts.js so the post-publish stamp-back doesn't hand Prisma siteId, createdAt, updatedAt, or the transient featuredImagePath field.

  • Per-site post title uniqueness. Post.title is no longer

globally unique; replaced with @@unique([siteId, title]). Migration 20260514180000_post_title_unique_per_site applied to prod Neon 2026-05-14T16:43:11Z.

  • Per-network health pills on the generate page. Each platform

card surfaces a chip (Connected / Renew soon / Expiring / Expired / Not connected). Sourced from helper/tokenStatus.js#inspectSiteSetting. Unconfigured/expired networks gate the checkbox and surface a "Connect → " deeplink to the matching #anchor on site settings.

  • Single-draft-per-site generator guard. New discardExistingDrafts

in routes/generator.js (called from /save and /publish), new GET /v1/panel/generator/draft.json and DELETE /v1/panel/generator/draft/:id.json, and a new amber DraftBlock on the generator page that offers Continue / Discard when a prior draft is in flight.

  • Cross-site post-link recovery. New

GET /v1/panel/posts/:id/locate.json and a "Switch to <site> to view" CTA in the post editor's not-found state, replacing a hard 404 when the post lives on a site the user has access to but isn't currently on. Inaccessible posts still report {found: false}.

  • Source-link citation in generated drafts. When the generator is

seeded from a source post, its canonical URL (falling back to sourceUrl, then site URL) is threaded through helper/generator.jshelper/aiDrafts.js, which appends a tail instructing the model to end each draft with Read the full article: <url> (exact URL, omit rather than truncate when the network's char budget is tight).

  • TOKENS_DIR absolute-path normalization. helper/token_paths.js

now resolves relative TOKENS_DIR values (./helper/configs) up front, so the returned path works for both fs and require() callers (require() previously treated relative values as node-module specifiers and threw).

  • JSON-aware error handler + 2 MB body limit. app-admin.js

global error handler returns JSON for Accept / Content-Type / /v1/-prefixed / /publish/-prefixed / .json-suffixed requests instead of HTML. JSON and urlencoded body limits raised to 2 MB so generator uploads with embedded markdown don't 413.

Recently completed

  • Generate-new-post page. New /panel/generator route + view: one

page, multi-platform AI drafts streamed via SSE, optional quote-card images, review board with per-card editing + regenerate, then Save-as- draft or Publish-with-progress. Inputs accept pasted notes, .md / .txt / .html file uploads, and URL scraping via the React-blog adapter. Backend: helper/generator.js (in-memory job store with EventEmitter + 1h TTL reaper), routes/generator.js (multer for uploads, SSE for progress, bounded-concurrency 3 for OpenAI calls, fan-out publish using existing per-platform helpers). Nav link added for superadmin/admin/editor.

  • Multi-pull-quote picker. When the React-blog import returns

multiple pullQuotes, the editor renders them as clickable chips above the body textarea per variant tab. Stored on variants._meta.pullQuotes so no schema change was needed.

  • Legacy helper/openai.js removed. Vestigial completions wrapper

whose only callers were commented-out imports. Cleaner audit surface; helper/aiDrafts.js is the sole AI entry point now.

  • 429 Retry-After extended to every publisher. LinkedIn, Pinterest,

Instagram, and Facebook (axios paths) now call rethrowIfRateLimited in their catch blocks. Reddit detects snoowrap's RatelimitError by name and converts to the shared RateLimitError with a 60s default retry-after (snoowrap doesn't surface a precise delta).

  • React-blog source adapter. helper/sources/reactBlog.js scrape(url)

fetches a public blog URL, parses with cheerio, returns a Post-shaped draft with title / excerpt / content / mediaUrl / headings / publishedAt + a pullQuotes array (full list) and pullQuote (first element). Wired through POST /panel/posts/import/react-blog. Multi-quote picker UI is the remaining piece.

  • Daily Neon backup tick. app-scheduler.js backupTick() shells out

to scripts/backup-neon.sh every SCHEDULER_BACKUP_MS (default 24h) with a 5-minute start delay (avoids restart-storm load). Toggleable via AUTO_BACKUP=0. Missing pg_dump logs a one-time warning + Sentry without breaking other ticks. RUNBOOK §5 documents the restore drill (fresh local Postgres + pg_restore --jobs=4).

  • scripts/rotate-credential-key.js + npm run db:rotate-cred-key.

Reads each SiteSetting with the OLD key, re-encrypts with NEW. Plaintext rows are upgraded in the same pass. Dry-run by default; APPLY=1 commits. Idempotent — a partial run resumes safely. RUNBOOK §4.5 documents the full procedure.

  • Calendar top performer + suggested-time pre-fill. routes/calendar.js

computes a composite engagement score per variant over the last 7 days and renders a "Top of the week" panel above the grid. The variant editor gets a "Suggest" button per platform that fills scheduledFor with the next public-data sweet-spot (LinkedIn 9am Tue–Thu, X 12pm weekdays, IG Wed/Fri 11am, FB Thu/Fri 1pm, Reddit Sun/Tue 8am, Pinterest Sat 8pm / Sun 2pm).

  • 429 Retry-After queueing. New helper/rateLimitError.js with

RateLimitError(network, retryAfterSeconds) + parseRetryAfter() + rethrowIfRateLimited(). Twitter wired (catches code === 429, prefers rateLimit.reset over the header). Scheduler's recordRateLimitedRetry() re-shelves variant.scheduledFor = err.retryAfter without consuming a retry. Publish route returns 429 with Retry-After header and {rateLimited: true} payload.

  • SiteSetting encryption at rest. New helper/cryptoSetting.js

with AES-256-GCM per sensitive column (instagram, twitter, linkedin, reddit, wordpress, googleCloud, imgur, youtube, pinterest, facebook, openAI; general left plaintext). Storage shape: {__enc:1, n:<base64 nonce>, c:<base64 ciphertext+tag>}. Wrappers (helper/database/siteSettings.js + helper/database/sites.js) decrypt on read, encrypt on write — lazy migration via plaintext passthrough on decryptBlob. Scheduler's token-refresh tick decrypts before inspection, re-encrypts before writing back. Key in CREDENTIAL_ENCRYPTION_KEY (64 hex chars). No-op without the key.

  • Pull-quote one-click seed. "Seed from pull quote" button per

variant copies post.pullQuote into the variant body. Shown only when the source post has a pull quote set.

  • STRATEGY Phase 5 — Facebook Reels. helper/facebook.js publishReel(post)

drives the three-phase /me/video_reels upload (start → transfer → finish). appHelper.publishToFacebook branches on variants.facebook.mediaType === 'reel'. Editor mediaType dropdown gains reel.

  • STRATEGY Phase 6 — Pinterest analytics. helper/pinterest.js fetchMetrics(pinId)

via /v5/pins/{pin_id}/analytics (90-day window; IMPRESSION,PIN_CLICK,SAVE,OUTBOUND_CLICK). Dispatcher's last notImplemented stub replaced. Editor display row + JS handler now surface clicks and outboundClicks alongside the existing fields.

  • Per-user timezones. New User.timezone column (default UTC);

migration 20260512170000_add_user_timezone. routes/calendar.js resolves viewer tz (query → session → DB → UTC), buckets days by the user's local calendar day, and surfaces "times shown in" in the header. Self-service POST /panel/users/me/timezone with IANA name validation. Dashboard exposes a Preferences card with a tz picker.

  • Quote-card photo blends. helper/quote_card.js renderQuoteCard()

accepts an optional bgImageUrl (URL or local path); when present, the photo is composited under a blurred + darkened + 0.72-opacity gradient. Per-variant "photo bg" checkbox in the editor; defaults to post.mediaUrl. Missing/unreachable URLs fall back cleanly to the gradient-only card.

  • STRATEGY Phase 4.2 — AI variant drafts. helper/aiDrafts.js

exposes draftForNetwork(post, network, {apiKey, model}) calling gpt-4o-mini chat completions with per-network strategies (LinkedIn long-form, X tight, Reddit title+body, IG hook, FB conversational, Pinterest pin-card). Wired through POST /panel/posts/ai-draft/:postId/:network and an "AI draft" button per variant tab, gated on siteSetting.openAI.apiKey.

  • STRATEGY Phase 5 — Reddit publisher. helper/reddit.js publish(post)

takes the merged post object, reads subreddit from variants.reddit.subreddit, returns {success, mediaId, url}. appHelper.publishToReddit matches the other publishers' signature; routes/publish.js activates case 'reddit' with a missing-subreddit guard and skips the featured-image gate for text self-posts.

  • STRATEGY Phase 5 — X threads. helper/twitter.js publishThread(post)

splits long bodies at paragraph → sentence → word → hard-slice boundaries with (n/N) numbering, posts as a reply chain via v2.tweet({reply: {in_reply_to_tweet_id}}). First tweet carries the featured image. publishToTwitter auto-routes when body > 270 chars or variants.x.threadMode === true. Editor X tab raised to 5000 chars with an auto-thread hint.

  • STRATEGY Phase 6 — Engagement metrics. helper/metrics.js

dispatcher with per-platform fetchers on reddit (snoowrap score + num_comments + upvote_ratio), twitter (v2.public_metrics), linkedin (/v2/socialActions/{urn}), instagram (Graph /{media-id}/insights with reel+feed metric union), facebook (/{id}?fields=reactions.summary,comments.summary,shares plus best-effort /insights). Pinterest stubbed (501 NOT_IMPLEMENTED). Manual refresh via POST /panel/posts/refresh-metrics/:postId/:network and per-variant button; scheduler metricsTick auto-refreshes every SCHEDULER_METRICS_MS (6h default) within METRICS_REFRESH_WINDOW_DAYS (14 default) when staleness exceeds METRICS_STALENESS_HOURS (6 default).

  • routes/index.js env-loader refactor. 197 → 61 lines. The 130-line

inline hydration loop is now a single loadSiteEnv(currentSite) call; parity confirmed against sample siteSetting.

  • Hardcoded secrets moved to env. helper/openai.js

(sk-proj-...), helper/instagram.js (FB Graph token + IG account id + FB page id), routes/engine.js (GitHub PAT + username), and scripts/finalize-admin.js (admin bootstrap password) all read from .env.admin now. Values copied verbatim into .env.admin (gitignored) to preserve current behavior. .env.example updated. The historical leaks are still in git history — rotate at the source for OpenAI, GitHub PAT, and Facebook Graph token.

  • Mongo → Neon (Postgres) migration — Phase 4 closed. Mongoose

removed, toLegacy shim retired, _idid across all views and routes (49 refs). Three latent route bugs caught and fixed during smoke-test (siteSetting object vs id). See NEON-MIGRATION.md §Phase 4.

Planned

  • Multi-pull-quote picker UI in the variant editor (data now flows via

reactBlog.scrape().pullQuotes; no UI wired yet).

  • Long-term archive of /data/backups to off-Fly storage (R2/S3).

Today: retention pruning lives in scripts/backup-neon.sh, on-volume only.

  • Flip CSP from Report-Only to enforcing once violation reports settle.
  • Encrypt SiteSetting.* credential blobs at rest (NEON-MIGRATION Phase 5).

Added

  • Public marketing routes/blog (placeholder), /colophon

(OSS credits), /privacy, /terms (template legals), /changelog (rendered from CHANGELOG.md).

  • Feeds + machine-readable surfaces/changelog.atom (Atom 1.0

release feed), /changelog.json (parsed releases), /llms.txt (llmstxt.org index for LLM crawlers), /api/version.json (build info: name, version, sha, builtAt, uptime).

  • Discovery files/.well-known/security.txt (RFC 9116, with

expires regen), /humans.txt, /manifest.webmanifest (PWA install).

  • URL redirects/admin, /admin/, /login, /signin, /dashboard

all 302 to their /panel/* equivalents (muscle-memory rescue).

  • Panel dashboard — first-time empty state with three setup-step

CTAs (role-gated), quick-link grid (Posts / Sites / Publish / Users) for users with a selected site.

  • Maintenance mode — env-flag-controlled middleware (MAINTENANCE=1)

+ branded /maintenance page. Allowlists health probes and static assets so the page itself renders.

  • Rate limiting — sliding-window IP-based limiter (zero npm dep)

applied to /panel/login (10/min), /publish (30/min), /panel/auth (20/min). Sends X-RateLimit-* + Retry-After headers.

  • CSP report-only header + /csp-report sink — collects browser

violation reports to stdout (Fly Logs) without enforcing. Toggle with CSP_REPORT_ONLY=0.

  • Marketing footer chips — live version chip (links to /changelog)

and status chip (reads /readyz client-side, fail-soft).

  • Bootstrap-compat shimpublic/css/panel-compat.css covers every

Bootstrap class the panel views actually use; CDN dropped.

  • Inline-SVG icon mixinviews/_icons.pug with 9 lucide-style

icons. Bootstrap Icons CDN dropped.

  • Brand assetslogo-on-dark.svg, logo-mark.svg, OG image

(PNG + SVG), PWA icons (180/192/512). public/images/README.md documents which file goes where.

  • A11y + UX polish — skip-to-content link, <noscript> fallback,

prefers-reduced-motion handling, :focus-visible brand rings, prefers-color-scheme: dark palette across brand.css/marketing.css, print stylesheet.

  • AI scraper opt-outrobots.txt blocks 13 LLM training bots

(GPTBot, ClaudeBot, Google-Extended, etc.) when BLOCK_AI_BOTS=1; default-off so search-LLMs can still index marketing copy.

  • Operational scriptsscripts/backup-neon.sh (pg_dump + retention

+ integrity probe + latest.dump symlink), scripts/seed-admin-user.js (Prisma-aware, re-runnable).

  • Custom error pagesviews/marketing/404.pug, marketing/error.pug

(public surface, branded); views/error.pug (panel surface, brand-aligned). app-admin.js error handler picks the template based on req.path.

  • DocsRELEASING.md (cut-a-release workflow), OPENGRAPH-AUDIT.md

(pre-share checklist), public/images/README.md (brand asset guide), helper/markdown.js (zero-dep Markdown renderer + Keep-a-Changelog parser used by /changelog, /changelog.json, /changelog.atom).

  • .editorconfig + .nvmrc — project formatting baseline + Node 20 pin.

Changed

  • Static asset Cache-Control — removed broken no-store blanket

policy. Now: images/fonts max-age=604800, immutable, CSS/JS max-age=3600, SVG/ico/manifest max-age=86400, fallback 5 min.

  • Log formatmorgan('dev') locally, morgan('combined') in

production (Apache CLF, parseable by Datadog/Loggly/Fly Logs). Health probes skipped in both modes.

  • Sentry — auto-imported as first line in all 5 batch entrypoints

(app.js, app-mavenhandle.js, app-alphafitness.js, app-adabwalay.js, app-whatmattered.js). Closes the RUNBOOK §7 follow-up.

  • Login page — full brand rewrite (split card, indigo focus, amber

error alert, no Bootstrap classes). Removed pre-filled test@gmail.com / 123456 credentials (security smell).

  • Panel sidebar logo — now uses logo-on-dark.svg (proper palette

for dark backgrounds) instead of logo.svg + a brightness(0) invert(1) filter that killed brand colors.

  • JSON-LD — expanded from single SoftwareApplication to @graph

with Organization (logo, contactPoint × 2), WebSite, and SoftwareApplication (featureList, full Starter + Studio offers with UnitPriceSpecification).

  • Marketing nav + footer — added Blog + Changelog + Colophon links;

PWA manifest + Atom + JSON feed <link rel> discovery in <head>.

  • Sitemap — now lists all 9 indexable marketing pages with

per-page changefreq + priority.

  • robots.txt — explicit Allow: /images/og-image.png, disallow

/healthz, /readyz. Optional AI-scraper block section toggleable.

  • X-Robots-Tag header — defense-in-depth noindex, nofollow, noarchive

on /panel/*, /publish/*, /healthz, /readyz.

  • Trust proxyapp.set('trust proxy', 1) so req.ip reflects the

real client IP (Fly's edge), not the loopback. Required for the rate limiter to score correctly.

  • 404 + error handler — picks marketing/404 for public surface,

panel error template for /panel/*. Brand-styled both surfaces.

Removed

  • Bootstrap 4.5.2 CSS CDN — replaced by panel-compat.css.
  • Bootstrap Icons CDN — replaced by inline-SVG icon mixin.
  • Duplicate static-asset middleware that was setting Cache-Control: no-store

on every public file (dead code after the route ordering fix).

  • X-Powered-By: Express response header.

Security

  • HSTS in production (180 days, includeSubDomains).
  • CSP Report-Only baseline (collects violations for later enforcement).
  • COOP, CORP, Origin-Agent-Cluster, Permissions-Policy, X-DNS-Prefetch-Control.
  • Rate limits on auth + publish endpoints.
  • security.txt discovery file (RFC 9116).
  • AI training-bot opt-out via robots.txt (toggleable).

Performance

  • Static-asset caching now actually works (broken no-store policy fixed).
  • decoding="async" on every <img>; fetchpriority="high" on the

above-fold logo; loading="lazy" below the fold.

  • Two external CDN dependencies removed (~250 KB + DNS roundtrips).
  • Width/height attrs on all images prevent layout shift.

Accessibility

  • Skip-to-content link as first focusable element on marketing layout.
  • prefers-reduced-motion: reduce kills all transitions/animations.
  • :focus-visible rings on every interactive element; no double-rings on inputs.
  • ARIA-hidden icons paired with text labels.
  • <noscript> fallback banner with reassuring copy.

[0.1.0] — 2026-05-11

The "Manifold" rebrand. Renamed from the internal "Trends" tool and production-readied the surface.

Added

  • Brand identity — logo (mark + wordmark), favicon, PWA icons, OG preview image
  • Marketing site (SSR Pug) — /, /features, /pricing, /about,

/privacy, /terms, /changelog

  • SEO infrastructure — sitemap.xml, robots.txt, humans.txt, JSON-LD

(SoftwareApplication + Organization), Open Graph + Twitter Card meta

  • Health probes — /healthz (liveness), /readyz (DB-dependent readiness)
  • Security headers middleware — X-Frame-Options, X-Content-Type-Options,

Referrer-Policy, HSTS (prod), Permissions-Policy, COOP, CORP

  • PWA manifest + apple-touch-icon — panel is installable to home screen
  • Sentry wired into all batch script entrypoints (closes RUNBOOK §7 follow-up)
  • GitHub Actions CI — pug compile, JS syntax, prisma validate, advisory npm audit
  • Open-source paperwork — LICENSE (Apache 2.0), CONTRIBUTING.md,

SECURITY.md, CODE_OF_CONDUCT.md, PR + issue templates

  • Mongo → Neon migration scaffolding — Prisma schema, initial migration,

data-cutover script, NEON-MIGRATION.md strategy doc

Changed

  • Product name "Trends" → "Manifold" everywhere it's a product reference

(preserved the Google Trends API feature code paths)

  • Fly.io app slug trendsmanifold
  • Fly volume trends_datamanifold_data
  • MongoDB database trendsdbmanifolddb
  • MongoDB user trendsadminmanifoldadmin
  • Cloudflare R2 bucket reference trends-mediamanifold-media

(rename of the actual bucket is deferred — env still points at the old name)

  • Docker user/group trendsmanifold
  • Panel layout — adopts Manifold brand colors (indigo + amber), favicon, theme-color
  • Error pages — branded 404 + 5xx with separate panel / marketing variants
  • Fly healthcheck path — /panel/login/healthz

Removed

  • Default test credentials pre-filled on the login form

(test@gmail.com / 123456)

Security

  • Removed X-Powered-By: Express from all responses
  • Added HSTS for production responses
  • Hardened default Permissions-Policy

Migration notes

  • If you have an existing trendsdb MongoDB database, run

scripts/rename-trendsdb-to-manifolddb.sh before flipping MONGODB_URI. See RUNBOOK.md §3.1.

  • If you had an existing Fly app called trends, follow the 9-step

cutover in RUNBOOK.md §3.1.


[0.0.1] — 2024-2025

The "Trends" prototype era. Not formally versioned. See git log for the full history before the rebrand. Highlights:

  • Multi-site Express + Mongoose panel
  • Working publishers for LinkedIn, Facebook, Instagram, X (Twitter),

Pinterest, YouTube, Imgur, WordPress; Reddit stubbed

  • Per-site batch entrypoints (alphafitness, adabwalay, mavenhandle, whatmattered)
  • Media pipeline — sharp, canvas, GraphicsMagick, FFmpeg
  • Google Sheets and WordPress as content sources
  • Cloudflare R2 + Fly.io Phase 0 deploy prep

Subscribe via Atom, JSON, or watch the repo on GitHub.