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