@meith/web 0.29.0 → 0.29.1

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.
@@ -16,6 +16,7 @@ import type { Actor } from '@meith/authorization'
16
16
  import { isAppError, metrics, statusForError, toPublicError, withSpan } from '@meith/core'
17
17
  import { currentRequestId } from '@meith/core/logger'
18
18
 
19
+ import { readJsonBody } from '@/server/api/body'
19
20
  import { JSON_MEDIA_TYPE } from '@/server/api/http'
20
21
  import { handlerFor } from '@/server/api/registry'
21
22
  import {
@@ -161,13 +162,30 @@ async function respondTo(
161
162
  )
162
163
  }
163
164
 
165
+ let body: Record<string, unknown> | null = null
166
+ if (matched.route.request !== undefined) {
167
+ const outcome = await readJsonBody(request)
168
+ if (outcome.kind === 'too-large') {
169
+ return fail(
170
+ 413,
171
+ 'payload_too_large',
172
+ 'The request body is larger than the API allows.',
173
+ headers,
174
+ )
175
+ }
176
+ if (outcome.kind === 'invalid') {
177
+ return fail(400, 'invalid_body', 'The request body is not valid JSON.', headers)
178
+ }
179
+ body = outcome.body
180
+ }
181
+
164
182
  try {
165
183
  const result = await handler({
166
184
  actor: caller.actor,
167
185
  token: caller.authenticated?.token ?? null,
168
186
  params: matched.params,
169
187
  url,
170
- body: matched.route.request === undefined ? null : await readJsonBody(request),
188
+ body,
171
189
  })
172
190
 
173
191
  return json(result.body, result.status, headers)
@@ -180,17 +198,6 @@ async function respondTo(
180
198
  }
181
199
  }
182
200
 
183
- async function readJsonBody(request: NextRequest): Promise<Record<string, unknown> | null> {
184
- try {
185
- const parsed: unknown = await request.json()
186
- return typeof parsed === 'object' && parsed !== null
187
- ? (parsed as Record<string, unknown>)
188
- : null
189
- } catch {
190
- return null
191
- }
192
- }
193
-
194
201
  export async function GET(request: NextRequest): Promise<Response> {
195
202
  return handle(request, 'GET')
196
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,53 +43,53 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.29.0",
47
- "@meith/admin": "0.29.0",
48
- "@meith/antispam": "0.29.0",
49
- "@meith/api": "0.29.0",
50
- "@meith/attachments": "0.29.0",
51
- "@meith/authorization": "0.29.0",
52
- "@meith/avatars": "0.29.0",
53
- "@meith/core": "0.29.0",
54
- "@meith/db": "0.29.0",
55
- "@meith/demo": "0.29.0",
56
- "@meith/drafts": "0.29.0",
57
- "@meith/drivers": "0.29.0",
58
- "@meith/events": "0.29.0",
59
- "@meith/forums": "0.29.0",
60
- "@meith/groups": "0.29.0",
61
- "@meith/i18n": "0.29.0",
62
- "@meith/import": "0.29.0",
63
- "@meith/install": "0.29.0",
64
- "@meith/mail": "0.29.0",
65
- "@meith/markdown": "0.29.0",
66
- "@meith/marketplace": "0.29.0",
67
- "@meith/messages": "0.29.0",
68
- "@meith/moderation": "0.29.0",
69
- "@meith/notifications": "0.29.0",
70
- "@meith/plugin-calendar": "0.29.0",
71
- "@meith/plugin-dues": "0.29.0",
72
- "@meith/plugin-kit": "0.29.0",
73
- "@meith/polls": "0.29.0",
74
- "@meith/posts": "0.29.0",
75
- "@meith/profile-fields": "0.29.0",
76
- "@meith/relations": "0.29.0",
77
- "@meith/reputation": "0.29.0",
78
- "@meith/runtime": "0.29.0",
79
- "@meith/search": "0.29.0",
80
- "@meith/settings": "0.29.0",
81
- "@meith/signatures": "0.29.0",
82
- "@meith/subscriptions": "0.29.0",
83
- "@meith/tasks": "0.29.0",
84
- "@meith/theme-clubhouse": "0.29.0",
85
- "@meith/theme-default": "0.29.0",
86
- "@meith/theme-kit": "0.29.0",
87
- "@meith/theme-midnight": "0.29.0",
88
- "@meith/theme-phasebook": "0.29.0",
89
- "@meith/theme-raidframe": "0.29.0",
90
- "@meith/threads": "0.29.0",
91
- "@meith/ui": "0.29.0",
92
- "@meith/upgrade": "0.29.0"
46
+ "@meith/accounts": "0.29.1",
47
+ "@meith/admin": "0.29.1",
48
+ "@meith/antispam": "0.29.1",
49
+ "@meith/api": "0.29.1",
50
+ "@meith/attachments": "0.29.1",
51
+ "@meith/authorization": "0.29.1",
52
+ "@meith/avatars": "0.29.1",
53
+ "@meith/core": "0.29.1",
54
+ "@meith/db": "0.29.1",
55
+ "@meith/demo": "0.29.1",
56
+ "@meith/drafts": "0.29.1",
57
+ "@meith/drivers": "0.29.1",
58
+ "@meith/events": "0.29.1",
59
+ "@meith/forums": "0.29.1",
60
+ "@meith/groups": "0.29.1",
61
+ "@meith/i18n": "0.29.1",
62
+ "@meith/import": "0.29.1",
63
+ "@meith/install": "0.29.1",
64
+ "@meith/mail": "0.29.1",
65
+ "@meith/markdown": "0.29.1",
66
+ "@meith/marketplace": "0.29.1",
67
+ "@meith/messages": "0.29.1",
68
+ "@meith/moderation": "0.29.1",
69
+ "@meith/notifications": "0.29.1",
70
+ "@meith/plugin-calendar": "0.29.1",
71
+ "@meith/plugin-dues": "0.29.1",
72
+ "@meith/plugin-kit": "0.29.1",
73
+ "@meith/polls": "0.29.1",
74
+ "@meith/posts": "0.29.1",
75
+ "@meith/profile-fields": "0.29.1",
76
+ "@meith/relations": "0.29.1",
77
+ "@meith/reputation": "0.29.1",
78
+ "@meith/runtime": "0.29.1",
79
+ "@meith/search": "0.29.1",
80
+ "@meith/settings": "0.29.1",
81
+ "@meith/signatures": "0.29.1",
82
+ "@meith/subscriptions": "0.29.1",
83
+ "@meith/tasks": "0.29.1",
84
+ "@meith/theme-clubhouse": "0.29.1",
85
+ "@meith/theme-default": "0.29.1",
86
+ "@meith/theme-kit": "0.29.1",
87
+ "@meith/theme-midnight": "0.29.1",
88
+ "@meith/theme-phasebook": "0.29.1",
89
+ "@meith/theme-raidframe": "0.29.1",
90
+ "@meith/threads": "0.29.1",
91
+ "@meith/ui": "0.29.1",
92
+ "@meith/upgrade": "0.29.1"
93
93
  },
94
94
  "scripts": {
95
95
  "dev": "next dev",
@@ -0,0 +1,79 @@
1
+ import 'server-only'
2
+
3
+ export const MAX_API_BODY_BYTES = 256 * 1024
4
+
5
+ export type JsonBodyOutcome =
6
+ | { readonly kind: 'ok'; readonly body: Record<string, unknown> | null }
7
+ | { readonly kind: 'too-large' }
8
+ | { readonly kind: 'invalid' }
9
+
10
+ async function readBounded(
11
+ request: Request,
12
+ limit: number,
13
+ ): Promise<Uint8Array | 'too-large' | 'unreadable'> {
14
+ const stream = request.body
15
+ if (stream === null) {
16
+ try {
17
+ const whole = new Uint8Array(await request.arrayBuffer())
18
+ return whole.byteLength > limit ? 'too-large' : whole
19
+ } catch {
20
+ return 'unreadable'
21
+ }
22
+ }
23
+
24
+ const reader = stream.getReader()
25
+ const chunks: Uint8Array[] = []
26
+ let total = 0
27
+
28
+ try {
29
+ let step = await reader.read()
30
+ while (!step.done) {
31
+ const chunk = step.value
32
+ if (chunk !== undefined) {
33
+ total += chunk.byteLength
34
+ if (total > limit) {
35
+ await reader.cancel().catch(() => {})
36
+ return 'too-large'
37
+ }
38
+ chunks.push(chunk)
39
+ }
40
+ step = await reader.read()
41
+ }
42
+ } catch {
43
+ return 'unreadable'
44
+ }
45
+
46
+ const body = new Uint8Array(total)
47
+ let offset = 0
48
+ for (const chunk of chunks) {
49
+ body.set(chunk, offset)
50
+ offset += chunk.byteLength
51
+ }
52
+ return body
53
+ }
54
+
55
+ export async function readJsonBody(
56
+ request: Request,
57
+ limit: number = MAX_API_BODY_BYTES,
58
+ ): Promise<JsonBodyOutcome> {
59
+ const declared = Number(request.headers.get('content-length') ?? '')
60
+ if (Number.isFinite(declared) && declared > limit) return { kind: 'too-large' }
61
+
62
+ const bytes = await readBounded(request, limit)
63
+ if (bytes === 'too-large') return { kind: 'too-large' }
64
+ if (bytes === 'unreadable') return { kind: 'invalid' }
65
+ if (bytes.byteLength === 0) return { kind: 'ok', body: null }
66
+
67
+ let parsed: unknown
68
+ try {
69
+ parsed = JSON.parse(new TextDecoder().decode(bytes))
70
+ } catch {
71
+ return { kind: 'invalid' }
72
+ }
73
+
74
+ return {
75
+ kind: 'ok',
76
+ body:
77
+ typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null,
78
+ }
79
+ }
@@ -1,7 +1,21 @@
1
1
  import 'server-only'
2
2
 
3
- function boardHost(request: Request): string | null {
4
- const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host')
3
+ import { env } from '@meith/core'
4
+
5
+ function canonicalOrigin(): URL | null {
6
+ const configured = env.APP_URL
7
+ if (configured === undefined || configured === '') return null
8
+
9
+ try {
10
+ return new URL(configured)
11
+ } catch {
12
+ return null
13
+ }
14
+ }
15
+
16
+ function requestHost(request: Request): string | null {
17
+ const forwarded = env.TRUSTED_PROXY_HOPS > 0 ? request.headers.get('x-forwarded-host') : null
18
+ const host = forwarded ?? request.headers.get('host')
5
19
  if (host === null) return null
6
20
 
7
21
  const trimmed = host.trim().toLowerCase()
@@ -12,14 +26,23 @@ export function isSameOrigin(request: Request): boolean {
12
26
  const origin = request.headers.get('origin')?.trim()
13
27
 
14
28
  if (origin !== undefined && origin !== '' && origin !== 'null') {
15
- const host = boardHost(request)
16
- if (host === null) return false
17
-
29
+ let presented: URL
18
30
  try {
19
- return new URL(origin).host.toLowerCase() === host
31
+ presented = new URL(origin)
20
32
  } catch {
21
33
  return false
22
34
  }
35
+
36
+ const canonical = canonicalOrigin()
37
+ if (canonical !== null) {
38
+ return (
39
+ presented.host.toLowerCase() === canonical.host.toLowerCase() &&
40
+ presented.protocol === canonical.protocol
41
+ )
42
+ }
43
+
44
+ const host = requestHost(request)
45
+ return host !== null && presented.host.toLowerCase() === host
23
46
  }
24
47
 
25
48
  const site = request.headers.get('sec-fetch-site')
@@ -16,7 +16,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
16
16
 
17
17
  import { activeDefinitions } from './plugin-host'
18
18
 
19
- export const CODE_VERSION = '0.29.0'
19
+ export const CODE_VERSION = '0.29.1'
20
20
 
21
21
  export interface UpgradeApplied {
22
22
  readonly plugins: readonly string[]