File size: 54,566 Bytes
e5f0c48
08aea0e
 
 
 
 
 
 
a9f22dc
 
 
 
c8045f2
0b390f8
 
e5f0c48
a5c9173
94af308
1a26e87
 
 
3b6eb00
 
 
 
66d03bb
 
 
 
 
 
 
0242444
 
 
 
 
 
 
0cfc3a7
 
 
 
d8b47d7
 
 
 
4233380
ac702e3
 
 
08aea0e
a5c9173
0242444
3cb8d24
0242444
08aea0e
 
 
 
 
 
3ed1d24
08aea0e
 
 
 
 
b9cb1c2
08aea0e
269a3c8
08aea0e
 
 
 
 
b9cb1c2
 
a9f22dc
a5c9173
08aea0e
69eaa4c
 
 
 
 
 
 
 
 
08aea0e
 
 
 
 
a5c9173
 
 
 
08aea0e
0242444
3cb8d24
08aea0e
a9f22dc
0242444
3cb8d24
a5c9173
08aea0e
a5c9173
1a26e87
3b6eb00
0242444
3cb8d24
66d03bb
08aea0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ed1d24
 
 
 
 
08aea0e
3ed1d24
 
 
 
 
 
 
 
 
 
 
08aea0e
 
 
b9cb1c2
6953a11
 
b9cb1c2
 
6953a11
b9cb1c2
 
a9f22dc
b9cb1c2
 
 
 
 
 
 
 
d8b47d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
071b4ec
 
 
d8b47d7
 
 
 
 
 
071b4ec
d8b47d7
a9f22dc
 
 
 
 
 
69eaa4c
 
a5c9173
a9f22dc
 
a5c9173
 
 
269a3c8
a9f22dc
69eaa4c
a5c9173
 
 
 
 
 
a9f22dc
 
 
69eaa4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5c9173
c8045f2
a6c789c
 
66d03bb
 
a5c9173
 
 
 
 
 
 
 
 
 
 
 
 
c8045f2
0b390f8
66d03bb
f90708b
a5c9173
 
 
 
 
 
 
 
c8045f2
f98e127
f90708b
66d03bb
 
 
a6c789c
0b390f8
a6c789c
66d03bb
 
f90708b
 
a5c9173
f98e127
a5c9173
 
 
 
 
 
 
 
 
1a26e87
 
94af308
1a26e87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3b6eb00
 
1a26e87
3b6eb00
 
 
1a26e87
3b6eb00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a26e87
 
 
 
 
66d03bb
5d06ea2
 
66d03bb
 
 
 
 
 
 
 
 
 
 
 
5d06ea2
66d03bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d06ea2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0242444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d3d08e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69eaa4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c15166b
 
 
 
 
0242444
3ed1d24
 
 
 
 
 
 
 
 
0cfc3a7
 
 
 
 
3ed1d24
 
 
 
 
0cfc3a7
 
24a3395
0cfc3a7
24a3395
 
 
 
 
 
 
 
 
0cfc3a7
 
 
 
 
 
af78672
 
 
 
 
 
 
 
 
0cfc3a7
 
 
ac702e3
0cfc3a7
 
 
 
 
 
 
 
3ed1d24
ac702e3
 
 
 
8d3d08e
 
 
 
 
 
 
 
 
 
 
3ed1d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269a3c8
 
 
 
 
 
 
 
 
 
 
a9f22dc
e5f0c48
a9f22dc
 
 
 
 
 
 
 
 
3cb8d24
 
 
 
 
 
 
 
 
f98e127
3cb8d24
 
f98e127
 
 
 
 
3cb8d24
f98e127
 
3cb8d24
 
 
 
 
 
 
f98e127
3cb8d24
f98e127
 
 
3cb8d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a243a76
3cb8d24
f98e127
 
 
 
 
 
 
 
 
 
 
 
 
35394c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9f22dc
6953a11
 
a9f22dc
 
01f0f9e
a9f22dc
 
 
 
 
 
 
a243a76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ed1d24
ac702e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ed1d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08aea0e
 
 
 
 
19ed294
 
 
 
08aea0e
a9f22dc
3cb8d24
a9f22dc
e5f0c48
a5c9173
 
a9f22dc
 
0242444
 
3cb8d24
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
"""Sign-in: the page logs in through Supabase, with Google or with a link sent to the email, and sends the session token with each call.

The token is checked by asking Supabase who it belongs to (GET /auth/v1/user), so no signing key is
kept here and a session revoked in Supabase stops working within CACHE_TTL.

Progress: each scored recording is added to the Supabase table `attempts` with the student's own
token, so row-level security ties the row to them (the table's SQL is in the README).

Teachers: the accounts in TEACHER_EMAILS also get the management panel, which shows every student's
progress. Those rows are read with the project's secret key, which passes row-level security and
never leaves the server.

Classes: the teacher opens classes in the panel (a name, e.g. "October 2026", the lessons it studies, its
students' emails and an optional nickname of each, and the homework: a topic of its lessons, with the day it is due),
kept in the Supabase table `classes`. A student may be in several. Once there is a class, only the classes'
students and the teachers are let in; before the first one (or without the secret key, which reads the table) any account is.

Videos: the teacher adds lesson videos in the panel's content part (a lesson, a title and a Vimeo or YouTube link) and moves
each to its place among the lesson's topics, kept in the Supabase table `videos`. A student's path shows them as
topics of their lessons.

Texts: the teacher also adds texts there (a story or a dialogue: a lesson, a title, its lines in Hebrew and in
Arabic written in Hebrew letters, and a dialogue's two voices), kept in the Supabase table `texts` and placed among
the lesson's topics like the videos. Each is a topic practised line by line (see app/texts.py).

Daily videos: a library the teacher keeps in the content part, a level's videos each with a title and a Vimeo or
YouTube link, in the Supabase table `dailies`. A class gives a video its day (the class's `dailies`); on that day its
students get a reminder above the path to watch it.

Library: other videos (a title and a link each), in the Supabase table `library`, one for every level. Every student
browses them from the tab at the screen's foot, whatever their level and class.

Tutors: the teacher gives a student the tutor's rank in the panel, in the list of all the students above the classes' tabs. It is the student's,
whatever classes they are in, so it is kept by email in the Supabase table `tutors`.

Flags: a teacher, a tutor, and an account in TUTORS_EMAIL, gets a flag under each score, for a recording that was said right and scored
wrong. A flag keeps what describes the case in the Supabase table `flags`, and the recording itself in the storage
bucket `flags`, both written with the secret key: the material for changing the scoring, and later for fine-tuning.

Lab: a teacher's recordings of the lab's diagnostic list (see lab.py), each labelled said right or with one planted
mistake, in the Supabase table `lab_samples` and the storage bucket `lab`, written with the secret key: the
benchmark set of the phoneme recognition.

Writing: a lesson's written homework (see workbook.py) is kept in the Supabase table `writings`, a row per student
and topic, written with the student's own token like the attempts, so it follows them to any device. The panel
reads every student's with the secret key.

Tutor: each conversation of the game יאללה נדבר (see tutor.py), what the student said and how the tutor answered, in the
Supabase table `tutor_chats`, written with the secret key, for the teacher to read.

Env:  SUPABASE_URL             (https://<project-ref>.supabase.co)
      SUPABASE_PUBLISHABLE_KEY  (the publishable/anon key; it is public and also goes to the page)
      TEACHER_EMAILS            (optional: the accounts with the management panel, separated by commas or spaces)
      TUTORS_EMAIL              (optional: more accounts that flag a wrong score, as the teachers do; separated by commas or spaces)
      DEVELOPER_EMAILS           (optional bootstrap accounts with full access, including the Lab)
      SUPABASE_SECRET_KEY       (the secret/service-role key, needed by the panel, the classes and the flags; never public)
Without them the service is open, as before.
"""
from __future__ import annotations

import json
import logging
import mimetypes
import os
import re
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone

log = logging.getLogger("ihki.auth")
TIMEOUT = 10
CACHE_TTL = 300  # seconds a checked token is trusted without asking again
CACHE_MAX = 2000
PAGE = 1000  # rows per request: Supabase's default ceiling
MAX_ATTEMPTS = 20_000  # of one student, read for their progress
MAX_CLASS_ATTEMPTS = 200_000  # of all students, read for the teacher's panel
CLASSES_TTL = 60  # seconds the class list is trusted without reading it again (each sign-in check uses it)

# Application-owned data. Supabase's auth users and managed schemas are deliberately not copied.
BACKUP_TABLES = (
    "dailies", "library", "videos", "texts", "classes", "writings", "tutors", "flags",
    "lab_samples", "calibrations", "scoring_rules", "user_access", "access_audit", "app_secrets",
    "system_events", "tutor_chats",
)
BACKUP_NATURAL_KEYS = {"writings": "user_id,topic", "tutors": "email", "scoring_rules": "id",
                       "user_access": "user_id", "app_secrets": "name", "tutor_chats": "id"}


class AuthUnavailable(RuntimeError):
    pass


class ClassConflict(ValueError):
    """Supabase refused a class: its name is taken."""


class SupabaseAuth:
    def __init__(self, url: str, key: str, teachers: frozenset[str] = frozenset(), secret: str = "",
                 tutors: frozenset[str] = frozenset(), developers: frozenset[str] = frozenset()):
        self.url, self.key = url.rstrip("/"), key
        self.teachers = teachers  # lower-case emails of the accounts with the management panel
        self.tutors = tutors  # lower-case emails of the accounts that flag a wrong score, besides the teachers
        self.developers = developers  # bootstrap accounts; only developers may use the lab
        self.secret = secret  # reads every student's rows and the classes; empty = the panel has no data
        self._cache: dict[str, tuple[float, dict]] = {}
        self._classes: tuple[float, list[dict]] | None = None
        self._videos: tuple[float, list[dict]] | None = None
        self._texts: tuple[float, list[dict]] | None = None
        self._ranked: tuple[float, frozenset[str] | None] | None = None
        self._access: dict[str, tuple[float, dict | None]] = {}
        self._level_videos: dict[str, tuple[float, list[dict]]] = {}   # by table: the daily videos, the library
        self._lock = threading.Lock()

    def user(self, token: str) -> dict | None:
        """The signed-in user of this access token ({"id", "email"}), or None when it is not valid."""
        now = time.monotonic()
        with self._lock:
            hit = self._cache.get(token)
        if hit and hit[0] > now:
            return hit[1]
        req = urllib.request.Request(
            self.url + "/auth/v1/user",
            headers={"Authorization": f"Bearer {token}", "apikey": self.key, "User-Agent": "ihki/1.0"},
        )
        try:
            with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
                data = json.load(res)
        except urllib.error.HTTPError as e:
            if e.code in (400, 401, 403):
                return None
            raise AuthUnavailable(f"supabase answered {e.code}") from e
        except (urllib.error.URLError, TimeoutError, ValueError) as e:
            raise AuthUnavailable(str(e)) from e
        if not data.get("id"):
            return None
        user = {"id": data["id"], "email": data.get("email", "")}
        with self._lock:
            if len(self._cache) >= CACHE_MAX:
                self._cache.clear()
            self._cache[token] = (now + CACHE_TTL, user)
        return user

    def record(self, token: str, row: dict) -> None:
        """Adds one row to `attempts` as the student of this token. A failure is logged, never raised."""
        def request(values: dict):
            return urllib.request.Request(
                self.url + "/rest/v1/attempts", data=json.dumps(values).encode(), method="POST",
                headers={"Authorization": f"Bearer {token}", "apikey": self.key, "Content-Type": "application/json",
                         "Prefer": "return=minimal", "User-Agent": "ihki/1.0"})
        try:
            urllib.request.urlopen(request(row), timeout=TIMEOUT).close()
        except urllib.error.HTTPError as e:
            if e.code == 400 and any(k in row for k in ("issues", "model", "version")):
                legacy = {k: v for k, v in row.items() if k not in ("issues", "model", "version")}
                try:
                    urllib.request.urlopen(request(legacy), timeout=TIMEOUT).close()
                    log.warning("attempt stored without issue diagnostics; migrate the attempts table (see README)")
                    return
                except (urllib.error.URLError, TimeoutError) as retry:
                    e = retry
            log.warning("attempt not recorded: %s", e)
        except (urllib.error.URLError, TimeoutError) as e:  # HTTPError is a URLError
            log.warning("attempt not recorded: %s", e)

    def attempts(self, token: str) -> list[dict]:
        """The student's non-empty scored recordings, newest first: cat, phrase, score, unrelated, created_at.
        Row-level security limits them to the token's student. What cannot be read is logged."""
        rows: list[dict] = []
        while len(rows) < MAX_ATTEMPTS:
            query = urllib.parse.urlencode({"select": "cat,phrase,score,unrelated,created_at",
                                            "order": "created_at.desc", "limit": PAGE, "offset": len(rows)})
            try:
                batch = self._get(f"/rest/v1/attempts?{query}", token, self.key)
            except (urllib.error.URLError, TimeoutError, ValueError) as e:
                log.warning("attempts not read: %s", e)
                break
            rows += batch
            if len(batch) < PAGE:
                break
        return rows

    def writings(self, token: str) -> dict[str, list[str]]:
        """The student's written homework, {topic id: its lines}. Row-level security limits it to the token's student.
        Before the table is created there is none; any other failure is raised, so the page keeps its own copy."""
        try:
            rows = self._get("/rest/v1/writings?select=topic,lines", token, self.key)
        except urllib.error.HTTPError as e:
            if e.code == 404:
                log.warning("the table `writings` is missing (see the README): written homework stays on the device")
                raise AuthUnavailable("the table writings is missing") from e
            raise AuthUnavailable(f"supabase answered {e.code}") from e
        except (urllib.error.URLError, TimeoutError, ValueError) as e:
            raise AuthUnavailable(str(e)) from e
        return {r["topic"]: [str(x) for x in r["lines"]] for r in rows if isinstance(r.get("lines"), list)}

    def save_writing(self, token: str, user_id: str, topic: str, lines: list[str]) -> None:
        """Keeps a topic's lines as the student of this token, in place of what they wrote in it before."""
        row = {"user_id": user_id, "topic": topic, "lines": lines, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
        req = urllib.request.Request(
            self.url + "/rest/v1/writings?on_conflict=user_id,topic", data=json.dumps(row).encode(), method="POST",
            headers={"Authorization": f"Bearer {token}", "apikey": self.key, "Content-Type": "application/json",
                     "Prefer": "resolution=merge-duplicates,return=minimal", "User-Agent": "ihki/1.0"})
        try:
            urllib.request.urlopen(req, timeout=TIMEOUT).close()
        except urllib.error.HTTPError as e:
            raise AuthUnavailable(f"supabase answered {e.code}: {e.read().decode(errors='replace')[:300]}") from e
        except (urllib.error.URLError, TimeoutError) as e:
            raise AuthUnavailable(str(e)) from e

    def all_writings(self) -> list[dict] | None:
        """Every student's written homework, for the panel: user_id, topic, lines. Before the table is created it is
        None: nothing a student writes is kept, which the panel says."""
        try:
            return self._admin(f"/rest/v1/writings?select=user_id,topic,lines&limit={PAGE * 10}")
        except AuthUnavailable as e:
            if "answered 404" not in str(e):
                raise
            log.warning("the table `writings` is missing (see the README): the panel shows no written homework")
            return None

    def _get(self, path: str, token: str, key: str):
        req = urllib.request.Request(
            self.url + path, headers={"Authorization": f"Bearer {token}", "apikey": key, "User-Agent": "ihki/1.0"})
        with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
            return json.load(res)

    def _admin(self, path: str, method: str = "GET", body: dict | None = None, prefer: str = "return=representation",
               timeout: int = TIMEOUT):
        """A call with the secret key, for the teacher's panel. Unlike a student's own progress, a failure is raised."""
        if not self.secret:
            raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
        req = urllib.request.Request(
            self.url + path, method=method, data=json.dumps(body).encode() if body is not None else None,
            headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": "application/json",
                     "Prefer": prefer, "User-Agent": "ihki/1.0"})
        try:
            with urllib.request.urlopen(req, timeout=timeout) as res:
                return json.loads(res.read() or b"null")
        except urllib.error.HTTPError as e:
            detail = e.read().decode(errors="replace")[:300]
            if e.code == 409:
                raise ClassConflict(detail) from e
            raise AuthUnavailable(f"supabase answered {e.code}: {detail}") from e
        except (urllib.error.URLError, TimeoutError, ValueError) as e:
            raise AuthUnavailable(str(e)) from e

    def backup_rows(self) -> dict[str, list[dict]]:
        """All application-owned table rows. Tables not installed in an older deployment are omitted."""
        result: dict[str, list[dict]] = {}
        for table in BACKUP_TABLES:
            rows = []
            try:
                for offset in range(0, 10_000_000, PAGE):
                    path = f"/rest/v1/{table}?select=*&limit={PAGE}&offset={offset}"
                    for attempt in range(3):
                        try:
                            batch = self._admin(path, timeout=60) or []
                            break
                        except AuthUnavailable as e:
                            if attempt == 2 or not any(x in str(e).lower() for x in ("timed out", "timeout")):
                                raise
                    rows.extend(batch)
                    if len(batch) < PAGE:
                        break
                result[table] = rows
            except AuthUnavailable as e:
                if "answered 404" not in str(e):
                    raise
        return result

    def final_scores(self) -> list[dict]:
        """One compact row per student and exercise: the highest score that drives the panel's final grades."""
        best: dict[tuple[str, str, str], dict] = {}
        for offset in range(0, MAX_CLASS_ATTEMPTS, PAGE):
            query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,created_at",
                                            "order": "id.asc", "limit": PAGE, "offset": offset})
            path = f"/rest/v1/attempts?{query}"
            for attempt in range(3):
                try:
                    batch = self._admin(path, timeout=60) or []
                    break
                except AuthUnavailable as e:
                    if attempt == 2 or not any(x in str(e).lower() for x in ("timed out", "timeout")):
                        raise
            for row in batch:
                key = (str(row["user_id"]), str(row["cat"]), str(row["phrase"]))
                if key not in best or int(row["score"]) > int(best[key]["score"]):
                    best[key] = row
            if len(batch) < PAGE:
                break
        return list(best.values())

    def restore_final_scores(self, rows: list[dict]) -> int:
        """Restore only grades that improve the currently stored best score, without restoring attempt history."""
        current = {(r["user_id"], r["cat"], r["phrase"]): int(r["score"]) for r in self.final_scores()}
        add = [{**r, "unrelated": False} for r in rows
               if int(r.get("score", -1)) > current.get((r.get("user_id"), r.get("cat"), r.get("phrase")), -1)]
        for start in range(0, len(add), 200):
            self._admin("/rest/v1/attempts", "POST", add[start:start + 200], prefer="return=minimal", timeout=60)
        return len(add)

    def restore_rows(self, tables: dict[str, list[dict]]) -> dict[str, int]:
        """Merge a validated application backup. Existing rows are updated; unrelated current rows remain."""
        restored: dict[str, int] = {}
        text_ids: dict[int, int] = {}
        daily_ids: dict[int, int] = {}
        for table in BACKUP_TABLES:
            rows = [dict(r) for r in tables.get(table, [])]
            if not rows:
                continue
            if table == "classes":
                for row in rows:
                    row["dailies"] = [{**d, "video": daily_ids.get(d.get("video"), d.get("video"))}
                                      for d in (row.get("dailies") or [])]
                    for field in ("homework",):
                        value = row.get(field, "")
                        if isinstance(value, str) and value.startswith("t") and value[1:].isdigit():
                            row[field] = "t" + str(text_ids.get(int(value[1:]), int(value[1:])))
                    row["homeworks"] = [{**h, "topic": "t" + str(text_ids.get(int(h["topic"][1:]), int(h["topic"][1:])))
                                          if isinstance(h.get("topic"), str) and h["topic"].startswith("t") and h["topic"][1:].isdigit()
                                          else h.get("topic")} for h in (row.get("homeworks") or [])]
            if table == "writings":
                field = "topic"
                for row in rows:
                    value = row.get(field, "")
                    if isinstance(value, str) and value.startswith("t") and value[1:].isdigit():
                        row[field] = "t" + str(text_ids.get(int(value[1:]), int(value[1:])))
            if table in BACKUP_NATURAL_KEYS:
                key = BACKUP_NATURAL_KEYS[table]
                for start in range(0, len(rows), 200):
                    self._admin(f"/rest/v1/{table}?on_conflict={key}", "POST", rows[start:start + 200],
                                prefer="resolution=merge-duplicates,return=minimal")
                restored[table] = len(rows)
                continue
            for original in rows:
                row = dict(original)
                old_id = row.get("id")
                if old_id is not None:
                    patch = dict(row)
                    patch.pop("id", None)
                    existing = self._admin(f"/rest/v1/{table}?id=eq.{urllib.parse.quote(str(old_id))}", "PATCH", patch)
                    if existing:
                        new_id = old_id
                    else:
                        row.pop("id", None)  # identity columns reject explicitly supplied values
                        made = self._admin(f"/rest/v1/{table}", "POST", row)
                        new_id = made[0].get("id") if made else None
                    if table == "texts" and new_id is not None:
                        text_ids[int(old_id)] = int(new_id)
                    if table == "dailies" and new_id is not None:
                        daily_ids[int(old_id)] = int(new_id)
                else:
                    self._admin(f"/rest/v1/{table}", "POST", row, prefer="return=minimal")
            restored[table] = len(rows)
        self._classes = self._videos = self._texts = self._ranked = None
        self._level_videos.clear()
        self._access.clear()
        return restored

    def classes(self, fresh: bool = False) -> list[dict]:
        """Every class, newest first: id, name, emails (lower-case), nicknames ({email: nickname}), lessons (lesson ids;
        empty in a class saved before classes had lessons), homeworks ([{"topic": a topic id, "due": its ISO date or
        None}], one for each topic the class has as homework), homework and due (the first of them, as a class had
        before it could have several; a class saved then has only these), dailies ([{"video": the id of a daily
        video, "date": the ISO date the class watches it}]), created_at.
        Kept for CLASSES_TTL, as each sign-in
        check asks. Without the secret key, or before the table is created, there are none."""
        now = time.monotonic()
        with self._lock:
            hit = self._classes
        if hit and hit[0] > now and not fresh:
            return hit[1]
        rows: list[dict] = []
        if self.secret:
            try:
                # every column: a table made before the nicknames still lets its students in
                rows = self._admin("/rest/v1/classes?select=*&order=created_at.desc")
                for r in rows:
                    r["nicknames"], r["lessons"] = r.get("nicknames") or {}, r.get("lessons") or []
                    r["homework"], r["due"] = r.get("homework") or "", r.get("due") or None
                    r["homeworks"], r["dailies"] = r.get("homeworks") or [], r.get("dailies") or []
                    r["teachers"] = [str(e).lower() for e in (r.get("teachers") or [])]
            except AuthUnavailable as e:
                if "answered 404" not in str(e):
                    raise
                log.warning("the table `classes` is missing (see the README): every account is let in")
        with self._lock:
            self._classes = (now + CLASSES_TTL, rows)
        return rows

    def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
                   lessons: list[str], homeworks: list[dict] = (), dailies: list[dict] | None = None,
                   subject: str = "", teachers: list[str] | None = None) -> None:
        """Adds a class (id None) or replaces one's name, emails, nicknames, lessons and homeworks ([{"topic", "due"}]),
        and its daily videos' days ([{"video", "date"}]; None leaves them as they are, so a table made before them
        still takes a class that has none). A name already taken raises ClassConflict."""
        first = homeworks[0] if homeworks else {"topic": "", "due": None}   # the columns of a class with one homework
        body = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons,
                "homework": first["topic"], "due": first["due"], "homeworks": list(homeworks)}
        if dailies is not None:
            body["dailies"] = list(dailies)
        if teachers is not None:
            body["teachers"] = list(teachers)
        if id is None:
            body["subject"] = subject
            self._admin("/rest/v1/classes", "POST", body)
        elif not self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "PATCH", body):
            raise KeyError(id)
        self._classes = None

    def delete_class(self, id: int) -> None:
        self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "DELETE")
        self._classes = None

    def videos(self, fresh: bool = False) -> list[dict]:
        """Every lesson video, each lesson's in the order the teacher gave them: id, lesson (a lesson id), title, url
        (the Vimeo or YouTube link), pos (how many of the lesson's own topics stand before it; None in a table made before videos
        had places: after them all) and ord (its turn among the videos of one place).
        Kept for CLASSES_TTL, as the path asks. Without the secret key, or before the table is created, there are none."""
        now = time.monotonic()
        with self._lock:
            hit = self._videos
        if hit and hit[0] > now and not fresh:
            return hit[1]
        rows: list[dict] = []
        if self.secret:
            try:
                # every column: a table made before the places still shows its videos
                rows = self._admin("/rest/v1/videos?select=*&order=id.asc")
                for r in rows:
                    r["pos"], r["ord"] = r.get("pos"), r.get("ord") or 0
            except AuthUnavailable as e:
                if "answered 404" not in str(e):
                    raise
                log.warning("the table `videos` is missing (see the README): the lessons have no videos")
        with self._lock:
            self._videos = (now + CLASSES_TTL, rows)
        return rows

    def add_video(self, lesson: str, title: str, url: str, pos: int, ord: int) -> None:
        self._admin("/rest/v1/videos", "POST", {"lesson": lesson, "title": title, "url": url, "pos": pos, "ord": ord})
        self._videos = None

    def place(self, places: list[tuple[str, int, int, int]]) -> None:
        """Moves videos and texts: (table, id, pos, ord) of each; the table is "videos" or "texts"."""
        try:
            for table, id, pos, ord in places:
                assert table in ("videos", "texts")
                self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"pos": pos, "ord": ord})
        finally:
            self._videos = self._texts = None

    def texts(self, fresh: bool = False) -> list[dict]:
        """Every text the teacher added: id, lesson (a lesson id), title, voices (a dialogue's two voices by name,
        the odd lines' and the even lines'; empty for a story), lines ([Hebrew, Arabic in Hebrew letters] each), and
        pos and ord as a video has them (see `videos`).
        Kept for CLASSES_TTL, as each score asks. Without the secret key, or before the table is created, there are none."""
        now = time.monotonic()
        with self._lock:
            hit = self._texts
        if hit and hit[0] > now and not fresh:
            return hit[1]
        rows: list[dict] = []
        if self.secret:
            try:
                rows = self._admin("/rest/v1/texts?select=*&order=id.asc")
                for r in rows:
                    r["voices"], r["ord"] = r.get("voices") or [], r.get("ord") or 0
            except AuthUnavailable as e:
                if "answered 404" not in str(e):
                    raise
                log.warning("the table `texts` is missing (see the README): the lessons have no texts")
        with self._lock:
            self._texts = (now + CLASSES_TTL, rows)
        return rows

    def add_text(self, lesson: str, title: str, voices: list[str], lines: list[list[str]], pos: int, ord: int) -> None:
        self._admin("/rest/v1/texts", "POST", {"lesson": lesson, "title": title, "voices": voices, "lines": lines,
                                               "pos": pos, "ord": ord})
        self._texts = None

    def delete_text(self, id: int) -> None:
        self._admin(f"/rest/v1/texts?id=eq.{int(id)}", "DELETE")
        self._texts = None

    def delete_video(self, id: int) -> None:
        self._admin(f"/rest/v1/videos?id=eq.{int(id)}", "DELETE")
        self._videos = None

    def level_videos(self, table: str, fresh: bool = False) -> list[dict]:
        """A level's videos that stand outside the path, in the order the teacher gave them (`ord`, see
        `order_level_videos`) and then, those never moved, in the order they were added: id, level (a level id), title,
        url (the Vimeo or YouTube link). `table` is "dailies" (the daily videos) or "library" (the video library,
        which is one for every level: its `level` is empty). Kept for CLASSES_TTL. Without the secret key, or before the table is created, there are none."""
        assert table in ("dailies", "library")
        now = time.monotonic()
        with self._lock:
            hit = self._level_videos.get(table)
        if hit and hit[0] > now and not fresh:
            return hit[1]
        rows: list[dict] = []
        if self.secret:
            try:
                rows = self._admin(f"/rest/v1/{table}?select=*&order=id.asc")
                rows.sort(key=lambda r: (r.get("ord") is None, r.get("ord") or 0, r["id"]))
            except AuthUnavailable as e:
                if "answered 404" not in str(e):
                    raise
                log.warning("the table `%s` is missing (see the README): it has no videos", table)
        with self._lock:
            self._level_videos[table] = (now + CLASSES_TTL, rows)
        return rows

    def add_level_video(self, table: str, level: str, title: str, url: str) -> None:
        assert table in ("dailies", "library")
        self._admin(f"/rest/v1/{table}", "POST", {"level": level, "title": title, "url": url})
        self._level_videos.pop(table, None)

    def delete_level_video(self, table: str, id: int) -> None:
        assert table in ("dailies", "library")
        self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "DELETE")
        self._level_videos.pop(table, None)

    def rename_level_video(self, table: str, id: int, title: str) -> None:
        assert table in ("dailies", "library")
        try:
            self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"title": title})
        finally:
            self._level_videos.pop(table, None)

    def order_level_videos(self, table: str, ids: list[int]) -> None:
        """Puts the table's videos in this order: each one's `ord` is its turn."""
        assert table in ("dailies", "library")
        try:
            for k, id in enumerate(ids):
                self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"ord": k})
        finally:
            self._level_videos.pop(table, None)

    def ranked(self, fresh: bool = False) -> frozenset[str] | None:
        """The lower-case emails the teacher gave the tutor's rank (the table `tutors`). Kept for CLASSES_TTL, as each
        sign-in check asks. None before the table is created; without the secret key there are none."""
        now = time.monotonic()
        with self._lock:
            hit = self._ranked
        if hit and hit[0] > now and not fresh:
            return hit[1]
        emails: frozenset[str] | None = frozenset()
        if self.secret:
            try:
                emails = frozenset(r["email"].lower() for r in self._admin("/rest/v1/tutors?select=email"))
            except AuthUnavailable as e:
                if "answered 404" not in str(e):
                    raise
                log.warning("the table `tutors` is missing (see the README): no student has the tutor's rank")
                emails = None
        with self._lock:
            self._ranked = (now + CLASSES_TTL, emails)
        return emails

    def set_tutor(self, email: str, on: bool, by: str) -> None:
        """Gives the account of this email the tutor's rank, or takes it back. `by` is the teacher who gave it."""
        email = email.lower()
        try:
            if on:
                self._admin("/rest/v1/tutors", "POST", {"email": email, "granted_by": by})
            else:
                self._admin(f"/rest/v1/tutors?email=eq.{urllib.parse.quote(email)}", "DELETE")
        except ClassConflict:   # has it already
            pass
        finally:
            self._ranked = None

    def _put_object(self, bucket: str, name: str, data: bytes, mime: str) -> bool:
        """Stores a recording in a private storage bucket; False (logged) when it cannot be stored."""
        if not self.secret:
            return False
        req = urllib.request.Request(
            f"{self.url}/storage/v1/object/{bucket}/{urllib.parse.quote(name)}", data=data, method="POST",
            headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": mime,
                     "User-Agent": "ihki/1.0"})
        try:
            urllib.request.urlopen(req, timeout=TIMEOUT).close()
            return True
        except (urllib.error.URLError, TimeoutError) as e:  # HTTPError is a URLError
            log.warning("a recording not stored (is there a storage bucket `%s`? see the README): %s", bucket, e)
            return False

    def _delete_objects(self, bucket: str, names: list[str]) -> bool:
        """Removes recordings of a private storage bucket; False (logged) when some could not be removed."""
        ok = True
        for k in range(0, len(names), 1000):   # the storage API takes up to 1000 names a call
            req = urllib.request.Request(
                f"{self.url}/storage/v1/object/{bucket}", method="DELETE",
                data=json.dumps({"prefixes": names[k:k + 1000]}).encode(),
                headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
                         "Content-Type": "application/json", "User-Agent": "ihki/1.0"})
            try:
                urllib.request.urlopen(req, timeout=TIMEOUT).close()
            except (urllib.error.URLError, TimeoutError) as e:
                log.warning("recordings of the storage bucket `%s` not removed: %s", bucket, e)
                ok = False
        return ok

    def _get_object(self, bucket: str, name: str) -> tuple[bytes, str]:
        """A recording of a private storage bucket and its media type."""
        req = urllib.request.Request(
            f"{self.url}/storage/v1/object/{bucket}/{urllib.parse.quote(name)}",
            headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "User-Agent": "ihki/1.0"})
        try:
            with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
                data = res.read()
                media_type = res.headers.get_content_type()
        except (urllib.error.URLError, TimeoutError) as e:
            raise AuthUnavailable(f"{bucket} audio unavailable: {e}") from e
        if media_type == "application/octet-stream":
            media_type = mimetypes.guess_type(name)[0] or media_type
        return data, media_type

    def ensure_backup_bucket(self) -> None:
        """Create the private application-backup bucket on first use."""
        try:
            self._admin("/storage/v1/bucket/backups")
        except AuthUnavailable as e:
            detail = str(e).lower()
            if not any(mark in detail for mark in ("answered 404", '"statuscode":"404"', "nosuchbucket", "bucket not found")):
                raise
            self._admin("/storage/v1/bucket", "POST", {"id": "backups", "name": "backups", "public": False},
                        prefer="return=minimal")

    def save_backup(self, name: str, data: bytes) -> None:
        self.ensure_backup_bucket()
        req = urllib.request.Request(
            f"{self.url}/storage/v1/object/backups/{urllib.parse.quote(name)}", data=data, method="POST",
            headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
                     "Content-Type": "application/zip", "User-Agent": "ihki/1.0"})
        try:
            urllib.request.urlopen(req, timeout=max(TIMEOUT, 60)).close()
        except urllib.error.HTTPError as e:
            raise AuthUnavailable(f"supabase storage answered {e.code}: {e.read().decode(errors='replace')[:300]}") from e
        except (urllib.error.URLError, TimeoutError) as e:
            raise AuthUnavailable(str(e)) from e

    def list_backups(self) -> list[dict]:
        self.ensure_backup_bucket()
        rows = self._admin("/storage/v1/object/list/backups", "POST",
                           {"prefix": "", "limit": 100, "offset": 0, "sortBy": {"column": "name", "order": "desc"}})
        return [r for r in (rows or []) if str(r.get("name", "")).endswith(".zip")]

    def add_flag(self, row: dict, audio: bytes, mime: str, name: str) -> bool:
        """Keeps a flagged score and its recording in the private `flags` bucket."""
        kept = self._put_object("flags", name, audio, mime)
        self._admin("/rest/v1/flags", "POST", {**row, "audio": name if kept else None})
        return kept

    def list_flags(self, status: str = "pending") -> list[dict]:
        """Flagged scores for the teacher's review, newest first."""
        query = urllib.parse.urlencode({"select": "*", "status": f"eq.{status}", "order": "created_at.desc"})
        return self._admin(f"/rest/v1/flags?{query}")

    def review_flag(self, flag_id: int, values: dict) -> None:
        """Records a teacher's decision about one flagged score."""
        self._admin(f"/rest/v1/flags?id=eq.{int(flag_id)}", "PATCH", values)

    def flag(self, flag_id: int) -> dict | None:
        """One flag's row, or None."""
        rows = self._admin(f"/rest/v1/flags?{urllib.parse.urlencode({'select': '*', 'id': f'eq.{int(flag_id)}', 'limit': 1})}")
        return rows[0] if rows else None

    def flag_audio(self, flag_id: int) -> tuple[bytes, str] | None:
        """Returns one private flag recording and its media type, or None when it was not stored."""
        query = urllib.parse.urlencode({"select": "audio", "id": f"eq.{int(flag_id)}", "limit": 1})
        rows = self._admin(f"/rest/v1/flags?{query}")
        name = rows[0].get("audio") if rows else None
        return self._get_object("flags", name) if name else None

    def add_lab_sample(self, row: dict, audio: bytes, mime: str, name: str, attempts: int = 1) -> bool:
        """Keeps a lab recording (see lab.py): the recording in the storage bucket `lab`, the row in the table
        `lab_samples`. As with a flag, a recording that cannot be stored leaves the row without it (False).
        `attempts` retries the upload (a bulk import outlives the storage's transient 5xx errors)."""
        kept = False
        for attempt in range(max(1, attempts)):
            if attempt:
                time.sleep(2 * attempt)
            kept = self._put_object("lab", name, audio, mime)
            if kept:
                break
        self._admin("/rest/v1/lab_samples", "POST", {**row, "audio": name if kept else None}, prefer="return=minimal")
        return kept

    def lab_samples(self, full: bool = False) -> list[dict]:
        """The lab's recordings, oldest first: what labels each one (and, with `full`, the scorer's whole result)."""
        cols = "*" if full else "id,item,speaker,label,mistake,score,heard_ipa,flagged,model,audio,created_at"
        # PostgREST caps a response (1000 rows by default), so the table is read in pages by id.
        rows, last, page = [], 0, 500 if full else 1000
        while True:
            query = urllib.parse.urlencode({"select": cols, "order": "id.asc", "id": f"gt.{last}", "limit": page})
            batch = self._admin(f"/rest/v1/lab_samples?{query}") or []
            rows.extend(batch)
            if len(batch) < page:
                return rows
            last = batch[-1]["id"]

    def lab_sample(self, sample_id: int) -> dict | None:
        """One lab recording's row, without the scorer's result, or None."""
        query = urllib.parse.urlencode({"select": "id,item,ar,ipa,speaker,label,mistake,audio,result", "id": f"eq.{int(sample_id)}",
                                        "limit": 1})
        rows = self._admin(f"/rest/v1/lab_samples?{query}")
        return rows[0] if rows else None

    def lab_audio(self, sample_id: int) -> tuple[bytes, str] | None:
        """One lab recording and its media type, or None when it was not stored."""
        row = self.lab_sample(sample_id)
        return self._get_object("lab", row["audio"]) if row and row.get("audio") else None

    def update_lab_result(self, sample_id: int, result: dict) -> None:
        """Updates a lab result's review metadata (the original scoring fields remain in the same JSON document)."""
        self._admin(f"/rest/v1/lab_samples?id=eq.{int(sample_id)}", "PATCH", {"result": result})

    def delete_lab_samples(self, speaker: str | None = None) -> int:
        """Deletes the lab's recordings of one speaker ("": the unnamed), or of every speaker when None: the rows,
        then their files in the bucket `lab` (a file that cannot be removed is logged and left). Returns how many rows."""
        where = {"speaker": f"eq.{speaker}"} if speaker is not None else {"id": "gt.0"}  # a DELETE needs a filter
        rows = self._admin(f"/rest/v1/lab_samples?{urllib.parse.urlencode({**where, 'select': 'id,audio'})}",
                           "DELETE") or []
        names = [r["audio"] for r in rows if r.get("audio")]
        if names:
            self._delete_objects("lab", names)
        return len(rows)

    def calibrations(self, status: str | None = None) -> list[dict]:
        """Calibration proposals, optionally filtered by their workflow status."""
        params = {"select": "*", "order": "created_at.desc"}
        if status:
            params["status"] = f"eq.{status}"
        return self._admin(f"/rest/v1/calibrations?{urllib.parse.urlencode(params)}")

    def add_calibration(self, row: dict) -> dict:
        """Creates one draft calibration proposal and returns its stored row."""
        made = self._admin("/rest/v1/calibrations", "POST", row)
        return made[0] if made else row

    def update_calibration(self, calibration_id: int, values: dict) -> None:
        """Updates and optionally applies or rejects a calibration proposal."""
        self._admin(f"/rest/v1/calibrations?id=eq.{int(calibration_id)}", "PATCH", values)

    def delete_calibration(self, calibration_id: int) -> bool:
        """Deletes a calibration proposal or rule; returns whether it existed."""
        return bool(self._admin(f"/rest/v1/calibrations?id=eq.{int(calibration_id)}", "DELETE"))

    def scoring_rules(self) -> list[dict]:
        """The teacher's switches over the rules of pronunciation-rules.md: id, active, updated_by, updated_at.
        A rule with no row is on."""
        return self._admin("/rest/v1/scoring_rules?select=*") or []

    def set_scoring_rule(self, rule_id: str, active: bool, by: str) -> None:
        """Switches one rule on or off (an upsert on its id)."""
        self._admin("/rest/v1/scoring_rules", "POST", {"id": rule_id, "active": bool(active), "updated_by": by,
                                                       "updated_at": datetime.now(timezone.utc).isoformat()},
                    prefer="resolution=merge-duplicates,return=minimal")

    def users(self) -> list[dict]:
        """Every account that has signed in: id, email, name (as Google gave it; empty for one who signed in by email)."""
        users: list[dict] = []
        for page in range(1, 51):
            batch = self._admin(f"/auth/v1/admin/users?page={page}&per_page={PAGE}").get("users", [])
            users += [{"id": u["id"], "email": u.get("email") or "",
                       "name": (u.get("user_metadata") or {}).get("full_name") or ""} for u in batch]
            if len(batch) < PAGE:
                break
        return users

    def access_for(self, user_id: str) -> dict | None:
        """The roles and student entitlements assigned to an account. None also means the migration table is absent."""
        now = time.monotonic()
        with self._lock:
            hit = self._access.get(user_id)
        if hit and hit[0] > now:
            return hit[1]
        try:
            rows = self._admin("/rest/v1/user_access?" + urllib.parse.urlencode({
                "user_id": f"eq.{user_id}", "select": "user_id,email,roles,entitlements,subject,updated_at"}))
            value = rows[0] if rows else None
        except AuthUnavailable as e:
            if "subject" in str(e).lower():
                rows = self._admin("/rest/v1/user_access?" + urllib.parse.urlencode({
                    "user_id": f"eq.{user_id}", "select": "user_id,email,roles,entitlements,updated_at"}))
                value = ({**rows[0], "subject": ""} if rows else None)
            elif "answered 404" not in str(e):
                raise
            else:
                value = None
        with self._lock:
            self._access[user_id] = (now + CLASSES_TTL, value)
        return value

    def access_rows(self) -> list[dict] | None:
        """All explicit access assignments, or None until the user_access table is installed."""
        try:
            return self._admin("/rest/v1/user_access?select=user_id,email,roles,entitlements,subject,updated_at") or []
        except AuthUnavailable as e:
            if "subject" in str(e).lower():
                return [{**r, "subject": ""} for r in
                        (self._admin("/rest/v1/user_access?select=user_id,email,roles,entitlements,updated_at") or [])]
            if "answered 404" in str(e):
                return None
            raise

    def set_access(self, target: dict, roles: list[str], entitlements: list[str], actor: dict) -> None:
        """Upsert an assignment and append a best-effort audit record."""
        old = self.access_for(target["id"])
        row = {"user_id": target["id"], "email": target["email"].lower(), "roles": roles,
               "entitlements": entitlements, "updated_at": datetime.now(timezone.utc).isoformat(),
               "updated_by": actor["id"]}
        self._admin("/rest/v1/user_access?on_conflict=user_id", "POST", row,
                    prefer="resolution=merge-duplicates,return=minimal")
        with self._lock:
            self._access.pop(target["id"], None)
        try:
            self._admin("/rest/v1/access_audit", "POST",
                        {"target_user_id": target["id"], "changed_by": actor["id"],
                         "old_access": old or {}, "new_access": row}, prefer="return=minimal")
        except AuthUnavailable as e:
            log.warning("access changed but audit row was not stored: %s", e)
        self.log_event("permission", actor, target["email"], {"roles": roles, "entitlements": entitlements})

    def set_admin_subject(self, target: dict, subject: str, actor: dict) -> None:
        """Set an admin's subject without changing their access assignment."""
        old = self.access_for(target["id"]) or {}
        row = {"user_id": target["id"], "email": target["email"].lower(),
               "roles": old.get("roles") or ["student"], "entitlements": old.get("entitlements") or [],
               "subject": subject, "updated_at": datetime.now(timezone.utc).isoformat(),
               "updated_by": actor["id"]}
        self._admin("/rest/v1/user_access?on_conflict=user_id", "POST", row,
                    prefer="resolution=merge-duplicates,return=minimal")
        with self._lock:
            self._access.pop(target["id"], None)
        self.log_event("permission", actor, target["email"], {"admin_subject": subject})

    def secret_rows(self) -> list[dict] | None:
        """The API keys set in the panel, encrypted (see keystore.py), or None until the table `app_secrets` exists."""
        try:
            return self._admin("/rest/v1/app_secrets?select=name,ciphertext,last4,updated_by,updated_at") or []
        except AuthUnavailable as e:
            if "answered 404" in str(e):
                return None
            raise

    def save_secret(self, row: dict) -> None:
        self._admin("/rest/v1/app_secrets?on_conflict=name", "POST",
                    {**row, "updated_at": datetime.now(timezone.utc).isoformat()},
                    prefer="resolution=merge-duplicates,return=minimal")

    def delete_secret(self, name: str) -> None:
        self._admin("/rest/v1/app_secrets?" + urllib.parse.urlencode({"name": f"eq.{name}"}), "DELETE",
                    prefer="return=minimal")

    def all_attempts(self) -> list[dict]:
        """Every student's attempts, as `attempts` gives one student's, each with its user_id. "Different sentence"
        rows remain marked as unrelated for diagnostics, but count toward progress like every non-empty recording."""
        rows: list[dict] = []
        while len(rows) < MAX_CLASS_ATTEMPTS:
            query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,unrelated,created_at",
                                            "order": "id.desc", "limit": PAGE, "offset": len(rows)})
            batch = self._admin(f"/rest/v1/attempts?{query}")
            rows += batch
            if len(batch) < PAGE:
                break
        return rows

    def activity(self, hours: int = 24, bucket_minutes: int = 15) -> dict:
        """Aggregated attempt counts for monitoring, without returning student identities.

        ``activity_summary`` supplies a distinct-student count for the whole window.  Keep the buckets usable with
        older Supabase installations until that small migration is applied.
        """
        buckets = self._admin("/rest/v1/rpc/activity_buckets", "POST",
                              {"p_hours": hours, "p_bucket_minutes": bucket_minutes})
        summary = None
        try:
            rows = self._admin("/rest/v1/rpc/activity_summary", "POST", {"p_hours": hours}) or []
            summary = rows[0] if rows else None
        except AuthUnavailable as e:
            if "answered 404" not in str(e):
                raise
        return {"buckets": buckets, "summary": summary}

    def log_event(self, kind: str, user: dict | None, label: str = "", details: dict | None = None) -> None:
        """Best-effort append to the management activity feed; a missing audit table never blocks the action."""
        try:
            self._admin("/rest/v1/system_events", "POST", {
                "kind": kind, "actor_id": user.get("id") if user else None,
                "actor_email": (user or {}).get("email", ""), "label": label[:200], "details": details or {},
            }, prefer="return=minimal")
        except AuthUnavailable as e:
            log.warning("system activity event not stored: %s", e)

    def recent_events(self, limit: int = 60) -> list[dict] | None:
        """Newest audit events, or None when the optional table has not been installed yet."""
        query = urllib.parse.urlencode({"select": "kind,actor_email,label,details,created_at",
                                        "order": "created_at.desc", "limit": max(1, min(limit, 200))})
        try:
            return self._admin(f"/rest/v1/system_events?{query}") or []
        except AuthUnavailable as e:
            if "answered 404" in str(e):
                return None
            raise

    def save_tutor_chat(self, row: dict) -> None:
        """Keeps a conversation with the tutor (see tutor.py), in place of what was kept of it before. A failure is
        logged, never raised: the conversation goes on without it."""
        try:
            self._admin("/rest/v1/tutor_chats?on_conflict=id", "POST", row,
                        prefer="resolution=merge-duplicates,return=minimal")
        except AuthUnavailable as e:
            log.warning("a tutor conversation not kept%s: %s",
                        " (the table `tutor_chats` is missing, see tutor.CREATE_TUTOR_CHATS)" if "answered 404" in str(e) else "", e)

    def tutor_chats_since(self, user_id: str, since: str) -> int:
        """How many conversations with the tutor the student started since this ISO time. 0 when they cannot be read."""
        query = urllib.parse.urlencode({"select": "id", "user_id": f"eq.{user_id}", "created_at": f"gte.{since}"})
        try:
            return len(self._admin(f"/rest/v1/tutor_chats?{query}") or [])
        except AuthUnavailable as e:
            log.warning("tutor conversations not counted: %s", e)
            return 0

    def all_attempt_issues(self) -> list[dict] | None:
        """Compact phoneme issues from all attempts. None means the diagnostic columns have not been migrated yet."""
        rows: list[dict] = []
        while len(rows) < MAX_CLASS_ATTEMPTS:
            query = urllib.parse.urlencode({"select": "user_id,phrase,score,issues,model,version",
                                            "unrelated": "is.false", "order": "id.desc",
                                            "limit": PAGE, "offset": len(rows)})
            try:
                batch = self._admin(f"/rest/v1/attempts?{query}")
            except AuthUnavailable as e:
                if any(name in str(e) for name in ("issues", "model", "version")):
                    return None
                raise
            rows += batch
            if len(batch) < PAGE:
                break
        return rows


def load_auth() -> SupabaseAuth | None:
    url = os.environ.get("SUPABASE_URL", "").strip()
    key = os.environ.get("SUPABASE_PUBLISHABLE_KEY", "").strip()
    if not url or not key:
        missing = [n for n, v in (("SUPABASE_URL", url), ("SUPABASE_PUBLISHABLE_KEY", key)) if not v]
        seen = sorted(k for k in os.environ if "SUPA" in k.upper())   # names only: shows a misspelt variable
        log.info("no sign-in, the service is open: %s missing or empty (similar names in the environment: %s)",
                 " and ".join(missing), seen or "none")
        return None
    emails = lambda name: frozenset(re.split(r"[\s,;]+", os.environ.get(name, "").strip().lower())) - {""}
    teachers, tutors, developers = emails("TEACHER_EMAILS"), emails("TUTORS_EMAIL"), emails("DEVELOPER_EMAILS")
    secret = os.environ.get("SUPABASE_SECRET_KEY", "").strip()
    log.info("sign-in on: %s", "the students of the classes" if secret else "any account (no SUPABASE_SECRET_KEY)")
    if os.environ.get("ALLOWED_EMAILS", "").strip():
        log.warning("ALLOWED_EMAILS is no longer read: the teacher lists the students in the panel's classes")
    if teachers and not secret:
        log.warning("TEACHER_EMAILS is set without SUPABASE_SECRET_KEY: the management panel has no data to show")
    if tutors and not secret:
        log.warning("TUTORS_EMAIL is set without SUPABASE_SECRET_KEY: a flag cannot be saved")
    return SupabaseAuth(url, key, teachers, secret, tutors, developers)