{"slug":"Norma","total":62,"limit":100,"offset":0,"since":null,"commits":[{"hash":"b9d1c04","date":"2026-07-23 08:20:56 -0700","author":"Steve Abrams","subject":"instagram-agent fully LIVE: real token installed, monitor/discover/report un-stubbed (Steve-authorized), Graph v21 metrics fixed","body":""},{"hash":"be52823","date":"2026-07-23 08:19:43 -0700","author":"Steve Abrams","subject":"auto-save: 2026-07-23T08:19:35 (2 files) — agents/instagram-agent/skills/discover.js agents/instagram-agent/skills/monitor.js","body":""},{"hash":"e6864e6","date":"2026-07-23 07:19:26 -0700","author":"Steve Abrams","subject":"auto-save: 2026-07-23T07:19:17 (1 files) — scripts/gmail-backfill.mjs","body":""},{"hash":"05b28cd","date":"2026-07-14 09:13:20 -0700","author":"Steve Abrams","subject":"instagram-agent: env-configurable Graph host (IG_GRAPH_HOST) — support Facebook-Login flow (page token + IG business id) alongside Instagram-Login","body":"Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"},{"hash":"68552ce","date":"2026-07-13 10:24:27 -0700","author":"Steve Abrams","subject":"instagram-agent: make reel skill account-aware (close cross-brand IG-token leak)","body":"- resolveAccount(account): 'dw' → IG_USER_ID/IG_ACCESS_TOKEN (unchanged);\n  other id X → IG_USER_ID_<X> / IG_ACCESS_TOKEN_<X> (X=upper, dashes→underscores),\n  mirroring dw-marketing-reels publish-social.mjs IG_AGENT_AUTH_<ID> naming.\n- non-dw account with NO token now returns { status:'no-creds-for-account',\n  account, note } instead of silently posting from the DW account (the bug).\n- echoes resolved account in response + logs; dw single-account simulation/live\n  path 100% intact. node --check passes; verified via in-process skill calls."},{"hash":"5d84a60","date":"2026-07-13 01:53:27 -0700","author":"Steve","subject":"chore: v0.1.3 (dashboard CSS refactor)","body":""},{"hash":"47943fb","date":"2026-07-13 01:53:27 -0700","author":"Steve Abrams","subject":"refactor(agent-base): hoist static dashboard CSS to a module const","body":"Behavior-preserving — moves the 26-line inline <style> block out of the\nper-request GET / handler into a DASHBOARD_CSS const (defined once, not\nrebuilt per page load). Rendered HTML identical; verified node --check +\nnorma-instagram boots + GET / 200 with CSS intact.\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"c7157cf","date":"2026-07-13 00:40:01 -0700","author":"Steve","subject":"chore: version bump (session close — SSRF guard + security P0 verification)","body":""},{"hash":"cb9d893","date":"2026-07-12 11:28:02 -0700","author":"Steve Abrams","subject":"docs(security): record P0#1-3 + P1 SSRF remediation as resolved in April audit","body":"Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"10f2f97","date":"2026-07-12 11:27:39 -0700","author":"Steve Abrams","subject":"security(P1): add shared SSRF guard blocking private/reserved/loopback hosts on user-URL fetches","body":"Wire lib/ssrf-guard.assertPublicUrl() into /api/nonprofit/enrich and\n/api/email-analyzer/fetch-url before the outbound fetch. Blocks non-http(s)\nprotocols, localhost, RFC1918 (10/172.16-31/192.168), loopback (127/::1),\nlink-local + cloud-metadata (169.254.0.0/16 incl. 169.254.169.254), and other\nreserved ranges; resolves DNS so hostnames pointing at private IPs are also\nblocked. Verified: private/reserved/file:// -> 400, public URL -> 200.\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"dab8014","date":"2026-07-12 07:04:15 -0700","author":"Steve","subject":"chore: version bump (session close — Norma platform + agent recovery, login/schema-drift fixes, dashboard)","body":""},{"hash":"b184c10","date":"2026-07-11 08:55:30 -0700","author":"Steve Abrams","subject":"Fix tier_credentials schema drift in users + gmail routes","body":"users: drop full_name/email/is_active; name from display_name, email from\nemail-shaped username, no is_active filter.\ngmail messages/[id]/assign/oauth: swap full_name->display_name in COALESCE,\nresolve email from username (no tier_credentials.email), drop is_active\nfilter on the assign target lookup.\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"86dc7d4","date":"2026-07-11 08:55:30 -0700","author":"Steve Abrams","subject":"Fix tier_credentials schema drift in auth + impersonate routes","body":"login: remove dead is_active block + swap row.full_name -> display_name.\nsession: drop full_name/email from SELECT (email inferred from username).\nimpersonate: drop is_active from SELECT + remove disabled-account guard.\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"9ba27ad","date":"2026-07-11 08:55:30 -0700","author":"Steve Abrams","subject":"Fix tier_credentials schema drift in settings CRUD routes","body":"Drop full_name/email/is_active/last_login_at/created_by column refs that\ndo not exist in this deployment. SELECT/INSERT/dynamic-UPDATE/RETURNING\nnow match the real schema; admin-disable guard removed (no is_active).\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"c421505","date":"2026-07-11 08:42:35 -0700","author":"Steve Abrams","subject":"auto-save: 2026-07-11T08:42:28 (1 files) — app/api/auth/login/route.ts","body":""},{"hash":"2439b98","date":"2026-07-10 12:06:20 -0700","author":"Steve Abrams","subject":"dashboard: harden escaping for attribute context (contrarian gate)","body":"Contrarian flagged attribute-context XSS: esc() escaped &<> but not quotes,\nwhile permalink->href and thumb->src render in double-quoted attributes; in\nlive mode discover() pulls hashtag-search results (other users' captions/\npermalinks) and helmet CSP is disabled, so an injection would be unmitigated.\nFixes: esc() now also escapes \" and ', and href/src accept only http(s) URLs\n(blocks javascript: scheme). Proven via headless Playwright with a hostile\npayload: no dialog, window.__pwned=false, 0 rogue img[onerror]/script nodes,\nmalicious permalink -> href='#', caption rendered as literal escaped text.\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"a1a4ebc","date":"2026-07-10 12:01:22 -0700","author":"Steve Abrams","subject":"Norma dashboard: enrich GET / with insights + media grid (cache-backed)","body":"Steve: dashboard was 'very little info, no images'. Now:\n- shared agent-base gains a generic dashboard() hook: renders a metrics[]\n  stat-card row + a media[] thumbnail grid + live/simulation badge. Base\n  stays generic (no IG specifics); agents supply the data.\n- instagram-agent supplies dashboard(): reads cache-backed monitor insights\n  (reach/impressions/followers/etc) + discover media. DTD-committed (2/2)\n  CACHE design: crons persist latest result to data/*.json; GET / reads the\n  cache and NEVER fires a metered Graph API call from a page view. Simulation\n  computes on-empty (free/local). Non-loading sim media_url -> placeholder tile.\n- data/ gitignored (runtime cache).\n\nVerified (simulation): mode badge, 6 metric cards, media grid w/ captions,\ncache files written, GET / 200 authed.\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"46eb638","date":"2026-07-10 11:35:53 -0700","author":"Steve Abrams","subject":"Norma instagram-agent: add RECOVERY.md (deps-location gotcha, auth, durability)","body":"Documents the shared/node_modules dependency-resolution gotcha the contrarian\nflagged: npm install in agents/shared/ (not the agent dir) when non-dotenv deps\ngo missing. Also records the .env/empty-password trap + reboot durability + the\ndw-uptime-probe watchlist follow-up.\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"3dbea14","date":"2026-07-10 11:30:11 -0700","author":"Steve Abrams","subject":"agent-base: fix route shadowing + server-render status dashboard","body":"Contrarian gate caught two real defects:\n- app.use('*') 404 catch-all was registered inside createAgentServer,\n  shadowing caller-added routes: instagram-agent /api/skill/reel + /story\n  were 404. Now deferred to start() (called last) -> custom routes win.\n  Verified reel/story now 200, bogus routes still 404.\n- GET / dashboard fetched /api/status client-side, which was fragile re:\n  Basic-auth on sub-requests. Now server-renders data inline (const S).\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"6f74352","date":"2026-07-10 11:23:48 -0700","author":"Steve Abrams","subject":"Norma agents: add read-only status dashboard at GET / (behind auth)","body":"Renders /api/status + cron + recent audit client-side; no write controls.\nFixes the 'open it in a browser -> 401/404' gap. Shared base, so all 11\nagents inherit it; only norma-instagram is currently live.\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"3b2f226","date":"2026-06-01 07:39:35 -0700","author":"Steve Abrams","subject":"Global search: mount on Supervisor + Pulse tiers","body":"Norma had GlobalSearch wired only into AppShell (admin) and NikkiShell\n(staff). Extend to the remaining two tiers:\n\n- Supervisor: import GlobalSearch and render it top-right of\n  app/supervisor/page.tsx. onNavigate pushes /?tab=<id>&id=<id>; teach\n  AppShell to read those params on mount so deep-links land on the right\n  tab.\n\n- Pulse: build a new PulseSearch component that matches the Pulse\n  Georgia-serif / cream / dark-green / gold theme (the existing\n  GlobalSearch is dark-themed and would clash). Hits a new\n  /api/search/public route that requires no auth and only returns\n  entities safe for end-users: petitions, politicians, organizations,\n  topics, articles. Intentionally excludes journalists, contacts,\n  grants, foundations — those are staff/admin tooling.\n\nFiles:\n  app/api/search/public/route.ts  (new — 5-entity public search)\n  components/PulseSearch.tsx       (new — Pulse-themed search bar + modal)\n  components/AppShell.tsx          (read ?tab=&id= deep-link params)\n  app/supervisor/page.tsx          (mount GlobalSearch top-right)\n  app/pulse/layout.tsx             (mount PulseSearch in nav)\n\nCmd+K / Ctrl+K opens search on both surfaces. Esc closes, arrow keys\nnavigate, Enter selects.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"eb3fcd7","date":"2026-05-31 00:01:07 -0700","author":"Steve Abrams","subject":"fix: add rel=noopener noreferrer to 32 target=_blank links in inbox mockup index; expand .gitignore backup patterns","body":"Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"},{"hash":"81abdbc","date":"2026-05-30 10:00:33 -0700","author":"Steve","subject":"security: strip hardcoded dw_admin DSN password -> env-first (.mjs straggler). No rotation/deploy.","body":""},{"hash":"bde9449","date":"2026-05-30 09:49:19 -0700","author":"Steve","subject":"security: strip hardcoded dw_admin DSN password -> env-first/passwordless. No rotation/deploy.","body":""},{"hash":"1033d7f","date":"2026-05-30 09:32:15 -0700","author":"Steve Abrams","subject":"security: strip hardcoded dw_admin DSN password -> env-first (DATABASE_URL); passwordless localhost fallback. No rotation/deploy.","body":""},{"hash":"36d0cb1","date":"2026-05-28 17:08:16 -0700","author":"Steve Abrams","subject":"wire Morning Batch + weekly_intelligence to PM2 cron — fixes 77d/72d stale tables","body":"Both writers existed but had no scheduled trigger:\n- /api/cron/daily (Morning Batch / sessions table) was never hit on prod\n- scripts/compute-intelligence.ts (weekly_intelligence table) wasn't in any\n  cron or ecosystem entry despite header saying 'Nightly cron script'\n\nAdds:\n- scripts/daily-cron.js: POSTs to /api/cron/daily with x-cron-secret (mirrors\n  credential-health-cron.js pattern)\n- ecosystem.config.js: norma-morning-batch (cron 0 14 * * * = 6 AM PT)\n- ecosystem.config.js: norma-intelligence (cron 0 8 * * * = midnight PT)\n\nBoth load .env.local via --env-file so CRON_SECRET/GEMINI_API_KEY reach the\nprocs. Surfaced by yolo task 49."},{"hash":"0713748","date":"2026-05-28 16:54:45 -0700","author":"Steve Abrams","subject":"norma-scraper: load .env.local via interpreter_args so SESSION_SECRET reaches the cron — fixes 401 loop","body":"scripts/scraper-cron.js builds an inline session token using SESSION_SECRET.\nWithout --env-file, the cron's secret was empty, signature mismatched the\nserver's HMAC, server returned 401, cron exited, pm2 restarted it every\n~2 min. norma-credential-health already had this fix; norma-scraper didn't.\n\nMirrors the norma-credential-health pattern (interpreter+interpreter_args).\n2026-05-28 fix surfaced by yolo task 49."},{"hash":"44cdbed","date":"2026-05-21 20:08:00 -0700","author":"Steve Abrams","subject":"defensive: explicit body[data-inbox-theme='classic-dense'] selector","body":"Previously the classic-dense theme relied on the :root base via *absence*\nof an override — switching FROM another theme BACK to classic-dense\nworked because CSS cascade restores :root values when overrides aren't\nmatched, but it's fragile: if a future theme sets a var that no other\ntheme touches, switching back to classic-dense would leak that var.\n\nNow classic-dense is a positive reset (32 explicit selectors + the :root\nbase = 33 layers of belt-and-suspenders). All token values mirror the\n:root defaults exactly."},{"hash":"76b8e92","date":"2026-05-21 18:45:51 -0700","author":"Steve Abrams","subject":"feat(theme): hoist InboxThemeBanner + AgeThemeSlider into app/layout.tsx","body":"Both controls now render on every page including /login, /signup, /pulse —\nnot just inside the authenticated AppShell. Matches the original intent\n('set theme and age at the start' from earlier in the session).\n\n- TopThemeBar.tsx: thin client wrapper bundling both components\n- layout.tsx (server component): renders <TopThemeBar /> before children\n- AppShell.tsx: removed duplicate banner + slider renders\n- Both still write to <body data-…>, so behavior is unchanged for\n  authenticated users; new behavior is that pre-auth visitors can also\n  set both. Persisted in localStorage."},{"hash":"59324fb","date":"2026-05-20 15:54:48 -0700","author":"Steve Abrams","subject":"docs(sessions): log brute-force-protection learnings (commit 2c9def0)","body":"- Why dedicated check/record/clear helpers vs the existing combined limiter.\n- Threshold-crossing extends reset to the lockout window, not the rolling window.\n- NextRequest.ip not stable across Next 16 runtimes — XFF header is the source.\n- Observation: sdcc.agentabrams.com nginx vhost currently proxies :9720\n  (Express norma-sdcc-pitch), not norma-email :7400. Smoke test ran against\n  127.0.0.1:7400 directly on prod via ssh.\n- Verified 10/15min/30min behavior end-to-end on the Next.js app."},{"hash":"2c9def0","date":"2026-05-20 15:48:57 -0700","author":"Steve Abrams","subject":"feat(auth): brute-force-resistant login rate-limit (10 fails/15min per IP, 30min lockout)","body":"- Add checkLoginAttempt / recordLoginFailure / clearLoginCounter helpers to\n  lib/rate-limit.ts, sharing the existing in-memory Map under a 'login:<ip>'\n  namespace (per learnings.md 2026-04-13 part 2).\n- Only HTTP 401 'Invalid credentials' responses increment the counter;\n  successful login clears it; 400/403/500 leave it untouched.\n- Once 10 failures land inside a 15-minute window, the IP is locked for 30\n  minutes — extended reset on threshold-crossing, not just window expiry.\n- 429 responses now carry Retry-After header + {retryAfter} JSON field.\n- Client IP extracted x-forwarded-for first (nginx in front) → x-real-ip →\n  'unknown'. NextRequest.ip intentionally not used (unstable across Next 16\n  runtimes).\n- No new deps; no Redis; the existing /api/v1/* API-key limiter is untouched."},{"hash":"adb6d64","date":"2026-05-20 14:31:15 -0700","author":"Steve Abrams","subject":"tasks: mark #52 (admin impersonation UI) DONE","body":""},{"hash":"1c1e1df","date":"2026-05-20 14:30:44 -0700","author":"Steve Abrams","subject":"feat(impersonate): wire the View-as dropdown + Acting-as banner into AppShell","body":"Backend was already shipped at /api/admin/impersonate (GET state,\nPOST {username} to start, DELETE to exit; preserves the real admin's\nsession in `norma-imp-by` cookie). This adds the UI:\n\n- components/ImpersonateMenu.tsx\n  - variant=\"dropdown\" — renders a \"View as…\" button in the AppShell\n    header, admin-only, lists every user with role chip + email; click\n    POSTs and hard-reloads\n  - variant=\"banner\" — renders an amber sticky bar at the very top of\n    the chrome while impersonating, shows \"Impersonating <user> · role\n    + Exit impersonation\" button (DELETE + hard-reload)\n  - Single component, two render modes so the same fetch + state logic\n    powers both\n\n- components/AppShell.tsx — mounts <ImpersonateMenu variant=\"banner\" />\n  above the InboxThemeBanner (sticky-top), and <ImpersonateMenu\n  variant=\"dropdown\" /> in the header next to <ApiRegistryButton/>.\n\nSmoke-verified end-to-end:\n- admin → POST {ryan} → session reports user=ryan role=intern\n- /api/admin/impersonate GET while impersonating reports impersonating:true\n- DELETE → session restored to admin"},{"hash":"e284feb","date":"2026-05-20 14:24:47 -0700","author":"Steve Abrams","subject":"session 2026-05-20: log roles+perms+modal-fanout+gmail-per-user+disk-save+admin-union","body":""},{"hash":"11bcabc","date":"2026-05-20 14:15:54 -0700","author":"Steve Abrams","subject":"docs+infra: gmail connect note (password redacted) + linter-touched messages route","body":""},{"hash":"c498d02","date":"2026-05-20 14:03:09 -0700","author":"Steve Abrams","subject":"fix(theme): apply 32 inbox-theme skins at body level so dropdown clicks visibly re-skin the whole page","body":"Selectors rewritten from\n  body[data-inbox-theme='X'] .gmail-crm-tab { … }\nto\n  body[data-inbox-theme='X'] { … }\n\nThe skin overrides --color-bg/--color-text/--color-primary/etc, which\nalready cascade through every component via var(--color-*), so the\nentire app re-themes on click instead of silently changing nothing\nwhen the user isn't on the Gmail CRM tab.\n\nclassic-dense is the implicit default (no explicit override) → 31\nexplicit body-level rules + the base = 32 functional themes."},{"hash":"ce205d8","date":"2026-05-20 14:01:01 -0700","author":"Steve Abrams","subject":"fix(theme): apply inbox-theme skins at body level so dropdown clicks re-skin the whole page immediately","body":"Previous selectors scoped down to body[data-inbox-theme] .gmail-crm-tab,\nwhich meant picking a theme produced no visible change unless the user\nwas already on the Gmail CRM tab. Steve hit that confusion ('nothing\nhappens when dropdown item is clicked').\n\nRewrote all 32 selectors to body[data-inbox-theme=...] — the skin sets\nCSS custom properties (--color-bg, --color-text, --color-primary, font)\nwhich cascade through every component that already uses var(--color-*),\nso the entire app re-themes on click. The 'inbox theme' name is now\na historical artifact; effectively a global Norma theme."},{"hash":"82446f2","date":"2026-05-20 13:58:22 -0700","author":"Steve Abrams","subject":"feat(theme): full-width inbox-theme banner at top of AppShell","body":"Replace the small dropdown in the header right-side cluster with a\nprominent sticky banner at the very top of the page:\n\n- ◀ prev / ▶ next cycle buttons (wraps around the 32-theme set)\n- 'N / 32' counter in monospace\n- 🎲 random button\n- direct-jump dropdown\n- current theme name + description caption\n- 'Gallery ↗' link to /mockups/inbox/index.html\n\nSits sticky above the AgeThemeSlider. Writes <body data-inbox-theme>,\nCSS skins in globals.css still scope to .gmail-crm-tab so the visual\neffect stays inbox-only. Persists to localStorage."},{"hash":"70fa5dc","date":"2026-05-20 13:50:34 -0700","author":"Steve Abrams","subject":"feat(theme): hoist inbox theme switcher to global AppShell header","body":"- lib/inbox-themes.ts: single source of truth for the 32-entry registry\n- components/InboxThemeSwitcher.tsx: header dropdown that writes\n  document.body.dataset.inboxTheme + persists to localStorage\n- AppShell.tsx renders <InboxThemeSwitcher /> next to API Registry button\n- GmailCRMTab.tsx: removed duplicate state + dropdown + data-attribute\n  (no more 'find the toggle inside the Gmail CRM tab'); kept the\n  .gmail-crm-tab className so CSS skins still scope down\n- globals.css: 32 selectors rewritten from\n    .gmail-crm-tab[data-inbox-theme='…']\n  to\n    body[data-inbox-theme='…'] .gmail-crm-tab\n  so the toggle on <body> propagates down to the inbox tab"},{"hash":"8f71b14","date":"2026-05-20 13:46:46 -0700","author":"Steve Abrams","subject":"fix(gmail): scope Gmail CRM to current user's mailbox, not hardcoded natalia","body":"The Gmail CRM header read 'Gmail CRM — natalia@studentdebtcrisis.org'\nfor EVERY logged-in user, and /api/gmail/messages always returned\nnatalia's mailbox regardless of who was logged in. So info@ saw natalia's\ninbox, which is the wrong sharing model.\n\n- /api/auth/session now returns fullName + email pulled from\n  tier_credentials (with username fallback when the username itself is\n  email-shaped).\n- /api/gmail/messages now resolves the mailbox via the authed user's\n  email (case-insensitive). When the user has no connected mailbox it\n  returns {messages: [], needs_connect: true} instead of leaking another\n  user's mail. Admin can opt-out via ?mailbox=all to see the union.\n- GmailCRMTab header now shows the logged-in user's email + a\n  \"Gmail not connected\" badge when needs_connect=true.\n\nAlso added placeholder mailbox rows for info@ + SabrinaAshley@ with\nneeds_reauth=true so the upcoming Connect-Gmail flow has a target row."},{"hash":"ee9135a","date":"2026-05-20 13:38:31 -0700","author":"Steve Abrams","subject":"feat(modals): fan out ResizableModal to 22 modals across 15 files","body":"Dispatched 4 parallel claude --print workers via the max-it skill. Each\nworker refactored a batch of modal-bearing components to use the shared\n<ResizableModal modalId=\"…\"/> wrapper, picking up full-screen toggle,\ncorner drag-resize, and per-modalId localStorage size persistence.\n\nModals converted (22):\n  ApiCredentialsSection (4)  — api-key-create / api-platform-connect /\n                               api-key-revoke-confirm / api-platform-disconnect-confirm\n  ManageTabsModal           — manage-tabs\n  AddGrantModal             — grant-create\n  AddPetitionModal          — petition-create\n  ScheduleModal             — pipeline-schedule\n  MemberEmailModal          — member-email-generator\n  BulkScheduleModal         — social-bulk-schedule\n  CompareModal              — email-analyzer-compare-versions\n  ContactsTab               — contacts-import-csv\n  InTheNewsTab              — intelligence-discover-articles\n  JournalistHub (2)         — journalist-article-detail / journalist-add\n  OldLeadsTab               — leads-rekindle-letter\n  ApiRegistryModal          — api-registry\n  LibraryTab                — library-item-create\n  AdvocacyTargets           — advocacy-petition-template\n  XSessionsTab              — xsessions-new-scan\n  StatementsTab             — statements-generate\n  PartnersTab               — partners-add\n  OnBoardTab (2)            — onboard-trigger-discovery / onboard-nonprofit-signup\n  OutreachPlaybookTab (2)   — outreach-add-pipeline-contact / outreach-generate-letter\n  SocialTab (2)             — social-add-stream / social-compose-post\n  PlatformPreview (2, prev) — platform-preview-save-confirm / platform-preview-publish-confirm\n  ComposeModal (prev)       — email-compose\n\nSkipped (intentionally, per ResizableModal rules):\n  PetitionDraftModal        — side-drawer, not centered modal\n  PipelineTab/PetitionsTab/GrantsTab/NewsTab/EmailSendsTab/EmailAnalyzer/\n    ApiRegistryButton        — no inline modals (delegate to external Modal*)\n  DashboardTab, PulseFeed, TopicRadar, NetworkGraph3D, EnhancedMindMap,\n    PulseGlobe                — no centered modals (inline panels only)\n\nPre-existing TS errors in lib/auth.ts (SESSION_SECRET narrowing) +\nQueryResultRow generics in unrelated files left untouched."},{"hash":"dfa8907","date":"2026-05-20 13:24:37 -0700","author":"Steve Abrams","subject":"feat(inbox): +16 themes (v17-v32) — total 32 live + dropdown-wired","body":"v17 Ottoman Tile · v18 Brutalist Concrete · v19 Library Catalog ·\nv20 Wabi-Sabi · v21 Cyberpunk Rain · v22 Bauhaus · v23 Memphis 80s ·\nv24 Vellum Manuscript · v25 Solarpunk · v26 Frutiger Aero ·\nv27 Crossword Grid · v28 8-Bit Pixel · v29 Watercolor · v30 Sakura ·\nv31 Forest Walk · v32 Botanical Print\n\nEach: standalone HTML mockup + matching .gmail-crm-tab[data-inbox-theme=...]\nCSS skin in globals.css + entry in INBOX_THEMES dropdown. All 32 selectable\nlive from the GmailCRMTab toolbar. Persisted in localStorage as\nnorma.inbox-theme.\n\nGallery updated to 32 cards at /mockups/inbox/index.html."},{"hash":"93205f4","date":"2026-05-20 13:00:56 -0700","author":"Steve Abrams","subject":"feat(inbox): 10 more theme mockups (v7-v16) + live theme dropdown in GmailCRMTab","body":"10 new visual directions, all wired as live themes:\n  v7  Spotify Dark        — pure black, green accent\n  v8  Minimal Mono        — black on white, Helvetica\n  v9  Newsprint           — sepia broadsheet, drop-cap serif body\n  v10 Glassmorphism       — frosted panels, cyan+pink neon on purple\n  v11 Terminal            — green phosphor, monospace, scanline overlay\n  v12 Mochi Pastel        — blush+lavender+mint, Nunito rounded\n  v13 High-Contrast A11y  — Atkinson Hyperlegible, 18px floor, ≥48px tap\n  v14 Material 3          — purple primary, rounded, FAB send button\n  v15 Synthwave           — magenta+cyan on indigo, retro-grid horizon\n  v16 Kanban Lanes        — Trello palette (lane layout in mockup only)\n\nAll 16 (v1-v16) now selectable from a Theme dropdown in the GmailCRMTab\ntoolbar. Each option rewrites the Norma CSS tokens FOR THE INBOX ONLY\nvia .gmail-crm-tab[data-inbox-theme='…'] in globals.css — the rest of\nthe app keeps its base theme. Choice persists to localStorage as\nnorma.inbox-theme.\n\nNote: structural layouts (3-pane v4, kanban v16, conversation v5) are\npalette+typography swaps in the live toggle; the structural reshape\nremains a future-prop component refactor. Full pixel-perfect mockups\nstay at /mockups/inbox/v1-v16.html for reference.\n\nGallery: /mockups/inbox/index.html with all 16 in a side-by-side grid."},{"hash":"b163e4d","date":"2026-05-20 12:43:00 -0700","author":"Steve Abrams","subject":"session 2026-05-20: log roles/perms/modals + infra fires + 4 user seed","body":""},{"hash":"68f7b63","date":"2026-05-20 12:40:53 -0700","author":"Steve Abrams","subject":"feat(mockups): 6 inbox UI variants — Classic Dense / Notion Cards / Linear Keyboard / Apple Mail / Front Conversation / Hey Zero-Distraction","body":"Each variant ships every feature from the live GmailCRMTab (assigned-to dropdown,\nper-user read receipts as pills, inline-expanded reading pane, AI draft reply)\nunder a distinct visual treatment so Steve can pick a direction.\n\n- index.html: side-by-side scaled-iframe gallery with one-line descriptions\n  + badges (familiar/dense/keyboard-first/team-inbox/etc.) for fast comparison.\n- v1-classic-dense: today's pattern, refined dark theme, tight rows.\n- v2-notion-cards: light theme, elevated cards w/ generous padding.\n- v3-linear-keyboard: monochrome, status-machine (Triage/Working/Replied/Snoozed),\n  J/K-style keyboard nav prompts.\n- v4-apple-mail: 3-pane (mailboxes | list | reading), multi-account ready.\n- v5-front-conversation: assignee-first, group inbox, internal-note threads,\n  collision detection ('Cody is also viewing').\n- v6-hey-zero: editorial Source Serif, Imbox/Feed/Paper Trail buckets, slow-mail vibe.\n\nBrowse at http://localhost:7400/mockups/inbox/index.html once norma-email\nis back up. To promote one to Figma frames: kick the mcp__figma__authenticate\nOAuth flow."},{"hash":"d433048","date":"2026-05-20 12:38:54 -0700","author":"Steve Abrams","subject":"feat(modals): shared ResizableModal with full-screen toggle + drag-resize","body":"- components/shared/ResizableModal.tsx — drop-in wrapper providing:\n  - Maximize/Restore icon in header → expand to full viewport\n  - Bottom-right corner drag handle → freeform resize\n  - localStorage per modalId remembers size + maximized state\n  - Esc-to-close, click-backdrop-to-close, smooth border-radius transition\n- Retrofit both modals in CredentialsSection (User Create/Edit, Delete\n  Confirm) as the proof point. Other Norma modals can adopt incrementally\n  by swapping their wrapper div for <ResizableModal modalId=\"…\" />."},{"hash":"38145df","date":"2026-05-20 12:32:18 -0700","author":"Steve Abrams","subject":"feat(gmail): per-message Assign-to + per-user read receipts + inline-expand layout","body":"DB (migration 025_email_assign_read.sql):\n- gmail_messages.assigned_user_id / assigned_at / assigned_by (FK tier_credentials)\n- gmail_message_reads (message_id, user_id, read_at) — one row per (msg, user)\n\nAPI:\n- GET /api/users — assignable users (admin/staff/intern) for the dropdown\n- POST /api/gmail/messages/[id]/assign {userId} — admin-only assignment\n- POST /api/gmail/messages/[id]/mark-read — current user records read (idempotent)\n- GET /api/gmail/messages — now returns assigned_name + read_by[] for each msg\n- GET /api/gmail/messages/[id] — auto-records read receipt for the CURRENT user\n  on view (per-user, replaces the legacy global is_read auto-flip)\n\nUI (GmailCRMTab):\n- Row footer chips: 'Assigned to <name>' (indigo) + 'Seen ×N' or 'Seen by X' (emerald)\n- Detail panel: Owner dropdown (admin-only) + Seen-by pill list with read-at tooltips\n- Layout: stacked column instead of 45/55 split — detail expands inline below the\n  list rather than as a sibling side-pane, per Steve's 'open inline with expanding\n  area' directive.\n- Auto mark-as-read happens server-side on GET /messages/[id]; no extra client call."},{"hash":"f6b2dba","date":"2026-05-20 12:25:58 -0700","author":"Steve Abrams","subject":"feat(auth): intern role + user fields + admin-editable role × feature permissions matrix","body":"- migration 024: extend tier_credentials role enum with 'intern'; add full_name,\n  email, is_active, last_login_at, created_by; create role_permissions table\n  (22 seeded feature_keys across 8 categories — gmail/drafts/petitions/contacts/\n  news/grants/outreach/social/settings).\n- lib/auth.ts: UserRole union type + ROLE_LABEL + ALL_ROLES exports.\n- lib/permissions.ts: hasPermission(role, feature) reader w/ 60s in-process cache.\n  admin implicitly allowed for every feature.\n- /api/admin/permissions GET/PUT — admin-only matrix editor.\n- /api/settings/tier-credentials POST/PUT now accept full_name/email/is_active;\n  admin row can't be disabled or demoted.\n- /api/auth/login: blocks is_active=false accounts (after pw check, so we\n  don't leak which usernames exist); bumps last_login_at; returns full_name.\n- CredentialsSection: new collapsible per-user permissions row showing every\n  feature the user can access. Add User modal grew Full Name / Email /\n  Active toggle / Intern role option.\n- PermissionsMatrix.tsx: admin-editable feature × role checkbox grid in\n  Settings, mounted directly under Users & Roles.\n\nAlso widened the 'admin'|'staff'|'pulse' role literal in AuthProvider,\nSidebar, and pulse layout to include 'intern'."},{"hash":"189ac33","date":"2026-05-20 12:24:19 -0700","author":"Steve Abrams","subject":"feat(admin): comprehensive API/Token/MCP registry + age-adaptive theme slider","body":"- lib/api-registry.ts: ~30 integrations across 10 categories (Gmail/Google OAuth,\n  Gemini, Congress/ProPublica/BLS/Census/FRED/Scorecard, Action Network, Twitter/X,\n  BlueSky, Reddit, Facebook/IG, Discord, Twitch, Slack, SMTP, internal infra, MCPs).\n  Each entry: env vars, purpose, signup URL, docs URL, OAuth scopes, pricing,\n  numbered get-this-token steps.\n- /api/registry route: admin-gated, returns set/missing status per env var.\n  Values NEVER leak — only booleans.\n- ApiRegistryModal: searchable, status-filterable, grouped by category. Per-item\n  card expands to show env vars (with copy buttons), scopes, pricing,\n  step-by-step instructions, and signup/docs links.\n- ApiRegistryButton in header (admin only) opens the modal.\n- AgeThemeSlider on top of every page: rewrites CSS tokens through 8 bands\n  (toddler/kid/tween/teen/adult/mature/senior/elder), persisted in localStorage.\n  CSS bands appended to globals.css — typography + palette only, NOT a WCAG\n  substitute."},{"hash":"2b2fc56","date":"2026-05-20 12:15:47 -0700","author":"Steve Abrams","subject":"ux(gmail): land with full-width inbox — default showContacts to false so users see all mail first; Contacts toggle stays in toolbar for outreach drilldown","body":""},{"hash":"4161ee6","date":"2026-05-20 11:57:20 -0700","author":"Steve Abrams","subject":"session 2026-05-20: log 9 task entries (petition debounce, modal video, 4 pay-down todos)","body":"Adds 2026-05-20 dated section (#33-#41) covering today's work:\n- #33 petition double-submit debounce (commit 9619a2a)\n- #34 sweep empty petition stubs\n- #35 promote ready drafts + feature 3 heroes\n- #36 modal training one-pager (commit fb319b1)\n- #37 narrated 9-modals walkthrough video w/ cloned voice\n\nPlus 4 pending pay-down items surfaced during this session:\n- #38 ComposeModal mounts 3 stub drafts before user types (same anti-pattern\n  as the petition create-page bug; same fix shape)\n- #39 CompareModal needs ≥2-version seed for future live demo capture\n- #40 ScheduleModal reused across 4 tabs — document canonical demo path\n- #41 'Select organization' login-step gate missing from CLAUDE.md"},{"hash":"fb319b1","date":"2026-05-20 10:51:42 -0700","author":"Steve Abrams","subject":"docs: modal training one-pager for SDCC staff (companion to walkthrough video)","body":"Covers all 9 admin-tier modals — Manage Tabs, Add Petition, Petition Draft\n(AI), Add Grant, Compose Email, AI Member Email, Schedule, Bulk Schedule,\nCompare. Each section: purpose, where to open, key fields, what saves to\nPG, gotchas. Common patterns + troubleshooting + data-flow table at the end.\n\nPairs with the modal walkthrough video being built by app-demo-video skill\n(output at ~/.claude/skills/app-demo-video/output/norma-modals-tour/)."},{"hash":"e46e6f9","date":"2026-05-20 09:55:28 -0700","author":"Steve Abrams","subject":"fix(editor): set immediatelyRender:false on Tiptap useEditor — Next 15 + React 19 SSR hydration error blocked petition pipeline editor render","body":""},{"hash":"4f1d74b","date":"2026-05-20 09:51:55 -0700","author":"Steve Abrams","subject":"fix(pipeline/reorder): cast org_id param as ::uuid not ::text — pipeline drag-drop was 500-ing for staff role with non-null orgId due to same uuid=text operator mismatch already fixed in pipeline/stats + settings","body":""},{"hash":"9619a2a","date":"2026-05-20 09:08:10 -0700","author":"Steve Abrams","subject":"petition create: debounce double-submit via useRef guard + 30-min server-side merge window","body":"Two-layer fix for the create-page double-submit bug that produced empty\nstub + real-content dupe rows (SAVE Plan drafts incident, 2026-05-20).\n\nClient (create/page.tsx): useRef inFlight guard runs synchronously inside\nhandleSubmit so React strict-mode double-fire and rapid clicks can't sneak\npast the state-based 'submitting' gate.\n\nServer (api/pulse/petitions/route.ts): before INSERT, look for a same-title\nactive petition created in the last 30 minutes. If found, MERGE the new\nsubmission's missing fields into it (body/description/target/category/tags/\ngoal — only fills empties) and return the existing row with idempotent:true.\nCatches rapid double-clicks (no-op merge) AND the publish-empty-then-publish-\nfilled pattern (stub gets filled in).\n\nSmoke-tested locally: 3 submits with same title -> 1 row, all idempotent."},{"hash":"eb21d0d","date":"2026-05-20 07:51:18 -0700","author":"Steve Abrams","subject":"fix(api): cast org_id param as ::uuid not ::text in pipeline/stats + settings","body":"petition_pipeline.org_id, drafts.org_id, news_items.org_id, collaborations.org_id,\npartners.org_id, old_leads.org_id are all uuid columns. The previous $1::text NULL\nguard caused pg to infer the bound param as text, then the org_id = $1 comparison\nthrew 'operator does not exist: uuid = text' for any non-null orgId.\n\n/api/pipeline/stats was 500-ing for SDCC staff. /api/settings would have done the\nsame had the .catch() not been swallowing it silently into a 0-row default.\n\nCast both occurrences as ::uuid so the null-guard and the comparison agree."},{"hash":"c0eed7c","date":"2026-05-20 02:23:09 -0700","author":"Steve Abrams","subject":"docs: morning wakeup brief — DB password fix + verification path","body":"Generated overnight while Steve was asleep. Single doc at repo root\ncovering: (1) the 5-second psql ALTER USER fix that unlocks everything,\n(2) what's already working, (3) what's still broken until the fix runs,\n(4) the 7 fixes committed overnight (CATEGORY_COLORS / IntegrationsTab /\ncredentials.rowCount + pulse-public UX), (5) the click-through audit\nartifacts at /tmp/, (6) post-fix curl verification commands.\n\nSurfaces the exact dw_admin password reset command Mac2 needs without\ntouching Kamatera's identical-named role."},{"hash":"52ff6c8","date":"2026-05-20 02:19:49 -0700","author":"Steve Abrams","subject":"fix(pulse-public): silence 401 spam, unblock topics for guests, hide Apple OAuth stub, add sign-in CTA","body":"5 fixes for unauthenticated Pulse visitor UX (per click-through audit\n2026-05-20 at /tmp/norma-audit-2026-05-20.md):\n\n1. /api/auth/session: return 200 + {authenticated:false} for guests\n   instead of 401. Every Pulse page calls this on mount; the 401 was\n   spamming red errors in browser DevTools + triggering Next.js prefetch\n   retries. Client code already checks data.authenticated.\n\n2. /api/petitions/topics: drop the requireRole gate. Route was 401'ing\n   guests despite middleware allow-listing /api/petitions/* for public\n   access. Topics list has no user-specific logic and should render\n   pre-signup so guests can browse before creating an account.\n\n3. /login: hide 'Continue with Apple' button behind NEXT_PUBLIC_APPLE_OAUTH_ENABLED\n   env flag. /api/auth/apple route does not exist, so clicking the button\n   gave a 404. Handler kept for when Apple OAuth ships; just no UI render.\n\n4. /pulse/activity: error banner with 'please sign in' message now renders\n   a green-themed card with an inline 'Sign in' CTA button linking to\n   /login. Previously was a generic red error box with no path forward\n   for guests."},{"hash":"720506c","date":"2026-05-20 01:58:27 -0700","author":"Steve Abrams","subject":"fix(types): unblock SocialTab render, IntegrationsTab metadata, credentials rowCount null","body":"3 fixes for issues found via tsc pre-flight before tomorrow's QA:\n\n1. components/social/SocialTab.tsx — define local CATEGORY_COLORS map at top\n   of file. Was being referenced at lines 3298,3299 without import; would\n   throw ReferenceError on social tab render. Same palette as the existing\n   defs in components/graph/PartnerTree.tsx + IdeaMindMap.tsx. If we expand\n   the palette later, lift this to a shared lib/category-colors.ts.\n\n2. components/IntegrationsTab.tsx:257 — wrap metadata.workspace_name\n   conditional in ternary instead of && so React's ReactNode contract is\n   satisfied. The && form was leaving 'unknown' in the children array.\n\n3. lib/credentials.ts:178 revokeApiKey — handle nullable rowCount via\n   nullish coalescing. Previous code would TypeError if pg returned null\n   rowCount on an UPDATE-RETURNING with zero matches."},{"hash":"ddf268e","date":"2026-05-19 13:37:55 -0700","author":"Steve Abrams","subject":"instagram-agent: enable live reel/story/post publishing via graph.instagram.com (Instagram Login API)","body":"App is Instagram-Login-only (Facebook Login disabled), so all calls use graph.instagram.com not graph.facebook.com. Adds _ig-api.js helper (container create/poll/publish + long-lived token exchange) and setup-ig-token.js one-shot token installer."},{"hash":"479708b","date":"2026-05-13 08:57:58 -0700","author":"Steve","subject":"snapshot: 12 file(s) changed, ~12 modified","body":""},{"hash":"4ad1c15","date":"2026-05-06 10:25:11 -0700","author":"Steve Abrams","subject":"initial scaffold (gitify-all 2026-05-06)","body":""}]}