9
2
Fork
You've already forked platform
2

tos backend and front-end modals #178

Manually merged
anton merged 2 commits from tos-flow into main 2026年04月11日 02:29:28 +02:00

This PR completes legal document tracking and enforcement for Terms of Service and Privacy Notice updates.

It upgrades the existing version-based legal tracking to exact-document tracking, ties each legal document row
to immutable caution/website source metadata, adds a production-ready web acceptance flow, and enforces
blocking re-acceptance at the API layer. It also adds minimum viable CLI handling so blocked CLI users get a
clear message instead of a generic API failure.

What’s Included

  • Added legal document provenance fields to legal_documents:
    • source_commit_sha
    • source_path
    • content_sha256
  • Added legal_document_id to user_legal_events so each acceptance event points to an exact legal artifact row
  • Backfilled current seeded ToS and Privacy Notice rows with source metadata from caution/website
  • Updated signup and legal re-acceptance flows to record the exact legal_documents row accepted
  • Updated legal status computation to compare exact document identity, not just display version strings
  • Added backend legal enforcement middleware for active documents marked requires_blocking_reacceptance = true
  • Kept /api/user/status and /api/legal/accept reachable while users are blocked
  • Added frontend legal acceptance modal with:
    • single-document and both-document flows
    • accept and decline/log-out path
    • app-shell integration so blocked users cannot continue using the platform
  • Added a small Settings > Legal read-only section showing acceptance history and document links
  • Added minimum viable CLI handling for legal_acceptance_required so CLI users see a targeted recovery message
  • Added an operator helper script to create new inactive legal-document rows from a caution/website repo path
    • commit
  • Updated admin tooling and legal e2e tests for the new provenance/document-id model

Behavioral Outcome

  • New users still get legal acceptance recorded at signup
  • Existing users with outdated active legal documents are prompted to review and accept updates in the web app
  • If an active document requires blocking re-acceptance, protected API access is denied until acceptance is
    recorded
  • CLI users are also blocked by the same backend rule, but now receive a clear message directing them to the
    web app

Testing

Verified:

  • frontend build
  • API legal unit tests
  • CLI tests
  • legal e2e flow
  • manual web testing for:
    • Terms only outdated
    • Privacy only outdated
    • both outdated
    • accept path
    • decline/back path
    • logout/re-login persistence
    • dashboard recovery after acceptance
  • manual CLI blocked-path check for apps list

Future Work

  • Add a first-class CLI legal review/accept flow instead of only redirecting users to the web app
  • Optionally expand Settings > Legal into a fuller legal-history view if we want more user-facing audit
    visibility
This PR completes legal document tracking and enforcement for Terms of Service and Privacy Notice updates. It upgrades the existing version-based legal tracking to exact-document tracking, ties each legal document row to immutable caution/website source metadata, adds a production-ready web acceptance flow, and enforces blocking re-acceptance at the API layer. It also adds minimum viable CLI handling so blocked CLI users get a clear message instead of a generic API failure. ### What’s Included - Added legal document provenance fields to legal_documents: - source_commit_sha - source_path - content_sha256 - Added legal_document_id to user_legal_events so each acceptance event points to an exact legal artifact row - Backfilled current seeded ToS and Privacy Notice rows with source metadata from caution/website - Updated signup and legal re-acceptance flows to record the exact legal_documents row accepted - Updated legal status computation to compare exact document identity, not just display version strings - Added backend legal enforcement middleware for active documents marked requires_blocking_reacceptance = true - Kept /api/user/status and /api/legal/accept reachable while users are blocked - Added frontend legal acceptance modal with: - single-document and both-document flows - accept and decline/log-out path - app-shell integration so blocked users cannot continue using the platform - Added a small Settings > Legal read-only section showing acceptance history and document links - Added minimum viable CLI handling for legal_acceptance_required so CLI users see a targeted recovery message - Added an operator helper script to create new inactive legal-document rows from a caution/website repo path + commit - Updated admin tooling and legal e2e tests for the new provenance/document-id model ### Behavioral Outcome - New users still get legal acceptance recorded at signup - Existing users with outdated active legal documents are prompted to review and accept updates in the web app - If an active document requires blocking re-acceptance, protected API access is denied until acceptance is recorded - CLI users are also blocked by the same backend rule, but now receive a clear message directing them to the web app ## Testing ### Verified: - frontend build - API legal unit tests - CLI tests - legal e2e flow - manual web testing for: - Terms only outdated - Privacy only outdated - both outdated - accept path - decline/back path - logout/re-login persistence - dashboard recovery after acceptance - manual CLI blocked-path check for apps list ## Future Work - Add a first-class CLI legal review/accept flow instead of only redirecting users to the web app - Optionally expand Settings > Legal into a fuller legal-history view if we want more user-facing audit visibility
@ -0,0 +12,4 @@
-- commit: 223267415eeb26d44a1afe9fb25b28fdf3312153
UPDATElegal_documents
SET
source_commit_sha='223267415eeb26d44a1afe9fb25b28fdf3312153',
Owner
Copy link

values should not be hardcoded in the database schema. an entry should be made either manually or using the script.

values should not be hardcoded in the database schema. an entry should be made either manually or using the script.
xenushka marked this conversation as resolved
@ -17,3 +27,4 @@
pubstruct LegalDocumentStatus{
pubactive_version: Option<String>,
publatest_user_version: Option<String>,
publatest_user_accepted_at: Option<DateTime<Utc>>,
Owner
Copy link

if the db entries are append only, this should be "accepted_at", not "latest_accepted_at", we find latest accepted at by sorting the terms accepted by the user by date, and then "popping" out the latest one. Same logic applies to latest_user_version

if the db entries are append only, this should be "accepted_at", not "latest_accepted_at", we find latest accepted at by sorting the terms accepted by the user by date, and then "popping" out the latest one. Same logic applies to `latest_user_version`
Author
Owner
Copy link

Agreed. The behavior was already derived from the latest append-only event, but the field names were misleading. Renamed them to accepted_version and accepted_at to reflect that they are derived from the latest accepted/acknowledged event rather than mutable "latest_*" fields.

Agreed. The behavior was already derived from the latest append-only event, but the field names were misleading. Renamed them to `accepted_version` and `accepted_at` to reflect that they are derived from the latest accepted/acknowledged event rather than mutable "latest_*" fields.
@ -33,0 +53,4 @@
document_type: &str,
)-> Result<Option<LegalDocumentIdentity>,sqlx::Error>{
sqlx::query_as(
"SELECT id, version, source_commit_sha, source_path, NULL::timestamptz AS occurred_at FROM legal_documents
Owner
Copy link

This should probably find the last signed ToS, not search by source_commit. If we mean to list "all signed docs", that's different, and I'm not sure we need to have that ability. If anything the user should only be able to see what the last signed documents are, since old ones are no longer relevant as they as superseded by new ones. Admins should be able to pull up the whole history if needed, so this data should not be deleted.

This should probably find the last signed ToS, not search by source_commit. If we mean to list "all signed docs", that's different, and I'm not sure we need to have that ability. If anything the user should only be able to see what the last signed documents are, since old ones are no longer relevant as they as superseded by new ones. Admins should be able to pull up the whole history if needed, so this data should not be deleted.
Author
Owner
Copy link

Agreed on the user-facing scope. The current code was already only returning the active document plus the user’s last accepted event, not a history by source commit. Went ahead and removed source_commit_sha / source_path from the /api/user/status payload entirely so this API is focused only on the current active doc and the user’s last accepted version. The full provenance remains in legal_documents / user_legal_events for admin/audit use.

Agreed on the user-facing scope. The current code was already only returning the active document plus the user’s last accepted event, not a history by source commit. Went ahead and removed `source_commit_sha` / `source_path` from the /api/user/status payload entirely so this API is focused only on the current active doc and the user’s last accepted version. The full provenance remains in `legal_documents` / `user_legal_events` for admin/audit use.
@ -41,3 +65,3 @@
/// For terms_of_service, looks for 'accepted' events.
/// For privacy_notice, looks for 'acknowledged' events.
asyncfn get_latest_user_version(
asyncfn get_latest_user_document(
Owner
Copy link

This also seems off... we should be finding the latest document by type. So "latest privacy policy" and/or "latest tos", we should not be searching by document version or otherwise. Worth checking in context.

This also seems off... we should be finding the latest document by type. So "latest privacy policy" and/or "latest tos", we should not be searching by document version or otherwise. Worth checking in context.
Author
Owner
Copy link

Good call on the naming. The query was already selecting the user’s most recent accepted document by document_type + event_type from user_legal_events ordered by occurred_at DESC, not searching by version. Renamed the helper to get_latest_user_document_by_type and clarified the comment to make that explicit.

Good call on the naming. The query was already selecting the user’s most recent accepted document by `document_type` + `event_type` from `user_legal_events` ordered by `occurred_at` DESC, not searching by version. Renamed the helper to `get_latest_user_document_by_type` and clarified the comment to make that explicit.
@ -97,6 +136,41 @@ pub async fn get_user_legal_status(pool: &PgPool, user_id: Uuid) -> Result<UserL
})
}
pubasyncfn get_blocking_document_requiring_acceptance(
Owner
Copy link

This also seems unnecessarily complicated. Whatever the latest document is, we need to check if the user has signed it.

This also seems unnecessarily complicated. Whatever the latest document is, we need to check if the user has signed it.
Author
Owner
Copy link

Agreed. Simplified get_blocking_document_requiring_acceptance() so it now reuses get_user_legal_status() and only checks whether the active ToS / Privacy document is marked requires_blocking_reacceptance and still requires action for the user. That avoids the extra per-document re-query/recompute path.

Agreed. Simplified `get_blocking_document_requiring_acceptance()` so it now reuses `get_user_legal_status()` and only checks whether the active ToS / Privacy document is marked `requires_blocking_reacceptance` and still requires action for the user. That avoids the extra per-document re-query/recompute path.
@ -1740,6 +1740,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/billing/subscription/cancel",post(subscriptions::cancel_subscription))
.route("/billing/subscription/reactivate",post(subscriptions::reactivate_subscription))
.layer(axum::middleware::from_fn_with_state(state.clone(),middleware::onboarding_middleware))
.layer(axum::middleware::from_fn_with_state(state.clone(),middleware::legal_middleware))
Owner
Copy link

why are these endpoints using axum::middleware instead of following the pattern of all other endpoints? Sidenote: we should ensure that the middleware auth is applied to all endpoints

why are these endpoints using `axum::middleware` instead of following the pattern of all other endpoints? Sidenote: we should ensure that the middleware auth is applied to all endpoints
Author
Owner
Copy link

I added legal_middleware to the same protected resource_routes middleware stack already used here for auth_middleware and onboarding_middleware, so I wasn’t trying to introduce a new routing pattern. resource_routes is still behind auth, while public/internal routes remain split separately as before.

If you want, I can do a small follow-up cleanup in this PR to make the protected/public/internal route grouping more explicit and verify there aren’t any protected routes missing auth, but I didn’t want to restructure routing beyond the legal gate without confirming that was desired.

I added `legal_middleware` to the same protected `resource_routes` middleware stack already used here for `auth_middleware` and `onboarding_middleware`, so I wasn’t trying to introduce a new routing pattern. `resource_routes` is still behind auth, while public/internal routes remain split separately as before. If you want, I can do a small follow-up cleanup in this PR to make the protected/public/internal route grouping more explicit and verify there aren’t any protected routes missing auth, but I didn’t want to restructure routing beyond the legal gate without confirming that was desired.
@ -0,0 +1,276 @@
#!/bin/bash
Owner
Copy link

manually test this if it works (do this after updating the db to not have hard-coded values)

manually test this if it works (do this after updating the db to not have hard-coded values)
Author
Owner
Copy link

Manually tested utils/add-legal-doc-from-website.sh after removing the hardcoded migration backfill and applying the updated schema locally.

Verified:

  • duplicate content is rejected when the same content_sha256 already exists
  • a different historical privacy.md commit successfully inserts a new inactive legal_documents row with the expected provenance fields (source_commit_sha, source_path, content_sha256)
Manually tested utils/add-legal-doc-from-website.sh after removing the hardcoded migration backfill and applying the updated schema locally. Verified: - duplicate content is rejected when the same `content_sha256` already exists - a different historical privacy.md commit successfully inserts a new inactive `legal_documents` row with the expected provenance fields (`source_commit_sha`, `source_path`, `content_sha256`)
anton manually merged commit b514df804e into main 2026年04月11日 02:29:28 +02:00
Sign in to join this conversation.
No reviewers
Labels
Clear labels
Compat/Breaking
Breaking change that won't be backward compatible
Component/Enclave-Builder
This issue affects Enclave Builder (git-push and CLI)
Component/Infrastructure
This issue affects generic Caution hosted infra components
Deploy/BYOC
Relates to Caution Bring-your-own-compute
Deploy/Full-managed
Relates to Caution fully managed cloud
Deploy/Self-hosted
Relates to Caution deployed on self-hosted infra
Interface/API
This issue affects the API
Interface/CLI
This issue affects the command line interface
Interface/Git
This issue affects the Git `push` interface
Interface/Web
This issue affects the web dashboard
Kind/Bug
Something is not working
Kind/Documentation
Documentation changes
Kind/Enhancement
Improve existing functionality
Kind/Feature
New functionality
Kind/Security
This is security issue
Kind/Testing
Issue or pull request related to testing
Priority
Critical
The priority is critical
Priority
High
The priority is high
Priority
Low
The priority is low
Priority
Medium
The priority is medium
Reviewed
Confirmed
Issue has been confirmed
Reviewed
Duplicate
This issue or pull request already exists
Reviewed
Invalid
Invalid issue
Reviewed
Won't Fix
This issue won't be fixed
Status
Abandoned
Somebody has started to work on this but abandoned work
Status
Blocked
Something is blocking this issue or pull request
Status
Need More Info
Feedback is required to reproduce issue or to continue work
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
caution/platform!178
Reference in a new issue
caution/platform
No description provided.
Delete branch "tos-flow"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?