interlatch 0.22.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.
Files changed (118) hide show
  1. chronicle/__init__.py +17 -0
  2. chronicle/__main__.py +3 -0
  3. chronicle/access.py +93 -0
  4. chronicle/agents.py +23 -0
  5. chronicle/analyze.py +498 -0
  6. chronicle/antigravity_parser.py +311 -0
  7. chronicle/artifacts.py +621 -0
  8. chronicle/bob_parser.py +259 -0
  9. chronicle/cases.py +75 -0
  10. chronicle/chat_import.py +325 -0
  11. chronicle/chatgpt_export.py +127 -0
  12. chronicle/claude_export.py +129 -0
  13. chronicle/cli.py +2980 -0
  14. chronicle/codex_cloud.py +138 -0
  15. chronicle/codex_parser.py +753 -0
  16. chronicle/config.py +627 -0
  17. chronicle/connectors.py +666 -0
  18. chronicle/container.py +202 -0
  19. chronicle/copilot_parser.py +494 -0
  20. chronicle/db.py +598 -0
  21. chronicle/desktop.py +543 -0
  22. chronicle/diagram.py +488 -0
  23. chronicle/digest.py +194 -0
  24. chronicle/export_md.py +210 -0
  25. chronicle/friction.py +841 -0
  26. chronicle/glossary.py +522 -0
  27. chronicle/groups.py +181 -0
  28. chronicle/hooks.py +169 -0
  29. chronicle/hub.py +2291 -0
  30. chronicle/i18n.py +37 -0
  31. chronicle/i18n_ja.py +421 -0
  32. chronicle/ingest.py +1190 -0
  33. chronicle/install.py +590 -0
  34. chronicle/instructions.py +699 -0
  35. chronicle/ladder.py +244 -0
  36. chronicle/learning.py +170 -0
  37. chronicle/llm.py +570 -0
  38. chronicle/mcp_server.py +403 -0
  39. chronicle/menubar.py +771 -0
  40. chronicle/migrate.py +580 -0
  41. chronicle/mirror.py +453 -0
  42. chronicle/news.py +110 -0
  43. chronicle/notify.py +92 -0
  44. chronicle/parser.py +908 -0
  45. chronicle/people.py +536 -0
  46. chronicle/pg.py +122 -0
  47. chronicle/pricing.py +168 -0
  48. chronicle/providers.py +591 -0
  49. chronicle/redact.py +50 -0
  50. chronicle/reviews.py +251 -0
  51. chronicle/screen.py +308 -0
  52. chronicle/search.py +238 -0
  53. chronicle/server.py +3009 -0
  54. chronicle/session_export.py +178 -0
  55. chronicle/statusline.py +258 -0
  56. chronicle/suggest.py +643 -0
  57. chronicle/synthesize.py +365 -0
  58. chronicle/systems.py +1265 -0
  59. chronicle/tailnet.py +97 -0
  60. chronicle/team_store.py +450 -0
  61. chronicle/update.py +404 -0
  62. chronicle/util.py +217 -0
  63. chronicle/views.py +249 -0
  64. chronicle/web/PLEX-OFL.txt +93 -0
  65. chronicle/web/app.css +2350 -0
  66. chronicle/web/app.js +7587 -0
  67. chronicle/web/art-book.webp +0 -0
  68. chronicle/web/art-camera.webp +0 -0
  69. chronicle/web/art-chip.webp +0 -0
  70. chronicle/web/art-clock.webp +0 -0
  71. chronicle/web/art-cloud.webp +0 -0
  72. chronicle/web/art-coins.webp +0 -0
  73. chronicle/web/art-container.webp +0 -0
  74. chronicle/web/art-dashboard.webp +0 -0
  75. chronicle/web/art-dex.webp +0 -0
  76. chronicle/web/art-document.webp +0 -0
  77. chronicle/web/art-flag.webp +0 -0
  78. chronicle/web/art-folder.webp +0 -0
  79. chronicle/web/art-gateway.webp +0 -0
  80. chronicle/web/art-gears.webp +0 -0
  81. chronicle/web/art-glitch.webp +0 -0
  82. chronicle/web/art-globe.webp +0 -0
  83. chronicle/web/art-laptop.webp +0 -0
  84. chronicle/web/art-log.webp +0 -0
  85. chronicle/web/art-lumi.webp +0 -0
  86. chronicle/web/art-magnifier.webp +0 -0
  87. chronicle/web/art-pip.webp +0 -0
  88. chronicle/web/art-plug.webp +0 -0
  89. chronicle/web/art-rack.webp +0 -0
  90. chronicle/web/art-tink.webp +0 -0
  91. chronicle/web/boot.js +25 -0
  92. chronicle/web/favicon.png +0 -0
  93. chronicle/web/i18n.js +50 -0
  94. chronicle/web/icon-180.png +0 -0
  95. chronicle/web/icon-192.png +0 -0
  96. chronicle/web/icon-512.png +0 -0
  97. chronicle/web/icon.png +0 -0
  98. chronicle/web/index.html +92 -0
  99. chronicle/web/ja.js +1880 -0
  100. chronicle/web/learning.js +438 -0
  101. chronicle/web/manifest.webmanifest +15 -0
  102. chronicle/web/panel.css +175 -0
  103. chronicle/web/panel.html +14 -0
  104. chronicle/web/panel.js +241 -0
  105. chronicle/web/plex-mono-400.woff2 +0 -0
  106. chronicle/web/plex-mono-500.woff2 +0 -0
  107. chronicle/web/plex-mono-600.woff2 +0 -0
  108. chronicle/web/plex-sans-400.woff2 +0 -0
  109. chronicle/web/plex-sans-500.woff2 +0 -0
  110. chronicle/web/plex-sans-600.woff2 +0 -0
  111. chronicle/web/plex-sans-700.woff2 +0 -0
  112. chronicle/web/rough.js +2 -0
  113. chronicle/worker.py +390 -0
  114. interlatch-0.22.0.dist-info/METADATA +221 -0
  115. interlatch-0.22.0.dist-info/RECORD +118 -0
  116. interlatch-0.22.0.dist-info/WHEEL +4 -0
  117. interlatch-0.22.0.dist-info/entry_points.txt +3 -0
  118. interlatch-0.22.0.dist-info/licenses/LICENSE +21 -0
chronicle/people.py ADDED
@@ -0,0 +1,536 @@
1
+ """People on a hub: who may send to it and open its dashboard, and with which role. No Tailscale needed.
2
+
3
+ Roles:
4
+ admin sees everything, invites people and changes roles, changes the hub's settings (team store, people)
5
+ member their computers send to the hub and get teammates' lessons back; sees the hub's dashboard
6
+ readonly sees the hub's dashboard; sends nothing, changes nothing
7
+
8
+ Projects: a member or read-only person sees the projects they were given (`interlatch hub invite … --project`), or
9
+ every project (`--all-projects`). Someone limited to projects sees only those projects' session summaries and project
10
+ lessons on the dashboard (access.py), and their computers share knowledge only, only for those projects (hub.py).
11
+ Admins always see everything. People added before projects existed see every project, as they did.
12
+
13
+ Everything starts with an invite. An admin adds a person with a role and gets a one-time code (expires after
14
+ INVITE_DAYS) to pass on by chat; the hub sends no email. The code is redeemed once, either by a computer
15
+ (`interlatch hub join <hub> --code …`: it becomes a push token for that person and that computer) or by a browser
16
+ (the invite link: it becomes a dashboard session). A person's own computer can later ask for a short sign-in link
17
+ (SIGNIN_MINUTES) to open the hub's dashboard without a password.
18
+
19
+ Only hashes of codes, tokens and sessions are stored; the secret itself is shown once (codes) or lives only on the
20
+ computer (tokens) or in the browser's cookie (sessions). Removing a person, or revoking a token or session, takes
21
+ effect at the next request. Whoever is at the hub computer itself is always an admin: that is how the first admin
22
+ is made, and how a hub whose admins are all gone is recovered.
23
+
24
+ While a hub has no people, it works as before: computers send with the hub's shared token. Once it has people the
25
+ shared token keeps working until `[hub] shared_token = false` (Devices › People, or the config), so computers that
26
+ joined before are not cut off.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import hmac
33
+ import json
34
+ import math
35
+ import re
36
+ import secrets
37
+ import sqlite3
38
+ import threading
39
+ import time
40
+ from datetime import datetime, timedelta, timezone
41
+
42
+ from .util import utcnow_iso
43
+
44
+ ROLES = ("admin", "member", "readonly")
45
+ ROLE_RANK = {"readonly": 0, "member": 1, "admin": 2}
46
+ INVITE_DAYS = 7
47
+ SIGNIN_MINUTES = 5
48
+ BROWSER_DAYS = 30 # a dashboard session lasts this long after it was last used
49
+ CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O, 1/I: read aloud or retyped from chat
50
+ LOCAL = "this computer" # the actor for things done at the hub itself
51
+ EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}$")
52
+
53
+
54
+ class PeopleError(Exception):
55
+ """A message template and its values, so the dashboard can show it in the viewer's language (`shown`)."""
56
+
57
+ def __init__(self, template: str, **values):
58
+ super().__init__(template.format(**values) if values else template)
59
+ self.template, self.values = template, values
60
+
61
+ def shown(self) -> str:
62
+ from .i18n import tr
63
+
64
+ return tr(self.template, **self.values)
65
+
66
+
67
+ class UnknownCode(PeopleError):
68
+ """A code this hub never issued (or not of the kind asked for): what a guess gets, so CodeAttempts counts it."""
69
+
70
+
71
+ class CodeAttempts:
72
+ """Wrong codes per address, for the two places a code is the credential (/signin?code= and /api/hub/join).
73
+
74
+ The first FREE wrong ones in WINDOW cost nothing. After them, a try from that address waits 1 s after the last wrong
75
+ one, then 2 s, 4 s and so on up to MAX_WAIT, and is answered without its code being looked at. Never a lockout: a
76
+ wait runs out, a quiet WINDOW forgets the address, and a code the hub issued (used or expired) doesn't count.
77
+ Codes are long and single-use, so this is belt and braces."""
78
+
79
+ FREE, WINDOW, MAX_WAIT, KEEP = 5, 15 * 60, 60, 10_000
80
+
81
+ def __init__(self, clock=time.monotonic):
82
+ self._clock, self._lock = clock, threading.Lock()
83
+ self._seen: dict[str, tuple[int, float]] = {} # address -> (wrong codes, when the last one came)
84
+
85
+ def _count(self, who: str, now: float) -> tuple[int, float]:
86
+ n, last = self._seen.get(who, (0, 0.0))
87
+ return (0, 0.0) if n and now - last > self.WINDOW else (n, last)
88
+
89
+ def wait(self, who: str) -> int:
90
+ """Seconds `who` still waits before a code of theirs is looked at; 0 to go ahead."""
91
+ with self._lock:
92
+ now = self._clock()
93
+ n, last = self._count(who, now)
94
+ if n < self.FREE:
95
+ return 0
96
+ return max(0, math.ceil(min(self.MAX_WAIT, 2 ** (n - self.FREE)) - (now - last)))
97
+
98
+ def wrong(self, who: str) -> None:
99
+ with self._lock:
100
+ now = self._clock()
101
+ self._seen[who] = (self._count(who, now)[0] + 1, now)
102
+ if len(self._seen) > self.KEEP: # many addresses at once: forget the half that went quiet first
103
+ cut = sorted(last for _, last in self._seen.values())[len(self._seen) // 2]
104
+ self._seen = {k: v for k, v in self._seen.items() if v[1] > cut}
105
+
106
+
107
+ def _hash(secret: str) -> str:
108
+ return hashlib.sha256(secret.strip().encode()).hexdigest()
109
+
110
+
111
+ def _now() -> datetime:
112
+ return datetime.now(timezone.utc)
113
+
114
+
115
+ def _iso(dt: datetime) -> str:
116
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
117
+
118
+
119
+ def _expired(at: str | None) -> bool:
120
+ return bool(at) and at < _iso(_now())
121
+
122
+
123
+ def new_code() -> str:
124
+ """XXXX-XXXX-XXXX: 60 bits, easy to read out and retype."""
125
+ raw = "".join(secrets.choice(CODE_ALPHABET) for _ in range(12))
126
+ return f"{raw[:4]}-{raw[4:8]}-{raw[8:]}"
127
+
128
+
129
+ def normalize_code(code: str) -> str:
130
+ raw = re.sub(r"[^A-Za-z0-9]", "", code or "").upper()
131
+ return f"{raw[:4]}-{raw[4:8]}-{raw[8:]}" if len(raw) == 12 else raw
132
+
133
+
134
+ def audit(conn: sqlite3.Connection, actor: str | None, action: str, person_id: int | None = None, **detail) -> None:
135
+ conn.execute("INSERT INTO people_audit(at, actor, action, person_id, detail) VALUES (?, ?, ?, ?, ?)",
136
+ (utcnow_iso(), actor, action, person_id, json.dumps(detail, ensure_ascii=False) if detail else None))
137
+
138
+
139
+ def actor_of(person: dict | None) -> str:
140
+ return f"person:{person['id']}" if person else LOCAL
141
+
142
+
143
+ def clean_projects(projects) -> list[str] | None:
144
+ """None (every project) or a sorted list of distinct project paths; anything else is refused."""
145
+ if projects is None:
146
+ return None
147
+ if isinstance(projects, str) or not isinstance(projects, (list, tuple, set)):
148
+ raise PeopleError("projects must be a list of project paths")
149
+ out = set()
150
+ for x in projects:
151
+ x = str(x or "").strip()
152
+ x = x.rstrip("/") or x
153
+ if not x.startswith("/") or len(x) > 1024 or "\0" in x:
154
+ raise PeopleError("{project} is not a project on this hub", project=x or "''")
155
+ out.add(x)
156
+ return sorted(out)
157
+
158
+
159
+ def projects_of(person: dict | None) -> list[str] | None:
160
+ """The projects `person` may see: None for every project (an admin, someone at the hub itself, or someone given
161
+ every project), else their list, possibly empty."""
162
+ if person is None or person.get("role") == "admin":
163
+ return None
164
+ raw = person.get("projects_json")
165
+ if raw is None:
166
+ return None
167
+ try:
168
+ got = json.loads(raw)
169
+ except ValueError:
170
+ return [] # unreadable: nothing, never everything
171
+ return [x for x in got if isinstance(x, str)] if isinstance(got, list) else []
172
+
173
+
174
+ def sees(person: dict | None, project_path: str | None) -> bool:
175
+ scope = projects_of(person)
176
+ return scope is None or (project_path is not None and project_path in scope)
177
+
178
+
179
+ # ------------------------------------------------------------------ people
180
+ def has_people(conn: sqlite3.Connection) -> bool:
181
+ return conn.execute("SELECT 1 FROM people WHERE removed_at IS NULL LIMIT 1").fetchone() is not None
182
+
183
+
184
+ def get(conn: sqlite3.Connection, person_id: int) -> dict | None:
185
+ r = conn.execute("SELECT * FROM people WHERE id = ?", (person_id,)).fetchone()
186
+ return dict(r) if r else None
187
+
188
+
189
+ def by_email(conn: sqlite3.Connection, email: str) -> dict | None:
190
+ r = conn.execute("SELECT * FROM people WHERE email = ? AND removed_at IS NULL", ((email or "").strip().lower(),)).fetchone()
191
+ return dict(r) if r else None
192
+
193
+
194
+ def add(conn: sqlite3.Connection, name: str, email: str | None, role: str, *, by: dict | None = None,
195
+ projects: list[str] | None = None) -> dict:
196
+ """Add a person. Email is optional but needed for a company sign-in (auth header) to find them. `projects`: the
197
+ project paths they see, None for every project (the command line and the dashboard make the inviter choose)."""
198
+ name, email = (name or "").strip(), (email or "").strip().lower() or None
199
+ projects = clean_projects(projects)
200
+ if not name:
201
+ raise PeopleError("a name is needed")
202
+ if role not in ROLES:
203
+ raise PeopleError("role must be one of: {roles}", roles=", ".join(ROLES))
204
+ if email and not EMAIL_RE.match(email):
205
+ raise PeopleError("{email} is not an email address", email=email)
206
+ if email and by_email(conn, email):
207
+ raise PeopleError("{email} is already on this hub", email=email)
208
+ if email: # someone removed earlier comes back as a new person: the old row keeps its history
209
+ conn.execute("UPDATE people SET email = NULL WHERE email = ? AND removed_at IS NOT NULL", (email,))
210
+ cur = conn.execute("INSERT INTO people(name, email, role, created_at, created_by, projects_json) VALUES (?, ?, ?, ?, ?, ?)",
211
+ (name[:120], email, role, utcnow_iso(), by["id"] if by else None,
212
+ None if projects is None else json.dumps(projects, ensure_ascii=False)))
213
+ audit(conn, actor_of(by), "add", cur.lastrowid, role=role, email=email, projects=projects)
214
+ conn.commit()
215
+ return get(conn, cur.lastrowid)
216
+
217
+
218
+ def set_role(conn: sqlite3.Connection, person_id: int, role: str, *, by: dict | None = None) -> dict:
219
+ p = get(conn, person_id)
220
+ if not p or p["removed_at"]:
221
+ raise PeopleError("no such person")
222
+ if role not in ROLES:
223
+ raise PeopleError("role must be one of: {roles}", roles=", ".join(ROLES))
224
+ if p["role"] == "admin" and role != "admin" and by is not None and _admins(conn) == [person_id]:
225
+ raise PeopleError("this is the hub's last admin: make someone else an admin first")
226
+ conn.execute("UPDATE people SET role = ? WHERE id = ?", (role, person_id))
227
+ audit(conn, actor_of(by), "role", person_id, before=p["role"], after=role)
228
+ conn.commit()
229
+ return get(conn, person_id)
230
+
231
+
232
+ def set_projects(conn: sqlite3.Connection, person_id: int, projects: list[str] | None, *, by: dict | None = None) -> dict:
233
+ """Which projects a person sees: a list of project paths, or None for every project. Takes effect at their
234
+ next request; what their computers already sent stays."""
235
+ p = get(conn, person_id)
236
+ if not p or p["removed_at"]:
237
+ raise PeopleError("no such person")
238
+ projects = clean_projects(projects)
239
+ before = None if p.get("projects_json") is None else projects_of({**p, "role": "member"})
240
+ conn.execute("UPDATE people SET projects_json = ? WHERE id = ?",
241
+ (None if projects is None else json.dumps(projects, ensure_ascii=False), person_id))
242
+ audit(conn, actor_of(by), "projects", person_id, before=before, after=projects)
243
+ conn.commit()
244
+ return get(conn, person_id)
245
+
246
+
247
+ def remove(conn: sqlite3.Connection, person_id: int, *, by: dict | None = None) -> None:
248
+ """Remove a person: their codes, computer tokens and browser sessions stop working at once. What their computers
249
+ sent stays on the hub."""
250
+ p = get(conn, person_id)
251
+ if not p or p["removed_at"]:
252
+ raise PeopleError("no such person")
253
+ if p["role"] == "admin" and by is not None and _admins(conn) == [person_id]:
254
+ raise PeopleError("this is the hub's last admin: make someone else an admin first")
255
+ now = utcnow_iso()
256
+ conn.execute("UPDATE people SET removed_at = ? WHERE id = ?", (now, person_id))
257
+ conn.execute("UPDATE people_tokens SET revoked_at = ? WHERE person_id = ? AND revoked_at IS NULL", (now, person_id))
258
+ conn.execute("UPDATE people_codes SET used_at = ? WHERE person_id = ? AND used_at IS NULL", (now, person_id))
259
+ audit(conn, actor_of(by), "remove", person_id)
260
+ conn.commit()
261
+
262
+
263
+ def _admins(conn: sqlite3.Connection) -> list[int]:
264
+ return [r[0] for r in conn.execute("SELECT id FROM people WHERE role = 'admin' AND removed_at IS NULL")]
265
+
266
+
267
+ def listing(conn: sqlite3.Connection) -> list[dict]:
268
+ """Everyone on the hub, with their computers, open browser sessions and unused invites (no secrets)."""
269
+ now = _iso(_now())
270
+ out = []
271
+ for p in conn.execute("SELECT * FROM people WHERE removed_at IS NULL ORDER BY role = 'admin' DESC, name"):
272
+ p = dict(p)
273
+ p["projects"] = projects_of(p)
274
+ toks = [dict(t) for t in conn.execute(
275
+ "SELECT t.token_hash, t.kind, t.machine_id, t.label, t.created_at, t.last_used, t.expires_at, m.name AS machine_name "
276
+ "FROM people_tokens t LEFT JOIN machines m ON m.id = t.machine_id "
277
+ "WHERE t.person_id = ? AND t.revoked_at IS NULL ORDER BY t.created_at", (p["id"],))]
278
+ p["computers"] = [{"id": t["token_hash"][:16], "machine_id": t["machine_id"], "name": t["machine_name"] or t["label"],
279
+ "since": t["created_at"], "last_used": t["last_used"]} for t in toks if t["kind"] == "computer"]
280
+ p["browsers"] = [{"id": t["token_hash"][:16], "label": t["label"], "since": t["created_at"], "last_used": t["last_used"]}
281
+ for t in toks if t["kind"] == "browser" and not (t["expires_at"] and t["expires_at"] < now)]
282
+ p["invites"] = [{"expires_at": r[0], "created_at": r[1]} for r in conn.execute(
283
+ "SELECT expires_at, created_at FROM people_codes WHERE person_id = ? AND kind = 'invite' AND used_at IS NULL "
284
+ "AND expires_at > ? ORDER BY created_at", (p["id"], now))]
285
+ out.append(p)
286
+ return out
287
+
288
+
289
+ # ------------------------------------------------------------------ one-time codes
290
+ def invite(conn: sqlite3.Connection, person_id: int, *, by: dict | None = None, days: int = INVITE_DAYS,
291
+ machine: str | None = None) -> str:
292
+ """A one-time code for this person: redeemed by `interlatch hub join … --code` (a computer) or the invite link
293
+ (a browser). Returns the code; only its hash is kept. With `machine`, the invite is for that one computer the hub
294
+ already knows: an admin's word that it is this person's, for a computer that can't show its key (may_claim)."""
295
+ p = get(conn, person_id)
296
+ if not p or p["removed_at"]:
297
+ raise PeopleError("no such person")
298
+ if machine and not conn.execute("SELECT 1 FROM machines WHERE id = ?", (machine,)).fetchone():
299
+ raise PeopleError("this hub doesn't know a computer {machine}", machine=machine)
300
+ code = new_code()
301
+ now = _now()
302
+ conn.execute("INSERT INTO people_codes(code_hash, person_id, kind, created_at, expires_at, machine_id) "
303
+ "VALUES (?, ?, 'invite', ?, ?, ?)", (_hash(code), person_id, _iso(now), _iso(now + timedelta(days=days)), machine))
304
+ audit(conn, actor_of(by), "invite", person_id, days=days, **({"machine": machine} if machine else {}))
305
+ conn.commit()
306
+ return code
307
+
308
+
309
+ def signin_code(conn: sqlite3.Connection, person: dict) -> str:
310
+ """A short-lived code that opens the dashboard as this person, asked for by one of their computers."""
311
+ code = secrets.token_urlsafe(24)
312
+ now = _now()
313
+ conn.execute("INSERT INTO people_codes(code_hash, person_id, kind, created_at, expires_at) VALUES (?, ?, 'signin', ?, ?)",
314
+ (_hash(code), person["id"], _iso(now), _iso(now + timedelta(minutes=SIGNIN_MINUTES))))
315
+ conn.commit()
316
+ return code
317
+
318
+
319
+ def peek_invite(conn: sqlite3.Connection, code: str) -> dict | None:
320
+ """The person an unused invite code is for, without using it up (e.g. to refuse a read-only person's computer
321
+ while the code still opens a browser); None when the code isn't a live invite."""
322
+ r = conn.execute("SELECT person_id, expires_at FROM people_codes WHERE kind = 'invite' AND used_at IS NULL "
323
+ "AND code_hash IN (?, ?)", (_hash(normalize_code(code)), _hash(code or ""))).fetchone()
324
+ if not r or _expired(r["expires_at"]):
325
+ return None
326
+ p = get(conn, r["person_id"])
327
+ return p if p and not p["removed_at"] else None
328
+
329
+
330
+ def _take_code(conn: sqlite3.Connection, code: str, kinds: tuple[str, ...]) -> dict:
331
+ # an invite as typed (any case, with or without dashes), or a sign-in code exactly as issued
332
+ r = conn.execute("SELECT * FROM people_codes WHERE code_hash IN (?, ?)",
333
+ (_hash(normalize_code(code)), _hash(code or ""))).fetchone()
334
+ h = r["code_hash"] if r else None
335
+ if not r or r["kind"] not in kinds:
336
+ raise UnknownCode("that code isn't known on this hub")
337
+ if r["used_at"]:
338
+ raise PeopleError("that code was already used; ask an admin for a new one")
339
+ if _expired(r["expires_at"]):
340
+ raise PeopleError("that code has expired; ask an admin for a new one")
341
+ p = get(conn, r["person_id"])
342
+ if not p or p["removed_at"]:
343
+ raise PeopleError("that person is no longer on this hub")
344
+ cur = conn.execute("UPDATE people_codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL", (utcnow_iso(), h))
345
+ if cur.rowcount != 1: # two redemptions at once: only one wins
346
+ raise PeopleError("that code was already used; ask an admin for a new one")
347
+ return p
348
+
349
+
350
+ def key_hash(key) -> str | None:
351
+ """What a computer's key (its machine-key file) is kept as; None for anything that isn't one."""
352
+ return _hash(key) if isinstance(key, str) and 32 <= len(key) <= 200 else None
353
+
354
+
355
+ def record_key(conn: sqlite3.Connection, machine_id: str, key) -> None:
356
+ """Keep a computer's key, as its hash, the first time it shows one (it says hello, or joins). One on record never
357
+ changes: a computer that later shows another isn't that computer."""
358
+ if h := key_hash(key):
359
+ conn.execute("UPDATE machines SET key_hash = ? WHERE id = ? AND key_hash IS NULL", (h, machine_id))
360
+
361
+
362
+ def invite_machine(conn: sqlite3.Connection, code: str) -> str | None:
363
+ """The one computer an invite was made for (`interlatch hub invite --computer`), if it was."""
364
+ r = conn.execute("SELECT machine_id FROM people_codes WHERE kind = 'invite' AND code_hash IN (?, ?)",
365
+ (_hash(normalize_code(code)), _hash(code or ""))).fetchone()
366
+ return r["machine_id"] if r else None
367
+
368
+
369
+ def may_claim(conn: sqlite3.Connection, code: str, machine_id: str, key) -> bool:
370
+ """Whether a (live) invite may make `machine_id` its person's computer. One the hub has never heard of may join. One
371
+ it knows (it said hello or sent sessions) only by showing the key the hub recorded from it, or with an invite an
372
+ admin made for that computer: anyone with an invite could otherwise claim another computer's id, and its sessions
373
+ with it, and lock it out (a computer that joined as a person sends with its own token only)."""
374
+ known = conn.execute("SELECT key_hash FROM machines WHERE id = ?", (machine_id,)).fetchone()
375
+ if known is None and not conn.execute("SELECT 1 FROM sessions WHERE machine_id = ? LIMIT 1", (machine_id,)).fetchone():
376
+ return True
377
+ if invite_machine(conn, code) == machine_id:
378
+ return True
379
+ stored, shown = known["key_hash"] if known else None, key_hash(key)
380
+ return bool(stored and shown and hmac.compare_digest(stored, shown))
381
+
382
+
383
+ NOT_THAT_COMPUTER = ("this hub already knows a computer with this id ({machine}) and can't tell this is it: an admin "
384
+ "makes an invite for that computer (`interlatch hub invite <name> --computer {machine}`), and you "
385
+ "join with that code")
386
+
387
+
388
+ def join_computer(conn: sqlite3.Connection, code: str, machine_id: str, machine_name: str | None = None,
389
+ key: str | None = None) -> tuple[dict, str]:
390
+ """Redeem an invite for a computer: (person, push token). The token replaces any earlier one of that computer of
391
+ the same person. A computer someone else's live token is bound to is refused: the invite would take it over, and
392
+ send (or take back) sessions as that computer. An admin removes that computer first. A computer the hub already
393
+ knows must show its key (may_claim). Both are checked only for a live invite, before it is used up: a wrong code
394
+ learns nothing about the computer."""
395
+ owner = peek_invite(conn, code)
396
+ if owner and conn.execute(
397
+ "SELECT 1 FROM people_tokens t JOIN people x ON x.id = t.person_id WHERE t.machine_id = ? AND t.kind = 'computer' "
398
+ "AND t.revoked_at IS NULL AND x.removed_at IS NULL AND t.person_id != ?", (machine_id, owner["id"])).fetchone():
399
+ raise PeopleError("this computer already joined this hub as someone else; an admin removes it from that "
400
+ "person first (Team › People)")
401
+ if owner and (meant := invite_machine(conn, code)) and meant != machine_id:
402
+ raise PeopleError("this invite is for another computer ({machine}); join from that one", machine=meant)
403
+ if owner and not may_claim(conn, code, machine_id, key):
404
+ raise PeopleError(NOT_THAT_COMPUTER, machine=machine_id)
405
+ p = _take_code(conn, code, ("invite",))
406
+ token = secrets.token_urlsafe(32)
407
+ now = utcnow_iso()
408
+ conn.execute("UPDATE people_tokens SET revoked_at = ? WHERE machine_id = ? AND kind = 'computer' AND revoked_at IS NULL",
409
+ (now, machine_id))
410
+ conn.execute("INSERT INTO people_tokens(token_hash, person_id, kind, machine_id, label, created_at) "
411
+ "VALUES (?, ?, 'computer', ?, ?, ?)", (_hash(token), p["id"], machine_id, (machine_name or "")[:120] or None, now))
412
+ conn.execute("UPDATE machines SET person_id = ? WHERE id = ?", (p["id"], machine_id))
413
+ audit(conn, actor_of(p), "join", p["id"], machine=machine_id, name=machine_name)
414
+ conn.commit()
415
+ return p, token
416
+
417
+
418
+ def open_browser(conn: sqlite3.Connection, code: str, label: str | None = None) -> tuple[dict, str]:
419
+ """Redeem an invite or a sign-in code in a browser: (person, session secret for the cookie)."""
420
+ p = _take_code(conn, code, ("invite", "signin"))
421
+ session = secrets.token_urlsafe(32)
422
+ now = _now()
423
+ conn.execute("INSERT INTO people_tokens(token_hash, person_id, kind, label, created_at, expires_at, last_used) "
424
+ "VALUES (?, ?, 'browser', ?, ?, ?, ?)",
425
+ (_hash(session), p["id"], (label or "")[:200] or None, _iso(now), _iso(now + timedelta(days=BROWSER_DAYS)), _iso(now)))
426
+ audit(conn, actor_of(p), "signin", p["id"])
427
+ conn.commit()
428
+ return p, session
429
+
430
+
431
+ LANDING_RE = re.compile(r"/[A-Za-z0-9/_.~%=&?+-]{0,500}") # a dashboard page: a hash route, such as /project?path=%2F…
432
+
433
+
434
+ def landing(page) -> str:
435
+ """Where a sign-in link opens the dashboard: the page its next asks for (a hash route), or Home. Only a route's own
436
+ characters pass, so the link can't lead off the dashboard or into the response's headers."""
437
+ return f"/#{page}" if isinstance(page, str) and LANDING_RE.fullmatch(page) else "/#/"
438
+
439
+
440
+ # ------------------------------------------------------------------ who is asking
441
+ def _by_token(conn: sqlite3.Connection, secret: str, kind: str) -> tuple[dict, dict] | None:
442
+ if not secret:
443
+ return None
444
+ t = conn.execute("SELECT * FROM people_tokens WHERE token_hash = ? AND kind = ?", (_hash(secret), kind)).fetchone()
445
+ if not t or t["revoked_at"] or _expired(t["expires_at"]):
446
+ return None
447
+ p = get(conn, t["person_id"])
448
+ if not p or p["removed_at"]:
449
+ return None
450
+ return p, dict(t)
451
+
452
+
453
+ def computer_bound(conn: sqlite3.Connection, machine_id: str) -> bool:
454
+ """Whether a computer joined this hub as a person who is still on it (a live computer token is bound to it).
455
+ Such a computer sends with its own token: the hub's shared token may not act as it (hub.authorize)."""
456
+ return conn.execute(
457
+ "SELECT 1 FROM people_tokens t JOIN people x ON x.id = t.person_id WHERE t.machine_id = ? AND t.kind = 'computer' "
458
+ "AND t.revoked_at IS NULL AND x.removed_at IS NULL", (machine_id,)).fetchone() is not None
459
+
460
+
461
+ def computer_person(conn: sqlite3.Connection, token: str, machine_id: str | None = None) -> dict | None:
462
+ """The person a computer's push token belongs to; None when it is no (longer a) valid token. A token presented by
463
+ another computer than the one it was issued to is refused."""
464
+ hit = _by_token(conn, token, "computer")
465
+ if not hit:
466
+ return None
467
+ p, t = hit
468
+ if machine_id and t["machine_id"] and t["machine_id"] != machine_id:
469
+ return None
470
+ if not t["last_used"] or t["last_used"][:16] != utcnow_iso()[:16]: # at most one write a minute
471
+ conn.execute("UPDATE people_tokens SET last_used = ? WHERE token_hash = ?", (utcnow_iso(), t["token_hash"]))
472
+ conn.commit()
473
+ return p
474
+
475
+
476
+ def browser_person(conn: sqlite3.Connection, session: str) -> dict | None:
477
+ """The person a dashboard session cookie belongs to, sliding its expiry forward; None when it isn't valid."""
478
+ hit = _by_token(conn, session, "browser")
479
+ if not hit:
480
+ return None
481
+ p, t = hit
482
+ now = _now()
483
+ if not t["last_used"] or t["last_used"][:13] != _iso(now)[:13]: # at most one write an hour
484
+ conn.execute("UPDATE people_tokens SET last_used = ?, expires_at = ? WHERE token_hash = ?",
485
+ (_iso(now), _iso(now + timedelta(days=BROWSER_DAYS)), t["token_hash"]))
486
+ conn.commit()
487
+ return p
488
+
489
+
490
+ def sign_out(conn: sqlite3.Connection, session: str) -> None:
491
+ conn.execute("UPDATE people_tokens SET revoked_at = ? WHERE token_hash = ? AND kind = 'browser' AND revoked_at IS NULL",
492
+ (utcnow_iso(), _hash(session)))
493
+ conn.commit()
494
+
495
+
496
+ def revoke(conn: sqlite3.Connection, person_id: int, token_id: str, *, by: dict | None = None) -> bool:
497
+ """Revoke one of a person's computer tokens or browser sessions by the short id listing() shows."""
498
+ if not re.fullmatch(r"[0-9a-f]{16}", token_id or ""):
499
+ return False
500
+ cur = conn.execute("UPDATE people_tokens SET revoked_at = ? WHERE person_id = ? AND substr(token_hash, 1, 16) = ? "
501
+ "AND revoked_at IS NULL", (utcnow_iso(), person_id, token_id))
502
+ if cur.rowcount:
503
+ audit(conn, actor_of(by), "revoke", person_id, token=token_id)
504
+ conn.commit()
505
+ return bool(cur.rowcount)
506
+
507
+
508
+ def allows(person: dict | None, need: str) -> bool:
509
+ """`person` (None: someone at the hub computer itself, always an admin) has at least role `need`."""
510
+ if person is None:
511
+ return True
512
+ return ROLE_RANK.get(person.get("role"), -1) >= ROLE_RANK[need]
513
+
514
+
515
+ def public(person: dict | None) -> dict:
516
+ """What the dashboard may know about who is viewing it."""
517
+ if person is None:
518
+ return {"id": None, "name": LOCAL, "email": None, "role": "admin", "here": True, "projects": None}
519
+ return {"id": person["id"], "name": person["name"], "email": person.get("email"), "role": person["role"], "here": False,
520
+ "projects": projects_of(person)}
521
+
522
+
523
+ def audit_log(conn: sqlite3.Connection, limit: int = 200) -> list[dict]:
524
+ rows = conn.execute("SELECT a.*, p.name AS person_name FROM people_audit a LEFT JOIN people p ON p.id = a.person_id "
525
+ "ORDER BY a.id DESC LIMIT ?", (limit,)).fetchall()
526
+ out = []
527
+ for r in rows:
528
+ d = dict(r)
529
+ d["detail"] = json.loads(d["detail"]) if d["detail"] else {}
530
+ if d["actor"] and d["actor"].startswith("person:"):
531
+ who = get(conn, int(d["actor"][7:]))
532
+ d["actor_name"] = who["name"] if who else d["actor"]
533
+ else:
534
+ d["actor_name"] = d["actor"]
535
+ out.append(d)
536
+ return out
chronicle/pg.py ADDED
@@ -0,0 +1,122 @@
1
+ """Connecting to a Postgres database: the settings file both the hub's team store (team_store.py) and the personal
2
+ mirror (mirror.py) keep their connection in, and checking what the dashboard sends for it.
3
+
4
+ A settings file holds PG* lines (PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE), readable by this user
5
+ only. The password goes in from the dashboard and never comes back out. The driver (psycopg) is an optional extra.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from pathlib import Path
13
+
14
+ log = logging.getLogger("chronicle.pg")
15
+
16
+ PG_KEYS = {"PGHOST": "host", "PGPORT": "port", "PGDATABASE": "dbname", "PGUSER": "user", "PGPASSWORD": "password",
17
+ "PGSSLMODE": "sslmode"}
18
+ SSLMODES = ("require", "verify-ca", "verify-full", "prefer", "disable")
19
+ SETTINGS = ("host", "port", "dbname", "user", "sslmode") # what the dashboard shows and edits; the password only goes in
20
+
21
+
22
+ class PgError(Exception):
23
+ pass
24
+
25
+
26
+ def read_env(path: Path) -> dict[str, str]:
27
+ """PG* lines of an env file: KEY=value, a value optionally in one pair of quotes; # starts a comment line."""
28
+ raw: dict[str, str] = {}
29
+ if path.stat().st_mode & 0o077:
30
+ log.warning("%s can be read by other users of this computer; chmod 600 it", path)
31
+ for line in path.read_text().splitlines():
32
+ if line.strip() and not line.lstrip().startswith("#") and "=" in line:
33
+ k, v = line.split("=", 1)
34
+ v = v.strip()
35
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
36
+ v = v[1:-1]
37
+ raw[k.strip()] = v
38
+ return raw
39
+
40
+
41
+ def params_from(path: Path, *, environ: bool = True) -> dict:
42
+ """psycopg connect() arguments from the env file at `path`, else (`environ`) the PG* environment; {} when neither
43
+ names a server and a database. Never logged: it holds a password."""
44
+ raw = read_env(path) if path.exists() else {k: v for k, v in os.environ.items() if k in PG_KEYS} if environ else {}
45
+ params = {PG_KEYS[k]: v for k, v in raw.items() if k in PG_KEYS and v}
46
+ if not params.get("host") or not params.get("dbname"):
47
+ return {}
48
+ params.setdefault("sslmode", "require")
49
+ return params
50
+
51
+
52
+ def shown(params: dict | None) -> dict | None:
53
+ """The connection as the dashboard shows it: everything but the password, which is only said to be there."""
54
+ if not params:
55
+ return None
56
+ return {**{k: params.get(k) or "" for k in SETTINGS}, "password_set": bool(params.get("password"))}
57
+
58
+
59
+ def check_settings(values: dict, saved: dict | None, error: type[Exception] = PgError) -> dict:
60
+ """psycopg connect() arguments from what the dashboard sent: a password left empty keeps the saved one."""
61
+ params = {}
62
+ for k in (*SETTINGS, "password"):
63
+ v = values.get(k)
64
+ v = "" if v is None else str(v)
65
+ if "\n" in v or "\r" in v or "\0" in v:
66
+ raise error(f"{k} can't contain a line break")
67
+ if k != "password":
68
+ v = v.strip()
69
+ if v:
70
+ params[k] = v
71
+ for k in ("host", "dbname", "user"):
72
+ if not params.get(k):
73
+ raise error({"host": "the database server's address is missing", "dbname": "the database name is missing",
74
+ "user": "the user name is missing"}[k])
75
+ port = params.get("port", "5432")
76
+ if not port.isdigit() or not 0 < int(port) < 65536:
77
+ raise error("the port must be a number from 1 to 65535")
78
+ params["port"] = port
79
+ params.setdefault("sslmode", "require")
80
+ if params["sslmode"] not in SSLMODES:
81
+ raise error(f"SSL mode must be one of: {', '.join(SSLMODES)}")
82
+ if "password" not in params:
83
+ if not saved or not saved.get("password"):
84
+ raise error("the password is missing")
85
+ params["password"] = saved["password"]
86
+ return params
87
+
88
+
89
+ def write_settings(path: Path, params: dict, header: str) -> None:
90
+ """Write an env file (readable by this user only), replacing it in one step."""
91
+ def quoted(v: str) -> str: # a value that reading would change keeps its exact text inside one pair of quotes
92
+ return f"'{v}'" if v != v.strip() or (len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'") else v
93
+
94
+ keys = {v: k for k, v in PG_KEYS.items()}
95
+ lines = [f"# {header}"]
96
+ lines += [f"{keys[k]}={quoted(str(params[k]))}" for k in (*SETTINGS[:4], "password", "sslmode") if params.get(k)]
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ tmp = path.with_name(path.name + f".tmp{os.getpid()}")
99
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
100
+ with os.fdopen(fd, "w") as fh:
101
+ fh.write("\n".join(lines) + "\n")
102
+ os.replace(tmp, path)
103
+
104
+
105
+ def driver_available() -> bool:
106
+ import importlib.util
107
+
108
+ return importlib.util.find_spec("psycopg") is not None
109
+
110
+
111
+ def where(params: dict) -> str:
112
+ return f"{params.get('dbname')} on {params.get('host')}"
113
+
114
+
115
+ def first_line(exc: BaseException) -> str:
116
+ return (str(exc).strip().splitlines() or [type(exc).__name__])[0]
117
+
118
+
119
+ def iso(value) -> str | None:
120
+ if value is None:
121
+ return None
122
+ return value.isoformat().replace("+00:00", "Z") if hasattr(value, "isoformat") else str(value)