@hgdc/auth 0.7.1 → 0.9.0

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/README.md CHANGED
@@ -7,6 +7,7 @@ Connexion d'un produit de la famille à Account (OIDC) : bouton « Se connecter
7
7
  | `@hgdc/auth` | `HGDC_ISSUER`, `HGDC_PROVIDER_ID`, `HGDC_SCOPES`, entrée commune (`HGDC_ENTRY_PATH`, `safeNextPath`, `hgdcEntryUrl`), rôles (`highestRole`, `hasRole`), `parseHgdcProfile`, `verifiedOrganizationsFromIdToken`, `verifyHgdcIdToken`, `planOrganizationSync`, fraîcheur (`membershipIsFresh`, `SESSION_TTL_SECONDS`, `MEMBERSHIP_TTL_MS`, `KEY_MEMBERSHIP_TTL_MS`) |
8
8
  | `@hgdc/auth/server` | `hgdcProvider` (plugin `genericOAuth`), `createAuthProxy`, `restoreFetchMode`, `createHgdcEntry` |
9
9
  | `@hgdc/auth/convex` | `requireIdentity`, `requireOrgMember`, `isAuthError`, `authErrorReason`, `AUTH_ERROR_MESSAGES` |
10
+ | `@hgdc/auth/events` | Événements poussés par Account : `ACCOUNT_EVENTS_PATH`, `signAccountEvent`, `verifyAccountEvent`, `handleAccountEventRequest`, `accountEventSchema`, `accountEventIsStale`, `ACCOUNT_EVENT_RETENTION_MS`, et pour Convex `vAccountEvent`, `accountEventsTable` |
10
11
  | `@hgdc/auth/react` | `HgdcSignInButton`, `startHgdcSignIn`, `HgdcMark` (le H tricolore de la famille, `HGDC_MARK_SVG`) |
11
12
 
12
13
  Pairs : better-auth 1.6, convex 1.46+, React 19 (chacun facultatif selon l'import).
@@ -182,7 +183,7 @@ const claimed = verifiedOrganizationsFromIdToken(hgdc.idToken ?? "", {
182
183
  now: Date.now(),
183
184
  }) // lève HgdcIdTokenError : émetteur, audience (clientId), iat de plus de 10 minutes
184
185
  const plan = planOrganizationSync(claimed, membershipsOfThisUser)
185
- // mutation interne : plan.upsert (espace + appartenance), plan.remove, et syncedAt = Date.now() partout
186
+ // mutation interne : plan.upsert (espace + appartenance), plan.remove, et syncedAt = issuedAt partout (iat du jeton, verifyHgdcIdToken), jamais Date.now()
186
187
 
187
188
  // chargeur de requireOrgMember
188
189
  load: async (ctx, { organizationId, userId }) => {
@@ -193,4 +194,53 @@ load: async (ctx, { organizationId, userId }) => {
193
194
 
194
195
  `verifyHgdcIdToken` rend aussi les claims du profil (nom, adresse, photo) pour les recopier. La signature n'est pas revérifiée : le jeton arrive du point de jeton d'Account, reçu en TLS par le serveur (OpenID Connect Core 3.1.3.7, point 6) ; le contrôle de fraîcheur empêche qu'un ancien jeton resté dans la table `account` rejoue des organisations. Une clé d'API vit hors session : `membershipIsFresh(member, now, KEY_MEMBERSHIP_TTL_MS)` lui laisse 30 jours. Les copies périmées se purgent par un cron.
195
196
 
197
+ ## Appliquer les événements d'Account
198
+
199
+ Account pousse les retraits, changements de rôle et suppressions d'organisation à chaque produit, sans attendre la session suivante (ADR 0014). Le produit expose `POST /hgdc/v1/account-events` sur son déploiement Convex :
200
+
201
+ ```ts
202
+ // convex/http.ts
203
+ import { ACCOUNT_EVENTS_PATH, handleAccountEventRequest } from "@hgdc/auth/events"
204
+
205
+ http.route({
206
+ path: ACCOUNT_EVENTS_PATH,
207
+ method: "POST",
208
+ handler: httpAction(async (ctx, request) =>
209
+ handleAccountEventRequest(request, {
210
+ audience: "host", // le produit : signé par Account, vérifié ici
211
+ secret: process.env.ACCOUNT_EVENTS_SECRET,
212
+ apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
213
+ }),
214
+ ),
215
+ })
216
+
217
+ // convex/schema.ts : accountEvents: accountEventsTable (de @hgdc/auth/events)
218
+
219
+ // convex/organizations.ts
220
+ export const applyAccountEvent = internalMutation({
221
+ args: { event: vAccountEvent },
222
+ returns: vAccountEventOutcome, // "applied" | "duplicate" | "ignored:<raison>"
223
+ handler: async (ctx, { event }) => {
224
+ const seen = await ctx.db
225
+ .query("accountEvents")
226
+ .withIndex("by_event_id", (q) => q.eq("eventId", event.id))
227
+ .first()
228
+ if (seen) return "duplicate"
229
+ await ctx.db.insert("accountEvents", { eventId: event.id, type: event.type, receivedAt: Date.now() })
230
+ // personne locale : compte better-auth `providerId: "hgdc"`, `accountId: event.userId`
231
+ // member.removed : retirer l'appartenance, sauf si accountEventIsStale(event, membre.syncedAt)
232
+ // member.role_changed : rôle et syncedAt = max(syncedAt, event.occurredAt) ; ne crée jamais d'appartenance
233
+ // organization.deleted : retirer toutes les appartenances, garder les données de l'espace
234
+ // rien à appliquer : "ignored:unknown_organization" | "ignored:unknown_user" | "ignored:not_member" | "ignored:stale"
235
+ return "applied"
236
+ },
237
+ })
238
+ ```
239
+
240
+ Le secret (32 caractères au moins) est propre au produit : `ACCOUNT_EVENTS_SECRET` chez lui, `ACCOUNT_EVENTS_SECRET_<PRODUIT>` chez Account avec l'adresse de l'endpoint. La signature `v2=` couvre l'audience (`x-hgdc-audience`, le produit destinataire) : un événement signé pour un autre produit rend 401. Le corps est lu en flux et plafonné à 8 Ko (`readBodyText`, réutilisable). Sans secret, l'endpoint répond 503 et Account réessaie (1, 5, 15, 60 minutes). Une signature invalide ou d'une autre audience, ou un horodatage à plus de 5 minutes rendent 401 ; un corps invalide ou un `occurredAt` à plus de 5 minutes dans le futur, 400. La réponse 200 porte l'issue : `{"status":"applied"}`, `{"status":"duplicate"}` ou `{"status":"ignored","reason":…}` (`unknown_organization`, `unknown_user`, `not_member`, `stale`), journalisée en avertissement chez le produit ; Account remonte à GlitchTip un retrait ignoré pour une autre raison que `unknown_organization`. Les identifiants se purgent après `ACCOUNT_EVENT_RETENTION_MS` (cron, index `by_received`).
241
+
242
+ Ordre : `accountEventIsStale(event, syncedAt)` compare l'événement à `syncedAt`, qui doit être l'`iat` du jeton lu à la connexion (`verifyHgdcIdToken(...).issuedAt`) et non l'heure de la copie : un jeton émis avant un retrait mais lu après ne doit pas le masquer.
243
+
244
+ Les gardes doivent lire l'appartenance à chaque requête (chargeur de `requireOrgMember`) : c'est ce qui rend le retrait immédiat.
245
+
196
246
  Tous droits réservés, Hugo Gebel Développement Conseil.
@@ -0,0 +1,183 @@
1
+ import { type TableDefinition } from "convex/server";
2
+ import { type Validator, type VFloat64, type VObject, type VString } from "convex/values";
3
+ import { z } from "zod";
4
+ import { type OrgRole } from "./index.ts";
5
+ /** Chemin de l'endpoint de chaque produit, sur son déploiement Convex (`https://<…>.convex.site`). */
6
+ export declare const ACCOUNT_EVENTS_PATH = "/hgdc/v1/account-events";
7
+ /** Produits destinataires : l'audience signée de chaque événement. */
8
+ export declare const ACCOUNT_EVENT_AUDIENCES: readonly ["host", "mail", "status", "portal", "analytics", "docs"];
9
+ export type AccountEventAudience = (typeof ACCOUNT_EVENT_AUDIENCES)[number];
10
+ /** En-têtes de la requête signée. */
11
+ export declare const ACCOUNT_EVENT_HEADERS: {
12
+ readonly id: "x-hgdc-event-id";
13
+ readonly timestamp: "x-hgdc-timestamp";
14
+ readonly audience: "x-hgdc-audience";
15
+ readonly signature: "x-hgdc-signature";
16
+ readonly signatureV2: "x-hgdc-signature-v2";
17
+ };
18
+ /** Écart toléré entre l'horodatage signé et l'horloge du produit, en secondes. */
19
+ export declare const ACCOUNT_EVENT_TOLERANCE_SECONDS = 300;
20
+ /** Avance tolérée de `occurredAt` sur l'horloge du produit, en millisecondes : au-delà, refusé. */
21
+ export declare const ACCOUNT_EVENT_FUTURE_TOLERANCE_MS: number;
22
+ /** Taille maximale du corps, en octets. Un événement en pèse moins de 400. */
23
+ export declare const ACCOUNT_EVENT_MAX_BYTES: number;
24
+ /** Longueur minimale du secret partagé (32 octets aléatoires en base64url : 43 caractères). */
25
+ export declare const ACCOUNT_EVENT_SECRET_MIN_LENGTH = 32;
26
+ /**
27
+ * Conservation des identifiants d'événements déjà traités chez le produit : 1 jour. Les nouveaux
28
+ * essais d'Account s'étalent sur 81 minutes, et une requête de plus de 5 minutes est refusée.
29
+ */
30
+ export declare const ACCOUNT_EVENT_RETENTION_MS: number;
31
+ type EventBase = {
32
+ /** Identifiant unique, le même à chaque nouvel essai : clé d'idempotence. */
33
+ id: string;
34
+ /** Instant du changement dans Account, en millisecondes. */
35
+ occurredAt: number;
36
+ /** Organisation d'Account (`id` du claim `organizations`). */
37
+ organizationId: string;
38
+ };
39
+ /** Événement d'organisation poussé par Account. Les personnes sont désignées par leur `sub` Account. */
40
+ export type AccountEvent = (EventBase & {
41
+ type: "member.removed";
42
+ /** Personne retirée : son `sub` Account, l'`accountId` de son compte `hgdc` chez le produit. */
43
+ userId: string;
44
+ /** Retrait par un admin, départ volontaire, ou compte supprimé par HGDC. */
45
+ reason: "removed" | "left" | "user_deleted";
46
+ }) | (EventBase & {
47
+ type: "member.role_changed";
48
+ userId: string;
49
+ /** Nouveau rôle, déjà ramené au plus fort (`highestRole`). */
50
+ role: OrgRole;
51
+ }) | (EventBase & {
52
+ type: "organization.deleted";
53
+ });
54
+ export declare const accountEventSchema: z.ZodType<AccountEvent>;
55
+ export type AccountEventType = AccountEvent["type"];
56
+ /**
57
+ * Pourquoi un produit n'a rien appliqué :
58
+ * - `unknown_organization` : aucun espace pour cette organisation (le seul cas normal) ;
59
+ * - `unknown_user` : personne sans compte `hgdc` chez le produit ;
60
+ * - `not_member` : personne connue, sans appartenance à cet espace ;
61
+ * - `stale` : appartenance confirmée par Account après l'événement (`accountEventIsStale`).
62
+ */
63
+ export declare const ACCOUNT_EVENT_IGNORED_REASONS: readonly ["unknown_organization", "unknown_user", "not_member", "stale"];
64
+ export type AccountEventIgnoredReason = (typeof ACCOUNT_EVENT_IGNORED_REASONS)[number];
65
+ /** Ce que le produit a fait de l'événement : `applied`, `duplicate` ou `ignored:<raison>`. */
66
+ export type AccountEventOutcome = "applied" | "duplicate" | `ignored:${AccountEventIgnoredReason}`;
67
+ /** Réponse 200 du produit. */
68
+ export type AccountEventResponse = {
69
+ status: "applied" | "duplicate";
70
+ } | {
71
+ status: "ignored";
72
+ reason: AccountEventIgnoredReason;
73
+ };
74
+ /** Côté Account : l'issue lue dans la réponse 200 d'un produit, ou `null` si illisible. */
75
+ export declare function accountEventOutcomeOf(body: unknown): AccountEventOutcome | null;
76
+ /** Identifiant d'événement neuf (UUID sans tirets), à garder pour tous les essais. */
77
+ export declare function newAccountEventId(): string;
78
+ /**
79
+ * Côté Account : corps et en-têtes d'un envoi à `audience`. À rappeler à chaque essai (horodatage
80
+ * neuf), avec le même événement (même `id`). `legacy` ajoute l'ancienne signature `v1=` (sans
81
+ * audience) pour un produit pas encore monté : `v1=` dans `x-hgdc-signature`, `v2=` dans
82
+ * `x-hgdc-signature-v2`. Sans `legacy`, `x-hgdc-signature` porte `v2=`.
83
+ */
84
+ export declare function signAccountEvent(event: AccountEvent, options: {
85
+ secret: string;
86
+ now: number;
87
+ audience: AccountEventAudience;
88
+ legacy?: boolean;
89
+ }): Promise<{
90
+ body: string;
91
+ headers: Record<string, string>;
92
+ }>;
93
+ export type BodyText = {
94
+ ok: true;
95
+ text: string;
96
+ } | {
97
+ ok: false;
98
+ reason: "too_large";
99
+ };
100
+ /**
101
+ * Lecture bornée du corps d'une requête : par morceaux, abandonnée dès qu'elle dépasse `maxBytes`
102
+ * (en octets, « é » en compte deux). `request.text()` lit tout avant qu'on puisse compter, y
103
+ * compris un envoi sans `content-length` (chunked).
104
+ */
105
+ export declare function readBodyText(request: Request, maxBytes: number): Promise<BodyText>;
106
+ /** Pourquoi une requête est refusée. La réponse ne porte que le code, jamais le détail. */
107
+ export type AccountEventRejection = "too_large" | "unauthorized" | "invalid_body";
108
+ export type AccountEventVerification = {
109
+ ok: true;
110
+ event: AccountEvent;
111
+ } | {
112
+ ok: false;
113
+ status: 400 | 401 | 413;
114
+ error: AccountEventRejection;
115
+ };
116
+ /**
117
+ * Côté produit : lit le corps en flux (plafond `ACCOUNT_EVENT_MAX_BYTES`), vérifie horodatage
118
+ * (±5 minutes), audience (celle du produit), signature `v2=` en temps constant, puis le corps
119
+ * (`accountEventSchema`), l'accord entre l'identifiant signé et celui du corps, et un
120
+ * `occurredAt` sans plus de 5 minutes d'avance. Ne dit rien du rejeu : à écarter par la table
121
+ * des événements traités.
122
+ */
123
+ export declare function verifyAccountEvent(request: Request, options: {
124
+ secret: string;
125
+ now: number;
126
+ audience: AccountEventAudience;
127
+ }): Promise<AccountEventVerification>;
128
+ /**
129
+ * Endpoint complet d'un produit, à envelopper dans une `httpAction` Convex :
130
+ *
131
+ * ```ts
132
+ * export const accountEvents = httpAction(async (ctx, request) =>
133
+ * handleAccountEventRequest(request, {
134
+ * audience: "host",
135
+ * secret: process.env.ACCOUNT_EVENTS_SECRET,
136
+ * apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
137
+ * }),
138
+ * )
139
+ * http.route({ path: ACCOUNT_EVENTS_PATH, method: "POST", handler: accountEvents })
140
+ * ```
141
+ *
142
+ * `apply` est une mutation interne qui, dans la même transaction, écarte un `id` déjà traité,
143
+ * l'enregistre et applique l'événement ; elle rend son issue détaillée (`AccountEventOutcome`),
144
+ * renvoyée à Account et journalisée en avertissement quand rien n'est appliqué. Sans secret
145
+ * posé : 503, Account réessaie plus tard. Une erreur de `apply` remonte (500) : Account réessaie.
146
+ */
147
+ export declare function handleAccountEventRequest(request: Request, options: {
148
+ audience: AccountEventAudience;
149
+ secret: string | undefined;
150
+ apply: (event: AccountEvent) => Promise<AccountEventOutcome>;
151
+ now?: number;
152
+ }): Promise<Response>;
153
+ /**
154
+ * Vrai si l'appartenance a été confirmée par Account après l'événement : un essai en retard ne
155
+ * doit pas défaire une connexion plus récente (personne retirée puis réinvitée, rôle changé deux
156
+ * fois). `syncedAt` doit être l'instant où Account a émis les droits recopiés, soit l'`iat` de
157
+ * l'id_token (`verifyHgdcIdToken(...).issuedAt`), jamais l'heure de la copie : un jeton émis avant
158
+ * un retrait puis lu après ne doit pas masquer ce retrait. Une copie sans `syncedAt` n'est jamais
159
+ * plus récente.
160
+ */
161
+ export declare function accountEventIsStale(event: Pick<AccountEvent, "occurredAt">, syncedAt: number | null | undefined): boolean;
162
+ /** Validateur Convex d'un `AccountEvent` : argument de la mutation interne qui l'applique. */
163
+ export declare const vAccountEvent: Validator<AccountEvent, "required", string>;
164
+ /** Validateur Convex de l'issue rendue par la mutation qui applique un événement. */
165
+ export declare const vAccountEventOutcome: Validator<AccountEventOutcome, "required", string>;
166
+ type AccountEventRow = {
167
+ eventId: string;
168
+ type: string;
169
+ receivedAt: number;
170
+ };
171
+ /**
172
+ * Table des événements déjà traités, pour l'idempotence (`accountEvents: accountEventsTable` dans
173
+ * le schéma du produit). Purge après `ACCOUNT_EVENT_RETENTION_MS` par l'index `by_received`.
174
+ */
175
+ export declare const accountEventsTable: TableDefinition<VObject<AccountEventRow, {
176
+ eventId: VString;
177
+ type: VString;
178
+ receivedAt: VFloat64;
179
+ }>, {
180
+ by_event_id: ["eventId", "_creationTime"];
181
+ by_received: ["receivedAt", "_creationTime"];
182
+ }>;
183
+ export {};
package/dist/events.js ADDED
@@ -0,0 +1,331 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import { z } from "zod";
4
+ import { ORG_ROLES } from "./index.js";
5
+ /*
6
+ * Événements d'organisation poussés par Account vers chaque produit (ADR 0014 du kit) : un retrait,
7
+ * un changement de rôle ou une organisation supprimée s'appliquent tout de suite, sans attendre la
8
+ * relecture des organisations à la connexion suivante.
9
+ *
10
+ * Signature HMAC-SHA256 par produit, Web Crypto seulement (Convex, Node, Bun, navigateurs) :
11
+ * `v2=` hex de HMAC(secret, "v2.<audience>.<id>.<horodatage>.<corps brut>"). L'audience (le
12
+ * produit destinataire) et l'identifiant de l'événement sont dans le MAC : un événement destiné à
13
+ * un autre produit est refusé même si deux secrets venaient à se confondre, et rejouer une requête
14
+ * captée dans la fenêtre de 5 minutes ne passe pas la table des événements déjà traités.
15
+ * L'ancien format `v1=` (sans audience) n'est plus accepté à la réception ; Account peut encore
16
+ * l'ajouter (`legacy`) le temps que tous les produits soient montés.
17
+ */
18
+ /** Chemin de l'endpoint de chaque produit, sur son déploiement Convex (`https://<…>.convex.site`). */
19
+ export const ACCOUNT_EVENTS_PATH = "/hgdc/v1/account-events";
20
+ /** Produits destinataires : l'audience signée de chaque événement. */
21
+ export const ACCOUNT_EVENT_AUDIENCES = [
22
+ "host",
23
+ "mail",
24
+ "status",
25
+ "portal",
26
+ "analytics",
27
+ "docs",
28
+ ];
29
+ /** En-têtes de la requête signée. */
30
+ export const ACCOUNT_EVENT_HEADERS = {
31
+ id: "x-hgdc-event-id",
32
+ timestamp: "x-hgdc-timestamp",
33
+ audience: "x-hgdc-audience",
34
+ signature: "x-hgdc-signature",
35
+ signatureV2: "x-hgdc-signature-v2",
36
+ };
37
+ /** Écart toléré entre l'horodatage signé et l'horloge du produit, en secondes. */
38
+ export const ACCOUNT_EVENT_TOLERANCE_SECONDS = 300;
39
+ /** Avance tolérée de `occurredAt` sur l'horloge du produit, en millisecondes : au-delà, refusé. */
40
+ export const ACCOUNT_EVENT_FUTURE_TOLERANCE_MS = ACCOUNT_EVENT_TOLERANCE_SECONDS * 1000;
41
+ /** Taille maximale du corps, en octets. Un événement en pèse moins de 400. */
42
+ export const ACCOUNT_EVENT_MAX_BYTES = 8 * 1024;
43
+ /** Longueur minimale du secret partagé (32 octets aléatoires en base64url : 43 caractères). */
44
+ export const ACCOUNT_EVENT_SECRET_MIN_LENGTH = 32;
45
+ /**
46
+ * Conservation des identifiants d'événements déjà traités chez le produit : 1 jour. Les nouveaux
47
+ * essais d'Account s'étalent sur 81 minutes, et une requête de plus de 5 minutes est refusée.
48
+ */
49
+ export const ACCOUNT_EVENT_RETENTION_MS = 24 * 60 * 60 * 1000;
50
+ const id = z.string().min(1).max(64);
51
+ const base = {
52
+ id: z.string().regex(/^[A-Za-z0-9_-]{16,64}$/),
53
+ occurredAt: z.number().int().positive(),
54
+ organizationId: id,
55
+ };
56
+ export const accountEventSchema = z.discriminatedUnion("type", [
57
+ z.strictObject({
58
+ ...base,
59
+ type: z.literal("member.removed"),
60
+ userId: id,
61
+ reason: z.enum(["removed", "left", "user_deleted"]),
62
+ }),
63
+ z.strictObject({
64
+ ...base,
65
+ type: z.literal("member.role_changed"),
66
+ userId: id,
67
+ role: z.enum(ORG_ROLES),
68
+ }),
69
+ z.strictObject({
70
+ ...base,
71
+ type: z.literal("organization.deleted"),
72
+ }),
73
+ ]);
74
+ /**
75
+ * Pourquoi un produit n'a rien appliqué :
76
+ * - `unknown_organization` : aucun espace pour cette organisation (le seul cas normal) ;
77
+ * - `unknown_user` : personne sans compte `hgdc` chez le produit ;
78
+ * - `not_member` : personne connue, sans appartenance à cet espace ;
79
+ * - `stale` : appartenance confirmée par Account après l'événement (`accountEventIsStale`).
80
+ */
81
+ export const ACCOUNT_EVENT_IGNORED_REASONS = [
82
+ "unknown_organization",
83
+ "unknown_user",
84
+ "not_member",
85
+ "stale",
86
+ ];
87
+ function isIgnoredReason(value) {
88
+ return ACCOUNT_EVENT_IGNORED_REASONS.includes(value);
89
+ }
90
+ function responseOf(outcome) {
91
+ if (outcome === "applied" || outcome === "duplicate")
92
+ return { status: outcome };
93
+ const reason = outcome.slice("ignored:".length);
94
+ return { status: "ignored", reason: isIgnoredReason(reason) ? reason : "not_member" };
95
+ }
96
+ /** Côté Account : l'issue lue dans la réponse 200 d'un produit, ou `null` si illisible. */
97
+ export function accountEventOutcomeOf(body) {
98
+ if (typeof body !== "object" || body === null)
99
+ return null;
100
+ const { status, reason } = body;
101
+ if (status === "applied" || status === "duplicate")
102
+ return status;
103
+ if (status === "ignored" && isIgnoredReason(reason))
104
+ return `ignored:${reason}`;
105
+ return null;
106
+ }
107
+ const encoder = new TextEncoder();
108
+ async function hmacHex(secret, payload) {
109
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
110
+ "sign",
111
+ ]);
112
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
113
+ return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
114
+ }
115
+ function constantTimeEqual(a, b) {
116
+ if (a.length !== b.length)
117
+ return false;
118
+ let diff = 0;
119
+ for (let i = 0; i < a.length; i++)
120
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
121
+ return diff === 0;
122
+ }
123
+ function assertSecret(secret) {
124
+ if (typeof secret !== "string" || secret.length < ACCOUNT_EVENT_SECRET_MIN_LENGTH)
125
+ throw new Error(`Secret des événements Account absent ou trop court (${ACCOUNT_EVENT_SECRET_MIN_LENGTH} caractères au moins).`);
126
+ }
127
+ function assertAudience(audience) {
128
+ if (!ACCOUNT_EVENT_AUDIENCES.includes(audience))
129
+ throw new Error("Audience d'événement Account inconnue.");
130
+ }
131
+ function signedPayloadV2(audience, eventId, timestamp, body) {
132
+ return `v2.${audience}.${eventId}.${timestamp}.${body}`;
133
+ }
134
+ /** Identifiant d'événement neuf (UUID sans tirets), à garder pour tous les essais. */
135
+ export function newAccountEventId() {
136
+ return crypto.randomUUID().replaceAll("-", "");
137
+ }
138
+ /**
139
+ * Côté Account : corps et en-têtes d'un envoi à `audience`. À rappeler à chaque essai (horodatage
140
+ * neuf), avec le même événement (même `id`). `legacy` ajoute l'ancienne signature `v1=` (sans
141
+ * audience) pour un produit pas encore monté : `v1=` dans `x-hgdc-signature`, `v2=` dans
142
+ * `x-hgdc-signature-v2`. Sans `legacy`, `x-hgdc-signature` porte `v2=`.
143
+ */
144
+ export async function signAccountEvent(event, options) {
145
+ assertSecret(options.secret);
146
+ assertAudience(options.audience);
147
+ const parsed = accountEventSchema.parse(event);
148
+ const body = JSON.stringify(parsed);
149
+ const timestamp = String(Math.floor(options.now / 1000));
150
+ const v2 = `v2=${await hmacHex(options.secret, signedPayloadV2(options.audience, parsed.id, timestamp, body))}`;
151
+ const headers = {
152
+ "content-type": "application/json",
153
+ [ACCOUNT_EVENT_HEADERS.id]: parsed.id,
154
+ [ACCOUNT_EVENT_HEADERS.timestamp]: timestamp,
155
+ [ACCOUNT_EVENT_HEADERS.audience]: options.audience,
156
+ };
157
+ if (options.legacy) {
158
+ headers[ACCOUNT_EVENT_HEADERS.signature] =
159
+ `v1=${await hmacHex(options.secret, `${parsed.id}.${timestamp}.${body}`)}`;
160
+ headers[ACCOUNT_EVENT_HEADERS.signatureV2] = v2;
161
+ }
162
+ else
163
+ headers[ACCOUNT_EVENT_HEADERS.signature] = v2;
164
+ return { body, headers };
165
+ }
166
+ /**
167
+ * Lecture bornée du corps d'une requête : par morceaux, abandonnée dès qu'elle dépasse `maxBytes`
168
+ * (en octets, « é » en compte deux). `request.text()` lit tout avant qu'on puisse compter, y
169
+ * compris un envoi sans `content-length` (chunked).
170
+ */
171
+ export async function readBodyText(request, maxBytes) {
172
+ const declared = Number(request.headers.get("content-length") ?? 0);
173
+ if (declared > maxBytes)
174
+ return { ok: false, reason: "too_large" };
175
+ if (!request.body)
176
+ return { ok: true, text: "" };
177
+ const reader = request.body.getReader();
178
+ const chunks = [];
179
+ let size = 0;
180
+ for (;;) {
181
+ const { done, value } = await reader.read();
182
+ if (done)
183
+ break;
184
+ size += value.byteLength;
185
+ if (size > maxBytes) {
186
+ await reader.cancel().catch(() => undefined);
187
+ return { ok: false, reason: "too_large" };
188
+ }
189
+ chunks.push(value);
190
+ }
191
+ const bytes = new Uint8Array(size);
192
+ let offset = 0;
193
+ for (const chunk of chunks) {
194
+ bytes.set(chunk, offset);
195
+ offset += chunk.byteLength;
196
+ }
197
+ return { ok: true, text: new TextDecoder().decode(bytes) };
198
+ }
199
+ const reject = (status, error) => ({
200
+ ok: false,
201
+ status,
202
+ error,
203
+ });
204
+ /**
205
+ * Côté produit : lit le corps en flux (plafond `ACCOUNT_EVENT_MAX_BYTES`), vérifie horodatage
206
+ * (±5 minutes), audience (celle du produit), signature `v2=` en temps constant, puis le corps
207
+ * (`accountEventSchema`), l'accord entre l'identifiant signé et celui du corps, et un
208
+ * `occurredAt` sans plus de 5 minutes d'avance. Ne dit rien du rejeu : à écarter par la table
209
+ * des événements traités.
210
+ */
211
+ export async function verifyAccountEvent(request, options) {
212
+ assertSecret(options.secret);
213
+ assertAudience(options.audience);
214
+ const read = await readBodyText(request, ACCOUNT_EVENT_MAX_BYTES);
215
+ if (!read.ok)
216
+ return reject(413, "too_large");
217
+ const raw = read.text;
218
+ const eventId = request.headers.get(ACCOUNT_EVENT_HEADERS.id) ?? "";
219
+ const timestamp = request.headers.get(ACCOUNT_EVENT_HEADERS.timestamp) ?? "";
220
+ const audience = request.headers.get(ACCOUNT_EVENT_HEADERS.audience) ?? "";
221
+ const signature = [
222
+ request.headers.get(ACCOUNT_EVENT_HEADERS.signatureV2),
223
+ request.headers.get(ACCOUNT_EVENT_HEADERS.signature),
224
+ ].find((value) => value?.startsWith("v2="));
225
+ if (!signature || audience !== options.audience)
226
+ return reject(401, "unauthorized");
227
+ if (!/^[A-Za-z0-9_-]{16,64}$/.test(eventId) || !/^\d{1,12}$/.test(timestamp))
228
+ return reject(401, "unauthorized");
229
+ if (Math.abs(options.now / 1000 - Number(timestamp)) > ACCOUNT_EVENT_TOLERANCE_SECONDS)
230
+ return reject(401, "unauthorized");
231
+ const expected = `v2=${await hmacHex(options.secret, signedPayloadV2(options.audience, eventId, timestamp, raw))}`;
232
+ if (!constantTimeEqual(signature, expected))
233
+ return reject(401, "unauthorized");
234
+ let json;
235
+ try {
236
+ json = JSON.parse(raw);
237
+ }
238
+ catch {
239
+ return reject(400, "invalid_body");
240
+ }
241
+ const parsed = accountEventSchema.safeParse(json);
242
+ if (!parsed.success || parsed.data.id !== eventId)
243
+ return reject(400, "invalid_body");
244
+ if (parsed.data.occurredAt > options.now + ACCOUNT_EVENT_FUTURE_TOLERANCE_MS)
245
+ return reject(400, "invalid_body");
246
+ return { ok: true, event: parsed.data };
247
+ }
248
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
249
+ /**
250
+ * Endpoint complet d'un produit, à envelopper dans une `httpAction` Convex :
251
+ *
252
+ * ```ts
253
+ * export const accountEvents = httpAction(async (ctx, request) =>
254
+ * handleAccountEventRequest(request, {
255
+ * audience: "host",
256
+ * secret: process.env.ACCOUNT_EVENTS_SECRET,
257
+ * apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
258
+ * }),
259
+ * )
260
+ * http.route({ path: ACCOUNT_EVENTS_PATH, method: "POST", handler: accountEvents })
261
+ * ```
262
+ *
263
+ * `apply` est une mutation interne qui, dans la même transaction, écarte un `id` déjà traité,
264
+ * l'enregistre et applique l'événement ; elle rend son issue détaillée (`AccountEventOutcome`),
265
+ * renvoyée à Account et journalisée en avertissement quand rien n'est appliqué. Sans secret
266
+ * posé : 503, Account réessaie plus tard. Une erreur de `apply` remonte (500) : Account réessaie.
267
+ */
268
+ export async function handleAccountEventRequest(request, options) {
269
+ const { secret } = options;
270
+ if (typeof secret !== "string" || secret.length < ACCOUNT_EVENT_SECRET_MIN_LENGTH)
271
+ return json(503, { error: "not_configured" });
272
+ const verified = await verifyAccountEvent(request, {
273
+ secret,
274
+ now: options.now ?? Date.now(),
275
+ audience: options.audience,
276
+ });
277
+ if (!verified.ok)
278
+ return json(verified.status, { error: verified.error });
279
+ const { event } = verified;
280
+ const outcome = await options.apply(event);
281
+ // Des identifiants seulement, jamais d'adresse : de quoi retrouver l'événement chez Account.
282
+ if (outcome !== "applied")
283
+ console.warn("Événement Account non appliqué", JSON.stringify({ id: event.id, type: event.type, organizationId: event.organizationId, outcome }));
284
+ return json(200, responseOf(outcome));
285
+ }
286
+ /**
287
+ * Vrai si l'appartenance a été confirmée par Account après l'événement : un essai en retard ne
288
+ * doit pas défaire une connexion plus récente (personne retirée puis réinvitée, rôle changé deux
289
+ * fois). `syncedAt` doit être l'instant où Account a émis les droits recopiés, soit l'`iat` de
290
+ * l'id_token (`verifyHgdcIdToken(...).issuedAt`), jamais l'heure de la copie : un jeton émis avant
291
+ * un retrait puis lu après ne doit pas masquer ce retrait. Une copie sans `syncedAt` n'est jamais
292
+ * plus récente.
293
+ */
294
+ export function accountEventIsStale(event, syncedAt) {
295
+ return typeof syncedAt === "number" && syncedAt > event.occurredAt;
296
+ }
297
+ const vRole = v.union(...ORG_ROLES.map((role) => v.literal(role)));
298
+ /** Validateur Convex d'un `AccountEvent` : argument de la mutation interne qui l'applique. */
299
+ export const vAccountEvent = v.union(v.object({
300
+ id: v.string(),
301
+ type: v.literal("member.removed"),
302
+ occurredAt: v.number(),
303
+ organizationId: v.string(),
304
+ userId: v.string(),
305
+ reason: v.union(v.literal("removed"), v.literal("left"), v.literal("user_deleted")),
306
+ }), v.object({
307
+ id: v.string(),
308
+ type: v.literal("member.role_changed"),
309
+ occurredAt: v.number(),
310
+ organizationId: v.string(),
311
+ userId: v.string(),
312
+ role: vRole,
313
+ }), v.object({
314
+ id: v.string(),
315
+ type: v.literal("organization.deleted"),
316
+ occurredAt: v.number(),
317
+ organizationId: v.string(),
318
+ }));
319
+ /** Validateur Convex de l'issue rendue par la mutation qui applique un événement. */
320
+ export const vAccountEventOutcome = v.union(v.literal("applied"), v.literal("duplicate"), v.literal("ignored:unknown_organization"), v.literal("ignored:unknown_user"), v.literal("ignored:not_member"), v.literal("ignored:stale"));
321
+ /**
322
+ * Table des événements déjà traités, pour l'idempotence (`accountEvents: accountEventsTable` dans
323
+ * le schéma du produit). Purge après `ACCOUNT_EVENT_RETENTION_MS` par l'index `by_received`.
324
+ */
325
+ export const accountEventsTable = defineTable({
326
+ eventId: v.string(),
327
+ type: v.string(),
328
+ receivedAt: v.number(),
329
+ })
330
+ .index("by_event_id", ["eventId"])
331
+ .index("by_received", ["receivedAt"]);
package/dist/index.d.ts CHANGED
@@ -141,10 +141,15 @@ export type IdTokenExpectation = {
141
141
  * La signature n'est pas revérifiée : le jeton vient du point de jeton d'Account, reçu par le
142
142
  * serveur en TLS (point 6). Le contrôle de fraîcheur empêche qu'un jeton ancien, que
143
143
  * better-auth n'aurait pas réécrit, rejoue des organisations périmées.
144
+ *
145
+ * `issuedAt` (`iat` en millisecondes) est l'instant où Account a lu les organisations : c'est lui,
146
+ * pas l'heure de la copie, qui doit devenir le `syncedAt` des appartenances recopiées
147
+ * (`accountEventIsStale` le compare aux événements poussés par Account).
144
148
  */
145
149
  export declare function verifyHgdcIdToken(idToken: string, expected: IdTokenExpectation): {
146
150
  claims: Record<string, unknown>;
147
151
  organizations: HgdcOrganization[];
152
+ issuedAt: number;
148
153
  };
149
154
  /**
150
155
  * Organisations d'un id_token reçu à la connexion, après `verifyHgdcIdToken`. À appeler dans
package/dist/index.js CHANGED
@@ -243,6 +243,10 @@ function payloadOf(idToken) {
243
243
  * La signature n'est pas revérifiée : le jeton vient du point de jeton d'Account, reçu par le
244
244
  * serveur en TLS (point 6). Le contrôle de fraîcheur empêche qu'un jeton ancien, que
245
245
  * better-auth n'aurait pas réécrit, rejoue des organisations périmées.
246
+ *
247
+ * `issuedAt` (`iat` en millisecondes) est l'instant où Account a lu les organisations : c'est lui,
248
+ * pas l'heure de la copie, qui doit devenir le `syncedAt` des appartenances recopiées
249
+ * (`accountEventIsStale` le compare aux événements poussés par Account).
246
250
  */
247
251
  export function verifyHgdcIdToken(idToken, expected) {
248
252
  if (!expected.issuer || !expected.clientId)
@@ -265,7 +269,7 @@ export function verifyHgdcIdToken(idToken, expected) {
265
269
  const result = organizationsClaim.safeParse(claims);
266
270
  if (!result.success)
267
271
  throw new HgdcIdTokenError("invalid_claims", `Organisations HGDC invalides : ${z.prettifyError(result.error)}`);
268
- return { claims, organizations: result.data.organizations ?? [] };
272
+ return { claims, organizations: result.data.organizations ?? [], issuedAt: iat * 1000 };
269
273
  }
270
274
  /**
271
275
  * Organisations d'un id_token reçu à la connexion, après `verifyHgdcIdToken`. À appeler dans
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/auth",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Connexion HGDC : bouton « Se connecter avec HGDC », config better-auth et gardes Convex.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/auth#readme",
@@ -27,6 +27,10 @@
27
27
  "types": "./dist/convex.d.ts",
28
28
  "default": "./dist/convex.js"
29
29
  },
30
+ "./events": {
31
+ "types": "./dist/events.d.ts",
32
+ "default": "./dist/events.js"
33
+ },
30
34
  "./server": {
31
35
  "types": "./dist/server.d.ts",
32
36
  "default": "./dist/server.js"