thth 2.7.0__tar.gz → 2.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {thth-2.7.0 → thth-2.9.0}/PKG-INFO +17 -3
- {thth-2.7.0 → thth-2.9.0}/README.md +16 -2
- {thth-2.7.0 → thth-2.9.0}/accounts.example/bluesky.json +1 -0
- {thth-2.7.0 → thth-2.9.0}/accounts.example/mastodon.json +1 -0
- {thth-2.7.0 → thth-2.9.0}/mcp/server.py +53 -5
- thth-2.9.0/skills/thth-admin/SKILL.md +14 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_accounts_dir.py +22 -22
- thth-2.9.0/tests/test_admin_http_mcp.py +60 -0
- thth-2.9.0/tests/test_admin_log.py +121 -0
- thth-2.9.0/tests/test_admin_notifications.py +30 -0
- thth-2.9.0/tests/test_admin_repairs.py +258 -0
- thth-2.9.0/tests/test_admin_report.py +154 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_analytics_comparison.py +21 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_auth.py +31 -31
- {thth-2.7.0 → thth-2.9.0}/tests/test_auth_identity_required.py +5 -5
- {thth-2.7.0 → thth-2.9.0}/tests/test_bundle.py +18 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_collect.py +33 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_collect_sent.py +1 -1
- {thth-2.7.0 → thth-2.9.0}/tests/test_fresh_install.py +6 -6
- {thth-2.7.0 → thth-2.9.0}/tests/test_incident_notifications.py +1 -1
- thth-2.9.0/tests/test_inflight_tag_body_hash.py +36 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_mastodon_adapter.py +1 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_packaging.py +2 -2
- {thth-2.7.0 → thth-2.9.0}/tests/test_select.py +14 -1
- {thth-2.7.0 → thth-2.9.0}/tests/test_thread_publish.py +67 -1
- {thth-2.7.0 → thth-2.9.0}/tests/test_token_scopes_record.py +4 -4
- {thth-2.7.0 → thth-2.9.0}/tests/test_token_set.py +18 -18
- thth-2.9.0/tests/test_v28_tags.py +247 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_where_cli.py +7 -1
- thth-2.9.0/thth/VERSION +1 -0
- {thth-2.7.0 → thth-2.9.0}/thth/account_cli.py +36 -6
- {thth-2.7.0 → thth-2.9.0}/thth/account_report.py +7 -1
- {thth-2.7.0 → thth-2.9.0}/thth/adapters/base.py +1 -0
- {thth-2.7.0 → thth-2.9.0}/thth/adapters/bluesky.py +133 -4
- {thth-2.7.0 → thth-2.9.0}/thth/adapters/mastodon.py +96 -2
- thth-2.9.0/thth/admin_log.py +260 -0
- thth-2.9.0/thth/admin_notifications.py +45 -0
- thth-2.9.0/thth/admin_report.py +512 -0
- {thth-2.7.0 → thth-2.9.0}/thth/analytics_comparison.py +41 -2
- {thth-2.7.0 → thth-2.9.0}/thth/analytics_report.py +2 -2
- {thth-2.7.0 → thth-2.9.0}/thth/approval.py +9 -0
- {thth-2.7.0 → thth-2.9.0}/thth/bundle.py +22 -3
- {thth-2.7.0 → thth-2.9.0}/thth/cli.py +21 -9
- {thth-2.7.0 → thth-2.9.0}/thth/collect.py +12 -0
- {thth-2.7.0 → thth-2.9.0}/thth/core.py +62 -25
- {thth-2.7.0 → thth-2.9.0}/thth/doctor.py +62 -0
- {thth-2.7.0 → thth-2.9.0}/thth/handoff_cursor.py +31 -2
- {thth-2.7.0 → thth-2.9.0}/thth/incident.py +6 -3
- {thth-2.7.0 → thth-2.9.0}/thth/lint.py +8 -3
- {thth-2.7.0 → thth-2.9.0}/thth/measured.py +2 -0
- {thth-2.7.0 → thth-2.9.0}/thth/oauth.py +66 -6
- {thth-2.7.0 → thth-2.9.0}/thth/report.py +13 -7
- {thth-2.7.0 → thth-2.9.0}/thth/report_http.py +15 -6
- {thth-2.7.0 → thth-2.9.0}/thth/report_isolation.py +51 -5
- {thth-2.7.0 → thth-2.9.0}/thth/report_service.py +27 -2
- {thth-2.7.0 → thth-2.9.0}/thth/select.py +12 -2
- thth-2.9.0/thth/tags.py +124 -0
- {thth-2.7.0 → thth-2.9.0}/thth/threads_read_cli.py +23 -2
- {thth-2.7.0 → thth-2.9.0}/thth/threadthrow.py +6 -3
- {thth-2.7.0 → thth-2.9.0}/thth/where_cli.py +37 -3
- thth-2.7.0/thth/VERSION +0 -1
- {thth-2.7.0 → thth-2.9.0}/.gitignore +0 -0
- {thth-2.7.0 → thth-2.9.0}/LICENSE +0 -0
- {thth-2.7.0 → thth-2.9.0}/accounts.example/threads.json +0 -0
- {thth-2.7.0 → thth-2.9.0}/bin/thth +0 -0
- {thth-2.7.0 → thth-2.9.0}/bin/thth-remote +0 -0
- {thth-2.7.0 → thth-2.9.0}/bin/thth-run +0 -0
- {thth-2.7.0 → thth-2.9.0}/pyproject.toml +0 -0
- {thth-2.7.0 → thth-2.9.0}/skills/thth/SKILL.md +0 -0
- {thth-2.7.0 → thth-2.9.0}/systemd/thth@.service +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/__init__.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/conftest.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/fixtures/build_injection_eval.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/fixtures/callback/worker_responses.json +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/fixtures/injection_eval.json +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/fixtures/umami-bile.md +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/__init__.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_oauth_server.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_push_adapter.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_redirect_server.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_search_adapter.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_threads_reader.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_threads_writes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/hold_lock.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/hold_repo_lock.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/helpers/worker_probe.mjs +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_27_acceptance_fixes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_account_name_check.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_account_report.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_adapter_boundary.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_after_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_analytics_report.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_api_diagnostic.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_api_error_body.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_app_env_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_approval.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_approve_cmd.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_ask.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_fifth_review.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_fourth_review.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_review.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_second_review.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_third_review.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_audit1_fixes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_audit2_fixes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_audit_b_counterexamples.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_audit_b_m1_semantics.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_audit_b_n1_depth.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_audit_b_r1_closure.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_audit_v201.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_audit_v23_details.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_behind_remote.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_bluesky_adapter.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_bluesky_secret_scrub.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_board_needs_review.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_board_running.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_charcount.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_cli_entrypoint.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_cli_help.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_cli_vm_path.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_collect_engagements.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_collect_runs.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_comparability.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_control_chars_refused.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_conversation_paging.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_count_for.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_docs_links.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_doctor.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_doctor_all_permissions.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_doctor_next_step.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_duplicate_front_matter_keys.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_editorial_audit.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_editorial_n3_n4.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_engagements.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_external_acceptance.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_fake_api.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_form_spec.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_form_spec_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_forms.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_front_matter_injection.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_healthcheck_notifications.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_hypotheses.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_hypothesis_boundaries.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_hypothesis_units.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_impact_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_inbox_granted_scopes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_independent_thread_review.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_independent_topic_review.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_independent_topic_review_r2.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_inflight.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_jst.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_lint.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_list_projections.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_llm_trial.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_llm_trial_engage.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_lock.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_m2_independent.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_maintain.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_mastodon_visibility.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_max_per_run.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_mcp.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_mcp_robustness.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_measured.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_media_accounts.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_media_next_steps.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_media_roundtrip.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_mismatch_diagnostics.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_n5_reply_null_element.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_n7_doctor_error_body.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_no_limits.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_observation_provenance.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_operations_handoff.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_preview.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_production_mode.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_pull_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_queue.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_quiet_hours.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_r2_r4_r5.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_reason_adoption.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_redact.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_redirect_token_leak.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_refresh.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_refresh_destination.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_rejudge_new.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_rejudge_r2_r3.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_replies.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_replies_refresh.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_repo_lock.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_repo_sync.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_report_checkout_import.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_report_http.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_report_isolation.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_report_service.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_reverse_audit.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_review_case_id_cluster_m2.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_review_case_id_cluster_r2.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_review_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_review_evidence_account.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_review_form_spec_and_case_id.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_review_provenance.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_review_records.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_revoke.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_roundtrip.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_run_missing_env.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_run_record_failclosed.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_runs_collected.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_runs_read_rows.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_schedule_multiple.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_schedule_view.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_scopes_source_requested.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_search_engage_targets.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_search_media_routes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_secret_value_redact.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_secrets_grep.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_security_small_fixes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_selfupdate.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_send.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_send_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_send_control_chars.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_send_media.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_send_visibility.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_sent_integrity.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_server_json.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_site.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_skill_doc.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_study_report.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_systemd.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_thread_read.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_threadrun.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_threads_read_permissions.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_threadshape.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_thth_root.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_token_exists.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_topic.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_topic_advice.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_topic_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_topic_models.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_topic_review_fixes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_topic_shelf_observers.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_topic_store.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_topics_record_concurrent.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_topics_tool.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_trial_frictions.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_v21b_approved_writes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_v31_analysis.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_v31_collection.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_value_domains_shown.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_version.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_who_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_writeback_rebase.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_writeback_validate.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/tests/test_x_metrics.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/__init__.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/__main__.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/accounts.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/adapters/__init__.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/adapters/threads.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/after_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/analytics_shapes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/analytics_threads.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/api_diagnostic.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/appenv.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/ask.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/ask_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/collection_context.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/collection_status.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/engagements.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/forms.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/healthcheck.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/httpsafe.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/inflight.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/jst.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/lock.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/maintain.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/operations_handoff.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/postid.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/queuefile.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/redact.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/replies.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/report_details.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/retract_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/runs.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/scopes.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/secrets_fs.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/selfupdate.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/sent.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/study_report.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/systemd_gen.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/thread_read.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/threadrun.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/threadshape.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/topic_advice.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/topic_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/topic_models.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/topic_store.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/topics.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/who_cli.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/writeback.py +0 -0
- {thth-2.7.0 → thth-2.9.0}/thth/x_metrics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thth
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.9.0
|
|
4
4
|
Summary: 承認済みの投稿だけを投げる門(Threads・Bluesky・Mastodon)。人が承認し、記録は利用者の git に残る。
|
|
5
5
|
Project-URL: Homepage, https://thth.me
|
|
6
6
|
Project-URL: Source, https://github.com/aokings/thth
|
|
@@ -33,7 +33,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
33
33
|
|
|
34
34
|
## 版
|
|
35
35
|
|
|
36
|
-
このソースの版は **2.
|
|
36
|
+
このソースの版は **2.9.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
|
|
37
37
|
|
|
38
38
|
版の正本は `thth/VERSION`(`server.json` はテストで一致を強制)。開発中の `main` には未配布の変更も含まれます。インストール済みの版は `thth --version`、VM の版と revision は `thth board` で確認してください。
|
|
39
39
|
|
|
@@ -59,6 +59,12 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
59
59
|
|
|
60
60
|
**トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
|
|
61
61
|
|
|
62
|
+
### v2.8.0: Bluesky/Mastodon のタグ
|
|
63
|
+
|
|
64
|
+
台帳の `hashtags: true` では本文のタグと `topic` を使えます。`topic: 茶` は公開本文の末尾に `#茶` を付け、同じタグが既にあれば重ねません。`hashtags: false` ではタグを追加せず、`topic` が効かないことを警告します。`max_hashtags` は公開本文のタグの上限(省略時 3、0 以上の整数)です。承認の指紋はタグを含む公開本文を使うため、以前の本文で承認した原稿は `approval_stale` になり、再確認・再承認が必要です。
|
|
65
|
+
|
|
66
|
+
`where`/`topics --search` の JSON は `by_tag` にタグ検索・観測の結果を分けます。ローカルの期間比較は `thth analytics-report <account> --compare-previous --by tag --json`。API で確認できたタグだけを使い、未観測はタグなしと混ぜません。媒体をまたぐ合算はしません。[利用手順](docs/使い方_プロジェクトのセッション向け_2026-09-09.md)・[2.8.0 リリースノート](docs/リリースノート_2.8.0_2026-09-19.md)。
|
|
67
|
+
|
|
62
68
|
### v2.7.0 の変更
|
|
63
69
|
|
|
64
70
|
- 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
|
|
@@ -108,7 +114,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
108
114
|
- **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
|
|
109
115
|
- **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
|
|
110
116
|
|
|
111
|
-
- **動くもの**(`thth --help` の全
|
|
117
|
+
- **動くもの**(`thth --help` の全 41 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`handoff-report`・`serve-reports`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`・`admin`。
|
|
112
118
|
- **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
|
|
113
119
|
- **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
|
|
114
120
|
- **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
|
|
@@ -128,3 +134,11 @@ mcp-name: io.github.aokings/thth
|
|
|
128
134
|
[形式と使い方](docs/分析レポート_v1.md)。
|
|
129
135
|
|
|
130
136
|
`handoff-report` / MCP `operations_handoff` はローカル運用状態・通知未処理を鮮度の制約付きで返します。[形式と使い方](docs/運用引継ぎレポート_v1.md)。
|
|
137
|
+
|
|
138
|
+
## 管理者の読み口(2.9.0)
|
|
139
|
+
|
|
140
|
+
`thth admin inventory|account|log|tokens|timers|release|diff` は秘密値を出さない読み取り専用レポートです。開始時は `thth admin diff --since-last-read`、変化した account は `thth admin account <name>`、週1回 `thth admin tokens`。`--json` で構造化結果を取得できます。既読 cursor の保存は `admin diff --since-last-read --mark-read --by <名前>` の明示時だけです。VM の `admin timers` は観測を `state/_admin/timers.json` に保存します。`doctor <name>` と admin の明示的な `--probe` は秘密を除いた診断記録を `state/<name>/doctor.json` に保存します。HTTP/MCP は時刻付きの記録を読むだけで、観測・保存を実行しません。
|
|
141
|
+
|
|
142
|
+
`account add`(`--force` の上書きも)・`auth`・`token set`・`token revoke` には `--by <名前>` が必須です。既存の作成来歴は上書きしません。`token revoke` はローカルcredential削除でありリモート側の認可取消ではありません。選定した管理変更は既定で管理者へ通知し、台帳の `notify_admin_on_change: false` で停止できます。
|
|
143
|
+
|
|
144
|
+
認証付きMCPの6道具とHTTPのadmin scopeは [管理者skill](skills/thth-admin/SKILL.md) を参照してください。管理レポートから承認・投稿・設定変更は行いません。ログ追記後のfsync失敗は変更を保持して `durability_unconfirmed`、部分追記は `outcome_uncertain` と報告します。再試行前に台帳とログを確認してください。
|
|
@@ -9,7 +9,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
9
9
|
|
|
10
10
|
## 版
|
|
11
11
|
|
|
12
|
-
このソースの版は **2.
|
|
12
|
+
このソースの版は **2.9.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
|
|
13
13
|
|
|
14
14
|
版の正本は `thth/VERSION`(`server.json` はテストで一致を強制)。開発中の `main` には未配布の変更も含まれます。インストール済みの版は `thth --version`、VM の版と revision は `thth board` で確認してください。
|
|
15
15
|
|
|
@@ -35,6 +35,12 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
35
35
|
|
|
36
36
|
**トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
|
|
37
37
|
|
|
38
|
+
### v2.8.0: Bluesky/Mastodon のタグ
|
|
39
|
+
|
|
40
|
+
台帳の `hashtags: true` では本文のタグと `topic` を使えます。`topic: 茶` は公開本文の末尾に `#茶` を付け、同じタグが既にあれば重ねません。`hashtags: false` ではタグを追加せず、`topic` が効かないことを警告します。`max_hashtags` は公開本文のタグの上限(省略時 3、0 以上の整数)です。承認の指紋はタグを含む公開本文を使うため、以前の本文で承認した原稿は `approval_stale` になり、再確認・再承認が必要です。
|
|
41
|
+
|
|
42
|
+
`where`/`topics --search` の JSON は `by_tag` にタグ検索・観測の結果を分けます。ローカルの期間比較は `thth analytics-report <account> --compare-previous --by tag --json`。API で確認できたタグだけを使い、未観測はタグなしと混ぜません。媒体をまたぐ合算はしません。[利用手順](docs/使い方_プロジェクトのセッション向け_2026-09-09.md)・[2.8.0 リリースノート](docs/リリースノート_2.8.0_2026-09-19.md)。
|
|
43
|
+
|
|
38
44
|
### v2.7.0 の変更
|
|
39
45
|
|
|
40
46
|
- 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
|
|
@@ -84,7 +90,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
84
90
|
- **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
|
|
85
91
|
- **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
|
|
86
92
|
|
|
87
|
-
- **動くもの**(`thth --help` の全
|
|
93
|
+
- **動くもの**(`thth --help` の全 41 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`handoff-report`・`serve-reports`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`・`admin`。
|
|
88
94
|
- **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
|
|
89
95
|
- **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
|
|
90
96
|
- **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
|
|
@@ -104,3 +110,11 @@ mcp-name: io.github.aokings/thth
|
|
|
104
110
|
[形式と使い方](docs/分析レポート_v1.md)。
|
|
105
111
|
|
|
106
112
|
`handoff-report` / MCP `operations_handoff` はローカル運用状態・通知未処理を鮮度の制約付きで返します。[形式と使い方](docs/運用引継ぎレポート_v1.md)。
|
|
113
|
+
|
|
114
|
+
## 管理者の読み口(2.9.0)
|
|
115
|
+
|
|
116
|
+
`thth admin inventory|account|log|tokens|timers|release|diff` は秘密値を出さない読み取り専用レポートです。開始時は `thth admin diff --since-last-read`、変化した account は `thth admin account <name>`、週1回 `thth admin tokens`。`--json` で構造化結果を取得できます。既読 cursor の保存は `admin diff --since-last-read --mark-read --by <名前>` の明示時だけです。VM の `admin timers` は観測を `state/_admin/timers.json` に保存します。`doctor <name>` と admin の明示的な `--probe` は秘密を除いた診断記録を `state/<name>/doctor.json` に保存します。HTTP/MCP は時刻付きの記録を読むだけで、観測・保存を実行しません。
|
|
117
|
+
|
|
118
|
+
`account add`(`--force` の上書きも)・`auth`・`token set`・`token revoke` には `--by <名前>` が必須です。既存の作成来歴は上書きしません。`token revoke` はローカルcredential削除でありリモート側の認可取消ではありません。選定した管理変更は既定で管理者へ通知し、台帳の `notify_admin_on_change: false` で停止できます。
|
|
119
|
+
|
|
120
|
+
認証付きMCPの6道具とHTTPのadmin scopeは [管理者skill](skills/thth-admin/SKILL.md) を参照してください。管理レポートから承認・投稿・設定変更は行いません。ログ追記後のfsync失敗は変更を保持して `durability_unconfirmed`、部分追記は `outcome_uncertain` と報告します。再試行前に台帳とログを確認してください。
|
|
@@ -160,7 +160,7 @@ TOOLS = [
|
|
|
160
160
|
"inputSchema": {
|
|
161
161
|
"type": "object",
|
|
162
162
|
"properties": {
|
|
163
|
-
"by": {"type": "string", "enum": ["kind", "hour_band", "topic"]},
|
|
163
|
+
"by": {"type": "string", "enum": ["kind", "hour_band", "topic", "tag"]},
|
|
164
164
|
"compare_previous": {"type": "boolean", "description": "直前の同じ日数との比較(既定false)"},
|
|
165
165
|
"account": {"type": "string"},
|
|
166
166
|
"project": {"type": "string"},
|
|
@@ -390,7 +390,7 @@ def validate_arguments(name: str, arguments) -> dict:
|
|
|
390
390
|
**型が合っていること**(知らない鍵も断る——綴りを間違えたまま既定値で
|
|
391
391
|
動いたことにしない)。判断はしない(中身の意味は CLI の仕事)。
|
|
392
392
|
"""
|
|
393
|
-
schema = _schema_for(name)
|
|
393
|
+
schema = next((tool["inputSchema"] for tool in ADMIN_TOOLS if tool["name"] == name), None) or _schema_for(name)
|
|
394
394
|
if schema is None:
|
|
395
395
|
return {} # 知らない道具は `call_tool()` が断る
|
|
396
396
|
if arguments is None:
|
|
@@ -400,8 +400,8 @@ def validate_arguments(name: str, arguments) -> dict:
|
|
|
400
400
|
f"{name}: arguments は object で渡してください"
|
|
401
401
|
f"(受け取った: {type(arguments).__name__})")
|
|
402
402
|
if name == "analytics_report" and "by" in arguments:
|
|
403
|
-
if arguments["by"] not in ("kind", "hour_band", "topic") or arguments.get("compare_previous") is not True:
|
|
404
|
-
raise ToolInputError("analytics_report: by は compare_previous=true と kind/hour_band/topic が必要です")
|
|
403
|
+
if arguments["by"] not in ("kind", "hour_band", "topic", "tag") or arguments.get("compare_previous") is not True:
|
|
404
|
+
raise ToolInputError("analytics_report: by は compare_previous=true と kind/hour_band/topic/tag が必要です")
|
|
405
405
|
if name == "operations_handoff" and "since_last_read" in arguments and type(arguments["since_last_read"]) is not bool:
|
|
406
406
|
raise ToolInputError("operations_handoff: since_last_read は boolean です")
|
|
407
407
|
props = schema.get("properties") or {}
|
|
@@ -495,9 +495,57 @@ def _topic_stdin(arguments: dict, *, with_proposal: bool) -> tuple:
|
|
|
495
495
|
return args, json.dumps(payload, ensure_ascii=False)
|
|
496
496
|
|
|
497
497
|
|
|
498
|
+
ADMIN_TOOLS = [
|
|
499
|
+
{"name": "thth_admin_" + name, "description": "Read-only administrator " + name,
|
|
500
|
+
"inputSchema": {"type": "object", "properties":
|
|
501
|
+
({"account": {"type": "string"}, "limit": {"type": "integer"}} if name == "account" else
|
|
502
|
+
{"account": {"type": "string"}, "since": {"type": "string"}, "event": {"type": "string"}} if name == "log" else
|
|
503
|
+
{"since_last_read": {"type": "boolean"}} if name == "diff" else {}),
|
|
504
|
+
"required": ["account"] if name == "account" else [], "additionalProperties": False}}
|
|
505
|
+
for name in ("inventory", "account", "log", "tokens", "release", "diff")]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def admin_context():
|
|
509
|
+
"""Authenticate the startup environment, rechecked on list and every call."""
|
|
510
|
+
import hashlib
|
|
511
|
+
import hmac
|
|
512
|
+
from datetime import datetime, timezone
|
|
513
|
+
from pathlib import Path
|
|
514
|
+
path = os.environ.get("THTH_REPORT_CREDENTIALS")
|
|
515
|
+
token = os.environ.get("THTH_REPORT_TOKEN")
|
|
516
|
+
if not path or not token:
|
|
517
|
+
return None
|
|
518
|
+
if APP_DIR not in sys.path:
|
|
519
|
+
sys.path.insert(0, APP_DIR)
|
|
520
|
+
from thth.report_http import load_credentials
|
|
521
|
+
from thth.report_isolation import validate_environment
|
|
522
|
+
try:
|
|
523
|
+
root, credentials = load_credentials(Path(path))
|
|
524
|
+
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
525
|
+
for expected, expiry, revoked, context in credentials:
|
|
526
|
+
if hmac.compare_digest(digest, expected) and not revoked and datetime.now(timezone.utc) < expiry and context.scope == "admin":
|
|
527
|
+
validate_environment(root, context.allowed_accounts, allow_unreadable=True)
|
|
528
|
+
return context
|
|
529
|
+
except (OSError, ValueError, TypeError):
|
|
530
|
+
pass
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
|
|
498
534
|
def call_tool(name: str, arguments: dict | None) -> dict:
|
|
499
535
|
"""CLI を呼んで結果を返すだけ。判断(条件分岐・整形)をここに書かない。"""
|
|
500
536
|
arguments = arguments or {}
|
|
537
|
+
if isinstance(name, str) and name.startswith("thth_admin_"):
|
|
538
|
+
context = admin_context()
|
|
539
|
+
if context is None:
|
|
540
|
+
return {"content": [{"type": "text", "text": "unauthorized"}], "isError": True}
|
|
541
|
+
from thth.report_service import execute_report, ReportServiceError
|
|
542
|
+
try:
|
|
543
|
+
request = {"operation": name[len("thth_"):], **arguments}
|
|
544
|
+
result = execute_report(context, request)
|
|
545
|
+
return {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]}
|
|
546
|
+
except (ValueError, TypeError, ReportServiceError) as error:
|
|
547
|
+
reason = "invalid_options" if str(error) == "invalid_options" else "invalid_request"
|
|
548
|
+
return {"content": [{"type": "text", "text": reason}], "isError": True}
|
|
501
549
|
if name == "thth_lint":
|
|
502
550
|
proc = run_cli(["lint", arguments["file"], "--json"])
|
|
503
551
|
text = proc.stdout
|
|
@@ -709,7 +757,7 @@ def _handle_request(req: dict):
|
|
|
709
757
|
if method == "notifications/initialized":
|
|
710
758
|
return None
|
|
711
759
|
if method == "tools/list":
|
|
712
|
-
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
|
|
760
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS + (ADMIN_TOOLS if admin_context() is not None else [])}}
|
|
713
761
|
if method == "tools/call":
|
|
714
762
|
params = req.get("params") or {}
|
|
715
763
|
if not isinstance(params, dict):
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: thth-admin
|
|
3
|
+
description: Read authenticated THTH administrator inventory, changes and token status; hand findings to a person.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
開始時は `thth admin diff --since-last-read --json` を読み、変化があった account を `thth admin account <name> --json` で確認する。週1回 `thth admin tokens --json` を確認する。
|
|
7
|
+
|
|
8
|
+
読んだ結果から承認・投稿・設定変更をしない。必要な対処は根拠・欠測理由・観測時刻とともに人に渡す。記録が無い null は正常・停止・期限なしの証拠ではない。API probe は人が明示的に必要とした場合の CLI `--probe` のみ。
|
|
9
|
+
|
|
10
|
+
CLI は SSH のOSユーザー権限で実行する。読む位置の更新が明示的に依頼された場合だけ `thth admin diff --since-last-read --mark-read --by <名前>`。HTTP/MCPはcursorを書けない。
|
|
11
|
+
|
|
12
|
+
MCPの6道具は `thth_admin_inventory`, `thth_admin_account`, `thth_admin_log`, `thth_admin_tokens`, `thth_admin_release`, `thth_admin_diff`。MCPプロセスへ `THTH_REPORT_CREDENTIALS`(owner-private service credential JSONのパス)と `THTH_REPORT_TOKEN`(対応するbearer credential)を秘密として渡す。scope adminの有効期限・取消・SHA256照合と全accountのroot分離検査が通るときだけ一覧に表示される。値を会話・ログ・引数に出さない。
|
|
13
|
+
|
|
14
|
+
資格情報例の構造: schema_version=1, root=絶対パス, credentials=[{sha256, expires_at, revoked:false, accounts:{}, scope:"admin"}]。既存のscope未指定はuserのまま。管理者HTTPのtimerは保存済み観測だけでありlive稼働保証ではない。
|
|
@@ -298,7 +298,7 @@ def 媒体ごとの必須(media: str) -> list:
|
|
|
298
298
|
@pytest.mark.parametrize("media", ["threads", "bluesky", "mastodon"])
|
|
299
299
|
def test_addは雛形から外へ1本書く(置き場, media):
|
|
300
300
|
r = run_thth(["account", "add", f"demo-{media}", "--media", media,
|
|
301
|
-
"--project", "demo", *媒体ごとの必須(media)])
|
|
301
|
+
"--project", "demo", *媒体ごとの必須(media), "--by", "test-operator"])
|
|
302
302
|
assert r.returncode == 0, r.stdout + r.stderr
|
|
303
303
|
|
|
304
304
|
path = os.path.join(置き場["外"], f"demo-{media}.json")
|
|
@@ -318,7 +318,7 @@ def test_addが作るものは必ずproduction_false(置き場, media):
|
|
|
318
318
|
"""**道具が作ったものが、いきなり本物を投げる形で生まれてはいけない**
|
|
319
319
|
(設計 §4.2「`production: true` を自分で書かない限り dry-run」)。"""
|
|
320
320
|
r = run_thth(["account", "add", f"demo-{media}", "--media", media,
|
|
321
|
-
"--project", "demo", *媒体ごとの必須(media)])
|
|
321
|
+
"--project", "demo", *媒体ごとの必須(media), "--by", "test-operator"])
|
|
322
322
|
assert r.returncode == 0, r.stdout + r.stderr
|
|
323
323
|
with open(os.path.join(置き場["外"], f"demo-{media}.json"), encoding="utf-8") as f:
|
|
324
324
|
data = json.load(f)
|
|
@@ -336,7 +336,7 @@ def test_addが作った台帳は_productionを付けて打っても_rehearsal
|
|
|
336
336
|
もう一方の関門は開けた状態で見る。
|
|
337
337
|
"""
|
|
338
338
|
assert run_thth(["account", "add", "demo-threads", "--media", "threads",
|
|
339
|
-
"--project", "demo"]).returncode == 0
|
|
339
|
+
"--project", "demo", "--by", "test-operator"]).returncode == 0
|
|
340
340
|
r = run_thth(["throw", "demo-threads", "--now", "--production"])
|
|
341
341
|
assert r.stdout.splitlines()[0] == "mode: rehearsal", r.stdout + r.stderr
|
|
342
342
|
|
|
@@ -344,7 +344,7 @@ def test_addが作った台帳は_productionを付けて打っても_rehearsal
|
|
|
344
344
|
def test_addは既にあるものを上書きしない(置き場):
|
|
345
345
|
台帳を置く(置き場["外"], "demo-threads", handle="先にあったほう")
|
|
346
346
|
r = run_thth(["account", "add", "demo-threads", "--media", "threads",
|
|
347
|
-
"--project", "demo"])
|
|
347
|
+
"--project", "demo", "--by", "test-operator"])
|
|
348
348
|
assert r.returncode == 1, r.stdout + r.stderr
|
|
349
349
|
assert "上書きしません" in r.stderr
|
|
350
350
|
with open(os.path.join(置き場["外"], "demo-threads.json"), encoding="utf-8") as f:
|
|
@@ -364,7 +364,7 @@ def test_addは互換のときは書く前にloudに断る_順番を間違えさ
|
|
|
364
364
|
台帳を置く(置き場["repo の中"], "repo2-threads")
|
|
365
365
|
|
|
366
366
|
r = run_thth(["account", "add", "demo-threads", "--media", "threads",
|
|
367
|
-
"--project", "demo"])
|
|
367
|
+
"--project", "demo", "--by", "test-operator"])
|
|
368
368
|
assert r.returncode == 1, r.stdout + r.stderr
|
|
369
369
|
assert "thth account migrate" in r.stderr
|
|
370
370
|
assert "以後読まれません" in r.stderr
|
|
@@ -381,7 +381,7 @@ def test_addは互換のときでもrepoの中に書かない(置き場):
|
|
|
381
381
|
"""**読みが互換 (c) に落ちていても、書く先は外**(`--force` で進んだとき)。"""
|
|
382
382
|
台帳を置く(置き場["repo の中"], "repo-threads")
|
|
383
383
|
r = run_thth(["account", "add", "demo-threads", "--media", "threads",
|
|
384
|
-
"--project", "demo", "--force"])
|
|
384
|
+
"--project", "demo", "--force", "--by", "test-operator"])
|
|
385
385
|
assert r.returncode == 0, r.stdout + r.stderr
|
|
386
386
|
assert os.path.exists(os.path.join(置き場["外"], "demo-threads.json"))
|
|
387
387
|
assert not os.path.exists(os.path.join(置き場["repo の中"], "demo-threads.json"))
|
|
@@ -396,7 +396,7 @@ def test_addのhandleの既定はprojectであってアカウント名ではな
|
|
|
396
396
|
**既定が残るのは Threads だけ**(監査 2・C10)——下の 2 本を見よ。
|
|
397
397
|
"""
|
|
398
398
|
assert run_thth(["account", "add", "nigamilab-threads", "--media", "threads",
|
|
399
|
-
"--project", "nigamilab"]).returncode == 0
|
|
399
|
+
"--project", "nigamilab", "--by", "test-operator"]).returncode == 0
|
|
400
400
|
with open(os.path.join(置き場["外"], "nigamilab-threads.json"), encoding="utf-8") as f:
|
|
401
401
|
assert json.load(f)["handle"] == "nigamilab"
|
|
402
402
|
|
|
@@ -408,7 +408,7 @@ def test_addのhandleの既定はprojectであってアカウント名ではな
|
|
|
408
408
|
def test_addのredirect_uriが台帳に入る(置き場):
|
|
409
409
|
"""(a) `--redirect-uri` で渡した値がそのまま台帳に入り、**ダミーは残らない**。"""
|
|
410
410
|
r = run_thth(["account", "add", "demo-threads", "--media", "threads",
|
|
411
|
-
"--project", "demo", "--redirect-uri", "https://thth.me/callback/"])
|
|
411
|
+
"--project", "demo", "--redirect-uri", "https://thth.me/callback/", "--by", "test-operator"])
|
|
412
412
|
assert r.returncode == 0, r.stdout + r.stderr
|
|
413
413
|
with open(os.path.join(置き場["外"], "demo-threads.json"), encoding="utf-8") as f:
|
|
414
414
|
data = json.load(f)
|
|
@@ -428,7 +428,7 @@ def test_addはredirect_uriを省いたら次の一手で1行言う(置き場):
|
|
|
428
428
|
(**`add` と `auth` の間に、どこにも書かれていない手作業**)。
|
|
429
429
|
"""
|
|
430
430
|
r = run_thth(["account", "add", "demo-threads", "--media", "threads",
|
|
431
|
-
"--project", "demo"])
|
|
431
|
+
"--project", "demo", "--by", "test-operator"])
|
|
432
432
|
assert r.returncode == 0, r.stdout + r.stderr
|
|
433
433
|
with open(os.path.join(置き場["外"], "demo-threads.json"), encoding="utf-8") as f:
|
|
434
434
|
assert json.load(f)["redirect_uri"] == "https://example.invalid/"
|
|
@@ -442,7 +442,7 @@ def test_addのredirect_uriはthreads以外では黙って捨てない(置き場
|
|
|
442
442
|
"""`redirect_uri` を読むのは Threads の `thth auth` だけ。**黙って捨てない**。"""
|
|
443
443
|
r = run_thth(["account", "add", "demo-bluesky", "--media", "bluesky",
|
|
444
444
|
"--project", "demo", "--handle", "demo2.bsky.social",
|
|
445
|
-
"--redirect-uri", "https://thth.me/callback/"])
|
|
445
|
+
"--redirect-uri", "https://thth.me/callback/", "--by", "test-operator"])
|
|
446
446
|
assert r.returncode == 2, r.stdout + r.stderr
|
|
447
447
|
assert "--redirect-uri は threads" in r.stderr, r.stderr
|
|
448
448
|
assert not os.path.exists(os.path.join(置き場["外"], "demo-bluesky.json"))
|
|
@@ -456,7 +456,7 @@ def test_addはblueskyのhandleを必須にする(置き場):
|
|
|
456
456
|
**認可を保存しない**。黙って外れた値を書くより、ここで 1 回聞くほうが安い。
|
|
457
457
|
"""
|
|
458
458
|
r = run_thth(["account", "add", "demo-bluesky", "--media", "bluesky",
|
|
459
|
-
"--project", "demo"])
|
|
459
|
+
"--project", "demo", "--by", "test-operator"])
|
|
460
460
|
assert r.returncode == 2, r.stdout + r.stderr
|
|
461
461
|
assert "--handle が要ります" in r.stderr, r.stderr
|
|
462
462
|
assert "name.bsky.social" in r.stderr, r.stderr # **例を添える**
|
|
@@ -467,13 +467,13 @@ def test_addはblueskyのhandleを必須にする(置き場):
|
|
|
467
467
|
def test_addはmastodonのhandleとinstanceを必須にする(置き場):
|
|
468
468
|
"""(e) Mastodon は利用者名+インスタンス。**どちらも道具には推測できない。**"""
|
|
469
469
|
handle無し = run_thth(["account", "add", "demo-mastodon", "--media", "mastodon",
|
|
470
|
-
"--project", "demo", "--instance", "https://mastodon.social"])
|
|
470
|
+
"--project", "demo", "--instance", "https://mastodon.social", "--by", "test-operator"])
|
|
471
471
|
assert handle無し.returncode == 2, handle無し.stdout + handle無し.stderr
|
|
472
472
|
assert "--handle が要ります" in handle無し.stderr, handle無し.stderr
|
|
473
473
|
assert "--instance https://mastodon.social" in handle無し.stderr, handle無し.stderr
|
|
474
474
|
|
|
475
475
|
instance無し = run_thth(["account", "add", "demo-mastodon", "--media", "mastodon",
|
|
476
|
-
"--project", "demo", "--handle", "user"])
|
|
476
|
+
"--project", "demo", "--handle", "user", "--by", "test-operator"])
|
|
477
477
|
assert instance無し.returncode == 2, instance無し.stdout + instance無し.stderr
|
|
478
478
|
assert "--instance が要ります" in instance無し.stderr, instance無し.stderr
|
|
479
479
|
assert "--handle user" in instance無し.stderr, instance無し.stderr
|
|
@@ -486,7 +486,7 @@ def test_addはthreadsのhandleを今までどおり既定で埋める(置き場
|
|
|
486
486
|
"""(f) **Threads は従来どおり。** `--handle` を必須にしたのは他の 2 媒体だけ
|
|
487
487
|
——Threads の handle は利用者名そのもので、`--project` の値がだいたい当たる。"""
|
|
488
488
|
r = run_thth(["account", "add", "nigamilab-threads", "--media", "threads",
|
|
489
|
-
"--project", "nigamilab"])
|
|
489
|
+
"--project", "nigamilab", "--by", "test-operator"])
|
|
490
490
|
assert r.returncode == 0, r.stdout + r.stderr
|
|
491
491
|
with open(os.path.join(置き場["外"], "nigamilab-threads.json"), encoding="utf-8") as f:
|
|
492
492
|
assert json.load(f)["handle"] == "nigamilab"
|
|
@@ -496,13 +496,13 @@ def test_addのinstanceは媒体で綴りが変わる(置き場):
|
|
|
496
496
|
"""Mastodon は `instance`、Bluesky は `service`(既存の台帳の綴り)。"""
|
|
497
497
|
assert run_thth(["account", "add", "demo-mastodon", "--media", "mastodon",
|
|
498
498
|
"--project", "demo", "--handle", "demo2", "--instance",
|
|
499
|
-
"https://example.social"]).returncode == 0
|
|
499
|
+
"https://example.social", "--by", "test-operator"]).returncode == 0
|
|
500
500
|
with open(os.path.join(置き場["外"], "demo-mastodon.json"), encoding="utf-8") as f:
|
|
501
501
|
assert json.load(f)["instance"] == "https://example.social"
|
|
502
502
|
|
|
503
503
|
assert run_thth(["account", "add", "demo-bluesky", "--media", "bluesky",
|
|
504
504
|
"--project", "demo", "--handle", "demo2.bsky.social",
|
|
505
|
-
"--instance", "https://pds.example"]).returncode == 0
|
|
505
|
+
"--instance", "https://pds.example", "--by", "test-operator"]).returncode == 0
|
|
506
506
|
with open(os.path.join(置き場["外"], "demo-bluesky.json"), encoding="utf-8") as f:
|
|
507
507
|
assert json.load(f)["service"] == "https://pds.example"
|
|
508
508
|
|
|
@@ -520,7 +520,7 @@ def test_addは置き場の外に書けない_名前を検査する(置き場, t
|
|
|
520
520
|
気づけない。`thth posts` が post_id を検査するのと同じ守り方で断る。
|
|
521
521
|
"""
|
|
522
522
|
外 = 置き場["外"]
|
|
523
|
-
r = run_thth(["account", "add", name, "--media", "threads", "--project", "demo"])
|
|
523
|
+
r = run_thth(["account", "add", name, "--media", "threads", "--project", "demo", "--by", "test-operator"])
|
|
524
524
|
assert r.returncode == 2, r.stdout + r.stderr
|
|
525
525
|
assert "使えない字" in r.stderr, r.stderr
|
|
526
526
|
|
|
@@ -536,23 +536,23 @@ def test_addは普通の名前を断らない(置き場):
|
|
|
536
536
|
"""検査が厳しすぎて実在の綴りを弾いていないこと(`.`・`-`・`_` は通る)。"""
|
|
537
537
|
for name in ("nigamilab-threads", "asmon_kanto-threads", "a.b-mastodon"):
|
|
538
538
|
r = run_thth(["account", "add", name, "--media", "threads",
|
|
539
|
-
"--project", "demo"])
|
|
539
|
+
"--project", "demo", "--by", "test-operator"])
|
|
540
540
|
assert r.returncode == 0, f"{name}: {r.stdout}{r.stderr}"
|
|
541
541
|
assert os.path.exists(os.path.join(置き場["外"], f"{name}.json"))
|
|
542
542
|
|
|
543
543
|
|
|
544
544
|
def test_addは足りない指定を黙って埋めない(置き場):
|
|
545
545
|
知らない媒体 = run_thth(["account", "add", "demo-x", "--media", "mixi",
|
|
546
|
-
"--project", "demo"])
|
|
546
|
+
"--project", "demo", "--by", "test-operator"])
|
|
547
547
|
assert 知らない媒体.returncode == 2
|
|
548
548
|
assert "invalid choice" in 知らない媒体.stderr
|
|
549
549
|
|
|
550
|
-
project無し = run_thth(["account", "add", "demo-threads", "--media", "threads"])
|
|
550
|
+
project無し = run_thth(["account", "add", "demo-threads", "--media", "threads", "--by", "test-operator"])
|
|
551
551
|
assert project無し.returncode == 2, project無し.stdout + project無し.stderr
|
|
552
552
|
assert "--project" in project無し.stderr
|
|
553
553
|
assert not os.path.exists(os.path.join(置き場["外"], "demo-threads.json"))
|
|
554
554
|
|
|
555
|
-
media無し = run_thth(["account", "add", "demo-threads", "--project", "demo"])
|
|
555
|
+
media無し = run_thth(["account", "add", "demo-threads", "--project", "demo", "--by", "test-operator"])
|
|
556
556
|
assert media無し.returncode == 2, media無し.stdout + media無し.stderr
|
|
557
557
|
assert "--media" in media無し.stderr
|
|
558
558
|
|
|
@@ -656,7 +656,7 @@ def test_置き場がファイルならaddは言葉で断る(置き場):
|
|
|
656
656
|
f.write("これはディレクトリではない\n")
|
|
657
657
|
|
|
658
658
|
r = run_thth(["account", "add", "demo-threads", "--media", "threads",
|
|
659
|
-
"--project", "demo"])
|
|
659
|
+
"--project", "demo", "--by", "test-operator"])
|
|
660
660
|
assert "Traceback" not in r.stderr, r.stderr
|
|
661
661
|
assert r.returncode == 2, r.stdout + r.stderr
|
|
662
662
|
assert "台帳を書けませんでした" in r.stderr, r.stderr
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime,timedelta,timezone
|
|
4
|
+
import pytest
|
|
5
|
+
from thth import accounts, report_http, admin_report
|
|
6
|
+
from thth.report_service import ReportContext,ReportServiceError,execute_report
|
|
7
|
+
from tests.test_mcp import _load_server_module
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def credentials(tmp_path,monkeypatch):
|
|
11
|
+
root=tmp_path/'tenant';root.mkdir(mode=0o700);(root/'accounts').mkdir()
|
|
12
|
+
monkeypatch.setenv('THTH_ROOT',str(root));monkeypatch.setenv('THTH_ACCOUNTS_DIR',str(root/'accounts'))
|
|
13
|
+
for name in ('first','second'):
|
|
14
|
+
cfg={k:None for k in accounts.REQUIRED_FIELDS}
|
|
15
|
+
cfg.update(account=name,project=name,media='threads',handle=name,repo_dir=str(root/'repos/_none'),
|
|
16
|
+
token=str(root/'secret'/name),env=str(root/'secret'/('env-'+name)),queue_dir='docs/sns/queue',replies_dir='data/sns/replies')
|
|
17
|
+
(root/'accounts'/(name+'.json')).write_text(json.dumps(cfg))
|
|
18
|
+
token='a'*43
|
|
19
|
+
value=dict(schema_version=1,root=str(root),credentials=[dict(sha256=hashlib.sha256(token.encode()).hexdigest(),
|
|
20
|
+
expires_at=(datetime.now(timezone.utc)+timedelta(hours=1)).isoformat(),revoked=False,accounts={},scope='admin')])
|
|
21
|
+
path=tmp_path/'credential.json';path.write_text(json.dumps(value));path.chmod(0o600)
|
|
22
|
+
return path,token,value
|
|
23
|
+
|
|
24
|
+
def test_user_admin_negative_contract():
|
|
25
|
+
user=ReportContext({'first':'first'})
|
|
26
|
+
with pytest.raises(ReportServiceError,match='unsupported_operation'):execute_report(user,dict(operation='admin_inventory'))
|
|
27
|
+
admin=ReportContext({'first':'first'},scope='admin')
|
|
28
|
+
for operation in ('admin_inventory','admin_diff','admin_account'):
|
|
29
|
+
with pytest.raises(ReportServiceError,match='invalid_request'):execute_report(admin,dict(operation=operation,mark_read=True))
|
|
30
|
+
|
|
31
|
+
def test_admin_all_registry_and_mcp_auth_rechecked(credentials,monkeypatch):
|
|
32
|
+
path,token,value=credentials
|
|
33
|
+
root,creds=report_http.load_credentials(path)
|
|
34
|
+
assert set(creds[0][3].allowed_accounts)=={'first','second'}
|
|
35
|
+
server=_load_server_module()
|
|
36
|
+
assert server.admin_context() is None
|
|
37
|
+
assert server.call_tool('thth_admin_inventory',{})['content'][0]['text']=='unauthorized'
|
|
38
|
+
monkeypatch.setenv('THTH_REPORT_CREDENTIALS',str(path));monkeypatch.setenv('THTH_REPORT_TOKEN',token)
|
|
39
|
+
assert server.admin_context().scope=='admin'
|
|
40
|
+
assert len(server.ADMIN_TOOLS)==6
|
|
41
|
+
result=server.call_tool('thth_admin_inventory',{})
|
|
42
|
+
assert set(json.loads(result['content'][0]['text'])['by_account'])=={'first','second'}
|
|
43
|
+
assert server.call_tool('thth_admin_diff',{'mark_read':True})['isError']
|
|
44
|
+
value['credentials'][0]['revoked']=True;path.write_text(json.dumps(value))
|
|
45
|
+
assert server.admin_context() is None
|
|
46
|
+
assert server.call_tool('thth_admin_inventory',{})['content'][0]['text']=='unauthorized'
|
|
47
|
+
|
|
48
|
+
def test_admin_preflight_checks_unlisted_account(credentials):
|
|
49
|
+
path,token,value=credentials
|
|
50
|
+
root=path.parent/'tenant';cfgpath=root/'accounts/second.json';cfg=json.loads(cfgpath.read_text());cfg['token']='/outside/secret';cfgpath.write_text(json.dumps(cfg))
|
|
51
|
+
with pytest.raises(ValueError):report_http.PrivateReportServer(path,0)
|
|
52
|
+
|
|
53
|
+
def test_admin_shared_state_symlink_is_rejected(credentials):
|
|
54
|
+
path,token,value=credentials
|
|
55
|
+
root=path.parent/'tenant';outside=path.parent/'external';outside.mkdir()
|
|
56
|
+
(root/'state').mkdir();(root/'state/_admin').symlink_to(outside)
|
|
57
|
+
(outside/'timers.json').write_text(json.dumps(dict(by_account={'first':dict(observed_at=datetime.now(timezone.utc).isoformat(),units=[dict(unit='FAKE_EXTERNAL_SECRET')])})))
|
|
58
|
+
with pytest.raises(ValueError):report_http.PrivateReportServer(path,0)
|
|
59
|
+
report=admin_report.answer('timers',via='http')
|
|
60
|
+
assert 'FAKE_EXTERNAL_SECRET' not in json.dumps(report)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
import pytest
|
|
6
|
+
from thth import account_cli, accounts, admin_log, oauth
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def root(tmp_path, monkeypatch):
|
|
10
|
+
monkeypatch.setenv('THTH_ROOT', str(tmp_path))
|
|
11
|
+
monkeypatch.setenv('THTH_ACCOUNTS_DIR', str(tmp_path / 'accounts'))
|
|
12
|
+
return tmp_path
|
|
13
|
+
|
|
14
|
+
def args(**kw):
|
|
15
|
+
value = dict(name='test-threads', by='tester', media='threads', project='test', handle='tester',
|
|
16
|
+
instance=None, repo_dir=None, redirect_uri=None, force=False, json=True)
|
|
17
|
+
value.update(kw)
|
|
18
|
+
return SimpleNamespace(**value)
|
|
19
|
+
|
|
20
|
+
def test_actor_required_before_any_write(root):
|
|
21
|
+
assert account_cli.cmd_add(args(by=None)) == 2
|
|
22
|
+
assert not (root / 'accounts').exists()
|
|
23
|
+
for fun in (oauth.run_auth, oauth.run_auth_bluesky, oauth.run_token_set):
|
|
24
|
+
assert fun('missing', log=lambda _: None) == 2
|
|
25
|
+
|
|
26
|
+
def test_creation_and_force_preserve_provenance(root):
|
|
27
|
+
assert account_cli.cmd_add(args()) == 0
|
|
28
|
+
path = root / 'accounts/test-threads.json'
|
|
29
|
+
old = json.loads(path.read_text())
|
|
30
|
+
assert old['provenance']['created_by'] == 'tester'
|
|
31
|
+
assert old['provenance']['created_via'] == 'cli'
|
|
32
|
+
assert account_cli.cmd_add(args(force=True, by='second', handle='changed')) == 0
|
|
33
|
+
current = json.loads(path.read_text())
|
|
34
|
+
assert current['provenance'] == old['provenance']
|
|
35
|
+
assert current['handle'] == 'changed'
|
|
36
|
+
rows, broken = admin_log.read()
|
|
37
|
+
assert [r['event'] for r in rows] == ['account_added', 'account_updated']
|
|
38
|
+
assert rows[-1]['diff']['handle'] == ['tester', 'changed']
|
|
39
|
+
assert not broken
|
|
40
|
+
before = path.read_bytes()
|
|
41
|
+
assert account_cli.cmd_add(args(force=True, by=None)) == 2
|
|
42
|
+
assert path.read_bytes() == before
|
|
43
|
+
|
|
44
|
+
def test_log_append_mode_secret_and_broken(root):
|
|
45
|
+
cfg = {'media': 'threads'}
|
|
46
|
+
diff = admin_log.difference({'token':'FAKE_OLD_TOKEN','env':'FAKE_OLD_SECRET','notification_email':'a@example.test'},
|
|
47
|
+
{'token':'FAKE_NEW_TOKEN','env':'FAKE_NEW_SECRET','notification_email':'b@example.test'})
|
|
48
|
+
admin_log.append('token_set', 'demo', cfg, by='tester', diff=diff)
|
|
49
|
+
path = root / 'state/_admin/accounts.ndjson'
|
|
50
|
+
first = path.read_bytes()
|
|
51
|
+
admin_log.append('production_enabled', 'demo', cfg, by='tester', diff={'production':[False,True]})
|
|
52
|
+
assert path.read_bytes().startswith(first)
|
|
53
|
+
assert path.stat().st_mode & 0o777 == 0o600
|
|
54
|
+
assert 'FAKE_' not in path.read_text() and '@' not in path.read_text()
|
|
55
|
+
assert json.loads(first)['diff']['token'] == ['present','present']
|
|
56
|
+
with path.open('a') as f: f.write('broken\n[]\n')
|
|
57
|
+
rows, broken = admin_log.read(event='token_set')
|
|
58
|
+
assert len(rows) == 1 and broken == 2
|
|
59
|
+
|
|
60
|
+
def test_log_symlink_refused(root):
|
|
61
|
+
target = root / 'outside'; target.write_text('unchanged')
|
|
62
|
+
(root / 'state/_admin').mkdir(parents=True)
|
|
63
|
+
(root / 'state/_admin/accounts.ndjson').symlink_to(target)
|
|
64
|
+
with pytest.raises(OSError):
|
|
65
|
+
admin_log.append('token_set', 'demo', {'media':'threads'}, by='tester')
|
|
66
|
+
assert target.read_text() == 'unchanged'
|
|
67
|
+
|
|
68
|
+
def test_revoke_logs_no_secret(root):
|
|
69
|
+
assert account_cli.cmd_add(args()) == 0
|
|
70
|
+
path = root / 'accounts/test-threads.json'
|
|
71
|
+
cfg = json.loads(path.read_text()); token = root / 'fake.token'
|
|
72
|
+
cfg['token'] = str(token); path.write_text(json.dumps(cfg))
|
|
73
|
+
token.write_text('{"access_token":"FAKE_PRIVATE_TOKEN"}')
|
|
74
|
+
assert oauth.run_token_revoke('test-threads', by=None) == 2
|
|
75
|
+
assert token.exists()
|
|
76
|
+
assert oauth.run_token_revoke('test-threads', by='tester') == 0
|
|
77
|
+
assert not token.exists()
|
|
78
|
+
rows, _ = admin_log.read(event='token_revoked')
|
|
79
|
+
assert rows[0]['diff']['token'] == ['present','absent']
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize('bad', ['permissions','symlink','fifo'])
|
|
82
|
+
def test_invalid_log_blocks_mutation(root, bad):
|
|
83
|
+
assert account_cli.cmd_add(args()) == 0
|
|
84
|
+
path = root/'accounts/test-threads.json'; old = path.read_bytes()
|
|
85
|
+
log=root/'state/_admin/accounts.ndjson'
|
|
86
|
+
if bad=='permissions': log.chmod(0o644)
|
|
87
|
+
else:
|
|
88
|
+
log.unlink()
|
|
89
|
+
if bad=='symlink': log.symlink_to(root/'outside')
|
|
90
|
+
else: os.mkfifo(log)
|
|
91
|
+
assert account_cli.cmd_add(args(force=True,by='second',handle='changed'))==2
|
|
92
|
+
assert path.read_bytes()==old
|
|
93
|
+
|
|
94
|
+
def test_log_rejects_malformed_diff_and_scrubs_other_columns(root):
|
|
95
|
+
admin_log.append('token_set','demo',{'media':'threads'},by='tester')
|
|
96
|
+
path=root/'state/_admin/accounts.ndjson'; row=json.loads(path.read_text())
|
|
97
|
+
row['diff']={'token':'FAKE_RAW_TOKEN'}
|
|
98
|
+
with path.open('a') as f:f.write(json.dumps(row)+'\n')
|
|
99
|
+
rows,broken=admin_log.read()
|
|
100
|
+
assert broken==1 and len(rows)==1 and 'FAKE_RAW_TOKEN' not in json.dumps(rows)
|
|
101
|
+
|
|
102
|
+
def test_late_log_failure_restores_original(root, monkeypatch):
|
|
103
|
+
assert account_cli.cmd_add(args()) == 0
|
|
104
|
+
path=root/'accounts/test-threads.json'; before=path.read_bytes()
|
|
105
|
+
monkeypatch.setattr(admin_log.os,'write',lambda *a: (_ for _ in ()).throw(OSError('disk full')))
|
|
106
|
+
assert account_cli.cmd_add(args(force=True,by='second',handle='changed'))==2
|
|
107
|
+
assert path.read_bytes()==before
|
|
108
|
+
|
|
109
|
+
def test_fsync_failure_keeps_record_and_change_consistent(root, monkeypatch, capsys):
|
|
110
|
+
assert account_cli.cmd_add(args())==0
|
|
111
|
+
monkeypatch.setattr(admin_log.os,'fsync',lambda *a: (_ for _ in ()).throw(OSError('fsync failed')))
|
|
112
|
+
assert account_cli.cmd_add(args(force=True,by='second',handle='changed'))==2
|
|
113
|
+
path=root/'accounts/test-threads.json'
|
|
114
|
+
assert json.loads(path.read_text())['handle']=='changed'
|
|
115
|
+
rows,broken=admin_log.read()
|
|
116
|
+
assert rows[-1]['diff']['handle']==['tester','changed'] and broken==0
|
|
117
|
+
assert 'durability_unconfirmed' in capsys.readouterr().err
|
|
118
|
+
|
|
119
|
+
def test_bluesky_jwt_diff_is_presence_only():
|
|
120
|
+
diff=admin_log.difference({'accessJwt':'FAKE_BSKY_ACCESS_JWT','refreshJwt':'FAKE_BSKY_REFRESH_JWT'}, {})
|
|
121
|
+
assert diff=={'accessJwt':['present','absent'],'refreshJwt':['present','absent']}
|