Skip to content

NOT READY : Freeform onboarding#68

Open
alchezar wants to merge 46 commits into
docs/tasks-029-033from
feat/freeform-onboarding
Open

NOT READY : Freeform onboarding#68
alchezar wants to merge 46 commits into
docs/tasks-029-033from
feat/freeform-onboarding

Conversation

@alchezar

@alchezar alchezar commented May 14, 2026

Copy link
Copy Markdown
Collaborator

FreeForm Onboarding

Zero-to-full-account workspace onboarding via structured paste. Admin walks
through a 4-step wizard (company -> providers -> budget -> invite link),
member joins via the link, generates an IC token, admin approves. All parsers
are deterministic CSV-style - no LLM.

Backend (iron_control_api)

  • Parsers: company_setup, providers, invites, usage_policy
  • New endpoints:
    • POST /freeform/{company,providers} - paste -> upsert
    • POST /workspace/budget - structured or freeform; persists default_model
    • GET /me/workspace - workspace info for current user (any role)
    • POST /invites/generate, GET /invites/{token}, POST /invites/{token}/accept
    • GET /invites/pending, POST /invites/seats/{id}/approve - seat-based approval
  • Migrations 031-034: workspaces, workspace_policy, invite_links, invite_seats, + seed row so a fresh DB doesn't 404

Frontend (iron_dashboard)

  • Shared FreeFormDialog / FreeFormToggle / AutoSetupWizard components
  • Setup pages: /setup/{company,providers,budget,users}
  • Public /join/{token} -> member dashboard /member (Gateway URL, IC token, default model)
  • "Setup Wizard" button on admin dashboard + sidebar links for admin-only setup routes
  • VITE_GATEWAY_URL env var (falls back to window.location.origin)
  • ESLint migrated to flat config

Manual test

  1. Login as admin -> click "Setup Wizard"
  2. Step 1: Acme Corp, acme.com, Client
  3. Step 2: openai: sk-test-fake\nanthropic: sk-ant-test-fake
  4. Step 3: limit all users $100/week\ndefault: claude-3-haiku
  5. Step 4: Generate -> Copy Link
  6. Open link in incognito -> set password -> lands on /member
  7. Generate IC token, copy
  8. Admin: Workspace Setup -> Invite Members -> Approve pending member

Security

  • Invite tokens: 32 random bytes, SHA-256 stored
  • Provider keys encrypted (AES-256-GCM) at rest
  • RBAC: ManageUsers for workspace/invite admin endpoints, ManageProviderKeys for provider endpoints
  • Member sessions have no refresh token by design

alchezar added 27 commits May 13, 2026 15:52
@alchezar alchezar changed the title NOT READY : Freeform onboarding NEEDS REVIEW : Freeform onboarding May 18, 2026
@alchezar alchezar requested a review from wanguardd May 18, 2026 14:18

@wanguardd wanguardd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System of Review completed analysis of 'Freeform onboarding'. Found 15 findings across 8 files.

📋 Review Summary

Category 🔴 Blocking 🟠🟡 Non-Blocking Total
🏛️ ORGANIZATIONAL 1 1 2
🐛 CORRECTNESS 3 1 4
🛡️ SECURITY 2 0 2
📋 REQUIREMENTS 1 1 2
📐 DESIGN 0 1 1
🔍 QUALITY 0 1 1
✅ TESTING 0 2 2
📖 DOCUMENTATION 0 1 1
Total 7 8 15

🏛️ ORGANIZATIONAL

🔴 Blocking · module/iron_control_api/src/freeform/ missing readme.md

A new permanent directory was added with no readme.md. The File Creation Protocol requires every permanent directory to have a readme.md with a Responsibility Table documenting its files.

Evidence:

ls module/iron_control_api/src/freeform/
company_setup.rs  invites.rs  mod.rs  providers.rs  usage_policy.rs
# no readme.md present

Action:

  • Create module/iron_control_api/src/freeform/readme.md with a Responsibility Table listing all five files and their single-sentence responsibilities

🟠 Important · src/routes/readme.md not updated for three new route files

freeform.rs, invites.rs, and workspace.rs were added to src/routes/ but not registered in the directory's Responsibility Table. The readme now has a stale inventory.

Evidence:

# routes/readme.md Responsibility Table contains:
agent_provider_key.rs, agents.rs, analytics/, auth/, budget/, health.rs,
ic_token.rs, keys.rs, limits.rs, mod.rs, providers.rs, tokens/, usage.rs,
users.rs, version.rs
# freeform.rs / invites.rs / workspace.rs — absent

Action:

  • Add rows for freeform.rs, invites.rs, and workspace.rs to the Responsibility Table in module/iron_control_api/src/routes/readme.md

🐛 CORRECTNESS

🔴 Blocking · Race condition in post_invite_accept — TOCTOU on seat limit

The seat availability check and seat counter increment are three separate un-transacted statements. Under concurrent requests for the same invite link, two threads can both read seats_used < seats_total, both proceed through user creation and seat insertion, and both increment the counter — resulting in more accepted seats than the link allows.

Evidence:

// module/iron_control_api/src/routes/invites.rs:295–356
// No BEGIN / COMMIT wraps this entire sequence:
SELECT seats_total, seats_used FROM invite_links WHERE token_hash = ?  // line 295
if seats_used >= seats_total { return Err(BadRequest) }                // line 314
INSERT INTO users ...                                                   // line 320
INSERT INTO invite_seats ...                                            // line 333
UPDATE invite_links SET seats_used = seats_used + 1 WHERE id = ?       // line 356

Action:

  • Wrap lines 295–356 of post_invite_accept in a single SQLite BEGIN IMMEDIATE transaction so the check, user creation, seat record, and counter update are atomic

🔴 Blocking · Approval bypass — JWT issued before approved_at is set

post_invite_accept returns a fully usable JWT access token immediately after the user row and seat record are created. The approved_at column on invite_seats is only set later by a separate admin approve endpoint. A member who accepts any invite link gets immediate, unrestricted system access without admin approval.

Evidence:

// module/iron_control_api/src/routes/invites.rs:365–374
let access_token = state
  .jwt_secret
  .generate_access_token(&user_id, &user_email, "developer", &token_jti)?;

Ok((StatusCode::CREATED, Json(AcceptInviteResponse { user_id, access_token })))
// invite_seats.approved_at is NULL at this point — never checked

Action:

  • On accept, return a pending-state response (e.g., { user_id, status: "pending_approval" }) instead of a JWT. Issue the full JWT only after an admin calls the approve endpoint, which sets approved_at

🟠 Important · Wrong RBAC permission on POST /workspace/budget

post_workspace_budget guards itself with Permission::ManageUsers — the permission for user account management — instead of Permission::ManageIcTokens, which governs IC token budget controls. A user who holds ManageUsers but not ManageIcTokens can set the workspace spending policy.

Evidence:

// module/iron_control_api/src/routes/workspace.rs:90
check_permission(&claims.role, Permission::ManageUsers)?;
// Should be: Permission::ManageIcTokens

Action:

  • Change Permission::ManageUsers to Permission::ManageIcTokens on line 90 of workspace.rs

🔴 Blocking · test_preview_invite_ok fails in local and CI test runs

Migration 034 seeds workspace id=1 with name = 'Workspace' via INSERT OR IGNORE. The test creates an invite expecting a workspace named 'Test Corp', but reads back 'Workspace' from the pre-existing seed row. The test fails with an assertion panic.

Evidence:

FAIL [0.239s] iron_control_api::invites_api_test test_preview_invite_ok
thread panicked at tests/invites_api_test.rs:255:
assertion `left == right` failed
  left: String("Workspace")
 right: "Test Corp"

# Root cause: migration 034_seed_default_workspace.sql line 7:
INSERT OR IGNORE INTO workspaces (id, name, ...) VALUES (1, 'Workspace', 'example.com', ...)
# Seed row is never overwritten before the test reads it back

Action:

  • After applying migrations in setup_test_db(), execute DELETE FROM workspaces (or INSERT OR REPLACE) before each test inserts its own workspace fixture, so tests are isolated from the seed data

🛡️ SECURITY

🔴 Blocking · No rate limiting on bcrypt-heavy POST /invites/{token}/accept

post_invite_accept performs a bcrypt hash at BCRYPT_COST on every request — a deliberately expensive CPU operation. The endpoint requires no authentication. An attacker can flood it with requests and saturate server CPU, causing denial of service.

Evidence:

// module/iron_control_api/src/routes/invites.rs:322 — no middleware guard
let password_hash = bcrypt::hash(&body.password, BCRYPT_COST)?;

// iron_control_api_server.rs:820 — route registered with no rate-limit layer
.route("/api/v1/invites/{token}/accept", post(routes::invites::post_invite_accept))

Action:

  • Apply a per-IP (or per-token) rate-limiter middleware to /invites/{token}/accept; or wrap the bcrypt call in a Semaphore to cap concurrent CPU-intensive operations

🔴 Blocking · Setup routes accessible to any authenticated user — admin guard missing

The four /setup/* routes (/setup/company, /setup/providers, /setup/budget, /setup/users) have meta: { requiresAuth: true } only. The navigation guard checks only isAuthenticated — it has no requiresAdmin branch. Any logged-in developer or member can navigate directly to admin-only setup pages by entering the URL.

Evidence:

// module/iron_dashboard/src/router/index.ts:61–82
{ path: '/setup/company',   meta: { requiresAuth: true } },  // no requiresAdmin
{ path: '/setup/providers', meta: { requiresAuth: true } },
{ path: '/setup/budget',    meta: { requiresAuth: true } },
{ path: '/setup/users',     meta: { requiresAuth: true } },

// Navigation guard at line 96 — no admin check:
if (requiresAuth && !authStore.isAuthenticated) { next('/login') }
// else: next() — any authenticated user proceeds

Action:

  • Add requiresAdmin: true to the meta of all four setup routes; add a check in the beforeEach guard to redirect non-admins to /dashboard when to.meta.requiresAdmin && !authStore.isAdmin

📋 REQUIREMENTS

🔴 Blocking · POST /api/v1/freeform/providers silently discards email addresses from mixed paste

Task 029 Slide 10–11 specifies that the Providers FreeForm paste handles both provider keys (provider: key lines) and team invite emails (bare email addresses) in a single block. post_providers only calls providers::parse() — the freeform::invites module exists and is unit-tested but has no HTTP route, and email addresses in the paste are silently discarded.

Evidence:

// module/iron_control_api/src/routes/freeform.rs:204
let entries = providers::parse(&body.text)?;  // only provider keys
// invites::parse() never called

// iron_control_api_server.rs:796–804 — no /freeform/invites route:
.route("/api/v1/freeform/company",    post(routes::freeform::post_company))
.route("/api/v1/freeform/providers",  post(routes::freeform::post_providers))
// /api/v1/freeform/invites — absent

// task/029_freeform_onboarding.md slide 10 paste block:
// gemini: AIzaSy...  openai: sk-proj-...  alice@acmecorp.com  bob@acmecorp.com
// slide 11: "✓ 10 team members (queued, not yet invited)"

Action:

  • Extend post_providers to also call freeform::invites::parse() on the same paste text, queue found email addresses as pending invite seats, and include a queued_invites count in the response; or create a dedicated POST /api/v1/freeform/invites handler

🟠 Important · requestable: policy directive not implemented in usage_policy.rs

Task 029 Slide 12–13 defines a requestable: directive that sets a workspace-wide list of models gated behind admin-approval requests. ParsedPolicy has only spending_cap and default_model; the parser has no branch for requestable:; and post_workspace_budget does not store or return requestable model data.

Evidence:

// module/iron_control_api/src/freeform/usage_policy.rs — ParsedPolicy struct:
pub struct ParsedPolicy {
  pub spending_cap: Option<SpendingCap>,
  pub default_model: Option<String>,
  // no requestable_models field
}
// No match arm for "requestable" in parse()

// task/029_freeform_onboarding.md slide 12:
// "requestable: claude-4-6-sonnet, gemini-3.1-pro-preview"
// slide 13: "✓ Requestable — claude-4-6-sonnet, gemini-3.1-pro-preview"

Action:

  • Add requestable_models: Vec<String> to ParsedPolicy; add a "requestable" parse branch that splits on commas; persist the list (new DB column or separate table); return it from GET /me/workspace

📐 DESIGN

🟠 Important · check_permission defined three times with identical implementation

The same check_permission function body is copy-pasted into freeform.rs, invites.rs, and workspace.rs. Any change to permission logic must be applied in three places.

Evidence:

// module/iron_control_api/src/routes/freeform.rs:88
fn check_permission(role_str: &str, permission: Permission) -> Result<(), ApiError> { ... }
// module/iron_control_api/src/routes/invites.rs:53  — identical
fn check_permission(role_str: &str, permission: Permission) -> Result<(), ApiError> { ... }
// module/iron_control_api/src/routes/workspace.rs:24 — identical
fn check_permission(role_str: &str, permission: Permission) -> Result<(), ApiError> { ... }

Action:

  • Move check_permission to module/iron_control_api/src/routes/mod.rs (or a dedicated routes::auth_helpers module), make it pub(super), and replace all three local definitions with use super::check_permission

🔍 QUALITY

🟡 Minor · nextest.toml status-level = "slow" suppresses passing test output

With status-level = "slow", only tests that exceed the 30-second slow threshold emit output. Passing tests are invisible in both local runs and CI logs, making it harder to confirm which tests ran or to debug test isolation issues.

Evidence:

# .config/nextest.toml
[profile.default]
status-level = "slow"   # only slow (>30s) or failing tests emit output
final-status-level = "fail"

Action:

  • Change status-level to "pass" or "fail" so test output is visible; if "slow" is intentional to reduce log noise, add a comment explaining the rationale

✅ TESTING

🟠 Important · test_kind: markers absent from all seven new test files

All seven test files added in this PR lack the //! test_kind: doc-comment header required by the project's test organization rulebook. Without markers, the test surface inventory cannot classify these tests.

Evidence:

# grep test_kind across all 7 new test files:
grep -r "test_kind" freeform_company_setup_test.rs freeform_invites_test.rs \
  freeform_providers_test.rs freeform_usage_policy_test.rs freeform_api_test.rs \
  invites_api_test.rs workspace_budget_test.rs
# 0 matches

Action:

  • Add //! test_kind: unit to the four parser unit tests (freeform_*_setup_test.rs, freeform_*_test.rs); add //! test_kind: integration to freeform_api_test.rs, invites_api_test.rs, and workspace_budget_test.rs

🟠 Important · Seven new test files not registered in tests/readme.md

The tests/readme.md Responsibility Table and directory tree list no entry for any of the seven new test files. The test inventory is stale.

Evidence:

grep "freeform\|invites_api\|workspace_budget" module/iron_control_api/tests/readme.md
# 0 matches
# 7 new files: freeform_api_test.rs, freeform_company_setup_test.rs,
#   freeform_invites_test.rs, freeform_providers_test.rs,
#   freeform_usage_policy_test.rs, invites_api_test.rs, workspace_budget_test.rs

Action:

  • Add rows for all seven new test files to the Responsibility Table in module/iron_control_api/tests/readme.md, with a brief responsibility description for each

📖 DOCUMENTATION

🟠 Important · module/iron_dashboard/src/components/freeform/ missing readme.md

A new permanent Vue component directory was added with no readme.md. The File Creation Protocol requires every permanent directory to have a readme.md with a Responsibility Table.

Evidence:

ls module/iron_dashboard/src/components/freeform/
AutoSetupWizard.vue  FreeFormDialog.vue  FreeFormToggle.vue  index.ts
# no readme.md

Action:

  • Create module/iron_dashboard/src/components/freeform/readme.md with a Responsibility Table for all four files

This is a solid effort on a complex feature. The parser layer is clean and well-tested in isolation. Let me know if anything needs clarification.


❗ > Note: This System of Review operates with the same information access and constraints as any human developer working on this repository. While the system is highly accurate (99%+ reliability), any confusion or apparent misunderstanding typically indicates gaps in repository documentation, unclear code organization, or insufficient project discipline — the same issues that would impact any team member. If the System cannot locate critical information and draws incorrect conclusions, a human developer would face identical challenges. We maintain high standards to respect our teammates' time, our clients' investment, and the integrity of this project. Please ensure the repository provides clear, discoverable context for all reviewers.

Review approval is a quality floor, not a quality ceiling. It confirms that identified issues are resolved — not that the code is free of all issues. The System reviews the changes in this PR, not the entire codebase. Proactive quality is a developer responsibility: unit test coverage, edge case analysis, and consistency with the surrounding codebase cannot be delegated to any review process. If the review surfaces one instance of a pattern, the developer is responsible for auditing all occurrences of that pattern throughout the codebase — not just the specific line cited.

When addressing review feedback: Open a separate commit for each point you address, clearly referencing the finding in the commit message. This ensures knowledge is captured in the repository history and helps other developers avoid the same pitfalls. If you cannot address a point, leave a detailed comment in this PR review thread explaining specifically what is wrong with the finding or why it cannot be addressed — never ignore feedback silently. Use comments only in rare cases when the System has genuinely missed existing context in the repository; prefer commits as the primary response mechanism to build institutional knowledge.

@wanguardd wanguardd changed the title NEEDS REVIEW : Freeform onboarding NOT READY : Freeform onboarding May 24, 2026
@alchezar

Copy link
Copy Markdown
Collaborator Author

Review #1 Response

Not applicable (stale / contradicts spec)

  • CORRECTNESS (Blocking - "Approval bypass: JWT issued before approved_at is set"): Not addressed by a code change - this contradicts the spec. Task 029 Slide 17 explicitly states that Confirm/accept "consumes one invite seat (atomically), issues an IC token, and enrolls the member into the workspace with the snapshot policy." Issuing the access token immediately on accept is the documented requirement, not a bypass. The approved_at column and the /approve + /pending endpoints provide admin visibility/audit over accepted seats; they are not an access gate. The member-side admin-approval flow in Slide 18 concerns requestable: model access, not onboarding. Changing accept to a pending-only response would violate the documented "under 30 seconds ... governed gateway token" requirement, so immediate token issuance is kept per spec.

Commits

1. c7b8276 docs(freeform): add readme.md with Responsibility Table

  • ORGANIZATIONAL - Added module/iron_control_api/src/freeform/readme.md with a Responsibility Table covering all five parser files (company_setup.rs, invites.rs, mod.rs, providers.rs, usage_policy.rs) plus a Directory Purpose section, satisfying the File Creation Protocol.

2. 3b01782 docs(routes): register freeform/invites/workspace in routes readme

  • ORGANIZATIONAL - Added rows for freeform.rs, invites.rs, and workspace.rs to the Responsibility Table in module/iron_control_api/src/routes/readme.md, restoring the route inventory.

3. 77cc294 test(invites): add regression test for TOCTOU seat-limit race on accept

  • CORRECTNESS (regression test) - Added a concurrency test that fires multiple simultaneous accepts at a single-seat invite link. On the un-transacted code it lets several accepts through (confirmed RED: 10 successes vs 1 expected), reproducing the TOCTOU race before the fix.

4. db31032 fix(invites): wrap accept seat-claim in atomic transaction

  • CORRECTNESS - Wrapped the seats_used re-check, user insert, seat insert, and counter increment in a single BEGIN IMMEDIATE transaction via a new private claim_invite_seat helper in routes/invites.rs (mirrors key_crud.rs::create_key_within_quota). The bcrypt hash was moved before the transaction so the SQLite write lock is not held during the expensive hash. Exactly one accept now wins the last seat; the rest are rejected.

5. 7bca3ec test(workspace): add regression test for Manager budget RBAC guard

  • CORRECTNESS (regression test) - Added test_manager_can_set_budget asserting a Manager (who holds ManageIcTokens but not ManageUsers) receives 200 on POST /workspace/budget. Confirmed RED (403) under the wrong guard before the fix.

6. 5ce7e4c fix(workspace): use ManageIcTokens permission for budget endpoint

  • CORRECTNESS - Changed the guard in post_workspace_budget from Permission::ManageUsers to Permission::ManageIcTokens (routes/workspace.rs), aligning the budget endpoint with IC-token budget controls (consistent with ic_token.rs). Note: the "ManageUsers without ManageIcTokens" direction in the finding does not exist in the RBAC matrix (only Admin holds ManageUsers, and Admin holds everything). The real defect was the opposite: Manager (ManageIcTokens, not ManageUsers) was wrongly rejected. The fix was verified against the matrix in rbac.rs.

7. fec12a7 fix(tests): avoid migration 034 workspace seed collision in invite tests

  • CORRECTNESS - Switched the test workspace fixture from INSERT OR IGNORE to INSERT OR REPLACE in invites_api_test.rs so it overrides migration 034's seed row (id=1, name='Workspace'); test_preview_invite_ok now reads back 'Test Corp'. A pattern audit found the same latent INSERT OR IGNORE collision in workspace_budget_test.rs (no test read name there, so it never failed) and fixed it too. Production code in freeform.rs (company upsert) was intentionally left untouched.

8. 06d449f feat(invites): rate-limit public bcrypt-heavy accept endpoint

  • SECURITY - Added an invite_accept_rate_limit middleware that reuses LoginRateLimiter (per-IP, 5 attempts / 5 min sliding window, IP taken from ConnectInfo - the TCP peer, never a spoofable X-Forwarded-For). It is attached to the accept MethodRouter only via from_fn_with_state, keeping the handler signature clean and the existing oneshot tests intact. Bursts are rejected with 429 + Retry-After BEFORE the bcrypt hash runs. The server is confirmed to use into_make_service_with_connect_info so the peer IP is available in production.

9. 743f1ff fix(dashboard): guard setup routes behind admin check

  • SECURITY - Added requiresAdmin: true to the four /setup/* route metas and a beforeEach branch redirecting non-admins to /dashboard, placed after the auth check so unauthenticated users still go to /login first (router/index.ts). This is a UX/defense-in-depth guard; the server RBAC remains the real gate.

10. 93269b5 feat(freeform): queue invite emails from providers paste

  • REQUIREMENTS - post_providers now classifies the mixed paste (bare-email lines vs provider:key lines) before parsing - both parsers are all-or-nothing, so they must not see each other's lines - runs invites::parse on the emails, and idempotently queues them as pending invite seats (invite_link_id NULL, email set). Migration 035 extends invite_seats to make invite_link_id nullable and add an email column with a CHECK (invite_link_id IS NOT NULL OR email IS NOT NULL). The response gained a queued_invites count, and crypto is now required only when provider keys are present (an emails-only paste no longer needs IRON_SECRETS_MASTER_KEY). Verified queued seats do not leak into get_pending_invites (INNER JOIN on invite_link_id + WHERE user_id IS NOT NULL) and do not affect seats_used.

11. efed3a8 feat(freeform): implement requestable models policy directive

  • REQUIREMENTS - Added requestable_models: Vec<String> to ParsedPolicy and a requestable: parse branch (comma-split, trims, rejects an empty list). Persisted via migration 036 (workspace_policy.requestable_models TEXT, comma-joined) with a COALESCE-preserving upsert in post_workspace_budget, and returned from GET /me/workspace. Added 4 unit tests in freeform_usage_policy_test.rs (happy path, trim/blank-skip, empty-list error, combines with other directives).

12. 8551938 refactor(routes): extract shared check_permission helper

  • DESIGN - Moved the identical check_permission body into routes/mod.rs as a pub(super) fn and replaced the three local copies (freeform.rs, invites.rs, workspace.rs) with super::check_permission, dropping the now-unused FromStr/Role/PermissionChecker imports. The unrelated PermissionChecker::check_permission async middleware in rbac.rs (different signature) was left untouched.

13. 0c704f9 chore(nextest): document intentional slow status-level

  • QUALITY - status-level = "slow" is intentional: it avoids emitting hundreds of passing-test lines in run/CI output. Per the finding's "add a comment if intentional" option, the value is kept and a comment now documents the rationale and notes that final-status-level = "fail" still surfaces failures in the end-of-run summary.

14. 038cc03 test(freeform): add test_kind markers to new test files

  • TESTING - Added a test_kind marker to all seven new files (4 unit parser tests, 3 integration suites), placed after the module-doc summary line so the rustdoc summary is preserved and #![allow(missing_docs)] stays valid. The marker is in backtick form (//! `test_kind`: unit) to match clippy::doc_markdown, which auto-wraps the identifier in //! doc comments.

15. 91b0c6b docs(tests): register new test files in tests readme

  • TESTING - Added rows for all seven new test files to the Responsibility Table in module/iron_control_api/tests/readme.md, each with File / Responsibility / Input-Output / Out-of-Scope columns. The non-exhaustive directory-tree block was left as is; the table is the real registry.

16. 4a081e0 docs(dashboard): add readme.md to components/freeform

  • DOCUMENTATION - Created module/iron_dashboard/src/components/freeform/readme.md with a Responsibility Table for AutoSetupWizard.vue, FreeFormDialog.vue, FreeFormToggle.vue, and index.ts, plus a Component Contracts section documenting each component's props, emits, and ownership (Wizard owns the step state machine and all API calls; Dialog is a controlled paste textarea with no API; Toggle is pure presentation).

@alchezar alchezar changed the title NOT READY : Freeform onboarding NEEDS REVIEW : Freeform onboarding May 25, 2026
@alchezar alchezar requested a review from wanguardd May 25, 2026 09:33

@wanguardd wanguardd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-Review: Response to Review #1

Thank you for the thorough, well-organized response. 14 of 15 findings were addressed with clear commits. One finding is retracted as incorrect. One new blocking issue has been identified from the post-fix CI run.


🔁 Retracted Finding

CORRECTNESS (Blocking — "Approval bypass: JWT issued before approved_at is set")Retracted. Finding was incorrect.

The author is right. Task 029 Slide 17 explicitly states: "Confirm consumes one invite seat (atomically), issues an IC token, and enrolls the member into the workspace with the snapshot policy." Slide 18 confirms the approve/deny flow governs requestable: model access only — not the IC token itself. The approved_at column and /approve endpoint are audit/visibility surfaces, not an access gate. Immediate token issuance on accept is the specified behavior, not a bypass. Apologies for the noise.


Summary: Original 15 Findings

# Category Finding Status
1 🏛️ ORGANIZATIONAL freeform/ missing readme.md ✅ Fixed — c7b8276
2 🏛️ ORGANIZATIONAL routes/readme.md stale ✅ Fixed — 3b01782
3 🐛 CORRECTNESS Race condition / TOCTOU on seat claim ✅ Fixed — db31032
4 🐛 CORRECTNESS Approval bypass — JWT before approved_at Retracted (spec-compliant)
5 🐛 CORRECTNESS Wrong RBAC permission on budget endpoint ✅ Fixed — 5ce7e4c (author note accepted: direction clarified)
6 🐛 CORRECTNESS test_preview_invite_ok fails ✅ Fixed — fec12a7
7 🛡️ SECURITY No rate limit on bcrypt-heavy accept endpoint ✅ Fixed — 06d449f
8 🛡️ SECURITY Setup routes accessible to any authenticated user ✅ Fixed — 743f1ff
9 📋 REQUIREMENTS Providers paste silently discards invite emails ✅ Fixed — 93269b5
10 📋 REQUIREMENTS requestable: policy directive not implemented ✅ Fixed — efed3a8
11 📐 DESIGN check_permission defined three times ✅ Fixed — 8551938
12 🔍 QUALITY nextest.toml status-level = "slow" undocumented ✅ Addressed — 0c704f9 (comment added; intentional)
13 ✅ TESTING test_kind: markers absent from 7 new test files ✅ Fixed — 038cc03
14 ✅ TESTING 7 new test files not in tests/readme.md ✅ Fixed — 91b0c6b
15 📖 DOCUMENTATION components/freeform/ missing readme.md ✅ Fixed — 4a081e0

🐛 CORRECTNESS

🔴 Blocking · Schema validation tests stale — CI still failing (2 tests)

The PR adds 4 new application tables via migrations 031–034 (workspaces, workspace_policy, invite_links, invite_seats) and 5 net new indexes via migrations 032–035. Two tests in iron_token_manager::database_initialization were not updated to reflect the new schema and are now failing in CI.

Evidence:

FAIL iron_token_manager::database_initialization test_migrations_are_idempotent
assertion `left == right` failed: Should have exactly 18 application tables after multiple runs
  left: 22
 right: 18

FAIL iron_token_manager::database_initialization test_production_schema_matches_test_schema
assertion `left == right` failed: Production schema should match test schema
  left:  ["agent_budgets", ..., "invite_links", "invite_seats", ..., "workspace_policy", "workspaces"]
  right: ["agent_budgets", ..., "project_provider_key_assignments", ..., "users"]
  # "invite_links", "invite_seats", "workspace_policy", "workspaces" absent from right

Three fixes required in module/iron_token_manager/tests/database_initialization.rs:

  1. Line 85 — update table count:
// Before:
assert_eq!(table_count, 18, "Should have exactly 18 application tables after multiple runs");
// After:
assert_eq!(table_count, 22, "Should have exactly 22 application tables after multiple runs");
  1. Lines 144–163 — add 4 missing tables to expected_tables (alphabetical positions):
let expected_tables = vec![
  "agent_budgets", "agents", "ai_provider_keys", "analytics_events",
  "api_call_traces", "api_tokens", "audit_log", "blacklist",
  "budget_change_requests", "budget_leases", "budget_modification_history",
  "invite_links",   // new — migration 032
  "invite_seats",   // new — migration 033
  "project_provider_key_assignments", "system_config", "token_blacklist",
  "token_usage", "usage_limits", "user_audit_log", "users",
  "workspace_policy",  // new — migration 031
  "workspaces",        // new — migration 031
];
  1. Line 178 — update index count (032: +2, 033: +2, 035: drops 033's 2 then creates 3 = net +1; total +5):
// Before:
assert_eq!(index_count, 54, "Should have 54 indexes across all migrations");
// After:
assert_eq!(index_count, 59, "Should have 59 indexes across all migrations");

Also (non-blocking, but worth catching in the same commit): test_all_migrations_have_guards at line 222 checks only migrations 1..=30. The 6 new migrations (031–036) all have guard tables — extend the range to 1..=36 for complete coverage.

Action:

  • Update database_initialization.rs: table count 18→22, add 4 tables to expected_tables, index count 54→59, and extend guard check range to 1..=36

The fix is a small, mechanical update to one test file. Once CI passes this PR is ready to merge.

@wanguardd wanguardd changed the title NEEDS REVIEW : Freeform onboarding NOT READY : Freeform onboarding May 26, 2026

@wanguardd wanguardd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-Review #3 — Freeform Onboarding

CI-BLOCK-1 from Re-Review #2 is still unresolved. This pass extends the analysis with requirements, security, and test-quality findings from a thorough read of the new migrations, parsers, route handlers, and frontend components.


BLOCKING

CI-BLOCK-1: database_initialization.rs — four assertions stale after PR migrations (unresolved from Re-Review #2)

module/iron_token_manager/tests/database_initialization.rs was not updated when migrations 031–036 were added. Three assertion values and one range literal must change before CI can pass.

test_schema_table_count — line 85

// current (wrong)
assert_eq!(table_count, 18, "Should have exactly 18 application tables after multiple runs");

// required
assert_eq!(table_count, 22, "Should have exactly 22 application tables after multiple runs");

The four new application tables are: workspaces (031), workspace_policy (031), invite_links (032), invite_seats (033/035).

test_production_schema_matches_test_schema — lines 143–163

Four entries are missing from expected_tables. Insert in alphabetical order:

let expected_tables = vec![
  "agent_budgets",
  "agents",
  "ai_provider_keys",
  "analytics_events",
  "api_call_traces",
  "api_tokens",
  "audit_log",
  "blacklist",
  "budget_change_requests",
  "budget_leases",
  "budget_modification_history",
  "invite_links",       // ADD — migration 032
  "invite_seats",       // ADD — migration 033/035
  "project_provider_key_assignments",
  "system_config",
  "token_blacklist",
  "token_usage",
  "usage_limits",
  "user_audit_log",
  "users",
  "workspace_policy",   // ADD — migration 031
  "workspaces",         // ADD — migration 031
];

test_production_schema_matches_test_schema — line 178

// current (wrong — 54)
assert_eq!(index_count, 54, "Should have 54 indexes across all migrations");

// required (59)
// Arithmetic: 032 +2, 033 +2, 035 drops 033's 2 and creates 3 (net +3) → total +5
assert_eq!(index_count, 59, "Should have 59 indexes across all migrations");

test_all_migrations_have_guards — line 223

// current — only verifies 001–030
let guard_tables: Vec<String> = (1..=30)

// required — migrations 031–036 all have guard tables; they must be verified
let guard_tables: Vec<String> = (1..=36)

REQUIREMENTS

REQ-W1: Magic-link login not implemented — spec Slide 1

Task 029 Slide 1: "The admin enters their email and receives a magic-link sign-in. No password. This is the only authentication surface — there is no separate sign-up."

auth/handlers.rs implements POST /api/v1/auth/login using email + password credentials. No magic-link endpoint exists. Task 029 does not list magic-link login in its "Out of scope" section, and the acceptance criteria states the full 20-slide flow must work end-to-end.

Options:

  1. Implement magic-link login as specified by Slide 1.
  2. If this is intentionally deferred, add a comment to the PR referencing the task that will cover it so reviewers can verify the scope decision.

REQ-W2: Registration FreeForm (first/last/birthday) not implemented — spec Slides 2–4, 15–17

Spec Slides 2–4 describe the admin landing on "Complete Your Registration" after clicking the magic link — a form with First Name, Last Name, Birthday, and a FreeForm toggle. Spec Slides 15–17 mirror this for members accepting an invite link.

InviteAcceptPage.vue shows only a password-entry form; there is no FreeForm toggle, no first/last/birthday fields, and no registration FreeForm endpoint. This is directly coupled to REQ-W1: without magic-link tokens there is no registration-trigger to hook the form onto.

Same resolution paths as REQ-W1.

REQ-W3: Spend-limit chip absent from invite preview — spec Slide 14

Spec Slide 14 shows three chips in the invite preview dialog:

  • gpt-5.4-mini default — present ✓
  • $100/week limitmissing
  • 10 seats remaining — present ✓

Backend (routes/invites.rs:107–116): InvitePreviewResponse lacks budget_amount_cents and budget_period. The existing query at line 221 already JOINs workspace_policy to fetch default_model; two additional SELECT columns are all that is needed.

// Add to InvitePreviewResponse
pub budget_amount_cents: Option<i64>,
pub budget_period: Option<String>,

Frontend (composables/useApi.ts:1022–1027): InvitePreviewResponse interface and InviteAcceptPage.vue both need updating to surface the chip. MeWorkspaceResponse (line 1052) already has these fields, showing the pattern is understood — it just needs to be applied here too.


SECURITY

SEC-W1: Email validator accepts whitespace in local part — freeform/invites.rs:20–29

fn is_valid_email(s: &str) -> bool {
  let Some((local, domain)) = s.split_once('@') else { return false; };
  !local.is_empty()
    && domain.contains('.')
    && !domain.starts_with('.')
    && !domain.ends_with('.')
    && domain.len() > 2
}

The caller trims leading/trailing whitespace (raw.trim()), but internal whitespace in the local part is not checked. "user name@example.com" passes: split_once('@') yields local = "user name", and !local.is_empty() is true. No SQL injection risk (parameterised queries throughout), but whitespace-containing strings queued as pending invites will fail silently if email delivery is ever added.

Fix: Add && !local.contains(char::is_whitespace) (and symmetrically && !domain.contains(char::is_whitespace)), or replace the predicate with a minimal regex:

// Catches the most common invalid cases without over-engineering
static EMAIL_RE: once_cell::sync::Lazy<regex::Regex> =
  once_cell::sync::Lazy::new(|| regex::Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]{2,}$").unwrap());

fn is_valid_email(s: &str) -> bool {
  EMAIL_RE.is_match(s)
}

Or, if keeping the manual predicate, simply add the whitespace check:

!local.is_empty()
  && !local.contains(char::is_whitespace)
  && domain.contains('.')
  && !domain.starts_with('.')
  && !domain.ends_with('.')
  && !domain.contains(char::is_whitespace)
  && domain.len() > 2

TESTING

TEST-W1: Bare .unwrap() throughout all four parser unit test files

All four new parser unit test files use bare .unwrap() without a .expect("LOUD FAILURE: …") message. When a test fails in CI, the output shows called \Result::unwrap()` on an `Err` value` with no indication of which test case failed or what the input was.

Affected call sites:

  • freeform_company_setup_test.rs:9,17,23company_setup::parse(…).unwrap()
  • freeform_invites_test.rs:14,33,40,47,67invites::parse(…).unwrap()
  • freeform_providers_test.rs:14,30,67,74,75providers::parse(…).unwrap()
  • freeform_usage_policy_test.rs:13,33,66,67,93,106,123usage_policy::parse(…).unwrap()

Fix: Replace every .unwrap() with .expect("LOUD FAILURE: <input> should parse as <expected result>") throughout all four files. Example:

// current
let result = company_setup::parse("Acme Corp, acme.com, Client").unwrap();

// required
let result = company_setup::parse("Acme Corp, acme.com, Client")
  .expect("LOUD FAILURE: 'Acme Corp, acme.com, Client' is valid client company setup");

TEST-W2: No end-to-end test for requestable_models persist-and-retrieve

freeform_usage_policy_test.rs verifies the parser recognises the requestable: directive and populates the struct correctly. However, workspace_budget_test.rs has no test for the full path:

  1. POST /workspace/budget with text: "limit all users $100/week - requestable: gpt-5, claude-3"
  2. GET /me/workspace
  3. Assert requestable_models == ["gpt-5", "claude-3"]

Migration 036 adds the requestable_models TEXT column. Without an end-to-end test, a regression in serialization, the budget-handler write path, or the GET /me/workspace read path would go undetected.


Note: pre-existing test failures in iron_cli

CI shows 8 failures in iron_cli::parameters_test that pre-date this branch. Nextest's fail-fast default halts the run before iron_token_manager tests execute, which means CI-BLOCK-1 does not yet appear in the CI run output as an explicit iron_token_manager failure — it will surface once the iron_cli failures are fixed. Both need to be addressed before merge: CI-BLOCK-1 in this PR, the iron_cli failures in a separate fix.


Summary: CI-BLOCK-1 (one assertion-update commit away from green) remains the only hard blocker. REQ-W1 and REQ-W2 are the most significant new findings — they represent a fundamental auth-surface deviation from the spec and should be clarified even if they are intentionally deferred. REQ-W3, SEC-W1, TEST-W1, and TEST-W2 are straightforward fixes. The core architecture — deterministic parsers, BEGIN IMMEDIATE seat-claim, policy snapshot on invite generation, TCP-peer rate-limiting — is correct and well-constructed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants