@alteran/astro 0.7.5 → 0.7.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteran/astro",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Astro integration for running a Cloudflare-hosted Bluesky PDS with Alteran.",
5
5
  "module": "index.js",
6
6
  "types": "index.d.ts",
@@ -212,15 +212,46 @@ export async function safeFetchJson(env: Env, url: string, label: string): Promi
212
212
  const ctl = new AbortController();
213
213
  const t = setTimeout(() => ctl.abort(), 3000);
214
214
  try {
215
- const response = await fetch(url, {
216
- signal: ctl.signal,
217
- redirect: 'error',
218
- headers: { accept: 'application/json' },
219
- });
220
- if (!response.ok) throw new Error(`${label} fetch failed: ${response.status}`);
215
+ let response: Response;
216
+ try {
217
+ response = await fetch(url, {
218
+ signal: ctl.signal,
219
+ // Cloudflare Workers' fetch rejects redirect: 'error' at the edge.
220
+ // Use 'manual' and treat any 3xx as a refused redirect.
221
+ redirect: 'manual',
222
+ headers: { accept: 'application/json' },
223
+ });
224
+ } catch (e) {
225
+ const err = new Error(`${label} fetch error: ${e instanceof Error ? e.message : String(e)}`);
226
+ throw err;
227
+ }
228
+ if ((response.status >= 300 && response.status < 400) || response.type === 'opaqueredirect') {
229
+ const err = new Error(`${label} fetch failed: redirected (status ${response.status})`);
230
+ Object.assign(err, {
231
+ metadataStatus: response.status,
232
+ metadataContentType: response.headers.get('content-type'),
233
+ metadataRedirected: true,
234
+ });
235
+ throw err;
236
+ }
237
+ if (!response.ok) {
238
+ const err = new Error(`${label} fetch failed: ${response.status}`);
239
+ Object.assign(err, {
240
+ metadataStatus: response.status,
241
+ metadataContentType: response.headers.get('content-type'),
242
+ metadataRedirected: response.redirected,
243
+ });
244
+ throw err;
245
+ }
221
246
  const ctype = response.headers.get('content-type') || '';
222
247
  if (!ctype.includes('application/json') && !ctype.includes('json')) {
223
- throw new Error(`${label} must be JSON`);
248
+ const err = new Error(`${label} must be JSON`);
249
+ Object.assign(err, {
250
+ metadataStatus: response.status,
251
+ metadataContentType: ctype,
252
+ metadataRedirected: response.redirected,
253
+ });
254
+ throw err;
224
255
  }
225
256
  const text = await readResponseTextBounded(response, label);
226
257
  return JSON.parse(text || '{}');
@@ -276,7 +307,7 @@ async function resolveHostAddresses(hostname: string): Promise<string[]> {
276
307
  url.searchParams.set('type', type);
277
308
  const response = await fetch(url.toString(), {
278
309
  headers: { accept: 'application/dns-json' },
279
- redirect: 'error',
310
+ redirect: 'manual',
280
311
  });
281
312
  if (!response.ok) continue;
282
313
  const body = await response.json().catch(() => null) as any;
@@ -0,0 +1,99 @@
1
+ import { errorMessage } from '../errors';
2
+
3
+ export type OauthParStage =
4
+ | 'metadata_fetch'
5
+ | 'metadata_shape'
6
+ | 'par_validate'
7
+ | 'client_auth'
8
+ | 'dpop'
9
+ | 'outer'
10
+ | 'success';
11
+
12
+ export type OauthParFormSummary = {
13
+ redirectUri: string;
14
+ responseType: string;
15
+ grantType: string | null;
16
+ scope: string;
17
+ codeChallengeMethod: string;
18
+ hasState: boolean;
19
+ hasCodeChallenge: boolean;
20
+ hasClientAssertion: boolean;
21
+ };
22
+
23
+ export type OauthParLogDetails = {
24
+ outcome: 'ok' | 'error';
25
+ requestId?: string | null;
26
+ error?: unknown;
27
+ clientId?: string | null;
28
+ form?: OauthParFormSummary | null;
29
+ metadataStatus?: number | null;
30
+ metadataContentType?: string | null;
31
+ metadataRedirected?: boolean | null;
32
+ };
33
+
34
+ // Read context off an Error attached by safeFetchJson when the metadata HTTP
35
+ // roundtrip itself failed; absent for shape/validation errors that never made
36
+ // a request.
37
+ export type FetchAugmentedError = Error & {
38
+ metadataStatus?: number;
39
+ metadataContentType?: string | null;
40
+ metadataRedirected?: boolean;
41
+ };
42
+
43
+ export function readFetchContext(error: unknown): {
44
+ metadataStatus: number | null;
45
+ metadataContentType: string | null;
46
+ metadataRedirected: boolean | null;
47
+ } {
48
+ if (!(error instanceof Error)) {
49
+ return { metadataStatus: null, metadataContentType: null, metadataRedirected: null };
50
+ }
51
+ const augmented = error as FetchAugmentedError;
52
+ return {
53
+ metadataStatus: typeof augmented.metadataStatus === 'number' ? augmented.metadataStatus : null,
54
+ metadataContentType: augmented.metadataContentType ?? null,
55
+ metadataRedirected: typeof augmented.metadataRedirected === 'boolean' ? augmented.metadataRedirected : null,
56
+ };
57
+ }
58
+
59
+ export function summarizeParForm(form: URLSearchParams): OauthParFormSummary {
60
+ return {
61
+ redirectUri: form.get('redirect_uri') ?? '',
62
+ responseType: form.get('response_type') ?? '',
63
+ grantType: form.get('grant_type'),
64
+ scope: form.get('scope') ?? '',
65
+ codeChallengeMethod: form.get('code_challenge_method') ?? '',
66
+ hasState: !!form.get('state'),
67
+ hasCodeChallenge: !!form.get('code_challenge'),
68
+ hasClientAssertion: !!form.get('client_assertion'),
69
+ };
70
+ }
71
+
72
+ export function logOauthPar(
73
+ stage: OauthParStage,
74
+ request: Request,
75
+ details: OauthParLogDetails,
76
+ ): void {
77
+ const url = new URL(request.url);
78
+ const record = {
79
+ level: details.outcome === 'ok' ? 'info' : 'error',
80
+ type: 'oauth_par',
81
+ stage,
82
+ outcome: details.outcome,
83
+ requestId: details.requestId ?? null,
84
+ method: request.method,
85
+ path: url.pathname,
86
+ timestamp: new Date().toISOString(),
87
+ clientId: details.clientId ?? null,
88
+ errorMessage: details.error !== undefined ? errorMessage(details.error) : null,
89
+ form: details.form ?? null,
90
+ metadataStatus: details.metadataStatus ?? null,
91
+ metadataContentType: details.metadataContentType ?? null,
92
+ metadataRedirected: details.metadataRedirected ?? null,
93
+ };
94
+ if (details.outcome === 'ok') {
95
+ console.log(JSON.stringify(record));
96
+ } else {
97
+ console.error(JSON.stringify(record));
98
+ }
99
+ }
package/src/middleware.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { defineMiddleware, sequence } from 'astro/middleware';
2
2
 
3
+ // Response.redirect() (and a few other constructors) returns a Response whose
4
+ // headers are immutable. Re-wrap into a fresh Response so downstream middleware
5
+ // can attach CORS / X-Request-ID without throwing "Can't modify immutable
6
+ // headers" at the Workers runtime.
7
+ function ensureMutableResponse(response: Response): Response {
8
+ return new Response(response.body, response);
9
+ }
10
+
3
11
  const cors = defineMiddleware(async ({ locals, request }, next) => {
4
12
  // Match atproto CORS implementation: use wildcard for public endpoints
5
13
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
@@ -20,7 +28,7 @@ const cors = defineMiddleware(async ({ locals, request }, next) => {
20
28
  return new Response(null, { status: 204, headers });
21
29
  }
22
30
 
23
- const response = await next();
31
+ const response = ensureMutableResponse(await next());
24
32
 
25
33
  // Set CORS headers on all responses (atproto standard)
26
34
  response.headers.set('Access-Control-Allow-Origin', '*');
@@ -43,7 +51,7 @@ const logger = defineMiddleware(async ({ request, locals }, next) => {
43
51
  const url = new URL(request.url);
44
52
 
45
53
  try {
46
- const response = await next();
54
+ const response = ensureMutableResponse(await next());
47
55
  const dur = Date.now() - start;
48
56
 
49
57
  // Structured logging
@@ -5,25 +5,51 @@ import { DpopNonceError } from '../../lib/oauth/dpop-errors';
5
5
  import { publicPdsOrigin } from '../../lib/oauth/consent';
6
6
  import { savePar } from '../../lib/oauth/store';
7
7
  import {
8
- fetchClientMetadata,
8
+ safeFetchJson,
9
9
  isSafeFetchUrl,
10
+ validateClientMetadataShape,
10
11
  validateParRequest,
11
12
  verifyClientAuthentication,
13
+ type OAuthClientMetadata,
12
14
  } from '../../lib/oauth/clients';
15
+ import {
16
+ logOauthPar,
17
+ readFetchContext,
18
+ summarizeParForm,
19
+ type OauthParFormSummary,
20
+ type OauthParStage,
21
+ } from '../../lib/oauth/observability';
13
22
 
14
23
  export const prerender = false;
15
24
 
16
25
  export async function POST({ locals, request }: APIContext) {
17
26
  const { env } = locals.runtime;
27
+ const requestId = (locals as { requestId?: string }).requestId ?? null;
28
+
29
+ let client_id = '';
30
+ let formSummary: OauthParFormSummary | null = null;
31
+
32
+ const log = (
33
+ stage: OauthParStage,
34
+ extra: { error?: unknown; outcome?: 'ok' | 'error'; metadataStatus?: number | null; metadataContentType?: string | null; metadataRedirected?: boolean | null } = {},
35
+ ) =>
36
+ logOauthPar(stage, request, {
37
+ outcome: extra.outcome ?? (extra.error !== undefined ? 'error' : 'ok'),
38
+ requestId,
39
+ error: extra.error,
40
+ clientId: client_id || null,
41
+ form: formSummary,
42
+ metadataStatus: extra.metadataStatus,
43
+ metadataContentType: extra.metadataContentType,
44
+ metadataRedirected: extra.metadataRedirected,
45
+ });
18
46
 
19
- // Enforce DPoP binding, but do not require a nonce on the initial PAR request.
20
47
  try {
21
48
  const ver = await verifyDpop(env, request, { consumeJti: false, requireNonce: false });
22
49
 
23
- // Parse form-encoded body
24
50
  const bodyText = await request.text();
25
51
  const form = new URLSearchParams(bodyText);
26
- const client_id = form.get('client_id') || '';
52
+ client_id = form.get('client_id') || '';
27
53
  const response_type = form.get('response_type') || '';
28
54
  const grant_type = form.get('grant_type') || undefined;
29
55
  const redirect_uri = form.get('redirect_uri') || '';
@@ -33,18 +59,37 @@ export async function POST({ locals, request }: APIContext) {
33
59
  const code_challenge_method = form.get('code_challenge_method') || '';
34
60
  const login_hint = form.get('login_hint') || undefined;
35
61
  const prompt = form.get('prompt') || undefined;
62
+ formSummary = summarizeParForm(form);
36
63
 
37
64
  if (!client_id || !isSafeFetchUrl(client_id)) {
38
- return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client_id must be a safe https metadata URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
65
+ const error = new Error('client_id must be a safe https metadata URL');
66
+ log('metadata_fetch', { error });
67
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: error.message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
39
68
  }
40
69
  if (!state) {
41
- return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'state required' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
70
+ const error = new Error('state required');
71
+ log('par_validate', { error });
72
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: error.message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
73
+ }
74
+
75
+ let rawMetadata: unknown;
76
+ try {
77
+ rawMetadata = await safeFetchJson(env, client_id, 'client metadata');
78
+ } catch (e) {
79
+ const ctx = readFetchContext(e);
80
+ log('metadata_fetch', { error: e, ...ctx });
81
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata fetch failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
82
+ }
83
+
84
+ let clientMeta: OAuthClientMetadata;
85
+ try {
86
+ clientMeta = validateClientMetadataShape(rawMetadata, client_id);
87
+ } catch (e) {
88
+ log('metadata_shape', { error: e });
89
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata invalid' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
42
90
  }
43
91
 
44
- // Fetch and validate client metadata
45
- let clientMeta;
46
92
  try {
47
- clientMeta = await fetchClientMetadata(env, client_id);
48
93
  validateParRequest(clientMeta, {
49
94
  response_type,
50
95
  grant_type,
@@ -54,7 +99,8 @@ export async function POST({ locals, request }: APIContext) {
54
99
  code_challenge_method,
55
100
  });
56
101
  } catch (e) {
57
- return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata fetch failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
102
+ log('par_validate', { error: e });
103
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'PAR request invalid' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
58
104
  }
59
105
 
60
106
  const issuerOrigin = publicPdsOrigin(env, request);
@@ -62,6 +108,7 @@ export async function POST({ locals, request }: APIContext) {
62
108
  try {
63
109
  clientAuth = await verifyClientAuthentication(env, client_id, issuerOrigin, clientMeta, form);
64
110
  } catch (e) {
111
+ log('client_auth', { error: e });
65
112
  return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client authentication failed' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
66
113
  }
67
114
 
@@ -80,7 +127,7 @@ export async function POST({ locals, request }: APIContext) {
80
127
  clientAuthMethod: clientAuth.method,
81
128
  clientAuthKeyId: clientAuth.keyId,
82
129
  createdAt: now,
83
- expiresAt: now + 300, // 5 minutes
130
+ expiresAt: now + 300,
84
131
  };
85
132
  await consumeDpopVerificationJti(env, ver);
86
133
  await savePar(env, id, rec);
@@ -88,11 +135,14 @@ export async function POST({ locals, request }: APIContext) {
88
135
  const request_uri = `urn:ietf:params:oauth:request_uri:${id}`;
89
136
  const headers = new Headers({ 'Content-Type': 'application/json' });
90
137
  setDpopNonceHeader(headers, await getAuthzNonce(env));
138
+ log('success', { outcome: 'ok' });
91
139
  return new Response(JSON.stringify({ request_uri, expires_in: 300 }), { status: 201, headers });
92
140
  } catch (e) {
93
141
  if (e instanceof DpopNonceError) {
142
+ log('dpop', { error: e });
94
143
  return dpopErrorResponse(env, e);
95
144
  }
145
+ log('outer', { error: e });
96
146
  const headers = new Headers({ 'Content-Type': 'application/json' });
97
147
  return new Response(JSON.stringify({ error: 'invalid_request', error_description: errorMessage(e) ?? 'Unknown error' }), { status: 400, headers });
98
148
  }