@remit/backend 0.0.94 → 0.0.96
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/config.ts +18 -0
- package/src/handlers/index.ts +6 -0
- 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
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarFeedResponse,
|
|
3
|
+
CalendarFeedStateResponse,
|
|
4
|
+
} from "@remit/api-openapi-types";
|
|
5
|
+
import {
|
|
6
|
+
buildCalendarFeed,
|
|
7
|
+
calendarFeedIsUnchanged,
|
|
8
|
+
calendarFeedIsUnmodifiedSince,
|
|
9
|
+
hashCalendarFeedToken,
|
|
10
|
+
isCalendarFeedToken,
|
|
11
|
+
mintCalendarFeedToken,
|
|
12
|
+
readCalendarFeedToken,
|
|
13
|
+
} from "@remit/calendar-service";
|
|
14
|
+
import type { CalendarFeedTokenItem } from "@remit/data-ports";
|
|
15
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
16
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
17
|
+
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
18
|
+
import { type RawApiResponse, rawApiResponse } from "../response.js";
|
|
19
|
+
import { getClient } from "../service/data-client.js";
|
|
20
|
+
import type {
|
|
21
|
+
CalendarDetailOperationIds,
|
|
22
|
+
CalendarFeedDetailOperationIds,
|
|
23
|
+
CalendarFeedOperationIds,
|
|
24
|
+
OperationHandler,
|
|
25
|
+
} from "../types.js";
|
|
26
|
+
import {
|
|
27
|
+
type CalendarDeps,
|
|
28
|
+
type CalendarOutcome,
|
|
29
|
+
calendarDepsOf,
|
|
30
|
+
findCalendarFor,
|
|
31
|
+
notFound,
|
|
32
|
+
refuseCalendar,
|
|
33
|
+
} from "./calendar.js";
|
|
34
|
+
|
|
35
|
+
const toFeedState = (
|
|
36
|
+
token: CalendarFeedTokenItem,
|
|
37
|
+
): CalendarFeedStateResponse => ({
|
|
38
|
+
calendarId: token.calendarId,
|
|
39
|
+
createdAt: token.createdAt,
|
|
40
|
+
rotatedAt: token.rotatedAt,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mints the calendar's feed address, or replaces the one it has.
|
|
45
|
+
*
|
|
46
|
+
* The plaintext exists here and nowhere else: the store keeps a digest, so this
|
|
47
|
+
* return value is the only chance anybody has to read the token. Ownership is
|
|
48
|
+
* settled before the mint — a calendar the caller does not hold is absent, not
|
|
49
|
+
* forbidden, which is the same answer `getCalendar` gives it.
|
|
50
|
+
*/
|
|
51
|
+
export const putCalendarFeedFor = async (
|
|
52
|
+
deps: CalendarDeps,
|
|
53
|
+
accountConfigId: string,
|
|
54
|
+
calendarId: string,
|
|
55
|
+
): Promise<CalendarOutcome<CalendarFeedResponse>> => {
|
|
56
|
+
const calendar = await findCalendarFor(deps, accountConfigId, calendarId);
|
|
57
|
+
if (!calendar.ok) return calendar;
|
|
58
|
+
|
|
59
|
+
const secret = mintCalendarFeedToken();
|
|
60
|
+
const stored = await deps.calendarFeedToken.put({
|
|
61
|
+
accountConfigId,
|
|
62
|
+
calendarId,
|
|
63
|
+
tokenHash: secret.tokenHash,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
value: {
|
|
69
|
+
calendarId: stored.calendarId,
|
|
70
|
+
feedToken: secret.token,
|
|
71
|
+
createdAt: stored.createdAt,
|
|
72
|
+
rotatedAt: stored.rotatedAt,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Whether the calendar has a feed address, and how old it is. */
|
|
78
|
+
export const findCalendarFeedFor = async (
|
|
79
|
+
deps: CalendarDeps,
|
|
80
|
+
accountConfigId: string,
|
|
81
|
+
calendarId: string,
|
|
82
|
+
): Promise<CalendarOutcome<CalendarFeedStateResponse>> => {
|
|
83
|
+
const calendar = await findCalendarFor(deps, accountConfigId, calendarId);
|
|
84
|
+
if (!calendar.ok) return calendar;
|
|
85
|
+
|
|
86
|
+
const stored = await deps.calendarFeedToken.findByCalendar(
|
|
87
|
+
accountConfigId,
|
|
88
|
+
calendarId,
|
|
89
|
+
);
|
|
90
|
+
if (!stored) {
|
|
91
|
+
return refuseCalendar("NotFound", `no feed on calendar ${calendarId}`);
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, value: toFeedState(stored) };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Revokes the calendar's feed address.
|
|
98
|
+
*
|
|
99
|
+
* A calendar with no feed is refused rather than answered 204: the caller asked
|
|
100
|
+
* for a subscription to stop working, and reporting success for one that was
|
|
101
|
+
* never there reads as confirmation that it has.
|
|
102
|
+
*/
|
|
103
|
+
export const revokeCalendarFeedFor = async (
|
|
104
|
+
deps: CalendarDeps,
|
|
105
|
+
accountConfigId: string,
|
|
106
|
+
calendarId: string,
|
|
107
|
+
): Promise<CalendarOutcome<null>> => {
|
|
108
|
+
const calendar = await findCalendarFor(deps, accountConfigId, calendarId);
|
|
109
|
+
if (!calendar.ok) return calendar;
|
|
110
|
+
|
|
111
|
+
const stored = await deps.calendarFeedToken.findByCalendar(
|
|
112
|
+
accountConfigId,
|
|
113
|
+
calendarId,
|
|
114
|
+
);
|
|
115
|
+
if (!stored) {
|
|
116
|
+
return refuseCalendar("NotFound", `no feed on calendar ${calendarId}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await deps.calendarFeedToken.delete(accountConfigId, calendarId);
|
|
120
|
+
return { ok: true, value: null };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* No feed answers to this address.
|
|
125
|
+
*
|
|
126
|
+
* One answer for an unknown token, a revoked one and a malformed one, with a
|
|
127
|
+
* body that says the same thing to all three: a caller probing addresses learns
|
|
128
|
+
* only whether the one it sent works, which is what it was going to find out
|
|
129
|
+
* anyway.
|
|
130
|
+
*/
|
|
131
|
+
const feedNotFound = (): RawApiResponse =>
|
|
132
|
+
rawApiResponse({
|
|
133
|
+
statusCode: 404,
|
|
134
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
135
|
+
body: "no calendar feed at this address\n",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Serves a calendar to whoever holds its secret address.
|
|
140
|
+
*
|
|
141
|
+
* The token is the whole credential, so a value that cannot be one is refused
|
|
142
|
+
* before anything is hashed, and the digest of what arrived is what the store is
|
|
143
|
+
* asked about — the plaintext is never compared against a stored one because
|
|
144
|
+
* none is stored.
|
|
145
|
+
*
|
|
146
|
+
* `Last-Modified` is the collection's own timestamp, not the newest event in
|
|
147
|
+
* it: an event deleted from a calendar leaves every survivor older than the
|
|
148
|
+
* change, and a validator that moves backwards tells a subscriber its cached
|
|
149
|
+
* copy is still good. The collection is stamped by the same sequence bump every
|
|
150
|
+
* write and every delete goes through, so it only ever moves forward.
|
|
151
|
+
*
|
|
152
|
+
* A row whose collection is gone is the same 404. It should not exist — a
|
|
153
|
+
* calendar's delete takes its token with it, in one transaction — and serving a
|
|
154
|
+
* blank calendar for one would be worse than saying nothing.
|
|
155
|
+
*/
|
|
156
|
+
export const serveCalendarFeed = async (
|
|
157
|
+
deps: CalendarDeps,
|
|
158
|
+
feedToken: string,
|
|
159
|
+
conditions: {
|
|
160
|
+
ifNoneMatch: string | undefined;
|
|
161
|
+
ifModifiedSince: string | undefined;
|
|
162
|
+
},
|
|
163
|
+
): Promise<RawApiResponse> => {
|
|
164
|
+
if (!isCalendarFeedToken(feedToken)) return feedNotFound();
|
|
165
|
+
|
|
166
|
+
const stored = await deps.calendarFeedToken.findByTokenHash(
|
|
167
|
+
hashCalendarFeedToken(feedToken),
|
|
168
|
+
);
|
|
169
|
+
if (!stored) return feedNotFound();
|
|
170
|
+
|
|
171
|
+
const collection = await deps.calendarCollection
|
|
172
|
+
.get(stored.accountConfigId, stored.calendarId)
|
|
173
|
+
.catch((error: unknown) => {
|
|
174
|
+
if (error instanceof NotFoundError) return null;
|
|
175
|
+
throw error;
|
|
176
|
+
});
|
|
177
|
+
if (!collection) return feedNotFound();
|
|
178
|
+
|
|
179
|
+
const objects = await deps.calendarObject.listByCalendar(stored.calendarId);
|
|
180
|
+
const feed = buildCalendarFeed(collection, objects);
|
|
181
|
+
const etag = `"${feed.etag}"`;
|
|
182
|
+
const lastModified = new Date(collection.updatedAt).toUTCString();
|
|
183
|
+
|
|
184
|
+
// `If-None-Match` wins outright when both arrive (RFC 9110 13.2.2): the tag
|
|
185
|
+
// is exact where the date is truncated to the second, so honouring the date
|
|
186
|
+
// as well could only turn a 200 the tag asked for into a 304.
|
|
187
|
+
const unchanged =
|
|
188
|
+
conditions.ifNoneMatch === undefined
|
|
189
|
+
? calendarFeedIsUnmodifiedSince(
|
|
190
|
+
conditions.ifModifiedSince,
|
|
191
|
+
collection.updatedAt,
|
|
192
|
+
)
|
|
193
|
+
: calendarFeedIsUnchanged(conditions.ifNoneMatch, feed.etag);
|
|
194
|
+
|
|
195
|
+
if (unchanged) {
|
|
196
|
+
return rawApiResponse({
|
|
197
|
+
statusCode: 304,
|
|
198
|
+
headers: { ETag: etag, "Last-Modified": lastModified },
|
|
199
|
+
body: "",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return rawApiResponse({
|
|
204
|
+
statusCode: 200,
|
|
205
|
+
headers: {
|
|
206
|
+
"Content-Type": "text/calendar; charset=utf-8",
|
|
207
|
+
ETag: etag,
|
|
208
|
+
"Last-Modified": lastModified,
|
|
209
|
+
},
|
|
210
|
+
body: feed.icalData,
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const CalendarFeedDetailOperations: Record<
|
|
215
|
+
CalendarFeedDetailOperationIds,
|
|
216
|
+
OperationHandler<CalendarDetailOperationIds>
|
|
217
|
+
> = {
|
|
218
|
+
CalendarDetailOperations_getCalendarFeed: async (
|
|
219
|
+
context,
|
|
220
|
+
...args: unknown[]
|
|
221
|
+
) => {
|
|
222
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
223
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
224
|
+
const { calendarId } = context.request.params as { calendarId: string };
|
|
225
|
+
const deps = calendarDepsOf(await getClient());
|
|
226
|
+
|
|
227
|
+
const found = await findCalendarFeedFor(deps, accountConfigId, calendarId);
|
|
228
|
+
if (!found.ok) return notFound(found.error.message);
|
|
229
|
+
return found.value;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
CalendarDetailOperations_putCalendarFeed: async (
|
|
233
|
+
context,
|
|
234
|
+
...args: unknown[]
|
|
235
|
+
) => {
|
|
236
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
237
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
238
|
+
const { calendarId } = context.request.params as { calendarId: string };
|
|
239
|
+
const deps = calendarDepsOf(await getClient());
|
|
240
|
+
|
|
241
|
+
const minted = await putCalendarFeedFor(deps, accountConfigId, calendarId);
|
|
242
|
+
if (!minted.ok) return notFound(minted.error.message);
|
|
243
|
+
return minted.value;
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
CalendarDetailOperations_revokeCalendarFeed: async (
|
|
247
|
+
context,
|
|
248
|
+
...args: unknown[]
|
|
249
|
+
) => {
|
|
250
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
251
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
252
|
+
const { calendarId } = context.request.params as { calendarId: string };
|
|
253
|
+
const deps = calendarDepsOf(await getClient());
|
|
254
|
+
|
|
255
|
+
const revoked = await revokeCalendarFeedFor(
|
|
256
|
+
deps,
|
|
257
|
+
accountConfigId,
|
|
258
|
+
calendarId,
|
|
259
|
+
);
|
|
260
|
+
if (!revoked.ok) return notFound(revoked.error.message);
|
|
261
|
+
return { statusCode: 204 };
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export const CalendarFeedOperations: Record<
|
|
266
|
+
CalendarFeedOperationIds,
|
|
267
|
+
OperationHandler<CalendarFeedOperationIds>
|
|
268
|
+
> = {
|
|
269
|
+
CalendarFeedOperations_getCalendarFeedIcal: async (
|
|
270
|
+
context,
|
|
271
|
+
...args: unknown[]
|
|
272
|
+
) => {
|
|
273
|
+
// From the raw path, not from the router's captured parameter: the matcher
|
|
274
|
+
// substitutes the parameter into the literal path and leaves the dot of
|
|
275
|
+
// ".ics" as a regex dot, so /feeds/calendar/<token>Xics routes here too —
|
|
276
|
+
// and answering it would serve the feed at an address nobody was handed.
|
|
277
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
278
|
+
const feedToken = readCalendarFeedToken(event.path);
|
|
279
|
+
if (feedToken === null) return feedNotFound();
|
|
280
|
+
|
|
281
|
+
const ifNoneMatch = context.request.headers["if-none-match"];
|
|
282
|
+
const ifModifiedSince = context.request.headers["if-modified-since"];
|
|
283
|
+
const deps = calendarDepsOf(await getClient());
|
|
284
|
+
|
|
285
|
+
return serveCalendarFeed(deps, feedToken, {
|
|
286
|
+
ifNoneMatch: typeof ifNoneMatch === "string" ? ifNoneMatch : undefined,
|
|
287
|
+
ifModifiedSince:
|
|
288
|
+
typeof ifModifiedSince === "string" ? ifModifiedSince : undefined,
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
};
|
package/src/handlers/calendar.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
CalendarCollectionItem,
|
|
14
14
|
ICalendarCollectionRepository,
|
|
15
15
|
ICalendarEventIndexRepository,
|
|
16
|
+
ICalendarFeedTokenRepository,
|
|
16
17
|
ICalendarObjectRepository,
|
|
17
18
|
ICalendarUnitOfWork,
|
|
18
19
|
UpdateCalendarCollectionInput,
|
|
@@ -24,6 +25,7 @@ import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
|
24
25
|
import type { RemitClient } from "../service/data-client.js";
|
|
25
26
|
import { getClient } from "../service/data-client.js";
|
|
26
27
|
import type {
|
|
28
|
+
CalendarCollectionDetailOperationIds,
|
|
27
29
|
CalendarDetailOperationIds,
|
|
28
30
|
CalendarOperationIds,
|
|
29
31
|
OperationHandler,
|
|
@@ -34,6 +36,7 @@ export interface CalendarDeps {
|
|
|
34
36
|
calendarCollection: ICalendarCollectionRepository;
|
|
35
37
|
calendarObject: ICalendarObjectRepository;
|
|
36
38
|
calendarEventIndex: ICalendarEventIndexRepository;
|
|
39
|
+
calendarFeedToken: ICalendarFeedTokenRepository;
|
|
37
40
|
calendarUnitOfWork: ICalendarUnitOfWork;
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -78,6 +81,7 @@ export const calendarDepsOf = (client: RemitClient): CalendarDeps => ({
|
|
|
78
81
|
calendarCollection: client.calendarCollection,
|
|
79
82
|
calendarObject: client.calendarObject,
|
|
80
83
|
calendarEventIndex: client.calendarEventIndex,
|
|
84
|
+
calendarFeedToken: client.calendarFeedToken,
|
|
81
85
|
calendarUnitOfWork: client.calendarUnitOfWork,
|
|
82
86
|
});
|
|
83
87
|
|
|
@@ -225,6 +229,7 @@ export const deleteCalendarFor = async (
|
|
|
225
229
|
);
|
|
226
230
|
await repos.calendarObject.delete(calendarId, object.calendarObjectId);
|
|
227
231
|
}
|
|
232
|
+
await repos.calendarFeedToken.delete(accountConfigId, calendarId);
|
|
228
233
|
await repos.calendarCollection.delete(accountConfigId, calendarId);
|
|
229
234
|
});
|
|
230
235
|
return { ok: true, value: null };
|
|
@@ -326,7 +331,7 @@ export const CalendarOperations: Record<
|
|
|
326
331
|
};
|
|
327
332
|
|
|
328
333
|
export const CalendarDetailOperations: Record<
|
|
329
|
-
|
|
334
|
+
CalendarCollectionDetailOperationIds,
|
|
330
335
|
OperationHandler<CalendarDetailOperationIds>
|
|
331
336
|
> = {
|
|
332
337
|
CalendarDetailOperations_getCalendar: async (context, ...args: unknown[]) => {
|
package/src/handlers/config.ts
CHANGED
|
@@ -14,6 +14,10 @@ import type { AccountConfigItem, MailboxItem } from "@remit/data-ports";
|
|
|
14
14
|
import { ConfigNotEmptyError, NotFoundError } from "@remit/data-ports/errors";
|
|
15
15
|
import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
|
|
16
16
|
import { logger } from "@remit/logger-lambda";
|
|
17
|
+
import {
|
|
18
|
+
EMBEDDING_PROVIDER_OFF,
|
|
19
|
+
readEmbeddingProviderFromEnv,
|
|
20
|
+
} from "@remit/search-service/from-env";
|
|
17
21
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
18
22
|
import { env } from "expect-env";
|
|
19
23
|
import type { Context } from "openapi-backend";
|
|
@@ -138,9 +142,22 @@ const emptyConfigResponse = (
|
|
|
138
142
|
updatedAt: now,
|
|
139
143
|
},
|
|
140
144
|
accounts: [],
|
|
145
|
+
semanticSearchEnabled: semanticSearchEnabled(),
|
|
141
146
|
};
|
|
142
147
|
};
|
|
143
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Whether this instance embeds anything, read from the same
|
|
151
|
+
* `SEARCH_EMBEDDING_PROVIDER` the search-index worker and the `remit` wrapper
|
|
152
|
+
* read. It rides GET /config because the semantic surfaces need it before they
|
|
153
|
+
* have a query to send: an off instance stores no vectors, so the Organize
|
|
154
|
+
* widen and semantic filters have nothing to read, and a client that learns
|
|
155
|
+
* that only from an empty result cannot tell it from a mailbox with nothing
|
|
156
|
+
* similar in it (#1068).
|
|
157
|
+
*/
|
|
158
|
+
const semanticSearchEnabled = (): boolean =>
|
|
159
|
+
readEmbeddingProviderFromEnv() !== EMBEDDING_PROVIDER_OFF;
|
|
160
|
+
|
|
144
161
|
export const ConfigOperations: Record<
|
|
145
162
|
ConfigOperationIds,
|
|
146
163
|
OperationHandler<ConfigOperationIds>
|
|
@@ -220,6 +237,7 @@ export const ConfigOperations: Record<
|
|
|
220
237
|
|
|
221
238
|
return {
|
|
222
239
|
accountConfig: toAccountConfigResponse(accountConfig),
|
|
240
|
+
semanticSearchEnabled: semanticSearchEnabled(),
|
|
223
241
|
...(pendingImport ? { pendingImport } : {}),
|
|
224
242
|
accounts: activeAccounts.map((acc) =>
|
|
225
243
|
toAccountResponse(
|
package/src/handlers/index.ts
CHANGED
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
CalendarEventOperations,
|
|
9
9
|
CalendarFreeBusyOperations,
|
|
10
10
|
} from "./calendar-event.js";
|
|
11
|
+
import {
|
|
12
|
+
CalendarFeedDetailOperations,
|
|
13
|
+
CalendarFeedOperations,
|
|
14
|
+
} from "./calendar-feed.js";
|
|
11
15
|
import {
|
|
12
16
|
CalendarSuggestionActionOperations,
|
|
13
17
|
CalendarSuggestionOperations,
|
|
@@ -55,6 +59,8 @@ export const handlers: Record<OperationIds, OperationHandler<any>> = {
|
|
|
55
59
|
...OrganizeJobDetailOperations,
|
|
56
60
|
...CalendarOperations,
|
|
57
61
|
...CalendarDetailOperations,
|
|
62
|
+
...CalendarFeedDetailOperations,
|
|
63
|
+
...CalendarFeedOperations,
|
|
58
64
|
...CalendarEventOperations,
|
|
59
65
|
...CalendarEventDetailOperations,
|
|
60
66
|
...CalendarFreeBusyOperations,
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { inspect } from "node:util";
|
|
5
|
+
import { redactCalendarFeedPath } from "@remit/calendar-service";
|
|
5
6
|
import { logger, withLogContext, withTelemetry } from "@remit/logger-lambda";
|
|
6
7
|
import type { APIGatewayProxyEvent, Context } from "aws-lambda";
|
|
7
8
|
import {
|
|
@@ -89,19 +90,17 @@ const api = new OpenAPIBackend({
|
|
|
89
90
|
|
|
90
91
|
api.register("postResponseHandler", postResponseHandler);
|
|
91
92
|
|
|
93
|
+
// The errors and the operation, and nothing that came off the wire. A request
|
|
94
|
+
// that fails validation is logged at error level, and the request it carries
|
|
95
|
+
// holds the caller's headers and cookies — an Authorization bearer among them,
|
|
96
|
+
// and for a calendar feed the token in the path is itself the credential
|
|
97
|
+
// (issue #1065). The path is redacted the same way the request scope redacts it.
|
|
92
98
|
api.register("validationFail", (c: OpenAPIContext, req: Request) => {
|
|
93
|
-
const operation = api.router.getOperation(c.operation?.operationId ?? "");
|
|
94
99
|
logger.error(
|
|
95
100
|
{
|
|
96
101
|
errors: c.validation.errors,
|
|
97
|
-
|
|
98
|
-
path: req.path,
|
|
102
|
+
path: redactCalendarFeedPath(req.path),
|
|
99
103
|
operation: c.operation?.operationId,
|
|
100
|
-
operationParameters: operation?.parameters,
|
|
101
|
-
validationContext: {
|
|
102
|
-
parsedRequest: c.request,
|
|
103
|
-
validationTarget: c.validation,
|
|
104
|
-
},
|
|
105
104
|
},
|
|
106
105
|
"Validation failed",
|
|
107
106
|
);
|
|
@@ -146,17 +145,19 @@ const readOriginHeader = (
|
|
|
146
145
|
// so a line gets attributed to the wrong request. The scope follows the request
|
|
147
146
|
// through its own async continuations and nothing else. It nests inside the one
|
|
148
147
|
// `withTelemetry` opens, which is where `requestId` comes from.
|
|
149
|
-
const rawHandler = async (event: APIGatewayProxyEvent, context: Context) =>
|
|
150
|
-
|
|
148
|
+
const rawHandler = async (event: APIGatewayProxyEvent, context: Context) => {
|
|
149
|
+
// A calendar feed carries its credential in the path, so the raw path is a
|
|
150
|
+
// secret and cannot be a log field (issue #1065). Redacted once, here, where
|
|
151
|
+
// every line inside the request picks it up from the scope.
|
|
152
|
+
const path = redactCalendarFeedPath(event.path);
|
|
153
|
+
|
|
154
|
+
return withLogContext(
|
|
151
155
|
{
|
|
152
|
-
path
|
|
156
|
+
path,
|
|
153
157
|
method: event.httpMethod,
|
|
154
158
|
},
|
|
155
159
|
async () => {
|
|
156
|
-
logger.debug(
|
|
157
|
-
{ method: event.httpMethod, path: event.path },
|
|
158
|
-
"Request received",
|
|
159
|
-
);
|
|
160
|
+
logger.debug({ method: event.httpMethod, path }, "Request received");
|
|
160
161
|
|
|
161
162
|
const origin = readOriginHeader(event.headers);
|
|
162
163
|
|
|
@@ -175,5 +176,6 @@ const rawHandler = async (event: APIGatewayProxyEvent, context: Context) =>
|
|
|
175
176
|
);
|
|
176
177
|
},
|
|
177
178
|
);
|
|
179
|
+
};
|
|
178
180
|
|
|
179
181
|
export const handler = withTelemetry(rawHandler);
|
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
|
+
});
|