discovery-media-player 0.1.126 → 0.1.128
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.
- package/docs/HOST-CONTRACT.md +22 -3
- package/docs/RETENTION.md +123 -124
- package/package.json +1 -1
- package/server/handler.js +11 -0
- package/server/presentations.js +131 -17
- package/server/routes-direct.js +16 -5
- package/server/schema.js +79 -6
- package/supabase/init.sql +68 -14
- package/supabase/migrations/0019-presence-lit-la-presentation.sql +181 -0
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -38,6 +38,7 @@ need.
|
|
|
38
38
|
"presenceStrict": true,
|
|
39
39
|
"presenceJetons": true,
|
|
40
40
|
"presenceDurcissement": "inconnu",
|
|
41
|
+
"presenceFusion": "inconnu",
|
|
41
42
|
"retentionSweep": false,
|
|
42
43
|
"hostShare": true,
|
|
43
44
|
"hostMail": true,
|
|
@@ -73,6 +74,7 @@ The three `presence*` fields report what the host has **observed**, not what it
|
|
|
73
74
|
| `presenceJetons` | measured — the host actually signed a throwaway token, so `PLAYER_PRESENCE_SECRET` works |
|
|
74
75
|
| `presenceStrict` | **effective** — `PLAYER_PRESENCE_STRICT` is set *and* tokens can be issued. A closed door announced over an open one would be the worse failure |
|
|
75
76
|
| `presenceDurcissement` | `actif` (a hardened call came back), `degrade` (migration 0018 is missing), `inconnu` (nothing attempted in this process — **not** a green light, and process-local: another instance may have seen otherwise) |
|
|
77
|
+
| `presenceFusion` | `actif` (a heartbeat used the fused contract — one round trip instead of two), `degrade` (migration 0019 is missing: heartbeats cost 3 round trips instead of 2, nothing breaks), `inconnu` (no heartbeat served in this process). Same three states, same trap, same reading rule as the row above |
|
|
76
78
|
|
|
77
79
|
⚠️ **Before you upgrade, do not read `presenceDurcissement`.** It is a *report of execution*: on an
|
|
78
80
|
instance where nothing is running it says `inconnu`, which means *nobody looked* — not *the migration
|
|
@@ -87,9 +89,26 @@ database, so it answers a global fact.
|
|
|
87
89
|
| `absente` | 0018 is missing: apply it **before** setting `PLAYER_PRESENCE_STRICT`, or bootstraps will be refused with `503` |
|
|
88
90
|
| `indetermine` | the question could not be asked — neither a yes nor a no |
|
|
89
91
|
|
|
90
|
-
The
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
The same answer carries **`schema.fusionBase`**, for migration `0019`, with the same three values.
|
|
93
|
+
Both come from **one** call in the normal case: `0019` succeeds `0018` and its argument set *contains*
|
|
94
|
+
it, so a call the long contract accepts proves both at once. The short contract is only asked again
|
|
95
|
+
when the long one is missing — i.e. exactly on the host that is behind and owes a precise answer.
|
|
96
|
+
|
|
97
|
+
| `fusionBase` | meaning |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `applique` | `0019` is in the database — a presence heartbeat costs **2** database round trips |
|
|
100
|
+
| `absente` | `0019` is missing: **nothing breaks**, a heartbeat costs **3** round trips instead of 2 (about 30 ops/s instead of 20 for 250 attendees). Applying it needs no redeploy — the player picks it up within a minute |
|
|
101
|
+
| `indetermine` | the question could not be asked — neither a yes nor a no |
|
|
102
|
+
|
|
103
|
+
⚠️ Unlike `0018`, a missing `0019` is a **cost**, not a risk: read it when you are sizing an
|
|
104
|
+
instance, not when you are deciding whether it is safe to run. A host missing it is also logged once
|
|
105
|
+
an hour, with the exact figures, so an idle instance still finds out.
|
|
106
|
+
|
|
107
|
+
The probe writes nothing, for **two independent reasons**: `p_page = null` on a slug that does not
|
|
108
|
+
exist leaves through `0019`'s *introuvable* branch before the insert, and `p_anon_cap = 0` already
|
|
109
|
+
left through the previous contract's *capped* branch. Two reasons rather than one, because a
|
|
110
|
+
diagnostic probe is the worst place to discover a regression. A real-Postgres test asserts that no
|
|
111
|
+
row appears. A host missing 0018 is also logged once an hour, so an idle instance still finds out.
|
|
93
112
|
|
|
94
113
|
That parameter **is** the one part of this card that needs the database, and only when you ask for
|
|
95
114
|
it. `verdict` is then one of:
|
package/docs/RETENTION.md
CHANGED
|
@@ -1,145 +1,144 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
1.
|
|
13
|
-
2.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
> ⚠️ **
|
|
17
|
-
>
|
|
18
|
-
>
|
|
19
|
-
>
|
|
20
|
-
>
|
|
21
|
-
>
|
|
22
|
-
>
|
|
1
|
+
# Data retention
|
|
2
|
+
|
|
3
|
+
This document is the **declared scope** of retention: every column of the schema whose *shape* can
|
|
4
|
+
carry personal data has a policy here — *purged after N* or *kept because*. A CI guard enumerates
|
|
5
|
+
the columns of the **live** schema (`information_schema`, never our memory of the file) and refuses
|
|
6
|
+
any personal-shaped column missing from this page: data without a written policy cannot enter the
|
|
7
|
+
schema without turning the build red.
|
|
8
|
+
|
|
9
|
+
The verification contract has two halves, deliberately **independent** (no shared code — no scope
|
|
10
|
+
function, no filter):
|
|
11
|
+
|
|
12
|
+
1. the **purge** (`server/retention.js`) declares what it erased, count by count;
|
|
13
|
+
2. the **census** (`supabase/recensement-retention.sql`, plain SQL) counts what remains inside the
|
|
14
|
+
claimed scope. The two numbers must contradict each other if either one lies.
|
|
15
|
+
|
|
16
|
+
> ⚠️ **Proposed windows, to be confirmed by the operator.** The durations below are reasoned
|
|
17
|
+
> defaults (analytics logs: 13 months, for year-on-year comparison; presentation archives: 12
|
|
18
|
+
> months after the end). A host adjusts them through `config.retention` — **whole months in
|
|
19
|
+
> [1, 120] only**. Any negative, zero, non-integer, `NaN`, `Infinity` or string value makes the
|
|
20
|
+
> purge FAIL before the first `DELETE`, naming the offending key: a configuration mistake never
|
|
21
|
+
> deletes anything. Bounds are computed in UTC and clamped to the last day of the target month
|
|
22
|
+
> ("31 March − 1 month" = 28 February, not 3 March).
|
|
23
23
|
>
|
|
24
|
-
> ⚠️ **
|
|
25
|
-
> `config.retention.balayage: true`.
|
|
26
|
-
>
|
|
27
|
-
>
|
|
28
|
-
>
|
|
29
|
-
> décision.
|
|
24
|
+
> ⚠️ **The automatic sweep is STRICTLY OPT-IN**: it runs only if the host writes
|
|
25
|
+
> `config.retention.balayage: true`. A host consuming the standalone context as-is inherits all of
|
|
26
|
+
> its capabilities by default — "nothing to wire up because nothing was unwired" — and a deletion is
|
|
27
|
+
> a business decision: it acts only where an operator has written it down. The `retention.run`
|
|
28
|
+
> action (trusted host or admin) stays available without opt-in: calling it IS the decision.
|
|
30
29
|
|
|
31
|
-
##
|
|
30
|
+
## Reading logs (external audience)
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
Purpose: reading statistics for a document that was sent out. **Purge: 13 months** after the event.
|
|
34
33
|
|
|
35
|
-
|
|
|
34
|
+
| column | contents | fate |
|
|
36
35
|
|---|---|---|
|
|
37
|
-
| `commercial_doc_views.recipient_email` |
|
|
38
|
-
| `commercial_doc_views.session_id` |
|
|
39
|
-
| `commercial_doc_views.ua` |
|
|
40
|
-
| `commercial_doc_sessions.recipient_email` | attribution
|
|
41
|
-
| `commercial_doc_sessions.session_id` |
|
|
42
|
-
| `commercial_doc_sessions.ip` | **
|
|
43
|
-
| `commercial_doc_sessions.ua` | User-Agent
|
|
44
|
-
| `commercial_doc_sessions.num_pages` / `commercial_doc_sessions.pages_time` |
|
|
36
|
+
| `commercial_doc_views.recipient_email` | who the read is attributed to | purged with the row, 13 months after `at` |
|
|
37
|
+
| `commercial_doc_views.session_id` | correlates the views of one session | same |
|
|
38
|
+
| `commercial_doc_views.ua` | browser (raw User-Agent) | same |
|
|
39
|
+
| `commercial_doc_sessions.recipient_email` | session attribution | purged with the row, 13 months after `last_at` |
|
|
40
|
+
| `commercial_doc_sessions.session_id` | session identifier | same |
|
|
41
|
+
| `commercial_doc_sessions.ip` | **IP address in the clear** | same — the most sensitive datum in the schema |
|
|
42
|
+
| `commercial_doc_sessions.ua` | raw User-Agent | same |
|
|
43
|
+
| `commercial_doc_sessions.num_pages` / `commercial_doc_sessions.pages_time` | page-by-page reading behaviour | same |
|
|
45
44
|
|
|
46
|
-
##
|
|
45
|
+
## Reading logs (internal team)
|
|
47
46
|
|
|
48
|
-
|
|
47
|
+
Same purpose, internal audience. **Purge: 13 months** after `last_at`.
|
|
49
48
|
|
|
50
|
-
|
|
|
49
|
+
| column | fate |
|
|
51
50
|
|---|---|
|
|
52
|
-
| `commercial_doc_internal_sessions.user_email` / `commercial_doc_internal_sessions.user_name` |
|
|
53
|
-
| `commercial_doc_internal_sessions.session_id` |
|
|
54
|
-
| `commercial_doc_internal_sessions.num_pages` / `commercial_doc_internal_sessions.pages_time` |
|
|
51
|
+
| `commercial_doc_internal_sessions.user_email` / `commercial_doc_internal_sessions.user_name` | purged with the row |
|
|
52
|
+
| `commercial_doc_internal_sessions.session_id` | same |
|
|
53
|
+
| `commercial_doc_internal_sessions.num_pages` / `commercial_doc_internal_sessions.pages_time` | same |
|
|
55
54
|
|
|
56
|
-
##
|
|
55
|
+
## Sending links (`commercial_doc_shares`)
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
A **live** link is a business record: its fields stay for as long as the distributed URL must keep
|
|
58
|
+
working. A **revoked** link serves nobody: **purged 13 months after revocation** (aligned with the
|
|
59
|
+
logs, which reference its slug). Revocation is **dated** by `commercial_doc_shares.revoked_at`
|
|
60
|
+
(migration 0013); links revoked before that column existed were given the migration's date — their
|
|
61
|
+
clock starts there, counting generously rather than inventing. Without the column, that particular
|
|
62
|
+
purge stays silent (schema probe); the others still run.
|
|
64
63
|
|
|
65
|
-
|
|
|
64
|
+
| column | contents | fate |
|
|
66
65
|
|---|---|---|
|
|
67
|
-
| `commercial_doc_shares.recipient_email` |
|
|
68
|
-
| `commercial_doc_shares.attested_recipient_email` |
|
|
69
|
-
| `commercial_doc_shares.recipient_name` |
|
|
70
|
-
| `commercial_doc_shares.created_by` | email
|
|
71
|
-
| `commercial_doc_shares.file_name` |
|
|
66
|
+
| `commercial_doc_shares.recipient_email` | who may forward on re-share | kept while the link lives; row purged 13 months after revocation |
|
|
67
|
+
| `commercial_doc_shares.attested_recipient_email` | who the host attests the link to | same |
|
|
68
|
+
| `commercial_doc_shares.recipient_name` | recipient's name | same |
|
|
69
|
+
| `commercial_doc_shares.created_by` | email of the salesperson who created it | same |
|
|
70
|
+
| `commercial_doc_shares.file_name` | file name (may carry a person's name) | business data, purged with the row |
|
|
72
71
|
|
|
73
|
-
##
|
|
72
|
+
## Live presentations
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
`updated_at`** —
|
|
77
|
-
`present-attachments` (
|
|
74
|
+
An **inactive** presentation (finished or abandoned) is an archive: **purged 12 months after
|
|
75
|
+
`updated_at`** — the presentation, its messages, its attendance records, and its attachments in the
|
|
76
|
+
`present-attachments` bucket (if the host provides `storage.remove`, otherwise the limit is stated
|
|
77
|
+
below).
|
|
78
78
|
|
|
79
|
-
|
|
|
79
|
+
| column | contents | fate |
|
|
80
80
|
|---|---|---|
|
|
81
|
-
| `doc_presentations.presenter_name` / `doc_presentations.owner_name` |
|
|
82
|
-
| `doc_presentations.owner_email` / `doc_presentations.owner_user_id` |
|
|
83
|
-
| `doc_presentations.owner_avatar` | URL
|
|
84
|
-
| `doc_presentations.control_hash` |
|
|
85
|
-
| `doc_presentations.content` |
|
|
86
|
-
| `doc_presentations.file_name` |
|
|
87
|
-
| `doc_presentation_messages.author_name` / `doc_presentation_messages.author_email` / `doc_presentation_messages.author_avatar` |
|
|
88
|
-
| `doc_presentation_messages.author_hash` |
|
|
89
|
-
| `doc_presentation_messages.body` |
|
|
90
|
-
| `doc_presentation_messages.reply_name` / `doc_presentation_messages.reply_text` |
|
|
91
|
-
| `doc_presentation_messages.attachment` | URL
|
|
92
|
-
| `doc_presentation_messages.client_key` |
|
|
93
|
-
| `doc_presentation_attendees.name` / `doc_presentation_attendees.email` / `doc_presentation_attendees.avatar` |
|
|
94
|
-
| `doc_presentation_attendees.attendee_key` |
|
|
95
|
-
| `doc_presentation_attendees.creator_ip_hash` | **
|
|
96
|
-
| `doc_presentation_attendees.last_token_at` / `doc_presentation_attendees.last_no_token_at` |
|
|
97
|
-
| `doc_presentation_attendees.pages` | pages
|
|
98
|
-
|
|
99
|
-
##
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
|
81
|
+
| `doc_presentations.presenter_name` / `doc_presentations.owner_name` | presenter's identity | purged with the row, 12 months after the end |
|
|
82
|
+
| `doc_presentations.owner_email` / `doc_presentations.owner_user_id` | owner | same |
|
|
83
|
+
| `doc_presentations.owner_avatar` | avatar URL | same |
|
|
84
|
+
| `doc_presentations.control_hash` | fingerprint of the control token (not the token) | same |
|
|
85
|
+
| `doc_presentations.content` | shared content (cards, media) | same |
|
|
86
|
+
| `doc_presentations.file_name` | name of the presented file | same |
|
|
87
|
+
| `doc_presentation_messages.author_name` / `doc_presentation_messages.author_email` / `doc_presentation_messages.author_avatar` | author's identity | purged with the presentation |
|
|
88
|
+
| `doc_presentation_messages.author_hash` | fingerprint of the author token | same |
|
|
89
|
+
| `doc_presentation_messages.body` | message body | same |
|
|
90
|
+
| `doc_presentation_messages.reply_name` / `doc_presentation_messages.reply_text` | quotation of another message | same |
|
|
91
|
+
| `doc_presentation_messages.attachment` | attachment URL | same — the bucket file included when `storage.remove` exists |
|
|
92
|
+
| `doc_presentation_messages.client_key` | idempotency key for sending | same |
|
|
93
|
+
| `doc_presentation_attendees.name` / `doc_presentation_attendees.email` / `doc_presentation_attendees.avatar` | attendee's identity | purged with the presentation |
|
|
94
|
+
| `doc_presentation_attendees.attendee_key` | presence identifier | same |
|
|
95
|
+
| `doc_presentation_attendees.creator_ip_hash` | **truncated fingerprint** of the IP that created the row (never the IP in the clear) — used for the anonymous-creation ceiling (migration 0015). ⚠️ **PSEUDONYMISED data, not anonymous**: a hashed IP remains personal data under the GDPR, and an unsalted SHA-256 can be recomputed exhaustively over the whole IPv4 space. Since 0.1.114 the fingerprint is an HMAC salted with `PLAYER_IP_HASH_SECRET` (failing that `PLAYER_PRESENCE_SECRET`, with domain separation) and bound to the `slug`, which prevents correlating one address across presentations. With no salt configured, the old unsalted fingerprint survives. | purged with the presentation |
|
|
96
|
+
| `doc_presentation_attendees.last_token_at` / `doc_presentation_attendees.last_no_token_at` | timestamp of the last heartbeat with / without a presence token — feeds the transition counter (migration 0017), carries no identity | purged with the presentation |
|
|
97
|
+
| `doc_presentation_attendees.pages` | pages the attendee viewed | same |
|
|
98
|
+
|
|
99
|
+
## Agent sessions (`doc_bot_sessions`)
|
|
100
|
+
|
|
101
|
+
Agent-guided walkthrough: **purged 13 months** after `last_at`.
|
|
102
|
+
|
|
103
|
+
| column | fate |
|
|
104
104
|
|---|---|
|
|
105
|
-
| `doc_bot_sessions.rating` / `doc_bot_sessions.rating_comment` |
|
|
106
|
-
| `doc_bot_sessions.in_tokens` / `doc_bot_sessions.out_tokens` / `doc_bot_sessions.cache_tokens` |
|
|
105
|
+
| `doc_bot_sessions.rating` / `doc_bot_sessions.rating_comment` | visitor's feedback — purged with the row |
|
|
106
|
+
| `doc_bot_sessions.in_tokens` / `doc_bot_sessions.out_tokens` / `doc_bot_sessions.cache_tokens` | AI volume (not personal, but carried by the row) — purged with it |
|
|
107
107
|
|
|
108
|
-
##
|
|
108
|
+
## Rate limits (`player_rate_limits`)
|
|
109
109
|
|
|
110
|
-
|
|
|
110
|
+
| column | contents | fate |
|
|
111
111
|
|---|---|---|
|
|
112
|
-
| `player_rate_limits.key` |
|
|
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
|
-
le même commit, et les copies antérieures suivent la politique de sauvegarde de l'hôte.
|
|
112
|
+
| `player_rate_limits.key` | may contain an **IP in the clear** (`hshare:<ip>`) or an email | row purged as soon as `expires_at` has passed (opportunistically, on every pass) |
|
|
113
|
+
|
|
114
|
+
## Limits stated rather than left unsaid
|
|
115
|
+
|
|
116
|
+
- **Orphaned attachments**: purging the rows erases the bucket file only if the host context
|
|
117
|
+
provides `storage.remove` (an optional capability). Without it, the URL becomes unreachable from
|
|
118
|
+
the product but the object survives in the bucket — said here rather than simulated.
|
|
119
|
+
- **The presentation ceiling is GLOBAL**: messages and attendance records each share one `plafond`
|
|
120
|
+
budget spread across every presentation of a run — not a ceiling per presentation (otherwise
|
|
121
|
+
500 × 5000 = 2.5 M possible rows). The loop stops when the budgets are exhausted, without
|
|
122
|
+
deleting the remaining presentations.
|
|
123
|
+
- **The dryRun report is complete for presentations**: `messagesExaminees`, `presencesExaminees`
|
|
124
|
+
and `fichiersCandidats` say what the REAL purge would do — same selection walk, no-op deletion,
|
|
125
|
+
`efface.* = 0`.
|
|
126
|
+
- **The purge advances in BOUNDED BATCHES** (200 rows, a ceiling of 5000 per table and 500
|
|
127
|
+
presentations per run): it selects a batch of identifiers, deletes them with `id=in.(…)`, and
|
|
128
|
+
starts again. The report (`r.rapport`) carries, per table: `examinees`, `supprimees`, `tronque`
|
|
129
|
+
(there is more left for the next pass). `retention.run` accepts `{ dryRun: true }`: it counts
|
|
130
|
+
without erasing anything — to be run before the first real purge of a large history.
|
|
131
|
+
- **Indexes** (migration 0014): `commercial_doc_sessions(last_at)`, `doc_bot_sessions(last_at)`,
|
|
132
|
+
`commercial_doc_shares(revoked_at) where revoked`. On a LARGE installation already in production,
|
|
133
|
+
create them by hand with `CREATE INDEX CONCURRENTLY` outside the migration (the migration lays
|
|
134
|
+
them down as ordinary indexes, which briefly locks writes — negligible on a young database, to be
|
|
135
|
+
avoided on a large active table).
|
|
136
|
+
- **The census does not run by itself in production**: it is a piece of SQL an operator runs (and
|
|
137
|
+
that CI executes on every run against a real, artificially aged database).
|
|
138
|
+
- **"What exists" has a depth in time that `information_schema` does not have** (a question from the
|
|
139
|
+
second host, with no mechanical answer): a column dropped from the schema leaves the scope of both
|
|
140
|
+
texts, but its data may survive in a dump, a backup or an archive table. This contract covers the
|
|
141
|
+
LIVE DATABASE; copies (backups, exports, migration dumps) are the operator's scope, named here
|
|
142
|
+
rather than simulated. Operational corollary: dropping a column holding personal data is itself a
|
|
143
|
+
retention act — its row leaves this document in the same commit, and earlier copies follow the
|
|
144
|
+
host's backup policy.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.128",
|
|
4
4
|
"description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pdf-viewer",
|
package/server/handler.js
CHANGED
|
@@ -624,6 +624,17 @@ async function handler(req, res) {
|
|
|
624
624
|
presenceDurcissement: (() => {
|
|
625
625
|
try { return require("./presentations").etatDurcissementBootstrap(); } catch { return "inconnu"; }
|
|
626
626
|
})(),
|
|
627
|
+
// ⚠️ LE CHEMIN FUSIONNÉ EST-IL EMPRUNTÉ ? Sans ce champ, un hôte à qui 0019 manque retombe en
|
|
628
|
+
// silence à trois allers-retours par battement : correct, mais deux fois plus cher sur le
|
|
629
|
+
// chemin le plus chaud du produit, et RIEN ne le dirait. Une dégradation qu'on ne peut pas
|
|
630
|
+
// observer est une dégradation qu'on découvre à la facture — ou jamais.
|
|
631
|
+
//
|
|
632
|
+
// ⚠️ Mêmes trois états, même piège : « inconnu » veut dire « personne n'a battu dans ce
|
|
633
|
+
// processus », pas « la migration manque ». Pour la question d'avant-déploiement, c'est
|
|
634
|
+
// `schema.fusionBase` qu'il faut lire — elle, elle interroge la base.
|
|
635
|
+
presenceFusion: (() => {
|
|
636
|
+
try { return require("./presentations").etatFusionBattement(); } catch { return "inconnu"; }
|
|
637
|
+
})(),
|
|
627
638
|
// ⚠️ LES JETONS DE PRÉSENCE SONT-ILS RÉELLEMENT ÉMIS ? Sans ce booléen, un exploitant qui vient
|
|
628
639
|
// de poser `PLAYER_PRESENCE_SECRET` n'a AUCUN moyen de vérifier que son réglage a pris : la
|
|
629
640
|
// carte affiche `presence: {0,0}` aussi bien quand le secret est actif que quand la variable est
|
package/server/presentations.js
CHANGED
|
@@ -839,6 +839,31 @@ function signatureAbsente(erreur) {
|
|
|
839
839
|
// silence. Un « oui » (la signature existe) n'a pas besoin d'expirer : une fonction ne disparaît pas.
|
|
840
840
|
let _bumpSansDurcissementJusqua = 0;
|
|
841
841
|
const MEMO_SANS_DURCISSEMENT_MS = 60 * 1000;
|
|
842
|
+
// ⚠️ CE QUE CE PROCESSUS A CONSTATÉ DU CHEMIN FUSIONNÉ — pas ce qu'il espère. Trois états, comme
|
|
843
|
+
// pour le durcissement, et pour la même raison : « pas dégradé » n'est pas « vérifié ». Un
|
|
844
|
+
// processus qui vient de démarrer n'a rien tenté ; rendre « actif » annoncerait une propriété sur
|
|
845
|
+
// la foi d'une absence d'observation.
|
|
846
|
+
//
|
|
847
|
+
// ⚠️ ET C'EST UN RAPPORT D'EXÉCUTION, PAS UN INVENTAIRE. Il ne se lit pas avant un déploiement : au
|
|
848
|
+
// repos il vaut « inconnu », ce qui veut dire « personne n'a regardé ». La question « la migration
|
|
849
|
+
// est-elle là ? » se pose à la BASE — c'est `schema.fusionBase`, qui la lui pose vraiment.
|
|
850
|
+
let _etatFusion = "inconnu";
|
|
851
|
+
function etatFusionBattement() {
|
|
852
|
+
// ⚠️ MÊME LECTURE QUE `etatDurcissementBootstrap`, DÉLIBÉRÉMENT. Les deux champs partagent leur
|
|
853
|
+
// vocabulaire et se lisent côte à côte dans la carte : leur donner des règles d'expiration
|
|
854
|
+
// différentes serait un piège pour qui les compare. Tant que le mémo court, la dernière
|
|
855
|
+
// observation vaut ; passé lui, une preuve NÉGATIVE périmée retombe sur l'ignorance et jamais sur
|
|
856
|
+
// la confiance. Un « oui » n'expire pas — une fonction ne disparaît pas toute seule.
|
|
857
|
+
if (Date.now() < _bumpSansFusionJusqua) return "degrade";
|
|
858
|
+
return _etatFusion === "degrade" ? "inconnu" : _etatFusion;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ⚠️ MÉMO DU CONTRAT FUSIONNÉ (0019), MÊME PATRON QUE 0018. La fusion est FONCTION-SEULE : aucune
|
|
862
|
+
// colonne à sonder, donc on la DEMANDE et on retient l'échec, sinon un hôte non migré paierait un
|
|
863
|
+
// aller-retour perdu à chaque battement. Soixante secondes : assez pour ne pas insister, assez court
|
|
864
|
+
// pour qu'appliquer la migration se voie sans redémarrer le processus.
|
|
865
|
+
let _bumpSansFusionJusqua = 0;
|
|
866
|
+
const MEMO_SANS_FUSION_MS = 60 * 1000;
|
|
842
867
|
// ⚠️ LE DERNIER MOT OBSERVÉ, ÉCRIT — pas déduit. Un booléen « on a essayé » ne pouvait pas dire si
|
|
843
868
|
// ce mot fut un succès ou un échec, et c'est lui qui décide. Comparer deux instants le disait, au
|
|
844
869
|
// prix d'une égalité possible à la milliseconde. On écrit donc l'état : « actif » (un appel durci
|
|
@@ -862,6 +887,33 @@ function erreurDurcissementAbsent() {
|
|
|
862
887
|
return e;
|
|
863
888
|
}
|
|
864
889
|
|
|
890
|
+
// ⚠️ L'APPEL FUSIONNÉ NE PASSE PAS PAR `appelerBump`, ET CE N'EST PAS UN OUBLI. `appelerBump` lit
|
|
891
|
+
// une signature absente comme « 0018 manque » : c'est vrai tant qu'UN SEUL fichier peut causer cet
|
|
892
|
+
// échec. Avec 0019, deux le peuvent — et le diagnostic se tromperait de fichier, exactement le défaut
|
|
893
|
+
// qu'on a retiré du message d'alerte quelques versions plus tôt (un nom FAUX est pire qu'absent :
|
|
894
|
+
// l'exploitant vérifie la migration nommée, la trouve appliquée, et conclut au faux positif).
|
|
895
|
+
//
|
|
896
|
+
// Ici on ne diagnostique donc RIEN : l'échec de signature arme le mémo et rend la main au chemin
|
|
897
|
+
// classique, qui refera le diagnostic avec un seul fichier possible. Le succès, lui, prouve que 0019
|
|
898
|
+
// est là — donc 0018 aussi, elle la précède — et le durcissement est bien celui qu'on a demandé.
|
|
899
|
+
async function appelerBumpFusionne(corps, durcissementVoulu) {
|
|
900
|
+
const reponse = await PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: corps });
|
|
901
|
+
// L'appel est REVENU : le contrat à 13 arguments existe. Ce qui se mesure est le RETOUR, jamais
|
|
902
|
+
// l'intention de partir — et un durcissement demandé qui revient par ici est bien appliqué, 0019
|
|
903
|
+
// succédant à 0018 elle ne peut pas être là sans elle.
|
|
904
|
+
_etatFusion = "actif";
|
|
905
|
+
if (durcissementVoulu) _etatDurcissement = "actif";
|
|
906
|
+
// ⚠️ ON PROJETTE PLUTÔT QUE DE RENDRE LA LIGNE TELLE QUELLE — une garde de ce dépôt l'exige, et
|
|
907
|
+
// elle a raison ici : le jour où la RPC rendra une colonne de plus, elle ne traversera pas cette
|
|
908
|
+
// fonction sans que quelqu'un l'ait décidé. Les sept champs sont ceux que 0019 déclare.
|
|
909
|
+
const l = Array.isArray(reponse) ? reponse[0] : reponse;
|
|
910
|
+
if (!l || typeof l.ok !== "boolean") return null; // forme inattendue : aucun verdict inventé
|
|
911
|
+
return {
|
|
912
|
+
ok: !!l.ok, created: !!l.created, capped: !!l.capped, usurpe: !!l.usurpe,
|
|
913
|
+
introuvable: !!l.introuvable, archivee: !!l.archivee, page: Math.max(0, Math.trunc(Number(l.page) || 0)),
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
865
917
|
async function appelerBump(corps, durcissementVoulu) {
|
|
866
918
|
const appel = (b) => PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: b });
|
|
867
919
|
if (durcissementVoulu && Date.now() < _bumpSansDurcissementJusqua) {
|
|
@@ -920,19 +972,36 @@ async function appelerBump(corps, durcissementVoulu) {
|
|
|
920
972
|
}
|
|
921
973
|
}
|
|
922
974
|
|
|
923
|
-
async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null, hasToken = null, onlyIfUnclaimed = false } = {}) {
|
|
975
|
+
async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null, hasToken = null, onlyIfUnclaimed = false, controlHash = null, sansFusion = false } = {}) {
|
|
924
976
|
const { key, name, email, avatar, isMember, isPresenter } = participant || {};
|
|
925
977
|
if (!slug || !key) return { ok: false, status: 400 };
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
const
|
|
978
|
+
// ⚠️ CHEMIN FUSIONNÉ (migration 0019) : C'EST LA BASE QUI LIT LA PRÉSENTATION, PLUS NOUS. Un
|
|
979
|
+
// battement coûtait TROIS allers-retours — débit par IP, lecture de la présentation, écriture. La
|
|
980
|
+
// lecture servait à trois choses, et à trois seulement : l'existence, la page courante, et
|
|
981
|
+
// l'identification du présentateur par son jeton de contrôle. Les trois se font désormais DANS la
|
|
982
|
+
// transaction d'écriture. Mesuré à 250 participants avant d'être engagé, pas supposé.
|
|
983
|
+
//
|
|
984
|
+
// On n'y va pas si l'appelant nous a DÉJÀ fourni la présentation : il l'a lue pour autre chose, la
|
|
985
|
+
// refaire lire ne ferait rien gagner. `sansFusion` est le repli explicite — il ne dépend pas du
|
|
986
|
+
// mémo, donc la reprise ci-dessous ne peut pas boucler même si l'horloge se comporte mal.
|
|
987
|
+
const fusion = presentation == null && !sansFusion && Date.now() >= _bumpSansFusionJusqua;
|
|
988
|
+
let pres = presentation;
|
|
989
|
+
if (!fusion) {
|
|
990
|
+
pres = presentation || await getPresentation(slug);
|
|
991
|
+
if (!pres) return { ok: false, status: 404 };
|
|
992
|
+
// ⚠️ La lecture est DÉJÀ faite ici : on s'en sert plutôt que d'en refaire une. Un battement de
|
|
993
|
+
// présence sur une session close n'a rien à mettre à jour — et il en arrive à chaque onglet
|
|
994
|
+
// resté ouvert, longtemps après la fin.
|
|
995
|
+
if (pres.active === false && pres.control_hash == null) return REFUS_ARCHIVE;
|
|
996
|
+
}
|
|
997
|
+
// `null` en mode fusionné : la base prendra sa propre `current_page`. C'est aussi le SIGNAL qu'elle
|
|
998
|
+
// attend pour appliquer les deux refus à notre place (cf. 0019) — un appelant qui a lu envoie
|
|
999
|
+
// toujours une page, un appelant qui n'a pas lu n'en a pas.
|
|
1000
|
+
const page = fusion ? null : Math.max(1, Math.trunc(Number(pres.current_page) || 1));
|
|
1001
|
+
// ⚠️ LA COMPARAISON DE JETON RESTE ICI HORS FUSION, sinon le présentateur perdrait son titre en
|
|
1002
|
+
// silence sur un hôte non migré : la décision a changé de camp, elle n'a pas disparu d'ici.
|
|
1003
|
+
const presentateurPreuve = !!isPresenter
|
|
1004
|
+
|| (!fusion && !!pres && controlHash != null && pres.control_hash != null && controlHash === pres.control_hash);
|
|
936
1005
|
|
|
937
1006
|
// ⚠️ CHEMIN ATOMIQUE (migration 0015) : upsert ET plafond de création anonyme en UN geste, à l'abri
|
|
938
1007
|
// des créations concurrentes. Absent (404, migration non appliquée) → on retombe sur la boucle
|
|
@@ -956,7 +1025,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
956
1025
|
const corpsRpc = {
|
|
957
1026
|
p_slug: String(slug), p_key: String(key), p_ip_hash: ipHash || null, p_page: page,
|
|
958
1027
|
p_name: (name || "").slice(0, 120), p_avatar: (avatar || "").slice(0, 600),
|
|
959
|
-
p_is_member: !!isMember, p_is_presenter:
|
|
1028
|
+
p_is_member: !!isMember, p_is_presenter: presentateurPreuve,
|
|
960
1029
|
p_max_gap_ms: ATTEND_MAX_GAP_MS, p_anon_cap: capAnon,
|
|
961
1030
|
};
|
|
962
1031
|
// true → last_token_at, false → last_no_token_at, null → ni l'un ni l'autre. 0017 seulement.
|
|
@@ -969,10 +1038,41 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
969
1038
|
// pour le processus : un hôte non migré ne paie pas deux allers-retours par battement, un seul.
|
|
970
1039
|
const durcissementVoulu = !!onlyIfUnclaimed;
|
|
971
1040
|
if (durcissementVoulu) corpsRpc.p_only_if_unclaimed = true;
|
|
1041
|
+
// ⚠️ EN MODE FUSIONNÉ, `p_control_hash` PART TOUJOURS — MÊME NULL, ET C'EST ESSENTIEL. PostgREST
|
|
1042
|
+
// résout une RPC par jeu d'arguments NOMMÉS : omettre l'argument quand le visiteur n'a pas de jeton
|
|
1043
|
+
// de contrôle ferait résoudre l'appel vers le contrat COURT, qui existe encore sur une base non
|
|
1044
|
+
// migrée et qui lit `p_page` null comme « page 1 ». On enregistrerait alors la page 1 pour tout le
|
|
1045
|
+
// monde, sans aucun refus, et RIEN ne le dirait. L'argument explicite est précisément ce qui fait
|
|
1046
|
+
// ÉCHOUER l'appel là où 0019 manque — donc ce qui déclenche le repli au lieu d'un faux succès.
|
|
1047
|
+
if (fusion) corpsRpc.p_control_hash = controlHash || null;
|
|
972
1048
|
try {
|
|
973
|
-
|
|
1049
|
+
let r;
|
|
1050
|
+
if (fusion) {
|
|
1051
|
+
try {
|
|
1052
|
+
r = await appelerBumpFusionne(corpsRpc, durcissementVoulu);
|
|
1053
|
+
} catch (erreurFusion) {
|
|
1054
|
+
// Toute autre erreur est une vraie panne : elle suit le chemin d'erreur commun ci-dessous.
|
|
1055
|
+
if (!signatureAbsente(erreurFusion)) throw erreurFusion;
|
|
1056
|
+
// 0019 n'est pas appliquée. On arme le mémo et on recommence par le chemin classique, qui
|
|
1057
|
+
// lira la présentation et décidera du présentateur ici — SANS rien perdre. Rien à signaler à
|
|
1058
|
+
// l'exploitant : le battement reste exact, il coûte simplement l'aller-retour d'avant.
|
|
1059
|
+
_etatFusion = "degrade";
|
|
1060
|
+
_bumpSansFusionJusqua = Date.now() + MEMO_SANS_FUSION_MS;
|
|
1061
|
+
return recordAttendance(slug, participant,
|
|
1062
|
+
{ presentation, ipHash, anonCap, hasToken, onlyIfUnclaimed, controlHash, sansFusion: true });
|
|
1063
|
+
}
|
|
1064
|
+
} else {
|
|
1065
|
+
r = await appelerBump(corpsRpc, durcissementVoulu);
|
|
1066
|
+
}
|
|
974
1067
|
const ligne = Array.isArray(r) ? r[0] : r;
|
|
975
1068
|
if (ligne && typeof ligne.ok === "boolean") {
|
|
1069
|
+
// ⚠️ LES DEUX REFUS QUE LA ROUTE OPPOSAIT AVANT D'APPELER, RENDUS PAR LA BASE (0019). Ils ne
|
|
1070
|
+
// sont pas décoratifs : sans eux, la fusion aurait supprimé un 404 et un refus d'archive en
|
|
1071
|
+
// déplaçant la décision. Ils arrivent DISTINCTS plutôt que fondus dans un `ok:false`, pour que
|
|
1072
|
+
// le client garde les mêmes codes qu'avant — 404 « cette présentation n'existe pas », 409
|
|
1073
|
+
// « elle est finie ». Un appelant qui a fourni la présentation ne les verra jamais.
|
|
1074
|
+
if (ligne.introuvable) return { ok: false, status: 404 };
|
|
1075
|
+
if (ligne.archivee) return REFUS_ARCHIVE;
|
|
976
1076
|
// Plafond de création atteint : on ne crée pas ce faux participant. 429 = « trop », pas une panne.
|
|
977
1077
|
if (ligne.capped) return { ok: false, status: 429 };
|
|
978
1078
|
// Bootstrap sur une ligne DÉJÀ RÉCLAMÉE par un porteur de jeton : rien n'a été écrit. 409 = « ce
|
|
@@ -1024,6 +1124,20 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1024
1124
|
// est conditionnée à la valeur LUE ; zéro ligne = quelqu'un d'autre a battu entre-temps (l'autre
|
|
1025
1125
|
// onglet du même participant) — on relit et on rejoue. Sans ça, deux onglets qui battent dans la
|
|
1026
1126
|
// même seconde perdaient une page vue : la seconde réécriture emportait la première.
|
|
1127
|
+
// ⚠️ LE REPLI A BESOIN DE LA PAGE, ET EN MODE FUSIONNÉ NOUS NE L'AVONS PAS. L'économie de 0019 ne
|
|
1128
|
+
// vaut que sur le chemin qui RÉUSSIT : ici la RPC a échoué (ou rendu une forme inattendue), donc on
|
|
1129
|
+
// paie la lecture qu'on avait évitée. Sans ça on écrirait `pages: [null]` et un présentateur
|
|
1130
|
+
// perdrait son titre — deux dégâts silencieux, au moment précis où l'on est déjà en difficulté.
|
|
1131
|
+
let pageEcrite = page;
|
|
1132
|
+
let presentateurEcrit = presentateurPreuve;
|
|
1133
|
+
if (pageEcrite == null) {
|
|
1134
|
+
const relu = await getPresentation(slug);
|
|
1135
|
+
if (!relu) return { ok: false, status: 404 };
|
|
1136
|
+
if (relu.active === false && relu.control_hash == null) return REFUS_ARCHIVE;
|
|
1137
|
+
pageEcrite = Math.max(1, Math.trunc(Number(relu.current_page) || 1));
|
|
1138
|
+
presentateurEcrit = presentateurEcrit
|
|
1139
|
+
|| (controlHash != null && relu.control_hash != null && controlHash === relu.control_hash);
|
|
1140
|
+
}
|
|
1027
1141
|
let cur = null;
|
|
1028
1142
|
for (let essai = 0; essai < 4; essai += 1) {
|
|
1029
1143
|
if (cur === null) {
|
|
@@ -1032,7 +1146,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1032
1146
|
}
|
|
1033
1147
|
if (!cur) {
|
|
1034
1148
|
const now = Date.now();
|
|
1035
|
-
const row = { slug: String(slug), attendee_key: String(key).slice(0, 200), name: (name || "").slice(0, 120) || null, email: lc(email) || null, avatar: (avatar || "").slice(0, 600) || null, is_member: !!isMember, is_presenter:
|
|
1149
|
+
const row = { slug: String(slug), attendee_key: String(key).slice(0, 200), name: (name || "").slice(0, 120) || null, email: lc(email) || null, avatar: (avatar || "").slice(0, 600) || null, is_member: !!isMember, is_presenter: presentateurEcrit, first_seen: new Date(now).toISOString(), last_seen: new Date(now).toISOString(), total_ms: 0, pages: [pageEcrite] };
|
|
1036
1150
|
try {
|
|
1037
1151
|
await PLAYER.db.request("doc_presentation_attendees", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
|
|
1038
1152
|
return { ok: true };
|
|
@@ -1059,7 +1173,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1059
1173
|
const gap = now - lu;
|
|
1060
1174
|
const addMs = gap > 0 && gap <= ATTEND_MAX_GAP_MS ? gap : 0;
|
|
1061
1175
|
const pages = Array.isArray(cur.pages) ? cur.pages.slice() : [];
|
|
1062
|
-
if (!pages.includes(
|
|
1176
|
+
if (!pages.includes(pageEcrite)) pages.push(pageEcrite);
|
|
1063
1177
|
const ecrit = await ecrireSiEncoreVrai(
|
|
1064
1178
|
`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&last_seen=eq.${enc(String(cur.last_seen))}`,
|
|
1065
1179
|
{ last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null,
|
|
@@ -1067,7 +1181,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1067
1181
|
// première ligne : un transfert de présentation change qui porte le titre, et une session qui
|
|
1068
1182
|
// s'authentifie en cours de route devient un membre. Figés, ils décriraient l'instant de
|
|
1069
1183
|
// l'arrivée et non la réalité — et le premier arrivé aurait raison pour toujours.
|
|
1070
|
-
is_member: !!isMember, is_presenter:
|
|
1184
|
+
is_member: !!isMember, is_presenter: presentateurEcrit },
|
|
1071
1185
|
);
|
|
1072
1186
|
if (ecrit) return { ok: true };
|
|
1073
1187
|
cur = null; // battu en vol : on relira l'état frais au tour suivant
|
|
@@ -1260,4 +1374,4 @@ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
|
|
|
1260
1374
|
module.exports = {
|
|
1261
1375
|
reacteurDepuisJeton,
|
|
1262
1376
|
purgerPerimees,
|
|
1263
|
-
messagePublic, CHAMPS_PUBLICS, etatDurcissementBootstrap, signatureAbsente, cheminPieceJointe, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent , STALE_MS};
|
|
1377
|
+
messagePublic, CHAMPS_PUBLICS, etatDurcissementBootstrap, etatFusionBattement, signatureAbsente, cheminPieceJointe, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent , STALE_MS};
|
package/server/routes-direct.js
CHANGED
|
@@ -78,9 +78,18 @@ async function traiter(req, res, body, _slug) {
|
|
|
78
78
|
// route ne le faisait pas. Et l'appartenance se prouve par le jeton d'accès de la session :
|
|
79
79
|
// cette route est un `fetch`, elle peut porter un en-tête (contrairement au suivi de
|
|
80
80
|
// lecture, qui part par `sendBeacon` et signe donc dans le corps).
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// ⚠️ CETTE ROUTE NE LIT PLUS LA PRÉSENTATION (0019). Elle la lisait pour trois choses —
|
|
82
|
+
// existe-t-elle, est-elle close, et l'appelant porte-t-il le jeton de contrôle — et les
|
|
83
|
+
// trois se décident maintenant dans la transaction d'écriture. Le battement passe de trois
|
|
84
|
+
// allers-retours à deux. Ce qui monte est l'EMPREINTE du jeton, jamais le jeton : la
|
|
85
|
+
// comparaison est la même qu'ici, faite sur la même donnée, un cran plus bas.
|
|
86
|
+
//
|
|
87
|
+
// ⚠️ Le 404 et le refus d'archive n'ont pas disparu, ils REMONTENT : `recordAttendance`
|
|
88
|
+
// rend { status: 404 } et REFUS_ARCHIVE à partir de ce que la base répond. Un déplacement
|
|
89
|
+
// de décision est l'endroit où une garde se perd — celles-ci sont mesurées, des deux côtés.
|
|
90
|
+
const controlHash = body.control
|
|
91
|
+
? require("crypto").createHash("sha256").update(String(body.control)).digest("hex")
|
|
92
|
+
: null;
|
|
84
93
|
// ⚠️ Une identité prouvée REMPLACE celle qu'on affirme — elle ne s'y ajoute pas. Sinon la
|
|
85
94
|
// vérification ne servirait qu'à décorer une affirmation qu'on croit toujours.
|
|
86
95
|
const profil = await profilDuJeton(req);
|
|
@@ -188,13 +197,15 @@ async function traiter(req, res, body, _slug) {
|
|
|
188
197
|
name: (profil && profil.name) || body.name,
|
|
189
198
|
email: profil ? profil.email : body.email,
|
|
190
199
|
avatar: (profil && profil.avatar) || body.avatar,
|
|
191
|
-
|
|
200
|
+
// ⚠️ ON N'AFFIRME PLUS LE TITRE, ON FOURNIT LA PREUVE (`controlHash`, plus bas) : c'est
|
|
201
|
+
// la base qui compare. Affirmer `false` ici n'enlève donc rien — le titre vient du jeton.
|
|
202
|
+
isMember: !!profil, isPresenter: false,
|
|
192
203
|
// ⚠️ UN BOOTSTRAP NE PEUT PAS S'EMPARER D'UNE PRÉSENCE DÉJÀ RÉCLAMÉE (0018). `wantToken`
|
|
193
204
|
// est AUTO-DÉCLARÉ : sans ce verrou, un attaquant le déclarait, posait la clé d'un
|
|
194
205
|
// participant enregistré, et écrasait sa ligne — l'usurpation même que l'étape 2 ferme
|
|
195
206
|
// pour les battements ordinaires. Le second hôte l'a EXÉCUTÉE sur sa prod. Le contrôle ne
|
|
196
207
|
// vaut que pour un bootstrap : un porteur de jeton est déjà prouvé, un membre aussi.
|
|
197
|
-
}, {
|
|
208
|
+
}, { ipHash, anonCap, hasToken, onlyIfUnclaimed: bootstrap, controlHash });
|
|
198
209
|
// On ÉMET (ou ré-émet) un jeton pour l'anonyme dont l'écriture a réussi : le client le garde
|
|
199
210
|
// et le renvoie aux battements suivants. `exp` court (6 h), ré-émis à chaque battement — pas
|
|
200
211
|
// de table anti-rejeu (le scellé d'archive et l'exp bornent déjà le rejeu, cf. 0007). Un
|
package/server/schema.js
CHANGED
|
@@ -313,6 +313,11 @@ async function vraimentSonderTout() {
|
|
|
313
313
|
return {
|
|
314
314
|
...etatDuSchema(), verdict: "indetermine",
|
|
315
315
|
durcissementBase: "indetermine", durcissementBaseCouvre: PORTEE_DURCISSEMENT,
|
|
316
|
+
// ⚠️ LE CHAMP DOIT ÊTRE LÀ ICI AUSSI, pour la raison qui a valu son correctif au voisin : un
|
|
317
|
+
// champ ABSENT ne se distingue pas d'un contrat plus ancien, et `undefined !== "applique"`
|
|
318
|
+
// est vrai par accident. Un hôte qui teste avant de déployer mérite un « je ne sais pas »
|
|
319
|
+
// explicite plutôt qu'un silence qui ressemble à un non.
|
|
320
|
+
fusionBase: "indetermine", fusionBaseCouvre: PORTEE_FUSION,
|
|
316
321
|
};
|
|
317
322
|
}
|
|
318
323
|
// ⚠️ LE TÉMOIN VIENT DE RÉPONDRE : tout « non » encore en cache est SUSPECT — il peut dater
|
|
@@ -324,7 +329,7 @@ async function vraimentSonderTout() {
|
|
|
324
329
|
const etat = etatDuSchema();
|
|
325
330
|
await ajouterSansRang(etat);
|
|
326
331
|
await ajouterPresence(etat);
|
|
327
|
-
await
|
|
332
|
+
await ajouterMigrationsDePresence(etat);
|
|
328
333
|
return etat;
|
|
329
334
|
}
|
|
330
335
|
|
|
@@ -378,12 +383,81 @@ const PORTEE_DURCISSEMENT =
|
|
|
378
383
|
+ "C'est ce champ-ci qu'on lit AVANT un déploiement ; « indetermine » = la question n'a pas pu "
|
|
379
384
|
+ "être posée, ce n'est ni un oui ni un non.";
|
|
380
385
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
386
|
+
// ⚠️ MÊME DISTINCTION, POUR 0019 : `fusionBase` est une propriété de la BASE, `presenceFusion` (la
|
|
387
|
+
// carte) est ce que CE processus a constaté en servant des battements. Le second vaut « inconnu » au
|
|
388
|
+
// repos — c'est-à-dire « personne n'a regardé » — et ne peut donc pas servir de contrôle avant
|
|
389
|
+
// déploiement. On a déjà écrit une consigne de pré-vol sur un champ qui refuse de se prononcer sans
|
|
390
|
+
// observation ; celui-ci existe pour qu'on n'ait pas à recommencer.
|
|
391
|
+
const PORTEE_FUSION =
|
|
392
|
+
"propriété de la BASE (migration 0019), globale à toutes les instances — à ne pas confondre avec "
|
|
393
|
+
+ "presenceFusion, qui est ce que CE processus a constaté en servant des battements. Sans 0019, "
|
|
394
|
+
+ "rien ne casse : un battement coûte 3 allers-retours au lieu de 2. « indetermine » = la question "
|
|
395
|
+
+ "n'a pas pu être posée, ce n'est ni un oui ni un non.";
|
|
396
|
+
|
|
397
|
+
// ⚠️ DEUX MIGRATIONS, UNE SEULE SONDE DANS LE CAS NORMAL. 0019 succède à 0018 et son jeu
|
|
398
|
+
// d'arguments CONTIENT le sien : un appel qui passe le contrat à 13 arguments prouve donc les deux
|
|
399
|
+
// d'un coup. On ne repose la question du durcissement que si le contrat long n'existe pas — c'est-
|
|
400
|
+
// à-dire sur un hôte en retard, qui est exactement celui à qui l'on doit une réponse précise.
|
|
401
|
+
//
|
|
402
|
+
// ⚠️ ET LA SONDE N'ÉCRIT TOUJOURS RIEN, POUR DEUX RAISONS INDÉPENDANTES. `p_page: null` sur un slug
|
|
403
|
+
// qui n'existe pas sort par la branche « introuvable » de 0019, avant l'insert ; et `p_anon_cap: 0`
|
|
404
|
+
// sortait déjà par la branche « capped » du contrat précédent. Deux raisons plutôt qu'une, parce
|
|
405
|
+
// qu'une sonde de diagnostic qui écrirait serait le pire endroit où découvrir une régression.
|
|
406
|
+
// ⚠️ UN SEUL ENDROIT QUI PARLE, PARCE QU'IL Y A PLUSIEURS RAISONS DE PARLER. La carte est publique
|
|
407
|
+
// donc appelable en boucle : un journal sans frein deviendrait une arme. Une fois par heure et par
|
|
408
|
+
// motif — et jamais bloquant, un diagnostic qui tombe en panne en diagnostiquant ne diagnostique
|
|
409
|
+
// plus rien.
|
|
410
|
+
async function journaliser(cle, message) {
|
|
411
|
+
try {
|
|
412
|
+
if (await PLAYER.limits.allow(cle, 1, 3600)) PLAYER.errors.capture(new Error(message), { route: "schema" });
|
|
413
|
+
} catch { /* jamais bloquant */ }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function ajouterMigrationsDePresence(etat) {
|
|
417
|
+
const commun = {
|
|
418
|
+
p_slug: SLUG_SONDE_DURCISSEMENT, p_key: "sonde", p_ip_hash: null,
|
|
384
419
|
p_name: "", p_avatar: "", p_is_member: false, p_is_presenter: false,
|
|
385
420
|
p_max_gap_ms: 0, p_anon_cap: 0, p_has_token: null, p_only_if_unclaimed: true,
|
|
386
421
|
};
|
|
422
|
+
const estSignatureAbsente = (erreur) => {
|
|
423
|
+
try { return require("./presentations.js").signatureAbsente(erreur); } catch { return false; }
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
etat.fusionBaseCouvre = PORTEE_FUSION;
|
|
427
|
+
try {
|
|
428
|
+
await PLAYER.db.request("rpc/player_attendance_bump",
|
|
429
|
+
{ method: "POST", body: { ...commun, p_page: null, p_control_hash: null } });
|
|
430
|
+
// Le contrat long a répondu : 0019 est là, donc 0018 aussi — elle la précède et 0019 reprend
|
|
431
|
+
// ses arguments. On ne repose pas une question dont la réponse vient d'être prouvée.
|
|
432
|
+
etat.fusionBase = "applique";
|
|
433
|
+
etat.durcissementBase = "applique";
|
|
434
|
+
etat.durcissementBaseCouvre = PORTEE_DURCISSEMENT;
|
|
435
|
+
return;
|
|
436
|
+
} catch (erreur) {
|
|
437
|
+
if (!estSignatureAbsente(erreur)) {
|
|
438
|
+
// ⚠️ UNE PANNE NE PROUVE RIEN — NI POUR L'UNE NI POUR L'AUTRE. Insister avec un second appel
|
|
439
|
+
// ne ferait que frapper une base déjà en difficulté pour en tirer la même absence de réponse.
|
|
440
|
+
etat.fusionBase = "indetermine";
|
|
441
|
+
etat.durcissementBase = "indetermine";
|
|
442
|
+
etat.durcissementBaseCouvre = PORTEE_DURCISSEMENT;
|
|
443
|
+
await journaliser("schema:sonde-presence-muette",
|
|
444
|
+
"sonde des migrations de présence (0018/0019) sans réponse exploitable : ni confirmées, ni "
|
|
445
|
+
+ "infirmées — " + ((erreur && erreur.message) || erreur));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
etat.fusionBase = "absente";
|
|
449
|
+
// ⚠️ ET ON LE DIT. C'est même toute la raison d'être de cette sonde : sans elle, un hôte à qui
|
|
450
|
+
// 0019 manque paie deux fois le chemin le plus chaud du produit sans que rien ne le signale.
|
|
451
|
+
// Le message dit la dégradation EXACTE — pas « ça ne marche pas », qui n'aide personne à
|
|
452
|
+
// décider si ça vaut une migration.
|
|
453
|
+
await journaliser("schema:fusion-absente",
|
|
454
|
+
"migration 0019-presence-lit-la-presentation.sql ABSENTE : rien ne casse, mais chaque "
|
|
455
|
+
+ "battement de présence coûte 3 allers-retours base au lieu de 2 — soit environ 30 op/s au "
|
|
456
|
+
+ "lieu de 20 pour 250 participants. L'appliquer ne demande aucun redéploiement.");
|
|
457
|
+
// On ne sait toujours rien de 0018 : le contrat court se demande à part, ci-dessous.
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const corps = { ...commun, p_page: 1 };
|
|
387
461
|
try {
|
|
388
462
|
await PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: corps });
|
|
389
463
|
etat.durcissementBase = "applique";
|
|
@@ -391,8 +465,7 @@ async function ajouterDurcissement(etat) {
|
|
|
391
465
|
// ⚠️ TROIS ISSUES, ET LA TROISIÈME N'EST PAS UNE DES DEUX AUTRES. Seule la signature absente
|
|
392
466
|
// prouve que 0018 manque ; une panne réseau ou un 500 ne prouvent RIEN, et les compter comme
|
|
393
467
|
// « absente » serait le défaut qu'on a mis trois versions à retirer d'ailleurs.
|
|
394
|
-
|
|
395
|
-
try { absente = require("./presentations.js").signatureAbsente(erreur); } catch { /* module absent */ }
|
|
468
|
+
const absente = estSignatureAbsente(erreur);
|
|
396
469
|
etat.durcissementBase = absente ? "absente" : "indetermine";
|
|
397
470
|
// ⚠️ ET ON LE DIT, PAS SEULEMENT DANS LA CARTE. Une garde de ce dépôt refuse qu'une écriture soit
|
|
398
471
|
// rattrapée en silence, et elle a raison ici pour une raison qu'elle ne pouvait pas connaître :
|
package/supabase/init.sql
CHANGED
|
@@ -386,7 +386,11 @@ begin
|
|
|
386
386
|
end
|
|
387
387
|
$$;
|
|
388
388
|
|
|
389
|
-
-- Présence en un seul geste + plafond de création anonyme atomique
|
|
389
|
+
-- Présence en un seul geste + plafond de création anonyme atomique, ET lecture de la présentation
|
|
390
|
+
-- dans la MÊME transaction que l'écriture. Cf. migrations 0015 puis 0019 — texte identique à 0019,
|
|
391
|
+
-- sans quoi rejouer la migration sur une base née d'ici changerait la forme (la CI le mesure).
|
|
392
|
+
drop function if exists public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean);
|
|
393
|
+
|
|
390
394
|
create or replace function public.player_attendance_bump(
|
|
391
395
|
p_slug text,
|
|
392
396
|
p_key text,
|
|
@@ -399,9 +403,11 @@ create or replace function public.player_attendance_bump(
|
|
|
399
403
|
p_max_gap_ms integer,
|
|
400
404
|
p_anon_cap integer,
|
|
401
405
|
p_has_token boolean default null,
|
|
402
|
-
p_only_if_unclaimed boolean default null
|
|
406
|
+
p_only_if_unclaimed boolean default null,
|
|
407
|
+
p_control_hash text default null
|
|
403
408
|
)
|
|
404
|
-
returns table (ok boolean, created boolean, capped boolean, usurpe boolean
|
|
409
|
+
returns table (ok boolean, created boolean, capped boolean, usurpe boolean,
|
|
410
|
+
introuvable boolean, archivee boolean, page integer)
|
|
405
411
|
language plpgsql
|
|
406
412
|
security definer
|
|
407
413
|
set search_path = public
|
|
@@ -410,8 +416,56 @@ declare
|
|
|
410
416
|
v_exists boolean;
|
|
411
417
|
v_claimed boolean;
|
|
412
418
|
v_count integer;
|
|
413
|
-
|
|
419
|
+
v_pres_active boolean;
|
|
420
|
+
v_pres_page integer;
|
|
421
|
+
v_pres_hash text;
|
|
422
|
+
v_pres_vue boolean := false;
|
|
423
|
+
v_presentateur boolean;
|
|
424
|
+
v_page integer;
|
|
414
425
|
begin
|
|
426
|
+
-- ⚠️ LA PRÉSENTATION EST LUE ICI, ET C'EST TOUT L'OBJET DE 0019. L'appelant la lisait avant
|
|
427
|
+
-- d'appeler : un battement coûtait donc DEUX allers-retours là où un seul suffit. La lecture
|
|
428
|
+
-- servait à trois choses — l'existence, l'identification du présentateur, la page courante — et
|
|
429
|
+
-- les trois se font mieux ici, dans la même transaction que l'écriture.
|
|
430
|
+
select active, current_page, control_hash
|
|
431
|
+
into v_pres_active, v_pres_page, v_pres_hash
|
|
432
|
+
from public.doc_presentations where slug = p_slug;
|
|
433
|
+
v_pres_vue := found;
|
|
434
|
+
|
|
435
|
+
-- ⚠️ LE SIGNAL EST « L'APPELANT A-T-IL LU », PAS « PORTE-T-IL UN JETON ». Première écriture de
|
|
436
|
+
-- cette migration : les deux refus ci-dessous étaient conditionnés à `p_control_hash is not null`.
|
|
437
|
+
-- Or un participant ANONYME n'envoie aucun jeton de contrôle — c'est-à-dire l'immense majorité des
|
|
438
|
+
-- battements — et il aurait donc perdu le 404 et le refus d'archive que la route lui oppose
|
|
439
|
+
-- aujourd'hui. Une garde perdue en chemin, à l'endroit exact que l'en-tête désigne comme risqué.
|
|
440
|
+
--
|
|
441
|
+
-- Ce qui décide, c'est si l'appelant a DÉJÀ lu la présentation, et ça se lit à `p_page` : un
|
|
442
|
+
-- appelant ANCIEN en fournit toujours une (les deux seuls appelants du dépôt envoient page >= 1,
|
|
443
|
+
-- et 1 pour la sonde de schéma) ; un appelant NEUF n'en a pas, puisqu'il n'a rien lu. `p_page is
|
|
444
|
+
-- null` dit donc exactement « c'est à toi de trancher » — pour un anonyme comme pour un
|
|
445
|
+
-- présentateur, alors que le jeton de contrôle ne parlait que du second.
|
|
446
|
+
if p_page is null and not v_pres_vue then
|
|
447
|
+
return query select false, false, false, false, true, false, 0; -- introuvable, rien écrit
|
|
448
|
+
return;
|
|
449
|
+
end if;
|
|
450
|
+
|
|
451
|
+
-- ⚠️ MÊME REFUS QUE LE CODE QU'ON REMPLACE : une présentation close ET archivée (control_hash
|
|
452
|
+
-- effacé) n'a plus rien à mettre à jour. Le dire ici évite que la fusion perde une garde en
|
|
453
|
+
-- chemin — c'est le risque propre à tout déplacement de décision.
|
|
454
|
+
if p_page is null and v_pres_active is false and v_pres_hash is null then
|
|
455
|
+
return query select false, false, false, false, false, true, 0; -- archivée, rien écrit
|
|
456
|
+
return;
|
|
457
|
+
end if;
|
|
458
|
+
|
|
459
|
+
-- ⚠️ LA DÉCISION QUI CHANGE DE CAMP : « qui est présentateur » se décidait en JavaScript, par
|
|
460
|
+
-- comparaison du sha256 du jeton de contrôle au control_hash de la ligne. Elle se décide ici
|
|
461
|
+
-- désormais — sur la MÊME donnée, dans la même transaction. On garde le OU avec l'argument de
|
|
462
|
+
-- l'appelant : un appelant ancien continue de décider lui-même, et ne perd rien.
|
|
463
|
+
v_presentateur := coalesce(p_is_presenter, false)
|
|
464
|
+
or (p_control_hash is not null and v_pres_hash is not null and p_control_hash = v_pres_hash);
|
|
465
|
+
|
|
466
|
+
-- La page vient de la BASE quand l'appelant ne la fournit pas — il n'a plus à la lire pour nous.
|
|
467
|
+
v_page := greatest(1, coalesce(p_page, v_pres_page, 1));
|
|
468
|
+
|
|
415
469
|
-- On lit l'existence ET l'état « réclamée » en un seul geste : un bootstrap n'a rien à écrire sur
|
|
416
470
|
-- une ligne dont un porteur de jeton s'est déjà servi.
|
|
417
471
|
select true, (last_token_at is not null) into v_exists, v_claimed
|
|
@@ -419,11 +473,11 @@ begin
|
|
|
419
473
|
where slug = p_slug and attendee_key = p_key;
|
|
420
474
|
|
|
421
475
|
if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
|
|
422
|
-
return query select false, false, false, true; -- usurpation : on n'écrit RIEN
|
|
476
|
+
return query select false, false, false, true, false, false, v_page; -- usurpation : on n'écrit RIEN
|
|
423
477
|
return;
|
|
424
478
|
end if;
|
|
425
479
|
|
|
426
|
-
if not coalesce(v_exists, false) and not p_is_member and not
|
|
480
|
+
if not coalesce(v_exists, false) and not p_is_member and not v_presentateur then
|
|
427
481
|
perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
|
|
428
482
|
-- Re-lire sous le verrou : la ligne a pu naître entre-temps — et si elle est née RÉCLAMÉE, un
|
|
429
483
|
-- bootstrap concurrent ne doit pas davantage l'écraser ici qu'au premier contrôle.
|
|
@@ -431,7 +485,7 @@ begin
|
|
|
431
485
|
from public.doc_presentation_attendees
|
|
432
486
|
where slug = p_slug and attendee_key = p_key;
|
|
433
487
|
if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
|
|
434
|
-
return query select false, false, false, true;
|
|
488
|
+
return query select false, false, false, true, false, false, v_page;
|
|
435
489
|
return;
|
|
436
490
|
end if;
|
|
437
491
|
if not coalesce(v_exists, false) then
|
|
@@ -441,7 +495,7 @@ begin
|
|
|
441
495
|
and creator_ip_hash is not distinct from p_ip_hash
|
|
442
496
|
and is_member = false and is_presenter = false;
|
|
443
497
|
if v_count >= p_anon_cap then
|
|
444
|
-
return query select false, false, true, false;
|
|
498
|
+
return query select false, false, true, false, false, false, v_page;
|
|
445
499
|
return;
|
|
446
500
|
end if;
|
|
447
501
|
end if;
|
|
@@ -451,7 +505,7 @@ begin
|
|
|
451
505
|
(slug, attendee_key, name, email, avatar, is_member, is_presenter,
|
|
452
506
|
first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
|
|
453
507
|
values
|
|
454
|
-
(p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member,
|
|
508
|
+
(p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, v_presentateur,
|
|
455
509
|
now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
|
|
456
510
|
case when p_has_token is true then now() end,
|
|
457
511
|
case when p_has_token is false then now() end)
|
|
@@ -467,21 +521,21 @@ begin
|
|
|
467
521
|
name = coalesce(nullif(p_name, ''), l.name),
|
|
468
522
|
avatar = coalesce(nullif(p_avatar, ''), l.avatar),
|
|
469
523
|
is_member = p_is_member,
|
|
470
|
-
is_presenter =
|
|
524
|
+
is_presenter = v_presentateur,
|
|
471
525
|
last_token_at = case when p_has_token is true then now() else l.last_token_at end,
|
|
472
526
|
last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
|
|
473
527
|
|
|
474
|
-
return query select true, not coalesce(v_exists, false), false, false;
|
|
528
|
+
return query select true, not coalesce(v_exists, false), false, false, false, false, v_page;
|
|
475
529
|
end;
|
|
476
530
|
$$;
|
|
477
|
-
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from public;
|
|
531
|
+
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) from public;
|
|
478
532
|
do $$
|
|
479
533
|
declare
|
|
480
534
|
r text;
|
|
481
535
|
begin
|
|
482
536
|
foreach r in array array['anon', 'authenticated'] loop
|
|
483
537
|
if exists (select 1 from pg_roles where rolname = r) then
|
|
484
|
-
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from %I', r);
|
|
538
|
+
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) from %I', r);
|
|
485
539
|
end if;
|
|
486
540
|
end loop;
|
|
487
541
|
end
|
|
@@ -489,7 +543,7 @@ $$;
|
|
|
489
543
|
do $$
|
|
490
544
|
begin
|
|
491
545
|
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
492
|
-
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) to service_role;
|
|
546
|
+
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) to service_role;
|
|
493
547
|
end if;
|
|
494
548
|
end
|
|
495
549
|
$$;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
-- LA RPC DE PRÉSENCE LIT LA PRÉSENTATION — UN BATTEMENT PASSE DE 3 À 2 ALLERS-RETOURS.
|
|
2
|
+
--
|
|
3
|
+
-- ⚠️ Dernier poste chiffré d'un audit externe, et il exigeait d'être MESURÉ avant d'être engagé.
|
|
4
|
+
-- La mesure existe : à 250 participants, le battement coûte 3 allers-retours (débit par IP, lecture
|
|
5
|
+
-- de présentation, écriture) — soit ~30 op/s. La lecture disparaît ici : ~20 op/s.
|
|
6
|
+
--
|
|
7
|
+
-- ⚠️ CE QUI CHANGE DE CAMP, ET IL FAUT LE LIRE COMME TEL : « qui est présentateur » se décidait en
|
|
8
|
+
-- JavaScript (sha256 du jeton de contrôle comparé au control_hash de la ligne). Cette décision passe
|
|
9
|
+
-- dans le SQL, sur la MÊME donnée et dans la MÊME transaction que l'écriture. Un déplacement de
|
|
10
|
+
-- décision est l'endroit où une garde se perd en chemin : les deux refus que le code JS opposait —
|
|
11
|
+
-- présentation introuvable, présentation close ET archivée — sont donc reproduits ici, et rendus
|
|
12
|
+
-- explicitement (`introuvable`, `archivee`) plutôt que confondus avec un simple `ok = false`.
|
|
13
|
+
--
|
|
14
|
+
-- ⚠️ RÉTROCOMPATIBLE DANS LES DEUX SENS. `p_control_hash` a un DEFAULT : une base NEUVE sert un code
|
|
15
|
+
-- ANCIEN sans rien changer (il passe p_page, décide lui-même du présentateur, et ne demande pas les
|
|
16
|
+
-- nouveaux refus). Le sens inverse — code neuf, base ancienne — reste impossible par construction
|
|
17
|
+
-- (PostgREST résout par jeu d'arguments nommés) : le player réessaie donc le contrat plus court, et
|
|
18
|
+
-- retombe sur sa lecture préalable. Dégrader, jamais casser, jamais en silence.
|
|
19
|
+
--
|
|
20
|
+
-- ⚠️ Sans lui : rien ne casse — le player redemande le contrat court, relit la présentation comme
|
|
21
|
+
-- avant, et le battement garde ses 3 allers-retours. Le titre de présentateur, les deux refus et la
|
|
22
|
+
-- page restent exacts : c'est le CHEMIN qui change, jamais le résultat.
|
|
23
|
+
|
|
24
|
+
drop function if exists public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean);
|
|
25
|
+
|
|
26
|
+
create or replace function public.player_attendance_bump(
|
|
27
|
+
p_slug text,
|
|
28
|
+
p_key text,
|
|
29
|
+
p_ip_hash text,
|
|
30
|
+
p_page integer,
|
|
31
|
+
p_name text,
|
|
32
|
+
p_avatar text,
|
|
33
|
+
p_is_member boolean,
|
|
34
|
+
p_is_presenter boolean,
|
|
35
|
+
p_max_gap_ms integer,
|
|
36
|
+
p_anon_cap integer,
|
|
37
|
+
p_has_token boolean default null,
|
|
38
|
+
p_only_if_unclaimed boolean default null,
|
|
39
|
+
p_control_hash text default null
|
|
40
|
+
)
|
|
41
|
+
returns table (ok boolean, created boolean, capped boolean, usurpe boolean,
|
|
42
|
+
introuvable boolean, archivee boolean, page integer)
|
|
43
|
+
language plpgsql
|
|
44
|
+
security definer
|
|
45
|
+
set search_path = public
|
|
46
|
+
as $$
|
|
47
|
+
declare
|
|
48
|
+
v_exists boolean;
|
|
49
|
+
v_claimed boolean;
|
|
50
|
+
v_count integer;
|
|
51
|
+
v_pres_active boolean;
|
|
52
|
+
v_pres_page integer;
|
|
53
|
+
v_pres_hash text;
|
|
54
|
+
v_pres_vue boolean := false;
|
|
55
|
+
v_presentateur boolean;
|
|
56
|
+
v_page integer;
|
|
57
|
+
begin
|
|
58
|
+
-- ⚠️ LA PRÉSENTATION EST LUE ICI, ET C'EST TOUT L'OBJET DE 0019. L'appelant la lisait avant
|
|
59
|
+
-- d'appeler : un battement coûtait donc DEUX allers-retours là où un seul suffit. La lecture
|
|
60
|
+
-- servait à trois choses — l'existence, l'identification du présentateur, la page courante — et
|
|
61
|
+
-- les trois se font mieux ici, dans la même transaction que l'écriture.
|
|
62
|
+
select active, current_page, control_hash
|
|
63
|
+
into v_pres_active, v_pres_page, v_pres_hash
|
|
64
|
+
from public.doc_presentations where slug = p_slug;
|
|
65
|
+
v_pres_vue := found;
|
|
66
|
+
|
|
67
|
+
-- ⚠️ LE SIGNAL EST « L'APPELANT A-T-IL LU », PAS « PORTE-T-IL UN JETON ». Première écriture de
|
|
68
|
+
-- cette migration : les deux refus ci-dessous étaient conditionnés à `p_control_hash is not null`.
|
|
69
|
+
-- Or un participant ANONYME n'envoie aucun jeton de contrôle — c'est-à-dire l'immense majorité des
|
|
70
|
+
-- battements — et il aurait donc perdu le 404 et le refus d'archive que la route lui oppose
|
|
71
|
+
-- aujourd'hui. Une garde perdue en chemin, à l'endroit exact que l'en-tête désigne comme risqué.
|
|
72
|
+
--
|
|
73
|
+
-- Ce qui décide, c'est si l'appelant a DÉJÀ lu la présentation, et ça se lit à `p_page` : un
|
|
74
|
+
-- appelant ANCIEN en fournit toujours une (les deux seuls appelants du dépôt envoient page >= 1,
|
|
75
|
+
-- et 1 pour la sonde de schéma) ; un appelant NEUF n'en a pas, puisqu'il n'a rien lu. `p_page is
|
|
76
|
+
-- null` dit donc exactement « c'est à toi de trancher » — pour un anonyme comme pour un
|
|
77
|
+
-- présentateur, alors que le jeton de contrôle ne parlait que du second.
|
|
78
|
+
if p_page is null and not v_pres_vue then
|
|
79
|
+
return query select false, false, false, false, true, false, 0; -- introuvable, rien écrit
|
|
80
|
+
return;
|
|
81
|
+
end if;
|
|
82
|
+
|
|
83
|
+
-- ⚠️ MÊME REFUS QUE LE CODE QU'ON REMPLACE : une présentation close ET archivée (control_hash
|
|
84
|
+
-- effacé) n'a plus rien à mettre à jour. Le dire ici évite que la fusion perde une garde en
|
|
85
|
+
-- chemin — c'est le risque propre à tout déplacement de décision.
|
|
86
|
+
if p_page is null and v_pres_active is false and v_pres_hash is null then
|
|
87
|
+
return query select false, false, false, false, false, true, 0; -- archivée, rien écrit
|
|
88
|
+
return;
|
|
89
|
+
end if;
|
|
90
|
+
|
|
91
|
+
-- ⚠️ LA DÉCISION QUI CHANGE DE CAMP : « qui est présentateur » se décidait en JavaScript, par
|
|
92
|
+
-- comparaison du sha256 du jeton de contrôle au control_hash de la ligne. Elle se décide ici
|
|
93
|
+
-- désormais — sur la MÊME donnée, dans la même transaction. On garde le OU avec l'argument de
|
|
94
|
+
-- l'appelant : un appelant ancien continue de décider lui-même, et ne perd rien.
|
|
95
|
+
v_presentateur := coalesce(p_is_presenter, false)
|
|
96
|
+
or (p_control_hash is not null and v_pres_hash is not null and p_control_hash = v_pres_hash);
|
|
97
|
+
|
|
98
|
+
-- La page vient de la BASE quand l'appelant ne la fournit pas — il n'a plus à la lire pour nous.
|
|
99
|
+
v_page := greatest(1, coalesce(p_page, v_pres_page, 1));
|
|
100
|
+
|
|
101
|
+
-- On lit l'existence ET l'état « réclamée » en un seul geste : un bootstrap n'a rien à écrire sur
|
|
102
|
+
-- une ligne dont un porteur de jeton s'est déjà servi.
|
|
103
|
+
select true, (last_token_at is not null) into v_exists, v_claimed
|
|
104
|
+
from public.doc_presentation_attendees
|
|
105
|
+
where slug = p_slug and attendee_key = p_key;
|
|
106
|
+
|
|
107
|
+
if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
|
|
108
|
+
return query select false, false, false, true, false, false, v_page; -- usurpation : on n'écrit RIEN
|
|
109
|
+
return;
|
|
110
|
+
end if;
|
|
111
|
+
|
|
112
|
+
if not coalesce(v_exists, false) and not p_is_member and not v_presentateur then
|
|
113
|
+
perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
|
|
114
|
+
-- Re-lire sous le verrou : la ligne a pu naître entre-temps — et si elle est née RÉCLAMÉE, un
|
|
115
|
+
-- bootstrap concurrent ne doit pas davantage l'écraser ici qu'au premier contrôle.
|
|
116
|
+
select true, (last_token_at is not null) into v_exists, v_claimed
|
|
117
|
+
from public.doc_presentation_attendees
|
|
118
|
+
where slug = p_slug and attendee_key = p_key;
|
|
119
|
+
if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
|
|
120
|
+
return query select false, false, false, true, false, false, v_page;
|
|
121
|
+
return;
|
|
122
|
+
end if;
|
|
123
|
+
if not coalesce(v_exists, false) then
|
|
124
|
+
select count(*) into v_count
|
|
125
|
+
from public.doc_presentation_attendees
|
|
126
|
+
where slug = p_slug
|
|
127
|
+
and creator_ip_hash is not distinct from p_ip_hash
|
|
128
|
+
and is_member = false and is_presenter = false;
|
|
129
|
+
if v_count >= p_anon_cap then
|
|
130
|
+
return query select false, false, true, false, false, false, v_page;
|
|
131
|
+
return;
|
|
132
|
+
end if;
|
|
133
|
+
end if;
|
|
134
|
+
end if;
|
|
135
|
+
|
|
136
|
+
insert into public.doc_presentation_attendees as l
|
|
137
|
+
(slug, attendee_key, name, email, avatar, is_member, is_presenter,
|
|
138
|
+
first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
|
|
139
|
+
values
|
|
140
|
+
(p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, v_presentateur,
|
|
141
|
+
now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
|
|
142
|
+
case when p_has_token is true then now() end,
|
|
143
|
+
case when p_has_token is false then now() end)
|
|
144
|
+
on conflict (slug, attendee_key) do update
|
|
145
|
+
set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
|
|
146
|
+
total_ms = l.total_ms + (
|
|
147
|
+
case
|
|
148
|
+
when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
|
|
149
|
+
then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
|
|
150
|
+
else 0
|
|
151
|
+
end),
|
|
152
|
+
pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
|
|
153
|
+
name = coalesce(nullif(p_name, ''), l.name),
|
|
154
|
+
avatar = coalesce(nullif(p_avatar, ''), l.avatar),
|
|
155
|
+
is_member = p_is_member,
|
|
156
|
+
is_presenter = v_presentateur,
|
|
157
|
+
last_token_at = case when p_has_token is true then now() else l.last_token_at end,
|
|
158
|
+
last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
|
|
159
|
+
|
|
160
|
+
return query select true, not coalesce(v_exists, false), false, false, false, false, v_page;
|
|
161
|
+
end;
|
|
162
|
+
$$;
|
|
163
|
+
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) from public;
|
|
164
|
+
do $$
|
|
165
|
+
declare
|
|
166
|
+
r text;
|
|
167
|
+
begin
|
|
168
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
169
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
170
|
+
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) from %I', r);
|
|
171
|
+
end if;
|
|
172
|
+
end loop;
|
|
173
|
+
end
|
|
174
|
+
$$;
|
|
175
|
+
do $$
|
|
176
|
+
begin
|
|
177
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
178
|
+
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) to service_role;
|
|
179
|
+
end if;
|
|
180
|
+
end
|
|
181
|
+
$$;
|