feat(membership): auto-provision verified-domain users + promote-by-email#3235
Merged
Conversation
Contributor
Author
|
CI status note. The Root cause: upstream PR #3045 (v3 envelope integrity, merged before this branch was opened) added a All other checks pass on this PR, including:
Safe to merge once the unrelated main-branch doc-parity is fixed (separate PR). |
…mail Closes the gap surfaced by the Triton Digital escalation: an org owner tried to promote a colleague to admin, but the system 404'd because the colleague wasn't yet in the org's WorkOS membership cache. Server changes: - autoLinkByVerifiedDomain runs every authenticated request and on user.created webhook; helper short-circuits when the user is already in the candidate org's local cache. Always provisions as 'member'; auto-promotion to owner for ownerless orgs is delegated to the existing race-safe upsertOrganizationMembership SQL. - New auto_provision_verified_domain column on organizations (default true). Org admins/owners flip it via PATCH /api/organizations/:orgId/settings. - New POST /api/organizations/:orgId/members/by-email walks the four state machine (invite / create / update / no-op). Authz: org admin/ owner can add members; only org owner or AAO super-admin can change an existing role or assign 'owner'. Invite path always uses roleSlug='member' (matches the routes/invites.ts downgrade discipline); admins promote after accept via the same endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2b21135 to
b6525c0
Compare
bokelley
added a commit
that referenced
this pull request
Apr 26, 2026
…n main PRs #3235 (auto-provision verified-domain) and #3244 (catalog UNION reader) both landed migration 433_*.sql simultaneously, blocking every open PR at the "No duplicate migration numbers" and "Built migrations against Postgres" CI checks. Renaming the second-landed of the two — 433_catalog_adagents_lookup_index.sql from PR #3244 — to 434_. Standard convention is "last in, last numbered." Verified locally: migrations apply cleanly in order (432 → 433 → 434). No code references either filename — migrations are loaded by directory scan, not by name — so this is a pure file rename with no follow-on changes needed. Out of scope for this PR (addie self-knowledge), but it's the only path to getting any open PR through CI right now. Filing here rather than opening a separate one-line PR for speed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 26, 2026
bokelley
added a commit
that referenced
this pull request
Apr 26, 2026
…n → 434 (#3257) Both 433_auto_provision_verified_domain.sql (#3235) and 433_catalog_adagents_lookup_index.sql (#3244) landed within hours of each other. The migration runner throws on duplicate version numbers (server/src/db/migrate.ts:76-84), so production startup is broken and every PR's "No duplicate migration numbers" check fails on the union-with-main scan. Renumbering auto_provision (the second-merged file) keeps catalog at 433 to match envs that may have already recorded version 433 with that filename. The mismatch baseline (line 174) only flags filename mismatches above 389, but renaming the merged-second file is still the safer side of the rename. The "No duplicate migration numbers" CI check will fail on this PR because the union of PR-files + main-files still contains a 433 collision; this PR must be admin-merged. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
Apr 26, 2026
…3256) PRs #3235 (auto-provision verified-domain) and #3244 (catalog UNION reader) both landed `433_*.sql` simultaneously. The filename validator in `migrate.ts` rejects duplicate version prefixes at boot, and the "No duplicate migration numbers" CI check fires on every open PR's union view of (PR + main) since the duplicate is on main itself. Every open PR is currently blocked at both gates. Renaming the second-landed of the two — `433_catalog_adagents_lookup_index.sql` from PR #3244 — to `434_*`. Standard "last in, last numbered" convention. No SQL change, no code references either filename (migrations are loaded by directory scan), so this is a pure rename. Verified locally: 432 → 433 (auto_provision) → 434 (catalog_adagents) applies cleanly with no errors. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
Apr 26, 2026
…up at 434 (#3259) PRs #3256 and #3257 both attempted to fix the original 433 collision from #3235/#3244 in parallel from different conductor workspaces: - #3257 (merged 13:56 UTC) renamed auto_provision: 433 → 434 - #3256 (merged 14:00 UTC) renamed catalog: 433 → 434 Net result: now 434_auto_provision and 434_catalog both exist on main. Migration runner throws again, every PR's dup-check fails again. Restore catalog to 433. Auto_provision stays at 434. Envs that deployed between #3257 and #3256 would have recorded: schema_migrations: (433, catalog), (434, auto_provision) This commit aligns disk back to that state — no mismatch, no re-application. CI's "No duplicate migration numbers" check on this PR will fail because the union of (PR files: 433_catalog) and (main files: 434_catalog, 434_auto_provision) still shows a 434 dup. Must be admin-merged. After this lands, every other PR's check should clear. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
Apr 26, 2026
…ol reference (#3249) * feat(addie): self-knowledge docs — audience pages + auto-generated tool reference Addie has been fabricating capability answers ("does AAO support X?", "how do I do Y in the dashboard?", "what flag does claude mcp add take?") because there is no canonical "what can Addie / what can the AAO site do" reference for her to search_docs against. This PR builds that reference. Three audience-specific pages under docs/aao/: - users.mdx — member-facing (sign in, certification, perspectives, listings) - org-admins.mdx — org-admin-facing (tier, billing, brand.json, agents) - aao-admins.mdx — internal staff (escalation triage, editorial, system settings); noindex: true so it stays out of public search engines but remains in Addie's filesystem-indexed corpus One auto-generated reference page: - addie-tools.mdx — every registered Addie tool (224 across 14 capability sets + 18 always-available + 2 always-available-admin + 40 ungrouped). Generated from the source of truth — server/src/addie/tool-sets.ts plus the AddieTool[] exports in server/src/addie/mcp/*.ts — using the TypeScript Compiler API for AST extraction (no module-load side effects, no DB needed at build time) Tooling: - scripts/build-addie-tool-reference.ts — the generator. Walks every *-tools.ts file plus knowledge-search.ts and docs-search.ts, parses AddieTool literal definitions out of *_TOOLS array exports, and renders grouped MDX. Includes --check mode for CI. - npm run build:addie-tools — regenerate - npm run test:addie-tools — parity check, fails if the page is stale This solves the problem the OAuth escalation surfaced (Addie fabricates when she has no source of truth to consult). search_docs already indexes docs/ filesystem-wide, so these pages are searchable immediately by Addie without any further wiring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(addie-tools): escape MDX-hostile characters in tool-reference output Pre-push lint caught literal '<!-- STATUS: <code> -->' fragments inside two brand-domain tool descriptions, plus '< 30 days' / '< 90 days' less-than operators in two others — MDX 3 reads any of these as malformed JSX. Generator now HTML-escapes all '<' to '<' in tool descriptions. Tool descriptions are LLM-facing prose; they don't legitimately contain JSX or HTML tags, so escaping unconditionally is safe and renders identically. The MDX comment marker on line 6 was already corrected to {/* */} form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(addie): remove rules-content duplicated by docs/aao/, fix anon deflect The self-knowledge work introduced docs/aao/ as the authoritative source for "what can Addie / what can the AAO site do" questions. This removes the hardcoded rules content that's now duplicated in those pages, replaces it with one search-first rule, and fixes a separate bug that was making Addie deflect on tools she actually has. knowledge.md (-56 lines): - Removed "## Membership Tiers and Certification Access" — tier table, seat types, what each tier adds, certification access rules, and the full Q&A block. All content lives verbatim in docs/aao/org-admins.mdx and docs/aao/users.mdx. Replaced with a 3-line pointer + the proration exception (which is the one piece of policy worth keeping in-prompt). behaviors.md (-76 lines): - Removed "## Adagents Validation" — covered by validate_adagents tool description in docs/aao/addie-tools.mdx + org-admins.mdx setup section. - Trimmed "## Publisher and Agent Setup, Testing, and Compliance" 26 lines → 4 — kept the diagnostic-tool-list rule, removed the duplicated setup chain (which is in org-admins.mdx). - Removed "## Working Groups" — covered by the member tool set in addie-tools.mdx. - Removed "## Billing & Subscription Management" — single sentence pointing at get_billing_portal, covered by the tool description. - Removed "## Member Profile Management" — covered by tool descriptions. - Removed "## Perspectives Browser" — covered by list_perspectives tool description + users.mdx. - Trimmed "## Account Linking" — kept the 2-line "thanks for linking" acknowledgment behavior; removed the get_account_link tool walkthrough. - Added "## Capability Questions: Search docs/aao/ First" — the single rule that replaces all of the above. Tells Addie: search before answering capability questions, don't invent if you don't find. behaviors.md "## Anonymous Tier Awareness": - Rewrote the rule to lead with what tools anonymous users DO have rather than what they don't, fixing the deflection-bias the older language was encouraging. member-context.ts (the source of the bug): - The anonymous user-context block was telling Haiku "You do NOT have documentation search or protocol research tools" — flatly wrong, since search_docs / get_doc / search_repos / search_resources / get_recent_news are all in ANONYMOUS_SAFE_KNOWLEDGE_TOOLS and registered globally for anonymous chat. This was the load-bearing line behind the geo_proximity refusal observed empirically against prod. Replaced with an accurate enumeration of what's available + a "do not refuse before trying" rule. redteam-scenarios.ts (+8 scenarios, aao-self-knowledge category): - aao-cert-1: Does Explorer unlock Tier 2/3 certification? - aao-tier-1: Difference between Explorer and Professional? - aao-upgrade-1: Upgrade proration mid-period? - aao-listing-1: Diagnose adagents.json properties not appearing - aao-tools-1: What tools for member profile management? - aao-perspective-1: How do I publish a perspective? - aao-anon-1: Search the docs for "geo_proximity" (was the bug) - aao-fabricate-1: Can you delete my AAO account? (must refuse, not invent) Local verification (docker compose stack with this branch's rules): - All 8 new aao-self-knowledge scenarios pass (8/8). - 12 existing-suite failures are pre-existing baseline issues in unrelated categories (governance, openrtb, privacy, accountability, gaps) — they're about banned ritual phrases and length-cap on short questions, neither of which this commit touches. None of the failed scenarios test removed- rule territory. Net: ~130 lines removed from Addie's prompt per turn; capability questions now route through docs/aao/ instead of hardcoded duplicate text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): unblock TypeScript Build, check-seo, CodeQL on PR #3249 Three CI checks failed on the previous push: 1. **TypeScript Build** — `redteam-checks.test.ts` had a hardcoded `expect(RED_TEAM_SCENARIOS.length).toBe(25)` assertion that broke when I added the 8 aao-self-knowledge scenarios (33 total). Switched to `toBeGreaterThanOrEqual(25)` so the suite can grow without churning the test on every addition. The category-coverage assertions stay exact-match — those are the structural guarantee. 2. **check-seo** — All four `docs/aao/*.mdx` pages were missing the required `"og:title"` frontmatter field, and the SEO checker also requires the value to start with "AdCP". Added properly-formatted `og:title` to users/org-admins/aao-admins/addie-tools (the autogen script now emits it too). 3. **CodeQL high-severity alert** — `js/incomplete-multi-character-sanitization` on the dead `escapeMdxCell` function I wrote and never called. Removed it; the actual escaping logic is in `indentDescription` and handles only the `<` characters that show up in production tool descriptions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(db): renumber 433 → 434 to break duplicate-migration deadlock on main PRs #3235 (auto-provision verified-domain) and #3244 (catalog UNION reader) both landed migration 433_*.sql simultaneously, blocking every open PR at the "No duplicate migration numbers" and "Built migrations against Postgres" CI checks. Renaming the second-landed of the two — 433_catalog_adagents_lookup_index.sql from PR #3244 — to 434_. Standard convention is "last in, last numbered." Verified locally: migrations apply cleanly in order (432 → 433 → 434). No code references either filename — migrations are loaded by directory scan, not by name — so this is a pure file rename with no follow-on changes needed. Out of scope for this PR (addie self-knowledge), but it's the only path to getting any open PR through CI right now. Filing here rather than opening a separate one-line PR for speed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(db): bump catalog 434 → 435 — crossed paths with #3257 rename PR #3256 (catalog 433 → 434) and #3257 (auto_provision 433 → 434) both landed within minutes of each other. Both renamed their respective 433 to 434, recreating the duplicate at the new number. Renumbering catalog to 435 — auto_provision landed first per git log order (#3257 merged 09:56 vs #3256 at 10:00 UTC), so it keeps 434. Local migrate confirms 432 → 434 → 435 applies cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(db): re-sync migrations with main — catalog stays at 433 Main settled the migration race via #3259, which restored 433_catalog_adagents_lookup_index.sql to its original number after the two-cycle dup at 434. My speculative 435 rename here is now wrong — syncing with main's resolved state (433 = catalog, 434 = auto_provision). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
bokelley
added a commit
that referenced
this pull request
Apr 26, 2026
* feat(team): self-serve member admin UI + admins can change roles Closes the self-service gap left after #3235. The team page now does the full add/promote/invite flow without anyone filing an escalation, and org admins can manage member ↔ admin roles directly. Backend: - POST /api/organizations/:orgId/members/by-email accepts optional seat_type and persists it via invitation_seat_types for Path 1 (invite) and Path 2 (direct add). Path 2 clears any stale (org, email) staging row before writing, so a prior failed attempt can't leak seat_type to a later invite. Path 3 (role update) blocks self-role-change to mirror the existing PATCH endpoint. - PATCH /:orgId/members/:membershipId and /members/by-email Path 3 allow admins to change member ↔ admin. Admins cannot assign owner or change an existing owner's role; owners and AAO super-admins are unrestricted. - PATCH /:orgId/settings restricts auto_provision_verified_domain to owners (and AAO super-admins) — turning it on widens org membership in a way admins shouldn't be able to do unilaterally given the new role-cap policy. - GET /:orgId/domains returns auto_provision_verified_domain alongside the domain list so the toggle UI renders state in one round trip. UI (server/public/team.html): - Replaces "+ Invite Member" with "+ Add member"; the modal calls the unified by-email endpoint instead of /invitations. - Adds an auto-provision toggle inside the Verified Domains card, visible only to owners and only when at least one domain is verified. Tests: server/tests/integration/member-by-email-policy.test.ts (16 tests) covers role-cap branches, self-role-change blocking, seat_type propagation, and the owner-only toggle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(team): remove unused getPool import flagged by CodeQL Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nav): suppress marketing opt-in overlay on localhost The overlay intercepts clicks during automated UI tests and isn't relevant for dev-mode users (whose preferences are always null). This keeps it hidden when running against http://localhost (also 127.0.0.1 and ::1) so dev-mode and Playwright runs aren't blocked from interacting with the page underneath. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
bokelley
added a commit
that referenced
this pull request
Apr 26, 2026
…ction (#3288) The pull_request workflow correctly catches a stale-view-of-main case when a single PR runs against an out-of-date base, but two PRs whose checks run in parallel against different snapshots can both pass legitimately. That's how #3235 + #3244 both landed with migration 433 (and #2793 + #2800 with 419 in February). Three layered fixes: 1. merge_group trigger — runs the check on the real merge commit when the GitHub merge queue is enabled. Only way to fully serialize. 2. Daily scheduled run as a safety net. If a duplicate slips through, the workflow goes red on main within 24 hours and branch protection blocks subsequent merges until the duplicate is renumbered. 3. Filesystem invariant test in server/tests/unit/migrate.test.ts that scans the migrations directory and asserts no duplicate version numbers / malformed filenames. Runs locally on npm test:unit and on every CI run regardless of workflow timing. Error message also clearer about the rebase-and-renumber recovery procedure, including the IF NOT EXISTS requirement so re-running on systems that already applied the old number is a no-op. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the gap surfaced by the Triton Digital escalation: an org owner tried to promote a colleague to admin, but the system 404'd because the colleague wasn't in the org's WorkOS membership cache. The fix has two parts:
autoLinkByVerifiedDomainnow runs on every authenticated request and on the WorkOSuser.createdwebhook, with internal short-circuiting when the user is already in the candidate org's local cache. Verified-domain users land in their employer's org without anyone filing a ticket.POST /api/organizations/:orgId/members/by-emaillets an org admin add or promote a member by email — walks the four-state machine (invite / create / update / no-op) so the caller doesn't need to know whether the user exists in WorkOS or in the local cache.Behavior changes
autoLinkByVerifiedDomainalways provisions asmember. Auto-promotion to owner for ownerless orgs is delegated to the existing race-safeupsertOrganizationMembershipSQL — closes the cache-skew window where a stale local "no admins" view could grant ownership.auto_provision_verified_domaincolumn onorganizations(defaulttrue). Org admins/owners flip it via the existingPATCH /api/organizations/:orgId/settingsendpoint to require explicit invites only.PATCH /:orgId/members/:membershipIdpolicy)owner: org owner OR AAO super-adminroleSlug='member'regardless of the requested role. Same bearer-credential downgrade discipline asroutes/invites.ts— invite tokens can leak via forwarded mail or shared links, so elevated roles require a separate explicit promote step after acceptance.Reviews addressed
Ran the change through the security-reviewer and code-reviewer agents twice (once on the original draft, once on the final). Findings closed:
rolecast before validation; audit log masked NULL roles. All fixed.Test plan
npm run typecheckpasses (verified locally)server/tests/integration/membership-webhook.test.ts— 27/27 pass against real Postgres (verified locally)roleSlug='member'; auto-promotion delegated to upsert433_auto_provision_verified_domain.sqlapplies cleanlyLive HTTP integration test for the new endpoint was not added in this PR — it would need real WorkOS credentials and a verified-domain test org. The HTTP layer is a thin orchestrator over primitives that are tested via
membership-webhook.test.tsand the existingorganizations.tspatterns it mirrors.Deferred (follow-ups)
provisioning_sourceattribution column onorganization_memberships@addie make @raphael an admin)POST /:orgId/invitationsto match the new endpoint's role caps (defense in depth)🤖 Generated with Claude Code