Repository navigation
Expand file tree
/
Copy pathopenapi.yaml
More file actions
3780 lines (3651 loc) · 163 KB
/
Copy pathopenapi.yaml
File metadata and controls
3780 lines (3651 loc) · 163 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# openGym HTTP API — hand-written OpenAPI 3.1 spec.
# Source of truth: api/server.js (a single framework-free Node file). Every route the
# server registers is documented here; if you add a route there, add it here too.
# Browsable version: https://opengym.duarte-santos.ch/api.html
openapi: 3.1.0
info:
title: openGym API
version: 1.3.9
summary: Passkey (WebAuthn) auth + per-user state storage for openGym
description: |
The backend of [openGym](https://opengym.duarte-santos.ch) — a free, open-source
gym & body-weight tracker. A plain Node server, no framework, JSON-file storage,
HMAC-signed session cookies.
### Authentication
Sign-in is by **passkey** (WebAuthn, via `@simplewebauthn/server`). A successful
`register/verify` or `login/verify` sets the session cookie; every authenticated
route then works with that cookie. The paired mobile app has no shared cookie jar,
so it redeems a short pairing code (`/api/pair/redeem`) for the *same* signed token
and sends it as `Authorization: Bearer <token>` instead.
An instance started with `PASSWORD_LOGIN=1` also offers **name-and-password
sign-in** (tag `password`): opt-in per profile, scrypt-hashed, throttled, with an
admin-issued one-time reset code instead of e-mail. A profile may also add an e-mail
address to sign in with instead of its name (`/api/account/email`); no mail is ever
sent to it. Its routes set exactly the same cookie. With the flag off (the default)
every one of them answers 404.
### Throttling
The `password` routes are counted per client address: at most 60 requests a minute
across them, and wrong answers (a password, a reset code, an invite code on password
signup, an e-mail address already in use) pause the address after 20 for 30 s,
doubling up to 15 min. Wrong passwords also pause the *account* they were tried
against after 5, for 1 min doubling up to 1 h — the same pause whether the account
was named by its name or its e-mail — and an identifier that names no account is
paused the same way, as typed. A password check counts from the moment it
starts, so checks sent at once get no more tries than checks sent one by one. A
paused caller gets `429 {"error": …, "code": "locked", "retryAfter": <s>}` with a
`Retry-After` header. Passkey sign-up and sign-in and the pairing routes are not
throttled. The two routes that redeem a one-time device link (tag `passkeys`) share
the per-address budget, and wrong codes pause the address for link redemption only,
after 20, the way wrong reset codes do. Adding or removing a passkey in Settings
(`POST /api/account/passkeys/options`, `DELETE /api/account/passkeys`) and making a
device code (`POST /api/account/device-link`) spend the per-address budget too, and a
current password given there as proof counts like one given at sign-in.
The address is the socket's, or with `TRUST_PROXY=1` the one a trusted proxy put in
`CF-Connecting-IP` / the last `X-Forwarded-For` entry / `X-Real-IP`; IPv6 clients are
counted by /64. Counters live in memory and reset on restart.
### Sessions
The cookie/bearer value is `<payload>.<hmac>` where the payload is
`<uid>:<expiry-ms>:<session-version>`, signed with a per-instance secret
(HMAC-SHA256). Sessions last `SESSION_DAYS` (default 90). `POST /api/logout/all`
bumps the user's session version, which invalidates every token ever issued for
the account.
### CSRF
State-changing browser requests must come from the app's own origin. The server
checks `Sec-Fetch-Site` (falling back to `Origin` vs the configured `ORIGIN`);
a mismatch is refused with `403 {"error":"cross-origin request refused"}` on any
non-GET route. Exempt: passkey sign-up and sign-in (`register/options`,
`register/verify`, `login/options`, `login/verify`) and `pair/redeem`, each of which
carries its own one-shot credential in the body, and any request authenticated with a
Bearer token.
The password routes are **not** exempt: a hostile page knows a valid name and
password of its own, and could otherwise sign a visitor into it (login CSRF).
Neither are the device-link routes: a link is redeemed on the app's own origin,
the only one a passkey can be created for.
### Environment-dependent behavior
- `INVITE_ONLY=1` — registration requires a valid invite code (minted by an admin).
- `ALLOW_GUEST=0` — hides the client-side "continue without account" mode; the
server merely reports the flag via `GET /api/config` (guest mode never talks to
this API at all).
- `ADMIN_UIDS=<uid>,<uid>` — user ids treated as admins (a `"admin": true` flag on
the user record in `db.json` works too). Admins get the `/api/admin/*` routes.
- `AUDIT_LOG` (default on), `AUDIT_MAX` (default 5000 events), `AUDIT_DAYS`
(default 90), `AUDIT_IP` (`off` | `net` | `full`, default off) — shape the audit
log served by `GET /api/admin/audit`.
- `PASSWORD_LOGIN=1` — adds the `password` routes and `password_login: true` in
`GET /api/config`. Off by default.
- `DEFAULT_LANG` (e.g. `pt-BR`; unset by default) — `default_lang` in `GET /api/config`,
the language of the sign-in screen and of every profile that never picked one.
- `TRUST_PROXY=1` — the throttle reads the client address from proxy headers (set by
the bundled `docker-compose.yml`, where only the web container can reach the API).
- `MEDIA_UPLOADS` (default on; `0` removes the `media` routes and the `media` block of
`GET /api/config`), `MEDIA_QUOTA_MB` (200 per profile, `0` = no cap),
`MEDIA_IMAGE_MAX_MB` (2), `MEDIA_GIF_MAX_MB` (8), `MEDIA_VIDEO_MAX_MB` (40),
`MEDIA_VIDEO_MAX_SEC` (60), `MEDIA_GC_GRACE_DAYS` (14), `MEDIA_UPLOADS_PER_HOUR` (600
per profile), `MEDIA_MIN_FREE_MB` (512, `0` = no floor) — photos and videos of custom
exercises (tag `media`). Every MB here is 2^20 bytes.
### Conventions
- Every response body is JSON with `Cache-Control: no-store`. The one exception is
`GET /api/media/{hash}`, which answers with the stored file itself
(`Cache-Control: private, no-store`).
- Errors are always `{"error": "<human-readable message>"}`. The `password`, throttle and
`media` routes add a stable `code` for the client to word in its own language.
- Unknown method+path pairs return `404 {"error":"not found"}`; an unhandled
exception returns `500 {"error":"server error"}`.
- Request bodies are parsed as JSON regardless of Content-Type. A body that does
not parse, or parses to anything but an object (`null`, a string, an array), is
`400 {"error":"invalid json"}` on every route that reads one; an empty body counts
as `{}`. A body over 5 MiB is `413 {"error":"body too large"}`; the rest of the
upload is discarded so the answer reaches the client.
- CORS: the request's `Origin` is reflected in `Access-Control-Allow-Origin`
**without** `Allow-Credentials` — cross-origin callers can only ever
authenticate with a Bearer token, never with the cookie.
license:
name: AGPL-3.0-or-later
identifier: AGPL-3.0-or-later
contact:
name: openGym
url: https://gitlab.com/DuarteSantos8/opengym
servers:
- url: /
description: Same origin as the openGym web app (the normal deployment)
- url: https://opengym.example.com
description: Your self-hosted instance
tags:
- name: meta
description: Health and public configuration
- name: auth
description: Passkey (WebAuthn) registration and login, sessions
- name: pairing
description: Mobile-app pairing (code from a signed-in browser tab)
- name: password
description: >-
Optional name-and-password sign-in (`PASSWORD_LOGIN=1`). Every route here is a 404
while the flag is off.
- name: passkeys
description: >-
More than one passkey on a profile: list, add, rename, remove — and one-time device
links, which let another device of the same person add a passkey of its own.
- name: data
description: Per-user state sync (the whole app state as one JSON blob)
- name: push
description: Web Push (VAPID) subscriptions, rest-timer and test pushes
- name: activity
description: Live-workout presence heartbeat
- name: admin
description: Admin dashboard — requires an admin session (`ADMIN_UIDS` or `admin:true`)
- name: media
description: >-
The photo, GIF or video of a user's own custom exercise, and the photos and videos
attached to a logged workout. Stored per profile (one quota for both) and named
by the SHA-256 of its bytes; the state only carries a `MediaRef`. Every route here is a
404 when the instance runs with `MEDIA_UPLOADS=0`.
- name: coach
description: >-
AI Coach — plans, reviews and debriefs. Every route here answers 503 unless the
instance has the Coach switched on and a provider connected.
security:
- cookieAuth: []
- bearerAuth: []
paths:
/api/health:
get:
tags: [meta]
operationId: getHealth
summary: Health check
description: Always public. Also reports how many user accounts exist.
security: []
responses:
'200':
description: The server is up.
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean, const: true }
users:
type: integer
description: Number of registered user accounts.
example: { ok: true, users: 7 }
/api/config:
get:
tags: [meta]
operationId: getConfig
summary: Instance configuration
description: |
The flags the login screen needs before anyone is signed in. Both reflect
environment variables on the server (`INVITE_ONLY`, `ALLOW_GUEST`), and so does
`password_login`, which is there only when `PASSWORD_LOGIN=1`, and `default_lang`,
which is there only when `DEFAULT_LANG` is set.
`media` is there unless the instance runs with `MEDIA_UPLOADS=0`, for everybody: the
caps are not a secret, and its absence is how the app knows this server does not
store photos and videos at all.
A caller with a valid session also gets a `coach` key: the block when the
instance has the AI Coach switched on **and** a provider connected, and `null`
when it has not. A caller with no session gets no key at all — the block names
the provider this instance talks to, which is the same fact
`GET /api/coach/disclosure` will not hand out unauthenticated.
The key is always present for a session, `null` included, so a client that
caches this answer can tell "no Coach on this instance" from "you were not
signed in when you asked" and knows whether asking again would change it.
security: []
responses:
'200':
description: Instance flags, plus the Coach block for a signed-in caller.
content:
application/json:
schema:
type: object
properties:
invite_only:
type: boolean
description: When true, registration requires a valid invite code.
allow_guest:
type: boolean
description: >-
When false, the client hides "continue without account".
Guest mode is purely client-side and never calls this API.
coach:
description: >-
Present for a signed-in caller and absent otherwise; `null` when this
instance has no Coach enabled and connected. Every Coach entry point
in the client hangs off the block being there.
type: ['object', 'null']
properties:
enabled: { type: boolean, const: true }
provider:
type: string
description: Provider id, e.g. `anthropic`, `compatible`, `fixture`.
providerLabel: { type: string, description: Human-readable provider name. }
authMode:
type: string
enum: [instance, profile]
description: Whose credential the jobs spend.
community:
type: boolean
description: Whether "compare with others" is offered on this instance.
password_login:
type: boolean
const: true
description: >-
Present (and true) only when the instance runs with
PASSWORD_LOGIN=1 — the client then offers name-and-password
sign-in next to passkeys.
default_lang:
type: string
example: pt-BR
description: >-
Present only when the instance runs with DEFAULT_LANG set: the
language tag the sign-in screen, and every profile that has never
picked a language in Settings, starts in. A profile's own choice
always wins; a tag the app has no translation for is ignored.
media:
$ref: '#/components/schemas/MediaConfig'
examples:
anonymous: { value: { invite_only: true, allow_guest: false } }
signedInNoCoach: { value: { invite_only: true, allow_guest: false, coach: null } }
signedIn:
value:
invite_only: true
allow_guest: false
coach: { enabled: true, provider: compatible, providerLabel: "OpenAI-compatible endpoint", authMode: instance, community: false }
passwordLogin: { value: { invite_only: true, allow_guest: false, password_login: true } }
media: { value: { invite_only: false, allow_guest: true, media: { imageMB: 2, gifMB: 8, videoMB: 40, videoSec: 60, quotaMB: 200, workouts: true } } }
/api/me:
get:
tags: [auth]
operationId: getMe
summary: Who am I?
description: |
Resolves the current session to a user. A paired phone (bearer token) whose token is
past half of `SESSION_DAYS` also receives a fresh `token` to use from now on; it carries
the account's current session version, so "sign out everywhere" revokes it like the old
one. Cookie sessions never get one.
responses:
'200':
description: A valid session.
content:
application/json:
schema:
type: object
properties:
user: { $ref: '#/components/schemas/SessionUser' }
token: { type: string, description: 'Bearer sessions past half their lifetime only — the renewed token.' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/register/options:
post:
tags: [auth]
operationId: registerOptions
summary: 'Start passkey registration (WebAuthn ceremony, step 1)'
description: |
Returns `PublicKeyCredentialCreationOptions` (from
`@simplewebauthn/server`'s `generateRegistrationOptions`) plus a challenge id
`cid`. The client passes `options` to
`navigator.credentials.create()` (or `@simplewebauthn/browser`'s
`startRegistration`) and sends the result to `/api/register/verify` together
with the `cid`. Challenges are one-shot and expire after 5 minutes.
CSRF-exempt (the challenge is the credential). No session required.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
maxLength: 40
description: Display name for the new profile (trimmed, max 40 chars).
code:
type: string
description: >-
Invite code — required (and validated) only when the instance
runs with INVITE_ONLY. Compared case-insensitively.
example: { name: "Ada", code: "3F9C21A07B54D688" }
responses:
'200':
description: Ceremony options.
content:
application/json:
schema:
type: object
properties:
cid:
type: string
description: One-shot challenge id, echo it back to /api/register/verify.
options:
$ref: '#/components/schemas/WebAuthnRegistrationOptions'
'400':
description: '`name` missing/empty.'
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "name required" }
'403':
description: Instance is invite-only and the code is missing, used, or revoked.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "a valid invite code is required" }
/api/register/verify:
post:
tags: [auth]
operationId: registerVerify
summary: 'Finish passkey registration (WebAuthn ceremony, step 2)'
description: |
Verifies the authenticator's attestation response against the stored
challenge (`verifyRegistrationResponse`), creates the user + credential, and
signs the caller in (sets the session cookie). On an invite-only instance the
invite is re-checked and burned here. CSRF-exempt.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [cid, credential]
properties:
cid:
type: string
description: The challenge id from /api/register/options.
credential:
$ref: '#/components/schemas/WebAuthnRegistrationCredential'
responses:
'200':
description: Registered and signed in. The session cookie is set.
headers:
Set-Cookie:
description: >-
Session cookie (`__Host-gymsid` on HTTPS deployments, `gymsid` over
plain http). HttpOnly, SameSite=Lax, Max-Age = SESSION_DAYS.
schema: { type: string }
content:
application/json:
schema:
type: object
properties:
user: { $ref: '#/components/schemas/SessionUser' }
'400':
description: >-
Challenge expired/replayed, or the attestation did not verify (wrong
origin/RP ID, malformed response…). The message is human-readable.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "challenge expired — try again" }
'403':
description: Invite-only and the code became invalid since step 1.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "invite code is no longer valid — ask for a new one" }
'409':
description: This credential id is already registered.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "credential already registered" }
/api/login/options:
post:
tags: [auth]
operationId: loginOptions
summary: 'Start passkey login (WebAuthn ceremony, step 1)'
description: |
Returns `PublicKeyCredentialRequestOptions`
(`generateAuthenticationOptions` with an empty `allowCredentials` — discoverable
credentials / resident keys are required at registration, so the browser offers
the user their passkeys itself) plus a one-shot challenge id `cid`.
CSRF-exempt. Takes no request body.
security: []
responses:
'200':
description: Ceremony options.
content:
application/json:
schema:
type: object
properties:
cid: { type: string }
options:
$ref: '#/components/schemas/WebAuthnAuthenticationOptions'
/api/login/verify:
post:
tags: [auth]
operationId: loginVerify
summary: 'Finish passkey login (WebAuthn ceremony, step 2)'
description: |
Verifies the assertion (`verifyAuthenticationResponse`), updates the signature
counter, and signs the caller in (sets the session cookie). CSRF-exempt.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [cid, credential]
properties:
cid:
type: string
description: The challenge id from /api/login/options.
credential:
$ref: '#/components/schemas/WebAuthnAuthenticationCredential'
responses:
'200':
description: Signed in. The session cookie is set.
headers:
Set-Cookie:
description: Session cookie (see /api/register/verify).
schema: { type: string }
content:
application/json:
schema:
type: object
properties:
user: { $ref: '#/components/schemas/SessionUser' }
'400':
description: Challenge expired/replayed, or the assertion did not verify.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "not verified" }
'403':
description: The account has been disabled by an admin.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "this account has been disabled" }
'404':
description: The passkey's credential id is unknown to this instance.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "unknown passkey — create a profile first" }
'500':
description: Credential exists but its user record is missing (corrupt db).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "user missing" }
/api/logout:
post:
tags: [auth]
operationId: logout
summary: Sign out (this device)
description: >-
Clears the session cookie. Always succeeds — an invalid or missing session is
a no-op. The token itself stays cryptographically valid until it expires; use
/api/logout/all to revoke tokens.
security: []
responses:
'200':
description: Cookie cleared.
headers:
Set-Cookie:
description: Expires the session cookie(s).
schema: { type: string }
content:
application/json:
schema: { $ref: '#/components/schemas/Ok' }
/api/logout/all:
post:
tags: [auth]
operationId: logoutAll
summary: Sign out everywhere
description: |
Bumps the account's session version, which invalidates **every** cookie and
Bearer token ever issued for it — on every device, including a copy someone
walked off with. Passkeys are untouched; signing back in works immediately.
Also clears the caller's own cookie. Unredeemed pairing codes for the account
are voided too.
responses:
'200':
description: All sessions revoked.
headers:
Set-Cookie:
description: Expires the caller's session cookie(s).
schema: { type: string }
content:
application/json:
schema: { $ref: '#/components/schemas/Ok' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/pair/create:
post:
tags: [pairing]
operationId: pairCreate
summary: Mint a pairing code for the mobile app
description: |
Called from an already signed-in browser tab (Settings → "Pair the mobile
app"). Returns an 8-character code (alphabet without 0/O/1/I) the phone
redeems within 5 minutes via /api/pair/redeem. One-shot.
responses:
'200':
description: A fresh pairing code.
content:
application/json:
schema:
type: object
properties:
code:
type: string
description: 8 chars from ABCDEFGHJKLMNPQRSTUVWXYZ23456789.
example: { code: "K7WQ2MZP" }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/pair/redeem:
post:
tags: [pairing]
operationId: pairRedeem
summary: Redeem a pairing code for a Bearer token
description: |
Called from the mobile app with the code shown in the browser. No session
required — the code *is* the credential (one-shot, 5-minute TTL). Returns the
same HMAC-signed session token the cookie would carry; the app sends it as
`Authorization: Bearer <token>` from then on. CSRF-exempt.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code:
type: string
description: The pairing code (case-insensitive).
example: { code: "K7WQ2MZP" }
responses:
'200':
description: Paired.
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: Signed session token, valid for SESSION_DAYS.
user: { $ref: '#/components/schemas/SessionUser' }
'400':
description: >-
Code unknown, already used, expired — or the account behind it is gone or
disabled (deliberately the same message for all of these).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "invalid or expired code" }
/api/login/password:
post:
tags: [password]
operationId: loginPassword
summary: Sign in with a profile name or e-mail and a password
description: |
Only a profile that has set a password can sign in this way. The identifier is the
profile's display name or the e-mail address it added (`POST /api/account/email`),
compared trimmed, NFKC-normalised and case-insensitively. Send it as `identifier`
(what the app sends), `name` (the original field, still accepted) or `email`. In
`identifier` and `name`, a value with an `@` is looked up as an e-mail first and
then as a name; `email` is looked up only as an e-mail.
A wrong password, an unknown name and a name whose profile has no password all get
the same `401` after the same scrypt work, so neither the answer nor its timing says
which names exist. So does a password that was right when its check started but
was changed, reset or removed before the check finished. Five wrong passwords for an
account pause it, whichever identifier they named it by (see *Throttling*); passkey
sign-in is never paused by this. Sets the
same session cookie as `/api/login/verify`. **Not** CSRF-exempt.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
description: One of `identifier`, `name` or `email`, and the password.
properties:
identifier: { type: string, description: A profile name or an e-mail address. }
name: { type: string, description: 'The original field: a profile name, or an e-mail address.' }
email: { type: string, description: An e-mail address only. }
password: { type: string, maxLength: 256 }
examples:
byName: { value: { identifier: "Ada", password: "correct horse battery staple" } }
byEmail: { value: { identifier: "ada@example.com", password: "correct horse battery staple" } }
legacy: { value: { name: "Ada", password: "correct horse battery staple" } }
responses:
'200':
description: Signed in. The session cookie is set.
headers:
Set-Cookie:
description: Session cookie (see /api/register/verify).
schema: { type: string }
content:
application/json:
schema:
type: object
properties:
user: { $ref: '#/components/schemas/SessionUser' }
'400':
description: Name or password missing.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "name and password required", code: "missing" }
'401':
description: Wrong name or password — deliberately one answer for every cause.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "wrong name or password", code: "bad-credentials" }
'403':
description: The right password for a disabled account.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "this account has been disabled", code: "disabled" }
'429': { $ref: '#/components/responses/TooManyRequests' }
'503': { $ref: '#/components/responses/Busy' }
/api/register/password:
post:
tags: [password]
operationId: registerPassword
summary: Create a profile with a name and password
description: |
For browsers that cannot make a passkey (plain http on a LAN address, some Firefox
setups). Creates the profile and signs it in. The same invite rules as passkey
registration apply: on an invite-only instance the code is checked first, checked
again after hashing, and burned. The password must be 10–256 characters and not one
of the passwords guessing scripts try first; no two profiles with a password may
share a name. An optional `email` is stored as the profile's sign-in e-mail (see
`POST /api/account/email`); one that is not an address is refused with `400
email-invalid`, one that is in use with `409 email-taken` — asked only after the
hash and the second invite check, so without a valid code it is never answered —
which counts against the caller's address like a wrong invite code. **Not**
CSRF-exempt.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, password]
properties:
name: { type: string, maxLength: 40 }
password: { type: string, minLength: 10, maxLength: 256 }
code: { type: string, description: Invite code (INVITE_ONLY only). }
email: { type: string, maxLength: 254, description: Optional sign-in e-mail. }
example: { name: "Ada", password: "correct horse battery staple", code: "3F9C21A07B54D688" }
responses:
'200':
description: Registered and signed in. The session cookie is set.
headers:
Set-Cookie:
description: Session cookie (see /api/register/verify).
schema: { type: string }
content:
application/json:
schema:
type: object
properties:
user: { $ref: '#/components/schemas/SessionUser' }
'400':
description: >-
The password breaks the policy — `too-short` (under 10 characters), `too-long`
(over 256) or `too-common` — a field is `missing`, or `email-invalid`: the
`email` given is not an e-mail address.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
examples:
policy: { value: { error: "this password is too easy to guess", code: "too-common" } }
email: { value: { error: "that is not an e-mail address", code: "email-invalid" } }
'403':
description: Invite-only and the code is missing, used or revoked.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "a valid invite code is required", code: "invite" }
'409':
description: >-
`name-taken` (see NameTaken) or `email-taken` — the e-mail is in use by another
profile.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "another profile already uses this e-mail address", code: "email-taken" }
'429': { $ref: '#/components/responses/TooManyRequests' }
'503': { $ref: '#/components/responses/Busy' }
/api/login/password-reset:
post:
tags: [password]
operationId: redeemPasswordReset
summary: Set a new password with an admin's one-time reset code
description: |
Redeems the code from `POST /api/admin/user/password-reset` (valid 24 h, single
use, stored only as a hash; dashes, spaces and case do not matter) and signs in.
A new password that breaks the policy is refused without using up the code. Every
other session of the account ends. Wrong codes count against the caller's address
only (see *Throttling*), never against the name, so they cannot keep a real code
from working. The profile is named by its name or its sign-in e-mail, in
`identifier`, `name` or `email` as at sign-in. **Not** CSRF-exempt.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code, next]
properties:
identifier: { type: string, description: The profile name or its sign-in e-mail. }
name: { type: string, description: The original field; an e-mail works here too. }
email: { type: string }
code: { type: string, example: "K7WQ-2MZP-4HXA" }
next: { type: string, minLength: 10, maxLength: 256 }
responses:
'200':
description: Password set and signed in. The session cookie is set.
headers:
Set-Cookie:
description: Session cookie (see /api/register/verify).
schema: { type: string }
content:
application/json:
schema:
type: object
properties:
user: { $ref: '#/components/schemas/SessionUser' }
'400':
description: >-
`reset-invalid` (wrong, used or expired code — one answer for all),
`missing`, or a password-policy code.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "that reset code is wrong or has expired", code: "reset-invalid" }
'403':
description: The account has been disabled.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "this account has been disabled", code: "disabled" }
'409': { $ref: '#/components/responses/NameTaken' }
'429': { $ref: '#/components/responses/TooManyRequests' }
'503': { $ref: '#/components/responses/Busy' }
/api/account/password:
get:
tags: [password]
operationId: getAccountPassword
summary: Whether this profile has a password
responses:
'200':
description: The profile's password state.
content:
application/json:
schema:
type: object
properties:
set: { type: boolean }
setAt: { oneOf: [{ type: string }, { type: 'null' }], description: ISO timestamp. }
passkeys: { type: integer, description: 'How many passkeys the profile has; 0 means the password cannot be removed.' }
name: { type: string, description: The name to sign in with. }
nameTaken:
type: boolean
description: Another profile already signs in with this name, so no password can be set here.
email:
oneOf: [{ type: string }, { type: 'null' }]
description: The e-mail this profile may sign in with (POST /api/account/email), or null.
example: { set: true, setAt: "2026-09-23T10:00:00.000Z", passkeys: 1, name: "Ada", nameTaken: false, email: "ada@example.com" }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
tags: [password]
operationId: setAccountPassword
summary: Set or change this profile's password
description: |
Proof first: `current` when the profile has a password, or a passkey assertion made
for this request (`cid` from `POST /api/login/options`, `credential` signed by one of
*this* profile's passkeys) — the only way to set a first password, and the way to
replace a forgotten one. A session alone is never enough. Wrong `current` values
count toward the same pause as wrong sign-ins. On success the session version is
bumped, which ends every other session and pending pairing code of the account; the
caller's own continues on a fresh cookie, or a fresh `token` for a Bearer caller.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [next]
properties:
next: { type: string, minLength: 10, maxLength: 256 }
current: { type: string }
cid: { type: string }
credential: { $ref: '#/components/schemas/WebAuthnAuthenticationCredential' }
responses:
'200':
description: Saved. Other sessions have ended.
headers:
Set-Cookie:
description: A fresh session cookie for this browser (cookie callers).
schema: { type: string }
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean, const: true }
token: { type: string, description: 'Bearer callers only — the token to use from now on.' }
'400': { $ref: '#/components/responses/PasswordPolicy' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: >-
No acceptable proof: `current-required`, `current-wrong`, `passkey-required`
(no password yet, so a passkey has to confirm), or `passkey` (the assertion did
not verify or belongs to another profile).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "your current password is not right", code: "current-wrong" }
'409': { $ref: '#/components/responses/NameTaken' }
'429': { $ref: '#/components/responses/TooManyRequests' }
'503': { $ref: '#/components/responses/Busy' }
delete:
tags: [password]
operationId: removeAccountPassword
summary: Remove this profile's password
description: >-
Refused while the profile has no passkey: the password would be its last way in.
Otherwise the body carries the same proof setting one takes — `current`, the
password itself, or a passkey assertion made for this request (`cid` from
`POST /api/login/options`, `credential` signed by one of *this* profile's
passkeys). A session alone is never enough: a stolen cookie must not take away
the owner's way in where passkeys do not work. The last-way-in refusal comes before
the proof is checked, and is checked again after it. Wrong `current` values count
toward the same pause as wrong sign-ins. Existing sessions are left alone
(`POST /api/logout/all` ends them). A profile without a password gets 200 as well,
with no proof asked. Audited as `auth.password.remove`.
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/OwnerProof' }
responses:
'200':
description: Removed (or there was none).
content:
application/json:
schema: { $ref: '#/components/schemas/Ok' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ProofRefused' }
'409':
description: The password is the profile's only way in.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "the password is the only way into this profile", code: "last-way-in" }
'429': { $ref: '#/components/responses/TooManyRequests' }
'503': { $ref: '#/components/responses/Busy' }
/api/account/email:
post:
tags: [password]
operationId: setAccountEmail
summary: Set, change or remove the e-mail this profile may sign in with
description: |
An e-mail address to type at `POST /api/login/password` instead of the profile
name. No mail is ever sent to it — there is no verification and no reset mail;
resets stay the admin's one-time code — so it is only an identifier. Stored
trimmed, NFKC-normalised and lower-cased; at most 254 characters; unique across
every profile, and never another password-holding profile's name. An empty or null
`email` removes it (as `DELETE` does).
The body carries the same proof as setting a password (see OwnerProof): a session
alone is never enough. Saving the address the profile already has answers 200 with
no proof asked. An address in use by another profile is refused only after the
proof, with `409 email-taken`, and counts against the caller's address and account
(20 free, then 30 s doubling to 15 min) — so the answer never costs less than a
passkey prompt or a password check, and runs out after a few tries. It says an
address is in use somewhere on this instance, never by whom. Audited as
`auth.email.set` / `auth.email.change` with the address masked (`a…@e…`).
requestBody:
required: true
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/OwnerProof'
- type: object
properties:
email:
oneOf: [{ type: string, maxLength: 254 }, { type: 'null' }]
example: { email: "ada@example.com", current: "correct horse battery staple" }
responses:
'200':
description: Saved (or removed).
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean, const: true }
email: { oneOf: [{ type: string }, { type: 'null' }], description: The address as stored. }
example: { ok: true, email: "ada@example.com" }
'400':
description: Not an e-mail address.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "that is not an e-mail address", code: "email-invalid" }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ProofRefused' }
'409':
description: Another profile already uses this address.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "another profile already uses this e-mail address", code: "email-taken" }
'429': { $ref: '#/components/responses/TooManyRequests' }
'503': { $ref: '#/components/responses/Busy' }
delete:
tags: [password]
operationId: removeAccountEmail
summary: Remove the e-mail this profile may sign in with
description: >-
The profile then signs in by its name only. Takes the same proof as setting one
(OwnerProof); a profile without an e-mail gets 200 with none asked. Audited as
`auth.email.remove`.
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/OwnerProof' }
responses:
'200':
description: Removed (or there was none).
content:
application/json:
schema: