flowstatemcp 2.5.0__py3-none-any.whl
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.
- flowstate/__init__.py +34 -0
- flowstate/__main__.py +7 -0
- flowstate/activity.py +224 -0
- flowstate/agent_guide.py +251 -0
- flowstate/app_keys.py +141 -0
- flowstate/approvals.py +974 -0
- flowstate/approve_page.py +565 -0
- flowstate/ask.py +107 -0
- flowstate/autosession.py +480 -0
- flowstate/backup.py +201 -0
- flowstate/brief.py +420 -0
- flowstate/card_offer.py +208 -0
- flowstate/card_taps.py +165 -0
- flowstate/card_trust.py +127 -0
- flowstate/claims.py +461 -0
- flowstate/cleanup.py +619 -0
- flowstate/cli.py +728 -0
- flowstate/cli_data.py +434 -0
- flowstate/cli_more.py +302 -0
- flowstate/cli_review.py +459 -0
- flowstate/cloudflared_check.py +49 -0
- flowstate/conflict_card_ui.py +286 -0
- flowstate/conflict_cards.py +1193 -0
- flowstate/conflict_reopen.py +320 -0
- flowstate/conflicts.py +420 -0
- flowstate/connections.py +33 -0
- flowstate/database.py +2191 -0
- flowstate/dbconn.py +308 -0
- flowstate/deletes.py +860 -0
- flowstate/embeddings.py +354 -0
- flowstate/erase.py +1047 -0
- flowstate/extras.py +71 -0
- flowstate/git_capture.py +157 -0
- flowstate/guard.py +224 -0
- flowstate/handoffs.py +168 -0
- flowstate/history.py +755 -0
- flowstate/history_page.py +276 -0
- flowstate/http_app.py +204 -0
- flowstate/identity.py +387 -0
- flowstate/install_name.py +140 -0
- flowstate/ledger.py +272 -0
- flowstate/legacy_args.py +65 -0
- flowstate/links.py +609 -0
- flowstate/markdown_io.py +242 -0
- flowstate/meaning.py +398 -0
- flowstate/memory_import.py +92 -0
- flowstate/migrations/001_auto_sessions.sql +16 -0
- flowstate/migrations/002_review_queue.sql +33 -0
- flowstate/migrations/003_links.sql +47 -0
- flowstate/migrations/004_review_mode.sql +24 -0
- flowstate/migrations/005_history.sql +21 -0
- flowstate/migrations/006_activity.sql +26 -0
- flowstate/migrations/007_meaning_index.sql +16 -0
- flowstate/migrations/008_git_capture.sql +12 -0
- flowstate/migrations/009_hard_rules.sql +13 -0
- flowstate/migrations/010_handoffs.sql +14 -0
- flowstate/migrations/011_record_meta.sql +13 -0
- flowstate/migrations/012_accountability.sql +71 -0
- flowstate/migrations/013_todo_claims.sql +36 -0
- flowstate/migrations/014_decision_conflicts.sql +41 -0
- flowstate/migrations/015_session_runs.sql +17 -0
- flowstate/migrations/016_write_receipts.sql +35 -0
- flowstate/migrations/017_run_views.sql +26 -0
- flowstate/migrations/018_run_views_upto.sql +4 -0
- flowstate/migrations/019_approvals.sql +47 -0
- flowstate/migrations/020_source_stamps.sql +10 -0
- flowstate/migrations/021_review_links.sql +11 -0
- flowstate/migrations/022_review_start_tickets.sql +10 -0
- flowstate/migrations/023_conflict_cards.sql +68 -0
- flowstate/migrations/024_rule_confirmations.sql +7 -0
- flowstate/migrations/025_confirmed_hash.sql +7 -0
- flowstate/migrations/__init__.py +149 -0
- flowstate/models.py +307 -0
- flowstate/names.py +157 -0
- flowstate/offsite.py +386 -0
- flowstate/offsite_crypto.py +272 -0
- flowstate/paths.py +265 -0
- flowstate/popup.py +238 -0
- flowstate/priority.py +66 -0
- flowstate/privatefs.py +476 -0
- flowstate/project_history.py +710 -0
- flowstate/provenance.py +696 -0
- flowstate/receipts.py +377 -0
- flowstate/relay/__init__.py +72 -0
- flowstate/relay/acme.py +271 -0
- flowstate/relay/api.py +164 -0
- flowstate/relay/cli.py +344 -0
- flowstate/relay/crypto.py +137 -0
- flowstate/relay/ct.py +118 -0
- flowstate/relay/runner.py +177 -0
- flowstate/relay/store.py +191 -0
- flowstate/relay/tunnel.py +413 -0
- flowstate/release_signers +14 -0
- flowstate/remote_auth.py +1219 -0
- flowstate/review.py +320 -0
- flowstate/review_apply.py +140 -0
- flowstate/review_card_ui.py +241 -0
- flowstate/review_cards.py +564 -0
- flowstate/review_mode.py +274 -0
- flowstate/review_page.py +1361 -0
- flowstate/review_phone.py +465 -0
- flowstate/review_tunnel.py +338 -0
- flowstate/routing.py +392 -0
- flowstate/rules.py +418 -0
- flowstate/safety.py +98 -0
- flowstate/saves.py +83 -0
- flowstate/scheduler.py +160 -0
- flowstate/schema.sql +713 -0
- flowstate/search.py +355 -0
- flowstate/secret_store.py +271 -0
- flowstate/secrets_scan.py +135 -0
- flowstate/selfupdate.py +1020 -0
- flowstate/server.py +1299 -0
- flowstate/session_contents.py +382 -0
- flowstate/session_records.py +629 -0
- flowstate/site_style.py +46 -0
- flowstate/stored.py +158 -0
- flowstate/story.py +442 -0
- flowstate/surface.py +173 -0
- flowstate/tasks.py +231 -0
- flowstate/term.py +40 -0
- flowstate/tool_labels.py +104 -0
- flowstate/tooldefs.py +383 -0
- flowstate/tools/__init__.py +118 -0
- flowstate/tools/core.py +545 -0
- flowstate/tools/files.py +416 -0
- flowstate/tools/git_ops.py +1081 -0
- flowstate/tools/intelligence.py +376 -0
- flowstate/tools/learning.py +282 -0
- flowstate/tools/problems.py +159 -0
- flowstate/tools/sessions.py +266 -0
- flowstate/tools/story.py +48 -0
- flowstate/tools/todos.py +79 -0
- flowstate/tools/utils.py +71 -0
- flowstate/tools/variables.py +345 -0
- flowstate/updates.py +482 -0
- flowstate/urls.py +7 -0
- flowstate/waiting.py +828 -0
- flowstate/who.py +302 -0
- flowstatemcp-2.5.0.dist-info/METADATA +88 -0
- flowstatemcp-2.5.0.dist-info/RECORD +143 -0
- flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
- flowstatemcp-2.5.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""The phone approval page (approvals.py, path 2), served by FlowState's HTTP app, which the
|
|
2
|
+
relay agent reaches at https://<handle>.fsmcp.com (TLS ends on this computer).
|
|
3
|
+
|
|
4
|
+
flowstatemcp.com signs the user in, then sends the browser to /approve/<item>#g=<grant>. The
|
|
5
|
+
fragment never reaches a server or a log. This page:
|
|
6
|
+
|
|
7
|
+
GET /approve/<item> an empty shell (no data); its script reads #g=, removes it from
|
|
8
|
+
the address bar at once (history.replaceState) and posts it
|
|
9
|
+
POST /approve/<item>/open checks the grant (approvals.verify_grant). Each grant opens
|
|
10
|
+
the page once. Answers with the item from the local database and
|
|
11
|
+
a page token, also set as a SameSite=Strict, Secure, HttpOnly
|
|
12
|
+
cookie, bound to that grant
|
|
13
|
+
POST /approve/<item>/decide the tap: the same grant, checked again, plus the page token in
|
|
14
|
+
the body and the cookie. Approve or Reject uses the grant and the
|
|
15
|
+
item's one-use code
|
|
16
|
+
POST /approve/<item>/record a session summary's per-record Keep / Remove / Bring back
|
|
17
|
+
(session_records.py): the same grant and page token, not used
|
|
18
|
+
up, so the page's final Keep all / Don't keep still works
|
|
19
|
+
|
|
20
|
+
Strict CSP (no inline script or style: both are files under /approve/assets/), frame-ancestors
|
|
21
|
+
'none' + X-Frame-Options DENY, no-store, no referrer. POSTs must be JSON, from this origin.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import secrets
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
|
|
30
|
+
from . import approvals, install_name, site_style
|
|
31
|
+
|
|
32
|
+
HEADERS = {
|
|
33
|
+
"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self'; "
|
|
34
|
+
"connect-src 'self'; img-src 'self'; base-uri 'none'; "
|
|
35
|
+
"form-action 'none'; frame-ancestors 'none'",
|
|
36
|
+
"X-Frame-Options": "DENY", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
|
|
37
|
+
"X-Content-Type-Options": "nosniff",
|
|
38
|
+
}
|
|
39
|
+
COOKIE = "fsa_page"
|
|
40
|
+
|
|
41
|
+
SHELL = """<!doctype html>
|
|
42
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
44
|
+
<meta name="referrer" content="no-referrer">
|
|
45
|
+
<title>FlowState approval</title>
|
|
46
|
+
<link rel="stylesheet" href="/approve/assets/approve.css">
|
|
47
|
+
<script src="/approve/assets/approve.js" defer></script>
|
|
48
|
+
</head><body><main class="wrap">
|
|
49
|
+
<p class="brand">FlowState</p>
|
|
50
|
+
<h1 id="title">Checking your approval link...</h1>
|
|
51
|
+
<p id="status" class="muted"></p>
|
|
52
|
+
<noscript><p class="muted">This page needs JavaScript to read your approval link.</p></noscript>
|
|
53
|
+
<section id="item" hidden>
|
|
54
|
+
<p class="eyebrow" id="project"></p>
|
|
55
|
+
<p id="summary"></p>
|
|
56
|
+
<p id="details" class="muted"></p>
|
|
57
|
+
<div id="saved"></div>
|
|
58
|
+
<p id="who" class="faint"></p>
|
|
59
|
+
<label id="name-row" hidden>Type the project's exact name to delete it
|
|
60
|
+
<input id="confirm-name" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
61
|
+
<div class="actions">
|
|
62
|
+
<button id="approve" class="btn" type="button">Approve</button>
|
|
63
|
+
<button id="reject" class="btn ghost" type="button">Reject</button>
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
<p class="faint small" id="foot">Your FlowState, on your own computer, shows this page. flowstatemcp.com
|
|
67
|
+
only confirmed it's you; it never sees what's in it.</p>
|
|
68
|
+
</main></body></html>
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
CSS = """:root{--bg:#fbfaf7;--ink:#1c1d1f;--muted:#62656b;--faint:#9a9ca1;--line:#e7e4dd;
|
|
72
|
+
--accent:#1f5f5b;--accent-soft:#e6efed;--danger:#8a2d22;
|
|
73
|
+
--sans:ui-sans-serif,-apple-system,"SF Pro Text","Segoe UI",Inter,Roboto,"Helvetica Neue",Arial,sans-serif;
|
|
74
|
+
--serif:ui-serif,"New York","Iowan Old Style",Georgia,"Times New Roman",serif}
|
|
75
|
+
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
|
76
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:17px/1.6 var(--sans);-webkit-font-smoothing:antialiased}
|
|
77
|
+
.wrap{max-width:560px;margin:0 auto;padding:28px 22px 48px}
|
|
78
|
+
.brand{font-weight:600;letter-spacing:-.01em;margin:0 0 32px}
|
|
79
|
+
h1{font-family:var(--serif);font-weight:500;font-size:28px;line-height:1.2;letter-spacing:-.01em;margin:0 0 14px}
|
|
80
|
+
.eyebrow{font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);margin:24px 0 8px}
|
|
81
|
+
.muted{color:var(--muted)}.faint{color:var(--faint)}.small{font-size:14px;margin-top:40px}
|
|
82
|
+
#summary{font-size:19px;margin:0 0 10px;overflow-wrap:anywhere}
|
|
83
|
+
#details{overflow-wrap:anywhere;white-space:pre-wrap}
|
|
84
|
+
label{display:block;margin:20px 0 0;font-size:15px;color:var(--muted)}
|
|
85
|
+
input{display:block;width:100%;margin-top:6px;font:inherit;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:#fff;color:var(--ink)}
|
|
86
|
+
.actions{display:flex;gap:14px;margin-top:28px}
|
|
87
|
+
.btn{flex:1;font:inherit;font-size:16px;font-weight:500;padding:15px 22px;border-radius:999px;border:0;cursor:pointer;background:var(--ink);color:var(--bg)}
|
|
88
|
+
.btn:hover{background:var(--accent)}.btn:disabled{opacity:.5;cursor:default}
|
|
89
|
+
.btn.ghost{background:none;color:var(--ink);border:1px solid var(--line)}
|
|
90
|
+
.btn.ghost:hover{border-color:var(--ink);background:none}
|
|
91
|
+
.err{color:var(--danger)}
|
|
92
|
+
.saved{margin:8px 0 0;padding-left:18px}.saved li{margin:8px 0;overflow-wrap:anywhere}
|
|
93
|
+
.saved .ex{color:var(--muted);font-size:14px;margin:2px 0 0}
|
|
94
|
+
.btn.more{flex:none;font-size:15px;padding:10px 18px;margin:4px 0 8px}
|
|
95
|
+
.state{display:inline-block;margin-left:8px;font-size:13px;border:1px solid var(--line);border-radius:999px;padding:0 8px;color:var(--accent)}
|
|
96
|
+
.state.st-removed,.state.st-gone{color:var(--danger)}.state.st-changed{color:var(--muted)}
|
|
97
|
+
.recrow{display:flex;gap:10px;margin:6px 0 2px}
|
|
98
|
+
.btn.rec{flex:none;font-size:15px;padding:8px 16px;min-height:44px}
|
|
99
|
+
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
100
|
+
.wrap{max-width:600px}
|
|
101
|
+
@media (min-width:640px){.wrap{background:#fff;border:1px solid var(--line);border-radius:10px;margin:32px auto;padding:28px 32px 36px}}
|
|
102
|
+
.brand{font-weight:500;font-size:17px;margin:0 0 24px}.brand b{color:var(--accent);font-weight:700}
|
|
103
|
+
h1{font-size:24px;line-height:1.25}
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
CSS = site_style.restyle(CSS)
|
|
107
|
+
|
|
108
|
+
# What a session saved, drawn on the phone pages (this one and review_phone.py): the first
|
|
109
|
+
# `shown` records, then a "Show N more" button that opens the rest in place. Every record sent
|
|
110
|
+
# is already in the page's data (session_contents.public, send=PHONE_MAX), so opening them asks
|
|
111
|
+
# nothing of the server. Text only (textContent), never markup. Spliced into each page's own
|
|
112
|
+
# function at /*SAVED_JS*/ (defines mk, localTime, endTime, savedItem and saved there).
|
|
113
|
+
SAVED_JS = r""" function mk(tag, cls, text) {
|
|
114
|
+
var e = document.createElement(tag);
|
|
115
|
+
if (cls) { e.className = cls; }
|
|
116
|
+
if (text) { e.textContent = text; }
|
|
117
|
+
return e;
|
|
118
|
+
}
|
|
119
|
+
function localTime(utc) { // the reader's own time; UTC, labelled, if it can't
|
|
120
|
+
var d = utc ? new Date(String(utc).replace(" ", "T") + "Z") : null;
|
|
121
|
+
if (!d || isNaN(d.getTime())) { return utc ? utc + " UTC" : ""; }
|
|
122
|
+
return d.toLocaleString([], {dateStyle: "medium", timeStyle: "short"});
|
|
123
|
+
}
|
|
124
|
+
function endTime(a, b) { // the end alone when the session ends the day it began
|
|
125
|
+
var x = new Date(String(a).replace(" ", "T") + "Z"), y = new Date(String(b).replace(" ", "T") + "Z");
|
|
126
|
+
if (isNaN(x.getTime()) || isNaN(y.getTime()) || x.toDateString() !== y.toDateString()) {
|
|
127
|
+
return localTime(b);
|
|
128
|
+
}
|
|
129
|
+
return y.toLocaleTimeString([], {timeStyle: "short"});
|
|
130
|
+
}
|
|
131
|
+
var ACT = {keep: "Keep", remove: "Remove", restore: "Bring back"};
|
|
132
|
+
function savedItem(e, act) { // act(entry, action, buttons): a record's own Keep / Remove
|
|
133
|
+
var li = mk("li", "");
|
|
134
|
+
li.appendChild(mk("strong", "", e.head || ""));
|
|
135
|
+
if (e.state) { li.appendChild(mk("span", "state st-" + e.state, e.state_label || e.state)); }
|
|
136
|
+
if (e.excerpt) { li.appendChild(mk("p", "ex", e.excerpt)); }
|
|
137
|
+
if (e.solution) { li.appendChild(mk("p", "ex", "Solution: " + e.solution)); }
|
|
138
|
+
if (act && e.actions && e.actions.length) {
|
|
139
|
+
var row = mk("div", "recrow"), btns = [];
|
|
140
|
+
e.actions.forEach(function (a) {
|
|
141
|
+
var b = mk("button", "btn ghost rec", ACT[a] || a);
|
|
142
|
+
b.type = "button";
|
|
143
|
+
b.addEventListener("click", function () {
|
|
144
|
+
btns.forEach(function (x) { x.disabled = true; });
|
|
145
|
+
act(e, a, btns);
|
|
146
|
+
});
|
|
147
|
+
btns.push(b);
|
|
148
|
+
row.appendChild(b);
|
|
149
|
+
});
|
|
150
|
+
li.appendChild(row);
|
|
151
|
+
}
|
|
152
|
+
return li;
|
|
153
|
+
}
|
|
154
|
+
function saved(c, act) { // what a session saved: each record, written as text
|
|
155
|
+
var box = mk("div", "");
|
|
156
|
+
if (c.started_utc) {
|
|
157
|
+
box.appendChild(mk("p", "faint", "Session " + localTime(c.started_utc) +
|
|
158
|
+
(c.ended_utc ? " to " + endTime(c.started_utc, c.ended_utc) : "") + (c.app ? ", by " + c.app : "")));
|
|
159
|
+
}
|
|
160
|
+
var entries = c.entries || [];
|
|
161
|
+
if (!entries.length) {
|
|
162
|
+
box.appendChild(mk("p", "muted", c.note || ""));
|
|
163
|
+
return box;
|
|
164
|
+
}
|
|
165
|
+
var shown = c.shown || entries.length;
|
|
166
|
+
box.appendChild(mk("p", "muted", "What this session saved (" + c.total + "):"));
|
|
167
|
+
if (act && c.explain) { box.appendChild(mk("p", "faint", c.explain)); }
|
|
168
|
+
var ul = mk("ul", "saved");
|
|
169
|
+
entries.slice(0, shown).forEach(function (e) { ul.appendChild(savedItem(e, act)); });
|
|
170
|
+
box.appendChild(ul);
|
|
171
|
+
var rest = entries.slice(shown);
|
|
172
|
+
if (rest.length) { // the rest is already here: open it in place
|
|
173
|
+
var hid = mk("ul", "saved");
|
|
174
|
+
hid.hidden = true;
|
|
175
|
+
rest.forEach(function (e) { hid.appendChild(savedItem(e, act)); });
|
|
176
|
+
var more = "Show " + rest.length + " more", less = "Show fewer";
|
|
177
|
+
var btn = mk("button", "btn ghost more", more);
|
|
178
|
+
btn.type = "button";
|
|
179
|
+
btn.setAttribute("aria-expanded", "false");
|
|
180
|
+
btn.addEventListener("click", function () {
|
|
181
|
+
hid.hidden = !hid.hidden;
|
|
182
|
+
btn.textContent = hid.hidden ? more : less;
|
|
183
|
+
btn.setAttribute("aria-expanded", hid.hidden ? "false" : "true");
|
|
184
|
+
});
|
|
185
|
+
box.appendChild(hid);
|
|
186
|
+
box.appendChild(btn);
|
|
187
|
+
}
|
|
188
|
+
if (c.more) {
|
|
189
|
+
box.appendChild(mk("p", "faint", c.more + " more not sent to this page: " +
|
|
190
|
+
"the review page on your computer lists all."));
|
|
191
|
+
} else if (rest.length) {
|
|
192
|
+
box.appendChild(mk("p", "faint", "The review page on your computer lists them too."));
|
|
193
|
+
}
|
|
194
|
+
return box;
|
|
195
|
+
}
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
JS = r"""(function () {
|
|
199
|
+
"use strict";
|
|
200
|
+
var m = /(?:^#|&)g=([A-Za-z0-9_.-]+)/.exec(location.hash || "");
|
|
201
|
+
var grant = m ? m[1] : null;
|
|
202
|
+
history.replaceState(null, "", location.pathname); // the grant leaves the address bar now
|
|
203
|
+
var base = location.pathname.replace(/\/+$/, "");
|
|
204
|
+
var token = null, needsName = false;
|
|
205
|
+
function $(id) { return document.getElementById(id); }
|
|
206
|
+
function say(title, text, bad) {
|
|
207
|
+
$("title").textContent = title;
|
|
208
|
+
$("status").textContent = text || "";
|
|
209
|
+
$("status").className = bad ? "err" : "muted";
|
|
210
|
+
}
|
|
211
|
+
function post(path, body) {
|
|
212
|
+
return fetch(base + path, {
|
|
213
|
+
method: "POST", credentials: "same-origin", cache: "no-store",
|
|
214
|
+
headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)
|
|
215
|
+
}).then(function (r) {
|
|
216
|
+
return r.json().then(function (j) { return {ok: r.ok, j: j}; });
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function decide(decision) {
|
|
220
|
+
$("approve").disabled = $("reject").disabled = true;
|
|
221
|
+
var body = {grant: grant, token: token, decision: decision};
|
|
222
|
+
if (needsName) { body.confirm_name = $("confirm-name").value; }
|
|
223
|
+
post("/decide", body).then(function (r) {
|
|
224
|
+
$("item").hidden = true;
|
|
225
|
+
if (r.ok) { say(decision === "approve" ? "Approved" : "Rejected", r.j.message); }
|
|
226
|
+
else { say("Not done", r.j.message || "Something went wrong.", true); }
|
|
227
|
+
}, function () { say("Not done", "Your FlowState didn't answer.", true); });
|
|
228
|
+
}
|
|
229
|
+
/*SAVED_JS*/
|
|
230
|
+
if (!grant) { say("This link is incomplete", "Open it again from the chat.", true); return; }
|
|
231
|
+
post("/open", {grant: grant}).then(function (r) {
|
|
232
|
+
if (!r.ok) { say("Can't show this request", r.j.message || "", true); return; }
|
|
233
|
+
var it = r.j.item;
|
|
234
|
+
token = r.j.token;
|
|
235
|
+
needsName = !!it.needs_name;
|
|
236
|
+
if (typeof r.j.name === "string" && r.j.name) { // this FlowState's name, as text (textContent)
|
|
237
|
+
$("foot").textContent = r.j.name + ", your FlowState on your own computer, shows this page. " +
|
|
238
|
+
"flowstatemcp.com only confirmed it's you; it never sees what's in it.";
|
|
239
|
+
}
|
|
240
|
+
say(it.question, "");
|
|
241
|
+
$("project").textContent = it.project + (r.j.name ? " · " + r.j.name : "");
|
|
242
|
+
$("summary").textContent = it.summary;
|
|
243
|
+
$("details").textContent = it.details || "";
|
|
244
|
+
function drawSaved(c) {
|
|
245
|
+
var box = $("saved");
|
|
246
|
+
while (box.firstChild) { box.removeChild(box.firstChild); }
|
|
247
|
+
box.appendChild(saved(c, function (e, action, btns) {
|
|
248
|
+
post("/record", {grant: grant, token: token, record: e.key, action: action}).then(function (r) {
|
|
249
|
+
if (r.ok && r.j.item && r.j.item.contents) { drawSaved(r.j.item.contents); $("status").textContent = r.j.message; }
|
|
250
|
+
else { btns.forEach(function (x) { x.disabled = false; }); $("status").textContent = r.j.message || "Not done."; $("status").className = "err"; }
|
|
251
|
+
}, function () { btns.forEach(function (x) { x.disabled = false; }); say("Not done", "Your FlowState didn't answer.", true); });
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
if (it.contents) { drawSaved(it.contents); }
|
|
255
|
+
$("approve").textContent = it.yes || "Approve";
|
|
256
|
+
$("reject").textContent = it.no || "Reject";
|
|
257
|
+
$("who").textContent = "Asked by " + it.queued_by + ", " + localTime(it.queued_at);
|
|
258
|
+
$("name-row").hidden = !needsName;
|
|
259
|
+
$("item").hidden = false;
|
|
260
|
+
if (it.card && it.card.sides) {
|
|
261
|
+
var box = document.querySelector(".actions");
|
|
262
|
+
$("approve").hidden = $("reject").hidden = true;
|
|
263
|
+
$("summary").textContent = "";
|
|
264
|
+
[["keep_a", "Keep A", "btn"], ["keep_b", "Keep B", "btn ghost"],
|
|
265
|
+
["keep_both", "Keep both", "btn ghost"], ["dismiss", "Dismiss", "btn ghost"]].forEach(function (b) {
|
|
266
|
+
var btn = document.createElement("button");
|
|
267
|
+
btn.className = b[2];
|
|
268
|
+
btn.type = "button";
|
|
269
|
+
btn.textContent = b[1];
|
|
270
|
+
btn.addEventListener("click", function () { decide(b[0]); });
|
|
271
|
+
box.appendChild(btn);
|
|
272
|
+
});
|
|
273
|
+
var sides = document.createElement("div");
|
|
274
|
+
it.card.sides.forEach(function (s) {
|
|
275
|
+
var node = document.createElement("p");
|
|
276
|
+
var ev = (s.evidence || []).map(function (e) {
|
|
277
|
+
return (e.label || "") + (e.excerpt ? " — " + e.excerpt : "");
|
|
278
|
+
}).filter(Boolean).join("; ");
|
|
279
|
+
node.textContent = "Claim " + (s.mark || "") + " (" + (s.who || "Unknown") +
|
|
280
|
+
(s.when ? ", " + s.when : "") + "): " + (s.claim || "") +
|
|
281
|
+
" Evidence: " + (ev || "none");
|
|
282
|
+
sides.appendChild(node);
|
|
283
|
+
});
|
|
284
|
+
$("details").textContent = "";
|
|
285
|
+
$("details").appendChild(sides);
|
|
286
|
+
}
|
|
287
|
+
$("approve").addEventListener("click", function () { decide("approve"); });
|
|
288
|
+
$("reject").addEventListener("click", function () { decide("reject"); });
|
|
289
|
+
}, function () { say("Can't show this request", "Your FlowState didn't answer.", true); });
|
|
290
|
+
})();
|
|
291
|
+
""".replace("/*SAVED_JS*/\n", SAVED_JS)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _sha(s: str) -> str:
|
|
295
|
+
return hashlib.sha256((s or "").encode()).hexdigest()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _now() -> str:
|
|
299
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def view(db_path, item_id: int) -> dict:
|
|
303
|
+
"""What the page shows, from the local database."""
|
|
304
|
+
from . import review
|
|
305
|
+
item = review.get_item(db_path, item_id)
|
|
306
|
+
conn = approvals._conn(db_path)
|
|
307
|
+
try:
|
|
308
|
+
row = conn.execute("SELECT name FROM projects WHERE id = ?", (item["project_id"],)).fetchone()
|
|
309
|
+
finally:
|
|
310
|
+
conn.close()
|
|
311
|
+
project = row[0] if row else f"project #{item['project_id']}"
|
|
312
|
+
p = json.loads(item.get("payload") or "{}")
|
|
313
|
+
from .identity import shown_row
|
|
314
|
+
app = shown_row(item, fallback=item.get("source") or "An app")
|
|
315
|
+
ask = approvals.describe(item, project, db_path)
|
|
316
|
+
card = None
|
|
317
|
+
summary = review.strip_tag(item.get("summary") or "")
|
|
318
|
+
details = item.get("details") or ""
|
|
319
|
+
if item["kind"] == "cleanup": # FlowState's own idea: one plain question and its reason
|
|
320
|
+
why = p.get("reason") or ""
|
|
321
|
+
question = f"Should FlowState {ask}?" + (f" {why[:1].upper()}{why[1:]}." if why else "")
|
|
322
|
+
from . import cleanup # the exact list, flagged if it changed (task 74)
|
|
323
|
+
listed = cleanup.preview(db_path, item)
|
|
324
|
+
if listed:
|
|
325
|
+
details = (details + "\n" if details else "") + listed["text"]
|
|
326
|
+
elif item["kind"] == "session_summary": # what Keep / Don't keep really do
|
|
327
|
+
question = f"{review.KEEP_QUESTION} {review.KEEP_HOW}" # Keep all: below, with records
|
|
328
|
+
elif item["kind"] == "delete": # the rows the database says will go, not the agent's words
|
|
329
|
+
from . import deletes
|
|
330
|
+
got = deletes.preview(db_path, item)
|
|
331
|
+
question, summary, details = got["headline"], got["root_line"], got["details"]
|
|
332
|
+
elif item["kind"] == "disagreement":
|
|
333
|
+
from . import conflict_cards
|
|
334
|
+
card = conflict_cards.phone_card(db_path, item)
|
|
335
|
+
question = (card or {}).get("headline") or "Two AIs disagree."
|
|
336
|
+
summary, details = "", ""
|
|
337
|
+
elif item["kind"] == "card_offer": # FlowState's own offer (card_offer.py), not an app's ask
|
|
338
|
+
question = item["summary"] + " Approve allows it: its taps on the cards count as yours. " \
|
|
339
|
+
"Reject keeps the text, and it isn't asked again."
|
|
340
|
+
elif item["kind"] == "reopen_conflict": # FlowState's question after an undo (conflict_reopen.py)
|
|
341
|
+
question = item["summary"] + " Approve reopens them: back in the brief, and you are " \
|
|
342
|
+
"asked again whether they conflict. Reject leaves them closed."
|
|
343
|
+
else:
|
|
344
|
+
question = f"{app} wants to {ask}."
|
|
345
|
+
keep = item["kind"] == "session_summary"
|
|
346
|
+
contents = None
|
|
347
|
+
if keep: # what the session saved: titles and excerpts, not just counts
|
|
348
|
+
from . import session_contents
|
|
349
|
+
c = session_contents.for_item(db_path, {**item, "project": project})
|
|
350
|
+
contents = session_contents.public(c, send=session_contents.PHONE_MAX)
|
|
351
|
+
if contents and contents["entries"]: # Keep all: the summary and every record not removed
|
|
352
|
+
question = f"{review.KEEP_QUESTION} {review.KEEP_ALL_HOW}"
|
|
353
|
+
out = {"id": item["id"], "project": project, "kind": item["kind"], "question": question,
|
|
354
|
+
"summary": summary, "details": details,
|
|
355
|
+
"yes": (review.KEEP_ALL if contents and contents["entries"] else review.KEEP)
|
|
356
|
+
if keep else "Approve", "no": review.DONT_KEEP if keep else "Reject",
|
|
357
|
+
"queued_by": shown_row(item, on_machine=True, fallback=item.get("source") or "An app"),
|
|
358
|
+
"queued_at": item.get("created_at") or "",
|
|
359
|
+
"needs_name": item["kind"] == "delete" and p.get("record_type") == "project"}
|
|
360
|
+
if card:
|
|
361
|
+
out["card"] = card
|
|
362
|
+
if contents is not None:
|
|
363
|
+
from . import session_contents
|
|
364
|
+
out["contents"] = contents
|
|
365
|
+
out["contents_text"] = "\n".join(
|
|
366
|
+
[session_contents.when_line(c)] + session_contents.lines(c)).strip()
|
|
367
|
+
out["queued_at_utc"] = out["queued_at"]
|
|
368
|
+
return out
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _same_origin(request) -> bool:
|
|
372
|
+
"""A JSON POST from this page's own origin (no form posts, no other sites)."""
|
|
373
|
+
if (request.headers.get("content-type") or "").split(";")[0].strip() != "application/json":
|
|
374
|
+
return False
|
|
375
|
+
site = request.headers.get("sec-fetch-site")
|
|
376
|
+
if site and site not in ("same-origin", "none"):
|
|
377
|
+
return False
|
|
378
|
+
origin = request.headers.get("origin")
|
|
379
|
+
if origin:
|
|
380
|
+
from urllib.parse import urlparse
|
|
381
|
+
if (urlparse(origin).netloc or "").lower() != (request.headers.get("host") or "").lower():
|
|
382
|
+
return False
|
|
383
|
+
return True
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def routes(db_path_fn):
|
|
387
|
+
"""Starlette routes; `db_path_fn()` gives the database in use."""
|
|
388
|
+
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
389
|
+
from starlette.routing import Route
|
|
390
|
+
|
|
391
|
+
def answer(body: dict, status: int = 200, cookie: str = None, path: str = "/approve"):
|
|
392
|
+
r = JSONResponse(body, status_code=status, headers=HEADERS)
|
|
393
|
+
if cookie is not None:
|
|
394
|
+
r.set_cookie(COOKIE, cookie, max_age=approvals.GRANT_SECONDS, path=path, secure=True,
|
|
395
|
+
httponly=True, samesite="strict")
|
|
396
|
+
return r
|
|
397
|
+
|
|
398
|
+
def refuse(e: approvals.ApprovalError, status: int = 403):
|
|
399
|
+
return answer({"error": e.code, "message": str(e)}, status)
|
|
400
|
+
|
|
401
|
+
async def shell(request):
|
|
402
|
+
return HTMLResponse(SHELL, headers=HEADERS)
|
|
403
|
+
|
|
404
|
+
async def asset(request):
|
|
405
|
+
name = request.path_params["name"]
|
|
406
|
+
if name == "approve.css":
|
|
407
|
+
return Response(CSS, media_type="text/css", headers=HEADERS)
|
|
408
|
+
if name == "approve.js":
|
|
409
|
+
return Response(JS, media_type="text/javascript", headers=HEADERS)
|
|
410
|
+
return answer({"error": "not found"}, 404)
|
|
411
|
+
|
|
412
|
+
async def _body(request):
|
|
413
|
+
if not _same_origin(request):
|
|
414
|
+
raise approvals.ApprovalError("bad_request", "This request didn't come from the page.")
|
|
415
|
+
try:
|
|
416
|
+
body = json.loads(await request.body() or b"{}")
|
|
417
|
+
except ValueError:
|
|
418
|
+
body = None
|
|
419
|
+
if not isinstance(body, dict) or not isinstance(body.get("grant"), str):
|
|
420
|
+
raise approvals.ApprovalError("bad_request", "The approval link is incomplete.")
|
|
421
|
+
return body
|
|
422
|
+
|
|
423
|
+
async def open_(request):
|
|
424
|
+
item_id = request.path_params["item"]
|
|
425
|
+
db = db_path_fn()
|
|
426
|
+
try:
|
|
427
|
+
body = await _body(request)
|
|
428
|
+
claims = await asyncio.to_thread(approvals.verify_grant, db, body["grant"], item_id)
|
|
429
|
+
token = secrets.token_urlsafe(32)
|
|
430
|
+
fresh = await asyncio.to_thread(_remember, db, body["grant"], item_id, token,
|
|
431
|
+
claims["exp"])
|
|
432
|
+
if not fresh:
|
|
433
|
+
raise approvals.ApprovalError("grant_used", "This approval link was already "
|
|
434
|
+
"opened. Open it again from the chat.")
|
|
435
|
+
item = await asyncio.to_thread(view, db, item_id)
|
|
436
|
+
except approvals.ApprovalError as e:
|
|
437
|
+
return refuse(e)
|
|
438
|
+
return answer({"item": item, "token": token, "name": install_name.get()}, cookie=token,
|
|
439
|
+
path=f"/approve/{item_id}")
|
|
440
|
+
|
|
441
|
+
async def decide(request):
|
|
442
|
+
item_id = request.path_params["item"]
|
|
443
|
+
db = db_path_fn()
|
|
444
|
+
try:
|
|
445
|
+
body = await _body(request)
|
|
446
|
+
if body.get("decision") not in ("approve", "reject", "keep_a", "keep_b", "keep_both",
|
|
447
|
+
"dismiss"):
|
|
448
|
+
raise approvals.ApprovalError("bad_request", "Choose Approve or Reject.")
|
|
449
|
+
claims = await asyncio.to_thread(approvals.verify_grant, db, body["grant"], item_id)
|
|
450
|
+
token = body.get("token")
|
|
451
|
+
if not isinstance(token, str) or request.cookies.get(COOKIE) != token:
|
|
452
|
+
raise approvals.ApprovalError("bad_token", "This page's session doesn't match. "
|
|
453
|
+
"Open the link again from the chat.")
|
|
454
|
+
if not await asyncio.to_thread(_use, db, body["grant"], item_id, token):
|
|
455
|
+
raise approvals.ApprovalError("grant_used", "This approval link was already "
|
|
456
|
+
"used. Open it again from the chat.")
|
|
457
|
+
except approvals.ApprovalError as e:
|
|
458
|
+
return refuse(e)
|
|
459
|
+
from .popup import as_user
|
|
460
|
+
label = "phone" # never the relay handle: apps read decision labels (audit D19)
|
|
461
|
+
keep = {"keep_a": "a", "keep_b": "b", "keep_both": "both"}.get(body["decision"])
|
|
462
|
+
if body["decision"] == "approve" or keep:
|
|
463
|
+
args = {"item_id": item_id, "confirm_name": body.get("confirm_name"),
|
|
464
|
+
"user_request": "The user approved this on their phone (flowstatemcp.com)."}
|
|
465
|
+
if keep:
|
|
466
|
+
args["keep"] = keep
|
|
467
|
+
args["user_request"] = "The user tapped a button on their phone (flowstatemcp.com)."
|
|
468
|
+
res = await asyncio.to_thread(as_user, "review_approve", args, label)
|
|
469
|
+
else:
|
|
470
|
+
args = {"item_id": item_id, "reason": "rejected on the user's phone",
|
|
471
|
+
"user_request": "The user rejected this on their phone (flowstatemcp.com)."}
|
|
472
|
+
res = await asyncio.to_thread(as_user, "review_reject", args, label)
|
|
473
|
+
if isinstance(res, dict) and "error" in res:
|
|
474
|
+
return answer({"error": res["error"], "message": res.get("message") or res["error"]}, 409)
|
|
475
|
+
done = {"approve": "Approved", "keep_a": "Kept the older entry",
|
|
476
|
+
"keep_b": "Kept the newer entry", "keep_both": "Kept both",
|
|
477
|
+
"dismiss": "Dismissed"}.get(body["decision"], "Rejected")
|
|
478
|
+
extra = " It can be undone from the chat (manage_history undo)." \
|
|
479
|
+
if body["decision"] == "approve" and isinstance(res, dict) and res.get("undo") else ""
|
|
480
|
+
return answer({"ok": True, "message": f"{done}. You can close this page.{extra}"})
|
|
481
|
+
|
|
482
|
+
async def record(request):
|
|
483
|
+
"""Keep / Remove / Bring back on one record of the session summary this page shows:
|
|
484
|
+
the same grant and page token as the tap (the grant stays usable for Keep all)."""
|
|
485
|
+
item_id = request.path_params["item"]
|
|
486
|
+
db = db_path_fn()
|
|
487
|
+
try:
|
|
488
|
+
body = await _body(request)
|
|
489
|
+
claims = await asyncio.to_thread(approvals.verify_grant, db, body["grant"], item_id)
|
|
490
|
+
token = body.get("token")
|
|
491
|
+
await asyncio.to_thread(approvals.require_phone_on, db) # off closes open pages
|
|
492
|
+
if not isinstance(token, str) or request.cookies.get(COOKIE) != token \
|
|
493
|
+
or not await asyncio.to_thread(_open_page, db, body["grant"], item_id, token):
|
|
494
|
+
raise approvals.ApprovalError("bad_token", "This page's session doesn't match. "
|
|
495
|
+
"Open the link again from the chat.")
|
|
496
|
+
if not isinstance(body.get("record"), str) or not isinstance(body.get("action"), str):
|
|
497
|
+
raise approvals.ApprovalError("bad_request", "Choose Keep or Remove for a record.")
|
|
498
|
+
except approvals.ApprovalError as e:
|
|
499
|
+
return refuse(e)
|
|
500
|
+
res = await asyncio.to_thread(record_as_user, db, item_id, body["record"], body["action"],
|
|
501
|
+
"phone")
|
|
502
|
+
item = await asyncio.to_thread(view, db, item_id)
|
|
503
|
+
if "error" in res:
|
|
504
|
+
return answer({"error": res["error"], "message": res["message"], "item": item}, 409)
|
|
505
|
+
return answer({"ok": True, "message": res["message"], "item": item})
|
|
506
|
+
|
|
507
|
+
return [Route("/approve/assets/{name}", asset, methods=["GET"]),
|
|
508
|
+
Route("/approve/{item:int}", shell, methods=["GET"]),
|
|
509
|
+
Route("/approve/{item:int}/record", record, methods=["POST"]),
|
|
510
|
+
Route("/approve/{item:int}/open", open_, methods=["POST"]),
|
|
511
|
+
Route("/approve/{item:int}/decide", decide, methods=["POST"])]
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _remember(db_path, grant: str, item_id: int, token: str, exp: int) -> bool:
|
|
515
|
+
"""First page load for this grant: store its hash and the page token's. False if seen."""
|
|
516
|
+
conn = approvals._conn(db_path)
|
|
517
|
+
try:
|
|
518
|
+
cur = conn.execute(
|
|
519
|
+
"""INSERT OR IGNORE INTO approval_grants (grant_hash, item_id, page_token_hash, seen_at,
|
|
520
|
+
expires_at) VALUES (?, ?, ?, ?, ?)""",
|
|
521
|
+
(approvals.grant_hash(grant), item_id, _sha(token), _now(),
|
|
522
|
+
datetime.fromtimestamp(exp, timezone.utc).strftime("%Y-%m-%d %H:%M:%S")))
|
|
523
|
+
conn.commit()
|
|
524
|
+
return cur.rowcount == 1
|
|
525
|
+
finally:
|
|
526
|
+
conn.close()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def record_as_user(db_path, item_id: int, record: str, action: str, label: str) -> dict:
|
|
530
|
+
"""A record decision from a phone page (session_records.decide) as the user: no agent
|
|
531
|
+
identity, labelled `label`. Errors come back as their payload."""
|
|
532
|
+
from . import identity, session_records
|
|
533
|
+
tok = identity.set_current(None)
|
|
534
|
+
try:
|
|
535
|
+
return session_records.decide(db_path, item_id, record, action, label)
|
|
536
|
+
except session_records.RecordError as e:
|
|
537
|
+
return e.payload
|
|
538
|
+
finally:
|
|
539
|
+
identity.reset(tok)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _open_page(db_path, grant: str, item_id: int, token: str) -> bool:
|
|
543
|
+
"""This grant's page is open with this token, not yet used for the final tap, not expired."""
|
|
544
|
+
conn = approvals._conn(db_path)
|
|
545
|
+
try:
|
|
546
|
+
return conn.execute(
|
|
547
|
+
"""SELECT 1 FROM approval_grants WHERE grant_hash = ? AND item_id = ?
|
|
548
|
+
AND page_token_hash = ? AND used_at IS NULL AND expires_at > ?""",
|
|
549
|
+
(approvals.grant_hash(grant), item_id, _sha(token), _now())).fetchone() is not None
|
|
550
|
+
finally:
|
|
551
|
+
conn.close()
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _use(db_path, grant: str, item_id: int, token: str) -> bool:
|
|
555
|
+
"""The tap: this grant's page token, not used yet and not expired. Marks it used."""
|
|
556
|
+
conn = approvals._conn(db_path)
|
|
557
|
+
try:
|
|
558
|
+
cur = conn.execute(
|
|
559
|
+
"""UPDATE approval_grants SET used_at = ? WHERE grant_hash = ? AND item_id = ?
|
|
560
|
+
AND page_token_hash = ? AND used_at IS NULL AND expires_at > ?""",
|
|
561
|
+
(_now(), approvals.grant_hash(grant), item_id, _sha(token), _now()))
|
|
562
|
+
conn.commit()
|
|
563
|
+
return cur.rowcount == 1
|
|
564
|
+
finally:
|
|
565
|
+
conn.close()
|