forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi.ts
More file actions
577 lines (528 loc) · 24 KB
/
Copy pathapi.ts
File metadata and controls
577 lines (528 loc) · 24 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
// The one place this app talks to OurHike's own backend.
//
// Until this existed there was no such place at all: no base URL among the
// build-time variables, no `Authorization` header anywhere in the client, and
// `flushOutbox` (lib/outbox.ts) referenced only by its own tests. A report
// reached IndexedDB and stayed there for the life of the install (#231).
//
// Distinct from lib/config.ts, which stays about the R2 bucket of published
// pipeline artifacts. Two different services with two different failure
// modes: the bucket is static files a hiker downloads once and reads offline
// forever, this is a live API reachable only with signal. Folding them into
// one module would put a "configured?" flag over both that is true for
// neither.
//
// Also distinct from lib/supabase.ts. Supabase is where a hiker AUTHENTICATES;
// this backend only ever verifies the JWT that comes back
// (backend/app/core/auth.py). The token is borrowed from there and sent here.
import { getAuthClient } from './supabase'
import type { OutboxItem } from './outbox'
import type { BackendReportStatus } from './reportStatus'
import type { ClosureReason, ClosureStatus } from './closureBanner'
import { PHOTO_CONTENT_TYPE } from './reportPhoto'
const RAW_BASE: string = import.meta.env.VITE_API_BASE_URL ?? ''
export const API_BASE_URL = RAW_BASE.replace(/\/+$/, '')
/**
* False when no backend was configured at build time.
*
* The same guard `DATA_CONFIGURED` provides for the data bucket, and for the
* same reason: without it a blank base makes every path relative, so requests
* resolve against the app's own origin and a report POST reaches the static
* host that serves the PWA. That returns a cheerful 200 with an HTML body,
* which is indistinguishable from success to anything checking `response.ok`
* - and the outbox would then drop a report nobody received.
*/
export const API_CONFIGURED = API_BASE_URL !== ''
export function apiUrl(path: string): string {
return `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`
}
/** A request that reached the server and came back refused.
*
* `detail` is the parsed response body, when there was one and it was JSON.
* Carried because the status alone is not enough to say what happened: the
* backend produces 422 for two unrelated reasons, and telling them apart
* decides whether a hiker's report is worth retrying (#412). Left `undefined`
* rather than defaulted, so "no body" and "a body saying nothing" stay
* distinguishable.
*/
export class ApiError extends Error {
readonly status: number
readonly detail: unknown
constructor(status: number, message: string, detail?: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.detail = detail
}
}
/** No backend to talk to, established before any request is attempted. */
export class ApiNotConfiguredError extends Error {
constructor() {
super('This build has no OurHike backend configured.')
this.name = 'ApiNotConfiguredError'
}
}
/** Signed out, or signed in with a session that has since expired. */
export class NotSignedInError extends Error {
constructor() {
super('Sending needs an account, and this device is not signed in.')
this.name = 'NotSignedInError'
}
}
/**
* The current Supabase access token, or null when there is no session.
*
* Read per request rather than held, because Supabase refreshes it in the
* background (`autoRefreshToken`, lib/supabase.ts) and a cached copy would go
* stale exactly during the long offline stretch this app is built around.
*/
export async function accessToken(): Promise<string | null> {
const client = getAuthClient()
if (client === null) return null
const { data } = await client.auth.getSession()
return data.session?.access_token ?? null
}
/**
* A request to the backend, with the bearer token attached.
*
* **Throws on any non-2xx, and that is load-bearing rather than stylistic.**
* `flushOutbox` tells sent from failed purely by whether `send` rejects, so a
* `fetch` that resolves with a 500 would be counted as delivered and the
* report removed from the queue. `fetch` only rejects on network failure, so
* without this every server error silently destroys someone's report.
*/
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
if (!API_CONFIGURED) throw new ApiNotConfiguredError()
const response = await fetch(apiUrl(path), init)
if (!response.ok) {
throw new ApiError(
response.status,
`${init.method ?? 'GET'} ${path} failed: ${response.status}`,
await errorBody(response),
)
}
return response
}
/**
* The parsed body of a refused response, or undefined.
*
* Every failure here is swallowed on purpose. This runs while building the
* error for a request that has ALREADY failed, and a body that is empty,
* truncated, HTML from a proxy, or unreadable because the connection died
* mid-read must not replace a useful `ApiError` with a parse exception. The
* caller loses the extra detail and keeps the status, which is what it had
* before this existed.
*/
async function errorBody(response: Response): Promise<unknown> {
try {
return await response.json()
} catch {
return undefined
}
}
/** Like `apiFetch`, but refuses before spending a request when signed out. */
async function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await accessToken()
// Checked here rather than left to the server's 401, because the round trip
// is the expensive part on a metered connection with one bar - and the
// answer is knowable without it.
if (token === null) throw new NotSignedInError()
return apiFetch(path, {
...init,
headers: {
...init.headers,
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
})
}
/**
* Like `apiFetch`, but attaches the token only if there happens to be one.
*
* A third stance, and the reason reads could not just borrow `authedFetch`
* (#286). That one refuses without a token, which is right for a write the
* server would refuse anyway. A read must not: browsing has never needed an
* account in this app, and `list_reports` is built to answer an anonymous
* caller with the public set.
*
* But the token still goes when it exists, and that is not a nicety - it is
* what lets a reporter see their own unmoderated report. Without it someone's
* own report vanishes from the app between submitting it and a moderator
* reaching it, which is precisely what "Waiting" on the More screen is
* describing.
*/
async function readFetch(path: string, signal?: AbortSignal): Promise<Response> {
const token = await accessToken()
return apiFetch(path, {
signal,
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
})
}
/**
* What `GET /reports` returns, limited to the fields this app reads.
*
* The backend sends more (`received_at`, `follow_up`, `club_id` and the rest
* of `ReportOut`); declaring only what is consumed keeps this honest about
* what the client actually depends on rather than mirroring a schema it does
* not use.
*/
export interface ReportSummary {
id: string
type: string
reporter_type: string
status: BackendReportStatus
severity: 'normal' | 'serious'
lat: number | null
lon: number | null
/**
* Miles from the southern terminus, as the reporting phone measured it
* (#244) - or null.
*
* **Null is the common case and will stay common**, so nothing may treat
* this as the only source of a mile. It is null for every report filed
* before the field existed, for a fix that did not land on the trail, and
* for a phone that had not downloaded the trail index yet. Where this app
* holds the centerline it should prefer its own snap of `lat`/`lon`, which
* is derived from the same index it measures the hiker against; this is
* what answers the cases that snap cannot, chiefly a report with a
* `poi_id` and no coordinates at all.
*/
mile: number | null
poi_id: string | null
note: string | null
/** ISO 8601, UTC-designated - the server stamps the `Z` on the way out. */
timestamp: string
}
/**
* Reports visible to this caller: public and moderated, plus their own at any
* status when a token went with the request.
*
* **Throws rather than returning `[]` on failure, and that is the point.** An
* empty list and a failed fetch draw the same map and mean opposite things on
* the ground - the wrong one of those tells a hiker a closed stretch of trail
* is open. The caller has to be able to say it does not know.
*/
export async function fetchReports(signal?: AbortSignal): Promise<ReportSummary[]> {
const response = await readFetch('/reports', signal)
return (await response.json()) as ReportSummary[]
}
/** What `GET /closures` returns, limited to the fields this app reads. */
export interface ClosureSummary {
id: string
reason_type: ClosureReason
note: string | null
status: ClosureStatus
start_mile_marker: number
end_mile_marker: number
/** ISO 8601, UTC-designated. */
reported_at: string
verified_at: string | null
}
/**
* Verified closures. Same throw-on-failure rule as `fetchReports`, and it
* matters more here: a closure is the one thing on this map whose absence a
* hiker would act on by walking into it.
*/
export async function fetchClosures(signal?: AbortSignal): Promise<ClosureSummary[]> {
const response = await readFetch('/closures', signal)
return (await response.json()) as ClosureSummary[]
}
/**
* Sends one queued report.
*
* `authored_at` travels with it, and that is the reason this function exists
* rather than the caller inlining a `fetch`. The outbox stores when a report
* was WRITTEN and the backend accepts that as `authored_at`, specifically so a
* blowdown written Monday and flushed Thursday still reads as Monday
* (lib/outbox.ts, and backend `create_report`). Omitting the field is not a
* visible failure: the server falls back to its own clock, so every offline
* report would quietly become a fresh one and the bug would look like correct
* data.
*/
export async function sendReport(item: OutboxItem): Promise<void> {
await authedFetch('/reports', {
method: 'POST',
// `id` is an idempotency key, not decoration (#243). The server returns
// the stored report instead of filing a second one, which is what makes
// the outbox's "a resend is recognisably the same report" true rather
// than aspirational - the classic trail failure is a request that
// commits and whose response never arrives.
body: JSON.stringify({ ...item.payload, id: item.id, authored_at: item.authoredAt }),
})
if (item.photo !== undefined) await sendReportPhoto(item.id, item.photo)
}
/**
* The photo, sent second, because the endpoint needs the row to exist (#234).
*
* **A throw from here keeps the whole item queued**, and that is correct
* rather than wasteful: the next flush re-POSTs the report, which #243 made
* idempotent, so a retry costs one duplicate request instead of a duplicate
* report - and the alternative, dropping the item once the report lands,
* would lose the photo on every hiker whose signal died between the two
* requests, which out here is most of them.
*
* The exception is a refusal retrying cannot fix. The report is already
* filed by then, so continuing to fail the item would tell a hiker their
* report is "waiting to send" about one a moderator can already see - a
* durable lie in exchange for bytes the server will never accept. So the
* photo is dropped and the item completes.
*
* That branch should be unreachable, and it is worth being exact about why,
* because a swallowed failure is worth suspecting. Every permanent code this
* endpoint returns is one lib/reportPhoto.ts has already made impossible:
* 415 needs a content type other than JPEG, which is what the canvas encodes;
* 413 needs more than 2 MB, which is the ladder's exit condition; 400 needs
* an empty body, which an encoded canvas is not. It is a valve for a bug in
* this app's own preparation step, not a path a working client takes.
*/
async function sendReportPhoto(reportId: string, photo: Blob): Promise<void> {
try {
await authedFetchBytes(`/reports/${reportId}/photo`, photo)
} catch (error) {
if (error instanceof ApiError && PERMANENTLY_UNACCEPTABLE_PHOTO.has(error.status))
return
throw error
}
}
/** The statuses that mean this photo will never be accepted, however often it
* is offered. NOT 503 - that is "no bucket on this deployment yet", which is
* precisely the case worth waiting out. */
const PERMANENTLY_UNACCEPTABLE_PHOTO = new Set([400, 413, 415])
/** Like `authedFetch`, but sends raw bytes rather than JSON. */
async function authedFetchBytes(path: string, body: Blob): Promise<Response> {
const token = await accessToken()
if (token === null) throw new NotSignedInError()
return apiFetch(path, {
method: 'PUT',
body,
headers: {
// Stated rather than left to the Blob's own type, which is whatever
// `toBlob` happened to set. The server checks this header and refuses
// anything else, so guessing is not an option available to us.
'Content-Type': PHOTO_CONTENT_TYPE,
Authorization: `Bearer ${token}`,
},
})
}
// An ALLOWLIST, and that is the whole design (#266).
//
// This began as "every 4xx except 401/408/429 is permanent", which stranded a
// report on any status nothing in this stack produces - a captive portal, a
// WAF or a proxy answering 400/451/494 would mark the entire queue
// unsendable, in exactly the network conditions this app exists for. It also
// contradicted the rule written directly below it.
//
// So only the two statuses THIS backend actually returns and means are here.
// Everything else - including 4xx codes that sound final - is somebody else's
// infrastructure talking, and gets retried:
//
// 401 the token was rejected; Supabase refreshes in the background.
// 403 create_report has no role gate, so this is never ours.
// 408 a network symptom wearing a 4xx.
// 413 `POST /reports` enforces no size limit. The photo endpoint does,
// but a photo refused for size is handled where it happens
// (`sendReportPhoto`) rather than here, because by then the report
// itself has already been filed and must not be marked unsendable.
// 429 explicitly "later".
//
// Written for a hiker reading a phone on a ridge, not for a log: each says
// what happened and, where there is one, what they can do about it.
const PERMANENT_REASONS: Record<number, string> = {
// The likeliest of the two, and partly fixable from their side: the server
// refuses an authored time more than five minutes ahead, so a phone whose
// clock runs fast has every report refused.
//
// Careful about what this promises. `authored_at` is stamped once when the
// report is written and is deliberately never re-derived - a report written
// Monday must still read as Monday when it flushes on Thursday. So fixing
// the clock does NOT rewrite an already-queued item: it becomes acceptable
// when real time catches up to the timestamp it is carrying. For a phone
// seven minutes fast that is a couple of minutes; for one set a day ahead
// it is a day. Saying "then try again" flatly was a promise this cannot
// keep.
422: 'Its date is in the future, so the server would not take it. Check your phone’s clock — if it was far out, this one may not send until that time has passed.',
409: 'The server already has a different report filed under this one’s id.',
}
// The backend returns 422 for two unrelated reasons, and the entry above is
// written for only one of them (#412).
//
// 1. The `authored_at` refusal, which is this app's own rule and is about
// the hiker's clock.
// 2. Request validation - a field this build does not send, a value this
// build still sends and the server has stopped accepting. That is
// version skew: an old client meeting a newer API, which RELEASING.md
// §8c's support window exists to bound and cannot prevent past its edge.
//
// They want opposite handling. The clock case is about this one report and
// resolves when real time catches up. Skew is about the whole app, resolves
// when it updates, and telling somebody to check their clock sends them to
// look at a setting that is fine.
//
// Told apart by which field the server named. FastAPI reports validation
// failures as `detail: [{loc: [...], ...}]`, and the `authored_at` rule -
// being a field validator on ReportCreate - names that field in `loc`.
// backend/tests/test_report_authored_at_contract.py pins that shape from the
// other side, because this is a cross-boundary assumption and the client
// cannot notice on its own if the body ever changes.
const AUTHORED_AT_FIELD = 'authored_at'
/** The version-skew message. Exported so tests name it once. */
export const OUTDATED_CLIENT_REASON =
'This version of the app is too old for the server to accept it. Update the app when you have signal, then try again — your report is kept until you do.'
function namesAuthoredAt(detail: unknown): boolean {
if (typeof detail !== 'object' || detail === null) return false
const entries = (detail as { detail?: unknown }).detail
if (!Array.isArray(entries)) return false
return entries.some((entry) => {
const loc = (entry as { loc?: unknown })?.loc
return Array.isArray(loc) && loc.includes(AUTHORED_AT_FIELD)
})
}
/**
* Whether a failed send is worth retrying: a sentence to show the hiker if
* it is not, or null if it is.
*
* The distinction exists because `flushOutbox` treated every failure the
* same, so a report the server would never accept sat in the queue saying
* "waiting to send" forever, indistinguishable from one waiting for signal
* (#243). Anything unrecognised is treated as retryable: keeping a report
* that might yet go is cheaper than stranding one that would have.
*/
export function permanentFailureReason(error: unknown): string | null {
if (!(error instanceof ApiError)) return null
// A 422 that does not name `authored_at` is validation failing on some
// other field, which this build cannot fix by trying again - but a newer
// build can, so the message says so and `flushOutbox` retries it once the
// app version changes rather than stranding it for good.
if (error.status === 422 && !namesAuthoredAt(error.detail)) {
return OUTDATED_CLIENT_REASON
}
return PERMANENT_REASONS[error.status] ?? null
}
// --- Moderation (#235) ----------------------------------------------------
//
// The queue could be acted on and read by the backend long before anything
// here could call it, so a `bad_hikers` report - one about being followed on
// trail - reached the audience `internal_only` names only if somebody ran
// curl. These are the calls the moderator screen makes.
//
// Every one of them is `authedFetch`: the backend gates all five behind
// `require_role(maintainer, club_admin)`, and this is the first place the
// client has ever cared what a role is.
/** The signed-in user's own profile. Only `role` is read today; the rest is
* what `GET /profiles/me` returns and is declared so it is not re-guessed. */
export interface ProfileSummary {
id: string
role: 'hiker' | 'maintainer' | 'club_admin'
display_name: string | null
}
export async function fetchMyProfile(signal?: AbortSignal): Promise<ProfileSummary> {
// Not `readFetch`: this endpoint IS the identity, so without a token there
// is no question to ask, and a 401 is the honest answer rather than a
// guess at an anonymous default.
const response = await authedFetch('/profiles/me', { method: 'GET', signal })
return (await response.json()) as ProfileSummary
}
/**
* A report as a MODERATOR sees it - the whole record, not the public subset.
*
* Wider than `ReportSummary` deliberately, and the extra fields are the ones
* the decision actually turns on: the note and the photo are the evidence,
* `visibility` is what says this is an incident note about a person rather
* than a blowdown, and `severity` is what a verify may change.
*/
export interface QueuedReport extends ReportSummary {
visibility: 'public' | 'internal_only' | 'club_only'
photo_url: string | null
reporter_id: string | null
}
/** A closure awaiting review. A line along the trail rather than a pin, which
* is why it is a different shape and not a flag on the rows above. */
export interface QueuedClosure {
id: string
reason_type: ClosureReason
note: string | null
status: ClosureStatus
start_mile_marker: number
end_mile_marker: number
reported_at: string
}
export interface ModerationQueue {
reports: QueuedReport[]
closures: QueuedClosure[]
}
/**
* Everything waiting on a moderator.
*
* **Throws rather than returning an empty queue**, for the same reason
* `fetchReports` does: "nothing is waiting" and "I could not ask" draw the
* same empty screen and mean opposite things. Here the wrong one of those
* tells a moderator there are no unreviewed safety reports.
*/
export async function fetchModerationQueue(
signal?: AbortSignal,
): Promise<ModerationQueue> {
const response = await authedFetch('/moderation/queue', { method: 'GET', signal })
return (await response.json()) as ModerationQueue
}
/**
* Verify a report, optionally saying something about its severity.
*
* **`severity` is omitted unless the moderator chose one, and that is not a
* formality (#251).** The backend treats an absent field as "said nothing"
* and an explicit `normal` as a de-escalation. Sending `normal` by default
* would silently clear a `serious` flag another moderator set - which is the
* flag that puts a warning pin on every phone on the trail.
*/
export async function verifyReport(
reportId: string,
severity?: 'normal' | 'serious',
): Promise<void> {
await authedFetch(`/reports/${reportId}/verify`, {
method: 'POST',
body: JSON.stringify(severity === undefined ? {} : { severity }),
})
}
export async function dismissReport(reportId: string): Promise<void> {
await authedFetch(`/reports/${reportId}/dismiss`, { method: 'POST', body: '{}' })
}
/**
* A URL that fetches one report's photo, good for a few minutes (#385).
*
* **The reason this is not just `<img src={apiUrl('/reports/x/photo')}>`.**
* That endpoint uses optional auth and an `<img>` cannot carry a token, so
* the request goes out anonymous and gets the PUBLIC answer - which for an
* `internal_only` `bad_hikers` photo is a 404 that renders as a broken image.
* A moderator would have no way to tell "there is no evidence" from "there is
* evidence and you are not being shown it", on the one screen built to tell
* those apart.
*
* So the token travels here, on a `fetch` that can carry it, and the URL it
* answers with goes in `src`. Images are exempt from CORS, so nothing new is
* needed on the private photo bucket - fetching the bytes cross-origin
* instead would have needed a CORS policy on the one bucket whose whole
* design is that nothing reaches it without a check.
*
* `readFetch`, not `authedFetch`: the endpoint answers an anonymous caller
* for a public photo, and a hiker looking at their own report is signed in
* without being a moderator. The token goes when there is one.
*
* **Throws on refusal rather than returning null**, so a caller cannot draw
* "no photo" over a photo it was refused - the whole failure this replaces.
*/
export async function fetchReportPhotoLink(
reportId: string,
signal?: AbortSignal,
): Promise<{ url: string; expiresIn: number }> {
const response = await readFetch(`/reports/${reportId}/photo/link`, signal)
const body = (await response.json()) as { url: string; expires_in: number }
return { url: body.url, expiresIn: body.expires_in }
}
export async function verifyClosure(closureId: string): Promise<void> {
// No body. A closure is born `closed`, so verifying one says everything
// that needs saying; the optional `status` covers confirming a reroute,
// which is a judgment this screen does not yet offer.
await authedFetch(`/closures/${closureId}/verify`, { method: 'POST', body: '{}' })
}
export async function dismissClosure(closureId: string): Promise<void> {
await authedFetch(`/closures/${closureId}/dismiss`, { method: 'POST', body: '{}' })
}