@remit/backend 0.0.129 → 0.0.131

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": "@remit/backend",
3
- "version": "0.0.129",
3
+ "version": "0.0.131",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -251,7 +251,7 @@ describe("the Microsoft consent for the services an account syncs", () => {
251
251
 
252
252
  assert.equal(
253
253
  location,
254
- `${WEB_ORIGIN}/settings/accounts?oauthError=scope_not_granted`,
254
+ `${WEB_ORIGIN}/settings/accounts?oauthError=scope_not_granted&oauthEmail=person%40example.com&missingServices=Calendar`,
255
255
  );
256
256
  assert.deepEqual(
257
257
  await client.account.listAllByAccountConfig(deriveAccountConfigId(sub)),
@@ -236,7 +236,24 @@ export function getWebOrigin(): string {
236
236
 
237
237
  type ScopeRedemption =
238
238
  | { kind: "granted"; refreshToken: string; grantedScopes: string[] }
239
- | { kind: "refused"; reason: "exchange_failed" | "scope_not_granted" };
239
+ | { kind: "refused"; reason: "exchange_failed" }
240
+ | {
241
+ kind: "refused";
242
+ reason: "scope_not_granted";
243
+ service: AccountServiceName;
244
+ };
245
+
246
+ const scopeRefusalUrl = (
247
+ webOrigin: string,
248
+ email: string,
249
+ missingServices: readonly AccountServiceName[],
250
+ ): string => {
251
+ const url = new URL(`${webOrigin}/settings/accounts`);
252
+ url.searchParams.set("oauthError", "scope_not_granted");
253
+ url.searchParams.set("oauthEmail", email);
254
+ url.searchParams.set("missingServices", missingServices.join(","));
255
+ return url.toString();
256
+ };
240
257
 
241
258
  const redeemEachService = async (
242
259
  serviceFor: (service: AccountServiceName) => MailOAuthService,
@@ -265,8 +282,10 @@ const redeemEachService = async (
265
282
  ? ("exchange_failed" as const)
266
283
  : ("scope_not_granted" as const);
267
284
  });
268
- if (typeof redeemed === "string")
285
+ if (redeemed === "exchange_failed")
269
286
  return { kind: "refused", reason: redeemed };
287
+ if (redeemed === "scope_not_granted")
288
+ return { kind: "refused", reason: redeemed, service };
270
289
  grantedScopes.push(...redeemed.grantedScopes);
271
290
  token = redeemed.refreshToken ?? token;
272
291
  }
@@ -512,6 +531,11 @@ export const MicrosoftOAuthOperations: Record<
512
531
  services.filter((service) => service !== firstService),
513
532
  accountConfigId,
514
533
  );
534
+ if (
535
+ redemption.kind === "refused" &&
536
+ redemption.reason === "scope_not_granted"
537
+ )
538
+ return redirect(scopeRefusalUrl(webOrigin, email, [redemption.service]));
515
539
  if (redemption.kind === "refused")
516
540
  return redirect(
517
541
  `${webOrigin}/settings/accounts?oauthError=${redemption.reason}`,
@@ -535,9 +559,7 @@ export const MicrosoftOAuthOperations: Record<
535
559
  },
536
560
  "MS OAuth callback: granted scopes lack a requested service",
537
561
  );
538
- return redirect(
539
- `${webOrigin}/settings/accounts?oauthError=scope_not_granted`,
540
- );
562
+ return redirect(scopeRefusalUrl(webOrigin, email, missingServices));
541
563
  }
542
564
 
543
565
  // Ensure the account config row exists for this user
@@ -36,6 +36,8 @@ const listEvents =
36
36
  CalendarEventOperations.CalendarEventOperations_listCalendarEvents as Handler;
37
37
  const createEvent =
38
38
  CalendarEventOperations.CalendarEventOperations_createCalendarEvent as Handler;
39
+ const getEvent =
40
+ CalendarEventDetailOperations.CalendarEventDetailOperations_getCalendarEvent as Handler;
39
41
  const updateEvent =
40
42
  CalendarEventDetailOperations.CalendarEventDetailOperations_updateCalendarEvent as Handler;
41
43
  const deleteEvent =
@@ -264,6 +266,118 @@ describe("GET /calendar-events", () => {
264
266
  });
265
267
  });
266
268
 
269
+ describe("GET /calendar-events?calendarObjectId", () => {
270
+ it("lists the occurrences of that one resource, across the caller's calendars", async () => {
271
+ const { event } = anAccount();
272
+ const defaultId = await defaultCalendarId(event);
273
+ const work = (await createCalendar(
274
+ contextOf({ requestBody: { urlSegment: "work", displayName: "Work" } }),
275
+ event,
276
+ )) as unknown as { calendarId: string };
277
+ const series = await seedEvent(event, {
278
+ calendarId: work.calendarId,
279
+ summary: "Stand-up",
280
+ start: "2026-09-07T09:00:00Z",
281
+ end: "2026-09-07T09:15:00Z",
282
+ recurrenceRule: "FREQ=WEEKLY;COUNT=3",
283
+ });
284
+ await seedEvent(event, {
285
+ calendarId: defaultId,
286
+ summary: "Dentist",
287
+ start: "2026-09-08T09:00:00Z",
288
+ end: "2026-09-08T10:00:00Z",
289
+ });
290
+
291
+ const listed = (await listEvents(
292
+ contextOf({
293
+ query: { ...WINDOW, calendarObjectId: series.calendarObjectId },
294
+ }),
295
+ event,
296
+ )) as unknown as { items: Instance[] };
297
+
298
+ assert.deepEqual(
299
+ listed.items.map((instance) => [
300
+ instance.calendarId,
301
+ instance.start.slice(0, 10),
302
+ ]),
303
+ [
304
+ [work.calendarId, "2026-09-07"],
305
+ [work.calendarId, "2026-09-14"],
306
+ [work.calendarId, "2026-09-21"],
307
+ ],
308
+ );
309
+ });
310
+
311
+ it("lists nothing for a resource on another account", async () => {
312
+ const stranger = anAccount();
313
+ const theirs = await seedEvent(stranger.event, {
314
+ calendarId: await defaultCalendarId(stranger.event),
315
+ summary: "Private",
316
+ start: "2026-09-07T09:00:00Z",
317
+ end: "2026-09-07T10:00:00Z",
318
+ });
319
+ const { event } = anAccount();
320
+
321
+ const listed = (await listEvents(
322
+ contextOf({
323
+ query: { ...WINDOW, calendarObjectId: theirs.calendarObjectId },
324
+ }),
325
+ event,
326
+ )) as unknown as { items: Instance[] };
327
+
328
+ assert.deepEqual(listed.items, []);
329
+ });
330
+ });
331
+
332
+ describe("GET /calendar-events/{calendarObjectId} without a calendar", () => {
333
+ it("finds the resource among the caller's calendars", async () => {
334
+ const { event } = anAccount();
335
+ await defaultCalendarId(event);
336
+ const work = (await createCalendar(
337
+ contextOf({ requestBody: { urlSegment: "work", displayName: "Work" } }),
338
+ event,
339
+ )) as unknown as { calendarId: string };
340
+ const seeded = await seedEvent(event, {
341
+ calendarId: work.calendarId,
342
+ summary: "Stand-up",
343
+ start: "2026-09-07T09:00:00Z",
344
+ end: "2026-09-07T09:15:00Z",
345
+ });
346
+
347
+ const found = (await getEvent(
348
+ contextOf({
349
+ params: { calendarObjectId: seeded.calendarObjectId },
350
+ query: {},
351
+ }),
352
+ event,
353
+ )) as unknown as { calendarId: string; dtStart: string };
354
+
355
+ assert.equal(found.calendarId, work.calendarId);
356
+ assert.match(found.dtStart, /^2026-09-07/);
357
+ });
358
+
359
+ it("answers not-found for a resource on another account", async () => {
360
+ const stranger = anAccount();
361
+ const theirs = await seedEvent(stranger.event, {
362
+ calendarId: await defaultCalendarId(stranger.event),
363
+ summary: "Private",
364
+ start: "2026-09-07T09:00:00Z",
365
+ end: "2026-09-07T10:00:00Z",
366
+ });
367
+ const { event } = anAccount();
368
+
369
+ const found = await getEvent(
370
+ contextOf({
371
+ params: { calendarObjectId: theirs.calendarObjectId },
372
+ query: {},
373
+ }),
374
+ event,
375
+ );
376
+
377
+ assert.equal(found.statusCode, 404);
378
+ });
379
+ });
380
+
267
381
  describe("POST /calendar-events", () => {
268
382
  it("refuses a recurrence rule it cannot read and writes nothing", async () => {
269
383
  const { event } = anAccount();
@@ -15,6 +15,7 @@ import {
15
15
  deleteCalendarObject,
16
16
  listBusySpans,
17
17
  listCalendarInstances,
18
+ listObjectInstances,
18
19
  OFFSET_BEARING,
19
20
  parseCalendar,
20
21
  projectCalendar,
@@ -490,6 +491,22 @@ export const deleteCalendarEventFor = async (
490
491
  );
491
492
  };
492
493
 
494
+ /** The collection holding a resource, found among the ones given. */
495
+ const locateResource = async (
496
+ deps: CalendarDeps,
497
+ collections: CalendarCollectionItem[],
498
+ calendarObjectId: string,
499
+ ): Promise<ResolvedResource | undefined> => {
500
+ for (const collection of collections) {
501
+ const object = await deps.calendarObject.find(
502
+ collection.calendarId,
503
+ calendarObjectId,
504
+ );
505
+ if (object) return { collection, object };
506
+ }
507
+ return undefined;
508
+ };
509
+
493
510
  const readCalendarIds = (value: unknown): string[] => {
494
511
  if (typeof value === "string") return value === "" ? [] : [value];
495
512
  if (Array.isArray(value)) return value.filter((id) => typeof id === "string");
@@ -545,6 +562,7 @@ export const CalendarEventOperations: Record<
545
562
  from?: string;
546
563
  to?: string;
547
564
  calendarId?: string | string[];
565
+ calendarObjectId?: string;
548
566
  };
549
567
  const window = readWindow(query.from, query.to);
550
568
  if (!window.ok) return badRequest(window.error);
@@ -557,6 +575,22 @@ export const CalendarEventOperations: Record<
557
575
  );
558
576
  if (!collections.ok) return answerRefusal(collections.error);
559
577
 
578
+ if (query.calendarObjectId) {
579
+ const located = await locateResource(
580
+ deps,
581
+ collections.value,
582
+ query.calendarObjectId,
583
+ );
584
+ if (!located) return { items: [] };
585
+ const own = await listObjectInstances(
586
+ deps,
587
+ located.collection,
588
+ located.object,
589
+ window.value,
590
+ );
591
+ return { items: own.map(toInstanceResponse) };
592
+ }
593
+
560
594
  const instances = await listCalendarInstances(
561
595
  deps,
562
596
  collections.value,
@@ -594,6 +628,20 @@ export const CalendarEventDetailOperations: Record<
594
628
  if (!request.ok) return badRequest(request.error);
595
629
  const deps = calendarEventDepsOf(calendarDepsOf(await getClient()));
596
630
 
631
+ if (request.value.calendarId === "") {
632
+ const located = await locateResource(
633
+ deps,
634
+ await listCalendarsFor(deps, accountConfigId),
635
+ request.value.calendarObjectId,
636
+ );
637
+ if (!located) {
638
+ return notFound(
639
+ `no event ${request.value.calendarObjectId} on this account`,
640
+ );
641
+ }
642
+ return toEventResponse(located.object);
643
+ }
644
+
597
645
  const resolved = await resolveResource(
598
646
  deps,
599
647
  accountConfigId,