@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/package.json +1 -1
- package/src/handlers/calendar-feed-validators.test.ts +228 -0
- package/src/handlers/calendar-feed.test.ts +439 -0
- package/src/handlers/calendar-feed.ts +291 -0
- package/src/handlers/calendar.ts +6 -1
- package/src/handlers/index.ts +6 -0
- package/src/handlers/outbox-list.test.ts +179 -0
- package/src/handlers/outbox.ts +7 -6
- package/src/handlers/unified-threads.ts +2 -27
- package/src/index.ts +17 -15
- package/src/jwt-auth.ts +25 -9
- package/src/log-redaction.test.ts +148 -0
- package/src/response.ts +72 -25
- package/src/service/compose-sqlite.ts +2 -0
- package/src/service/create-remit-client.ts +4 -0
- package/src/types.ts +24 -0
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
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
71
|
-
method:
|
|
72
|
-
path:
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
...
|
|
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
|