@remit/backend 0.0.95 → 0.0.97

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/src/jwt-auth.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type JwtVerifier,
5
5
  resolveVerifierConfig,
6
6
  } from "@remit/auth-service/verifier";
7
+ import { readCalendarFeedToken } from "@remit/calendar-service";
7
8
  import { logger } from "@remit/logger-lambda";
8
9
  import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
9
10
 
@@ -56,24 +57,39 @@ const hasLocalBypass = (): boolean =>
56
57
  Boolean(process.env.LOCAL_ACCOUNT_CONFIG_ID);
57
58
 
58
59
  /**
59
- * The one route that runs without a token.
60
+ * The routes that run without a token.
60
61
  *
61
62
  * Microsoft redirects the browser here after consent, so the request carries no
62
63
  * Authorization header and no session to derive one from. The handler takes its
63
64
  * identity from the HMAC-signed `state` parameter and validates it there; that
64
65
  * signature is the gate, not a JWT.
65
66
  *
66
- * Hardcoded and single-entry, matching the edge exemption in
67
- * packages/apisix/src/route-table.ts. `@useAuth(NoAuth)` in the spec does not
68
- * open a path here — a future public route is added by hand.
67
+ * A calendar feed carries its credential in the path for the same reason: a
68
+ * subscribed client polls a URL and has no session to present, so the token is
69
+ * the gate and the handler compares it against a stored hash.
70
+ *
71
+ * Hardcoded, matching the edge exemption in packages/apisix/src/route-table.ts.
72
+ * `@useAuth(NoAuth)` in the spec does not open a path here — a future public
73
+ * route is added by hand.
69
74
  */
70
- const PUBLIC_ROUTE = {
71
- method: "GET",
72
- path: "/accounts/oauth/microsoft/callback",
73
- };
75
+ const PUBLIC_ROUTES: readonly {
76
+ method: string;
77
+ matches: (path: string) => boolean;
78
+ }[] = [
79
+ {
80
+ method: "GET",
81
+ matches: (path) => path === "/accounts/oauth/microsoft/callback",
82
+ },
83
+ {
84
+ method: "GET",
85
+ matches: (path) => readCalendarFeedToken(path) !== null,
86
+ },
87
+ ];
74
88
 
75
89
  const isPublicRoute = (event: APIGatewayProxyEvent): boolean =>
76
- event.httpMethod === PUBLIC_ROUTE.method && event.path === PUBLIC_ROUTE.path;
90
+ PUBLIC_ROUTES.some(
91
+ (route) => route.method === event.httpMethod && route.matches(event.path),
92
+ );
77
93
 
78
94
  /**
79
95
  * Authenticate a self-host request from a better-auth RS256 JWT.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Issue #1065: what a request is allowed to leave behind in the log.
3
+ *
4
+ * A calendar feed's token is in its path and is the whole credential, so the
5
+ * path is a secret; a validation failure is logged at error level and the
6
+ * request it carries holds the caller's Authorization header. Both are asserted
7
+ * against captured stdout rather than by reading the call sites, because the
8
+ * leak was a field bound two frames away from the handler that logged it.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { after, before, describe, it } from "node:test";
13
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
14
+
15
+ // pino writes to `process.stdout` only when its `write` has been replaced, so
16
+ // the hook goes in before the logger is imported — which every import below
17
+ // does transitively. Writes pass through whenever nothing is capturing, so the
18
+ // test runner's own output still reaches the terminal.
19
+ const originalWrite = process.stdout.write.bind(process.stdout);
20
+ const written: string[] = [];
21
+ let capturing = false;
22
+
23
+ process.stdout.write = ((
24
+ chunk: string | Uint8Array,
25
+ ...rest: unknown[]
26
+ ): boolean => {
27
+ if (capturing && typeof chunk === "string") {
28
+ written.push(chunk);
29
+ return true;
30
+ }
31
+ return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
32
+ }) as typeof process.stdout.write;
33
+
34
+ process.env.LOG_LEVEL = "trace";
35
+
36
+ const { calendarFeedPath, mintCalendarFeedToken } = await import(
37
+ "@remit/calendar-service"
38
+ );
39
+ const { _resetForTest, setClient } = await import("./service/data-client.js");
40
+ const { createCalendarSqliteClient } = await import(
41
+ "./handlers/calendar-sqlite-fixture.js"
42
+ );
43
+
44
+ let handler: (
45
+ event: APIGatewayProxyEvent,
46
+ context: unknown,
47
+ ) => Promise<APIGatewayProxyResult>;
48
+ let cleanup: () => void;
49
+
50
+ const capture = async (
51
+ run: () => Promise<APIGatewayProxyResult>,
52
+ ): Promise<{ response: APIGatewayProxyResult; logged: string }> => {
53
+ written.length = 0;
54
+ capturing = true;
55
+ try {
56
+ const response = await run();
57
+ return { response, logged: written.join("") };
58
+ } finally {
59
+ capturing = false;
60
+ }
61
+ };
62
+
63
+ const send = (
64
+ event: Partial<APIGatewayProxyEvent> & { httpMethod: string; path: string },
65
+ ): Promise<APIGatewayProxyResult> =>
66
+ handler(
67
+ {
68
+ headers: {},
69
+ queryStringParameters: null,
70
+ body: null,
71
+ requestContext: {},
72
+ ...event,
73
+ } as unknown as APIGatewayProxyEvent,
74
+ { awsRequestId: "log-redaction", functionName: "test" },
75
+ );
76
+
77
+ before(async () => {
78
+ _resetForTest();
79
+ process.env.DATA_BACKEND = "sqlite";
80
+ const fixture = await createCalendarSqliteClient();
81
+ cleanup = fixture.cleanup;
82
+ setClient(fixture.client);
83
+ ({ handler } = (await import("./index.js")) as unknown as {
84
+ handler: typeof handler;
85
+ });
86
+ });
87
+
88
+ after(() => {
89
+ _resetForTest();
90
+ process.stdout.write = originalWrite as typeof process.stdout.write;
91
+ cleanup();
92
+ });
93
+
94
+ describe("what a request leaves in the log", () => {
95
+ it("never writes the feed token a subscriber polls with", async () => {
96
+ const { token } = mintCalendarFeedToken();
97
+
98
+ const { response, logged } = await capture(() =>
99
+ send({ httpMethod: "GET", path: calendarFeedPath(token) }),
100
+ );
101
+
102
+ assert.equal(
103
+ response.statusCode,
104
+ 404,
105
+ "an unknown token, but still routed",
106
+ );
107
+ assert.ok(logged.length > 0, "the request has to have logged something");
108
+ assert.equal(
109
+ logged.includes(token),
110
+ false,
111
+ "the token in the path is the credential, so the path cannot be a log field",
112
+ );
113
+ assert.ok(
114
+ logged.includes("/feeds/calendar/<redacted>.ics"),
115
+ "which route was served is what an operator reads the log for",
116
+ );
117
+ });
118
+
119
+ it("never writes the credentials a failing request arrived with", async () => {
120
+ // A session is already established, so the bearer is never verified and the
121
+ // request reaches validation — where the whole parsed request, headers and
122
+ // all, used to be logged. The delete refuses because it declares
123
+ // `calendarId` as a required query parameter and none was sent.
124
+ const { response, logged } = await capture(() =>
125
+ send({
126
+ httpMethod: "DELETE",
127
+ path: "/calendar-events/8f14e45f-ceea-467a-9c9e-9c9e9c9e9c9e",
128
+ headers: {
129
+ Authorization: "Bearer x",
130
+ Cookie: "session=secret-cookie-value",
131
+ },
132
+ requestContext: {
133
+ authorizer: { claims: { sub: "log-redaction-sub" } },
134
+ } as unknown as APIGatewayProxyEvent["requestContext"],
135
+ }),
136
+ );
137
+
138
+ assert.equal(response.statusCode, 400, response.body);
139
+ assert.ok(logged.includes("Validation failed"));
140
+ assert.equal(logged.includes("Bearer x"), false);
141
+ assert.equal(logged.includes("secret-cookie-value"), false);
142
+ assert.equal(
143
+ logged.includes("parsedRequest"),
144
+ false,
145
+ "the request that failed is not a field; its errors are",
146
+ );
147
+ });
148
+ });
package/src/response.ts CHANGED
@@ -7,23 +7,50 @@ import {
7
7
  resolveAllowedOrigin,
8
8
  } from "./request-context.js";
9
9
 
10
- export const formatResponse = (
11
- body: Record<string, unknown>,
12
- statusCode = 200,
13
- ): APIGatewayProxyResult => {
14
- if (body.statusCode && typeof body.statusCode === "number") {
15
- statusCode = body.statusCode;
16
- }
17
-
18
- if ("body" in body && body.body && typeof body.body === "object") {
19
- body = body.body as Record<string, unknown>;
20
- }
10
+ const RAW_API_RESPONSE: unique symbol = Symbol("remit.rawApiResponse");
11
+
12
+ /**
13
+ * A response whose bytes are not JSON — an iCalendar feed, and whatever later
14
+ * joins it.
15
+ *
16
+ * Every other handler returns a plain object and lets this module decide the
17
+ * status, the content type and the serialization. That is the right default and
18
+ * stays the default; a handler that has to own its own media type says so with
19
+ * `rawApiResponse` rather than by returning a shape this one has to guess at.
20
+ * The CORS and correlation headers are still added here, so a raw response is
21
+ * not a way around them.
22
+ */
23
+ export interface RawApiResponse {
24
+ statusCode: number;
25
+ headers: Record<string, string>;
26
+ body: string;
27
+ }
28
+
29
+ type MarkedRawApiResponse = RawApiResponse & {
30
+ readonly [RAW_API_RESPONSE]: true;
31
+ };
21
32
 
22
- logger.debug({ statusCode }, "response");
33
+ export const rawApiResponse = (
34
+ response: RawApiResponse,
35
+ ): MarkedRawApiResponse =>
36
+ Object.assign(response, { [RAW_API_RESPONSE]: true as const });
23
37
 
38
+ const isRawApiResponse = (
39
+ body: Record<string, unknown>,
40
+ ): body is MarkedRawApiResponse & Record<string, unknown> =>
41
+ RAW_API_RESPONSE in body;
42
+
43
+ /**
44
+ * The headers every response carries whatever its body is: the CORS grant the
45
+ * browser needs, and the id the request's log lines are already tagged with.
46
+ * Returning that id is what makes a bug report's "correlation id" resolve to a
47
+ * server-side line; the browser can only read a non-safelisted header when it
48
+ * is exposed.
49
+ */
50
+ const envelopeHeaders = (): Record<string, string> => {
24
51
  const allowOrigin = resolveAllowedOrigin(getRequestOrigin());
25
52
 
26
- const corsHeaders: Record<string, string> = {
53
+ const headers: Record<string, string> = {
27
54
  "Access-Control-Allow-Origin": allowOrigin,
28
55
  "Access-Control-Allow-Headers": "Authorization,Content-Type",
29
56
  "Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
@@ -31,26 +58,46 @@ export const formatResponse = (
31
58
  };
32
59
 
33
60
  if (allowOrigin !== "*") {
34
- corsHeaders["Access-Control-Allow-Credentials"] = "true";
61
+ headers["Access-Control-Allow-Credentials"] = "true";
35
62
  }
36
63
 
37
- // The id the request's log lines are already tagged with. Returning it is
38
- // what makes a bug report's "correlation id" resolve to a server-side line;
39
- // the browser can only read a non-safelisted header when it is exposed.
40
64
  const correlationId = getRequestCorrelationId();
41
- const correlationHeaders: Record<string, string> = correlationId
42
- ? {
43
- "x-correlation-id": correlationId,
44
- "Access-Control-Expose-Headers": "x-correlation-id",
45
- }
46
- : {};
65
+ if (correlationId) {
66
+ headers["x-correlation-id"] = correlationId;
67
+ headers["Access-Control-Expose-Headers"] = "x-correlation-id";
68
+ }
69
+
70
+ return headers;
71
+ };
72
+
73
+ export const formatResponse = (
74
+ body: Record<string, unknown>,
75
+ statusCode = 200,
76
+ ): APIGatewayProxyResult => {
77
+ if (body.statusCode && typeof body.statusCode === "number") {
78
+ statusCode = body.statusCode;
79
+ }
80
+
81
+ if (isRawApiResponse(body)) {
82
+ logger.debug({ statusCode: body.statusCode }, "response");
83
+ return {
84
+ statusCode: body.statusCode,
85
+ headers: { ...envelopeHeaders(), ...body.headers },
86
+ body: body.body,
87
+ };
88
+ }
89
+
90
+ if ("body" in body && body.body && typeof body.body === "object") {
91
+ body = body.body as Record<string, unknown>;
92
+ }
93
+
94
+ logger.debug({ statusCode }, "response");
47
95
 
48
96
  return {
49
97
  statusCode: statusCode,
50
98
  headers: {
51
99
  "Content-Type": "application/json",
52
- ...corsHeaders,
53
- ...correlationHeaders,
100
+ ...envelopeHeaders(),
54
101
  },
55
102
  body: JSON.stringify(body),
56
103
  };
@@ -6,6 +6,7 @@ import {
6
6
  AddressRepo,
7
7
  CalendarCollectionRepo,
8
8
  CalendarEventIndexRepo,
9
+ CalendarFeedTokenRepo,
9
10
  CalendarObjectRepo,
10
11
  CalendarSuggestionRepo,
11
12
  ConfigImportRepo,
@@ -86,6 +87,7 @@ export const buildSqliteClient = async (): Promise<RemitClient> => {
86
87
  calendarCollection: new CalendarCollectionRepo(genericDb),
87
88
  calendarObject: new CalendarObjectRepo(genericDb),
88
89
  calendarEventIndex: new CalendarEventIndexRepo(genericDb),
90
+ calendarFeedToken: new CalendarFeedTokenRepo(genericDb),
89
91
  calendarSuggestion: new CalendarSuggestionRepo(genericDb),
90
92
  calendarUnitOfWork: new DrizzleCalendarUnitOfWork(genericDb),
91
93
  unitOfWork: new DrizzleUnitOfWork(messageDataDb),
@@ -6,6 +6,7 @@ import type {
6
6
  IAddressRepository,
7
7
  ICalendarCollectionRepository,
8
8
  ICalendarEventIndexRepository,
9
+ ICalendarFeedTokenRepository,
9
10
  ICalendarObjectRepository,
10
11
  ICalendarSuggestionRepository,
11
12
  ICalendarUnitOfWork,
@@ -130,6 +131,7 @@ export interface RemitClient {
130
131
  calendarCollection: ICalendarCollectionRepository;
131
132
  calendarObject: ICalendarObjectRepository;
132
133
  calendarEventIndex: ICalendarEventIndexRepository;
134
+ calendarFeedToken: ICalendarFeedTokenRepository;
133
135
  calendarSuggestion: ICalendarSuggestionRepository;
134
136
  calendarUnitOfWork: ICalendarUnitOfWork;
135
137
 
@@ -225,6 +227,7 @@ export interface RemitClientRepositories {
225
227
  calendarCollection: ICalendarCollectionRepository;
226
228
  calendarObject: ICalendarObjectRepository;
227
229
  calendarEventIndex: ICalendarEventIndexRepository;
230
+ calendarFeedToken: ICalendarFeedTokenRepository;
228
231
  calendarSuggestion: ICalendarSuggestionRepository;
229
232
  calendarUnitOfWork: ICalendarUnitOfWork;
230
233
  unitOfWork?: IUnitOfWork;
@@ -466,6 +469,7 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
466
469
  calendarCollection: repositories.calendarCollection,
467
470
  calendarObject: repositories.calendarObject,
468
471
  calendarEventIndex: repositories.calendarEventIndex,
472
+ calendarFeedToken: repositories.calendarFeedToken,
469
473
  calendarSuggestion: repositories.calendarSuggestion,
470
474
  calendarUnitOfWork: repositories.calendarUnitOfWork,
471
475
  unitOfWork: repositories.unitOfWork,
package/src/types.ts CHANGED
@@ -63,6 +63,10 @@ export type OperationIds =
63
63
  | "CalendarDetailOperations_getCalendar"
64
64
  | "CalendarDetailOperations_updateCalendar"
65
65
  | "CalendarDetailOperations_deleteCalendar"
66
+ | "CalendarDetailOperations_getCalendarFeed"
67
+ | "CalendarDetailOperations_putCalendarFeed"
68
+ | "CalendarDetailOperations_revokeCalendarFeed"
69
+ | "CalendarFeedOperations_getCalendarFeedIcal"
66
70
  | "CalendarEventOperations_listCalendarEvents"
67
71
  | "CalendarEventOperations_createCalendarEvent"
68
72
  | "CalendarEventDetailOperations_getCalendarEvent"
@@ -180,6 +184,26 @@ export type CalendarDetailOperationIds = MatchPrefix<
180
184
  OperationIds
181
185
  >;
182
186
 
187
+ /**
188
+ * The feed sub-resource of `/calendars/{calendarId}`, handled beside the
189
+ * collection itself rather than inside it: minting a credential and serving one
190
+ * calendar's settings are different jobs on the same path.
191
+ */
192
+ export type CalendarFeedDetailOperationIds =
193
+ | "CalendarDetailOperations_getCalendarFeed"
194
+ | "CalendarDetailOperations_putCalendarFeed"
195
+ | "CalendarDetailOperations_revokeCalendarFeed";
196
+
197
+ export type CalendarCollectionDetailOperationIds = Exclude<
198
+ CalendarDetailOperationIds,
199
+ CalendarFeedDetailOperationIds
200
+ >;
201
+
202
+ export type CalendarFeedOperationIds = MatchPrefix<
203
+ "CalendarFeedOperations_",
204
+ OperationIds
205
+ >;
206
+
183
207
  export type CalendarEventOperationIds = MatchPrefix<
184
208
  "CalendarEventOperations_",
185
209
  OperationIds