@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.
@@ -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
+ };
@@ -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
- CalendarDetailOperationIds,
334
+ CalendarCollectionDetailOperationIds,
330
335
  OperationHandler<CalendarDetailOperationIds>
331
336
  > = {
332
337
  CalendarDetailOperations_getCalendar: async (context, ...args: unknown[]) => {
@@ -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,
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Issue #1013: the outbox listing took the head of the configured account list,
3
+ * so on an instance with two accounts the second account's drafts and failed
4
+ * sends were never on screen.
5
+ *
6
+ * The operation takes no account parameter and does not gain one — the fan-out
7
+ * is the handler's job. What is pinned here is that every account under the
8
+ * caller's config is asked for, and that the continuation token the handler
9
+ * hands back resumes the one merged ordering rather than one account's.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { afterEach, describe, it } from "node:test";
14
+ import type {
15
+ AccountItem,
16
+ IAccountRepository,
17
+ IOutboxMessageRepository,
18
+ OutboxMessageItem,
19
+ ResultList,
20
+ } from "@remit/data-ports";
21
+ import { APPENDED_UID_NONE } from "@remit/data-ports";
22
+ import { OutboxMessageStatus } from "@remit/domain-enums";
23
+ import type { APIGatewayProxyEvent } from "aws-lambda";
24
+ import type { Context } from "openapi-backend";
25
+ import { deriveAccountConfigId } from "../auth.js";
26
+ import {
27
+ _resetForTest,
28
+ type RemitClient,
29
+ setClient,
30
+ } from "../service/data-client.js";
31
+ import { OutboxOperations } from "./outbox.js";
32
+
33
+ const SUB = "cognito-sub-1013";
34
+ const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
35
+ const FIRST_ACCOUNT = "acc-first";
36
+ const SECOND_ACCOUNT = "acc-second";
37
+
38
+ const outboxRow = (
39
+ outboxMessageId: string,
40
+ accountId: string,
41
+ createdAt: number,
42
+ ): OutboxMessageItem => ({
43
+ outboxMessageId,
44
+ accountId,
45
+ accountConfigId: ACCOUNT_CONFIG_ID,
46
+ fromAddress: `${accountId}@example.com`,
47
+ toAddresses: ["recipient@example.com"],
48
+ ccAddresses: [],
49
+ bccAddresses: [],
50
+ references: [],
51
+ messageIdValue: `<${outboxMessageId}@example.com>`,
52
+ status: OutboxMessageStatus.draft,
53
+ appendedUid: APPENDED_UID_NONE,
54
+ createdAt,
55
+ updatedAt: createdAt,
56
+ });
57
+
58
+ /**
59
+ * Four rows, the two accounts alternating in time, so a page of two can only
60
+ * hold rows from both and a listing that follows one account is visibly short.
61
+ */
62
+ const rows: OutboxMessageItem[] = [
63
+ outboxRow("out-1", FIRST_ACCOUNT, 4000),
64
+ outboxRow("out-2", SECOND_ACCOUNT, 3000),
65
+ outboxRow("out-3", FIRST_ACCOUNT, 2000),
66
+ outboxRow("out-4", SECOND_ACCOUNT, 1000),
67
+ ];
68
+
69
+ const askedFor: string[][] = [];
70
+
71
+ /** The keyset the drizzle repository implements, over the fixture above. */
72
+ const outboxRepository = {
73
+ listByAccounts: async (
74
+ accountIds: string[],
75
+ options?: { limit?: number; continuationToken?: string },
76
+ ): Promise<ResultList<OutboxMessageItem>> => {
77
+ askedFor.push([...accountIds]);
78
+ const limit = options?.limit ?? 2;
79
+ const after = options?.continuationToken
80
+ ? (JSON.parse(
81
+ Buffer.from(options.continuationToken, "base64url").toString("utf8"),
82
+ ) as { createdAt: number })
83
+ : undefined;
84
+ const matching = rows
85
+ .filter((row) => accountIds.includes(row.accountId))
86
+ .filter((row) => after === undefined || row.createdAt < after.createdAt)
87
+ .sort((left, right) => right.createdAt - left.createdAt);
88
+ const items = matching.slice(0, limit);
89
+ const last = items[items.length - 1];
90
+ const hasMore = matching.length > limit;
91
+ return {
92
+ items,
93
+ continuationToken:
94
+ hasMore && last
95
+ ? Buffer.from(JSON.stringify({ createdAt: last.createdAt })).toString(
96
+ "base64url",
97
+ )
98
+ : undefined,
99
+ };
100
+ },
101
+ } as unknown as IOutboxMessageRepository;
102
+
103
+ const accountRepository = {
104
+ listAllByAccountConfig: async (): Promise<AccountItem[]> =>
105
+ [FIRST_ACCOUNT, SECOND_ACCOUNT].map(
106
+ (accountId) =>
107
+ ({
108
+ accountId,
109
+ accountConfigId: ACCOUNT_CONFIG_ID,
110
+ email: `${accountId}@example.com`,
111
+ }) as unknown as AccountItem,
112
+ ),
113
+ } as unknown as IAccountRepository;
114
+
115
+ const installClient = (): void => {
116
+ askedFor.length = 0;
117
+ setClient({
118
+ account: accountRepository,
119
+ outboxMessage: outboxRepository,
120
+ } as unknown as RemitClient);
121
+ };
122
+
123
+ const authorizedEvent = (): APIGatewayProxyEvent =>
124
+ ({
125
+ body: null,
126
+ requestContext: { authorizer: { claims: { sub: SUB } } },
127
+ }) as unknown as APIGatewayProxyEvent;
128
+
129
+ const queryContext = (query: Record<string, string> = {}): Context =>
130
+ ({ request: { query } }) as unknown as Context;
131
+
132
+ type ListResult = {
133
+ items: { outboxMessageId: string; accountId: string }[];
134
+ continuationToken?: string;
135
+ };
136
+
137
+ const listOutbox = async (query?: Record<string, string>) =>
138
+ (await (
139
+ OutboxOperations.OutboxOperations_listOutboxMessages as unknown as (
140
+ context: Context,
141
+ event: APIGatewayProxyEvent,
142
+ ) => Promise<ListResult>
143
+ )(queryContext(query), authorizedEvent())) as ListResult;
144
+
145
+ afterEach(() => {
146
+ _resetForTest();
147
+ });
148
+
149
+ describe("the outbox of an instance with two accounts (#1013)", () => {
150
+ it("lists rows from every account, not just the first", async () => {
151
+ installClient();
152
+
153
+ const first = await listOutbox();
154
+
155
+ assert.deepEqual(askedFor, [[FIRST_ACCOUNT, SECOND_ACCOUNT]]);
156
+ assert.deepEqual(
157
+ [...new Set(first.items.map((item) => item.accountId))].sort(),
158
+ [FIRST_ACCOUNT, SECOND_ACCOUNT],
159
+ );
160
+ });
161
+
162
+ it("resumes the merged ordering on the second page", async () => {
163
+ installClient();
164
+
165
+ const first = await listOutbox();
166
+ assert.ok(first.continuationToken, "a second page is offered");
167
+
168
+ const second = await listOutbox({
169
+ continuationToken: first.continuationToken,
170
+ });
171
+
172
+ const seen = [...first.items, ...second.items].map(
173
+ (item) => item.outboxMessageId,
174
+ );
175
+ assert.deepEqual(seen, ["out-1", "out-2", "out-3", "out-4"]);
176
+ assert.equal(new Set(seen).size, 4, "no row on both pages");
177
+ assert.equal(second.continuationToken, undefined, "paging terminates");
178
+ });
179
+ });
@@ -128,15 +128,16 @@ export const OutboxOperations: Record<
128
128
 
129
129
  const client = await getClient();
130
130
 
131
- const accounts = await client.account.list(accountConfigId);
132
- if (accounts.items.length === 0) {
131
+ const accounts =
132
+ await client.account.listAllByAccountConfig(accountConfigId);
133
+ if (accounts.length === 0) {
133
134
  return { items: [], continuationToken: null };
134
135
  }
135
136
 
136
- const accountId = accounts.items[0].accountId;
137
- const result = await client.outboxMessage.listByAccount(accountId, {
138
- continuationToken,
139
- });
137
+ const result = await client.outboxMessage.listByAccounts(
138
+ accounts.map((account) => account.accountId),
139
+ { continuationToken },
140
+ );
140
141
 
141
142
  return {
142
143
  items: result.items.map((item) => toOutboxMessageResponse(item)),
@@ -3,6 +3,7 @@ import type {
3
3
  IAccountSettingRepository,
4
4
  MailboxItem,
5
5
  } from "@remit/data-ports";
6
+ import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
6
7
  import { MailboxSpecialUse } from "@remit/domain-enums";
7
8
  import type { APIGatewayProxyEvent } from "aws-lambda";
8
9
  import type { Context } from "openapi-backend";
@@ -81,32 +82,6 @@ const SEARCH_EXCLUDED_SPECIAL_USE: readonly string[] = [
81
82
  MailboxSpecialUse.Trash,
82
83
  ];
83
84
 
84
- /**
85
- * Special-use folders that hold a second copy of mail already reachable through
86
- * the folder it actually lives in — Gmail's All Mail, Starred and Important.
87
- * Used to pick which duplicate to drop, never to decide what is searched.
88
- */
89
- const VIRTUAL_COPY_SPECIAL_USE: readonly string[] = [
90
- MailboxSpecialUse.All,
91
- MailboxSpecialUse.Flagged,
92
- MailboxSpecialUse.Important,
93
- ];
94
-
95
- /**
96
- * Well-known Gmail virtual paths, for servers that do not advertise the
97
- * special-use attribute. Whole path, never a prefix — a user's own
98
- * `Starred ideas` folder is real mail.
99
- */
100
- const VIRTUAL_COPY_FULL_PATHS: readonly string[] = [
101
- "[gmail]/all mail",
102
- "[gmail]/starred",
103
- "[gmail]/important",
104
- ];
105
-
106
- const isVirtualCopy = (mailbox: MailboxItem): boolean =>
107
- mailbox.specialUse?.some((use) => VIRTUAL_COPY_SPECIAL_USE.includes(use)) ===
108
- true || VIRTUAL_COPY_FULL_PATHS.includes(mailbox.fullPath.toLowerCase());
109
-
110
85
  const isExcludedFromSearch = (mailbox: MailboxItem): boolean =>
111
86
  mailbox.specialUse?.some((use) =>
112
87
  SEARCH_EXCLUDED_SPECIAL_USE.includes(use),
@@ -202,7 +177,7 @@ export const buildInboxMailboxMap = async (
202
177
  if (!isExcludedFromSearch(mailbox)) {
203
178
  searchMailboxIds.add(mailbox.mailboxId);
204
179
  }
205
- if (isVirtualCopy(mailbox)) {
180
+ if (isVirtualCopyMailbox(mailbox)) {
206
181
  virtualCopyMailboxIds.add(mailbox.mailboxId);
207
182
  }
208
183
  if (mailbox.fullPath.toUpperCase() === "INBOX") {
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
- method: req.method,
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
- withLogContext(
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: event.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);