diff --git a/bookit/src/services/rule.service.ts b/bookit/src/services/rule.service.ts index 9eeffca..ab1b843 100644 --- a/bookit/src/services/rule.service.ts +++ b/bookit/src/services/rule.service.ts @@ -4,6 +4,7 @@ import { to } from "../utils"; import { Prisma, rule, room } from "@prisma/client"; import { GraphQLError } from "graphql"; import { validRange } from "../utils/date-range"; +import { calendarDate, zonedDateTime } from "../utils/time-zone"; type RuleBooking = Omit & { room: room[] }; @@ -19,28 +20,23 @@ export interface ExplicitRule { description: string | null; } -/** - * 6 -> "06" - * 16 -> "16" - */ -const zero = (n: number): string => { - return n >= 10 ? n.toString() : "0" + n; -}; +// Rules are expanded per calendar day in the time zone of the rooms, and the +// Date values below hold the instants of those wall-clock times. -export const day = (date: Date): string => { - return `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(date.getDate())}`; -}; +/** The calendar date (`YYYY-MM-DD`) of an instant. */ +export const day = calendarDate; -export const dayApplies = (date: Date, day_mask: number): boolean => { - const dayIndex = (Math.floor(date.getDay()) + 6) % 7; +/** Whether a calendar date (`YYYY-MM-DD`) is in a day mask (bit 0 is Monday). */ +export const dayApplies = (date: string, day_mask: number): boolean => { + const dayIndex = (new Date(`${date}T00:00:00Z`).getUTCDay() + 6) % 7; return (day_mask >> dayIndex) % 2 > 0; }; -const insertRule = (explicitRules: ExplicitRule[], current: Date, rule: rule): void => { +const insertRule = (explicitRules: ExplicitRule[], date: string, rule: rule): void => { explicitRules.push({ - start: new Date(day(current) + "T" + rule.start_time), - end: new Date(day(current) + "T" + rule.end_time), + start: zonedDateTime(date, rule.start_time), + end: zonedDateTime(date, rule.end_time), ...rule, }); }; @@ -63,24 +59,27 @@ export const toExplicitRules = (rules: rule[], from: Date, to: Date): ExplicitRu } for (const rule of rules) { - const current = new Date(Math.max(from.getTime(), rule.start_date.getTime())); - const end = new Date(Math.min(to.getTime(), rule.end_date.getTime())); + const first = day(new Date(Math.max(from.getTime(), rule.start_date.getTime()))); + const last = day(new Date(Math.min(to.getTime(), rule.end_date.getTime()))); - current.setHours(0, 0, 0, 0); - end.setHours(0, 0, 0, 0); + // Calendar arithmetic happens at UTC midnight, which no TZ setting affects. + const current = new Date(`${first}T00:00:00Z`); + const end = new Date(`${last}T00:00:00Z`); while (current <= end) { - if (dayApplies(current, rule.day_mask)) { + const date = current.toISOString().slice(0, 10); + + if (dayApplies(date, rule.day_mask)) { if (explicitRules.length >= 10_000) { throw new GraphQLError("Too many recurring rules. Choose a shorter date range.", { extensions: { code: "BAD_USER_INPUT" }, }); } - insertRule(explicitRules, current, rule); + insertRule(explicitRules, date, rule); } - current.setDate(current.getDate() + 1); + current.setUTCDate(current.getUTCDate() + 1); } } @@ -153,17 +152,11 @@ export const doesObeyRules = (rules: rule[], event: RuleBooking): Error | null = return null; }; -const stockholmDate = new Intl.DateTimeFormat("sv-SE", { - timeZone: "Europe/Stockholm", - year: "numeric", - month: "2-digit", - day: "2-digit", -}); - -// UTC-midnight rule dates include the entire last day in Stockholm time. +// Rule dates are stored as UTC midnight of their calendar date, so these +// bounds include the entire last day of a rule. export const ruleDateBounds = (from: Date, to: Date) => ({ - end_date: { gte: new Date(`${stockholmDate.format(from)}T00:00:00.000Z`) }, - start_date: { lte: new Date(`${stockholmDate.format(to)}T00:00:00.000Z`) }, + end_date: { gte: new Date(`${day(from)}T00:00:00.000Z`) }, + start_date: { lte: new Date(`${day(to)}T00:00:00.000Z`) }, }); export const getRulesBetween = async (prisma: Prisma.TransactionClient, from: Date, to: Date) => { @@ -218,8 +211,8 @@ export const createRule = async ( rule.start_date = day(start); rule.end_date = day(end); - const start_time = new Date(rule.start_date + "T" + rule.start_time); - const end_time = new Date(rule.start_date + "T" + rule.end_time); + const start_time = zonedDateTime(rule.start_date, rule.start_time); + const end_time = zonedDateTime(rule.start_date, rule.end_time); if (!validDateTime(start_time, end_time)) { return { diff --git a/bookit/src/tests/process-time-zone.ts b/bookit/src/tests/process-time-zone.ts new file mode 100644 index 0000000..8e23a84 --- /dev/null +++ b/bookit/src/tests/process-time-zone.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; + +// UTC offsets of 2021-08-19T12:00:00Z, in minutes, as Date reports them. +const zones = { UTC: 0, "America/Chicago": 300, "Pacific/Kiritimati": -840, "Asia/Kolkata": -330 }; + +/** + * Runs a check once per process time zone. Node re-reads TZ when it is + * assigned, but only in the main thread, so each switch is verified. + */ +export const inEveryProcessTimeZone = (check: (zone: string) => void): void => { + const previous = process.env.TZ; + + try { + for (const [zone, offset] of Object.entries(zones)) { + process.env.TZ = zone; + + assert.equal(new Date("2021-08-19T12:00:00Z").getTimezoneOffset(), offset, `TZ=${zone}`); + check(zone); + } + } finally { + if (previous === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = previous; + } + } +}; diff --git a/bookit/src/tests/rule.test.ts b/bookit/src/tests/rule.test.ts index 237a6c4..cec75f1 100644 --- a/bookit/src/tests/rule.test.ts +++ b/bookit/src/tests/rule.test.ts @@ -11,6 +11,7 @@ import { } from "../services/rule.service"; import type { InputEvent } from "../generated/schema"; import { rule, room } from "@prisma/client"; +import { inEveryProcessTimeZone } from "./process-time-zone"; const defaultRule: rule = { id: "", @@ -55,27 +56,25 @@ const dummyRules: rule[] = [ }, ]; +// Rule times are Stockholm wall-clock times; in August that is UTC+02:00. describe("Rule utility functions", () => { - it("Should create ISO date", () => { - const date = new Date("2021-08-19"); - const time = "17:00"; - - assert.equal( - new Date("2021-08-19T17:00").toISOString(), - new Date(day(date) + "T" + time).toISOString(), - ); + it("uses the Stockholm calendar date of an instant", () => { + assert.equal(day(new Date("2021-08-19T00:00:00Z")), "2021-08-19"); + assert.equal(day(new Date("2021-08-18T22:00:00Z")), "2021-08-19"); + assert.equal(day(new Date("2021-08-18T21:59:59Z")), "2021-08-18"); + assert.equal(day(new Date("2021-01-31T23:30:00Z")), "2021-02-01"); }); it("Should allow day", () => { - assert.equal(dayApplies(new Date("2021-08-19"), 0b0001000), true, "0b0001000"); - assert.equal(dayApplies(new Date("2021-08-19"), 0b0001100), true, "0b0001100"); - assert.equal(dayApplies(new Date("2021-08-19"), 0b0011000), true, "0b0011000"); + assert.equal(dayApplies("2021-08-19", 0b0001000), true, "0b0001000"); + assert.equal(dayApplies("2021-08-19", 0b0001100), true, "0b0001100"); + assert.equal(dayApplies("2021-08-19", 0b0011000), true, "0b0011000"); }); it("Should not allow day", () => { - assert.equal(dayApplies(new Date("2021-08-19"), 0b1110111), false, "0b1110111"); - assert.equal(dayApplies(new Date("2021-08-19"), 0b0000000), false, "0b0000000"); - assert.equal(dayApplies(new Date("2021-08-19"), 0b0000111), false, "0b0000111"); + assert.equal(dayApplies("2021-08-19", 0b1110111), false, "0b1110111"); + assert.equal(dayApplies("2021-08-19", 0b0000000), false, "0b0000000"); + assert.equal(dayApplies("2021-08-19", 0b0000111), false, "0b0000111"); }); }); @@ -108,12 +107,28 @@ describe("Rules to ExplicitRules", () => { assert.equal(day(result[1].start), "2021-08-20"); }); + it("expands rules the same way regardless of the TZ of the process", () => { + const expected = toExplicitRules(dummyRules, new Date("2021-08-16"), new Date("2021-08-22")); + + inEveryProcessTimeZone((zone) => { + assert.deepEqual( + toExplicitRules(dummyRules, new Date("2021-08-16"), new Date("2021-08-22")), + expected, + zone, + ); + }); + + assert.equal(expected.length, 5); + assert.equal(expected[0].start.toISOString(), "2021-08-17T05:00:00.000Z"); + assert.equal(expected[4].end.toISOString(), "2021-08-20T15:00:00.000Z"); + }); + it("Should create one mini rule", () => { const expected: ExplicitRule[] = [ { ...defaultExplicitRules, - start: new Date("2021-08-20T08:00"), - end: new Date("2021-08-20T17:00"), + start: new Date("2021-08-20T08:00+02:00"), + end: new Date("2021-08-20T17:00+02:00"), }, ]; @@ -131,13 +146,13 @@ describe("Rules to ExplicitRules", () => { const expected: ExplicitRule[] = [ { ...defaultExplicitRules, - start: new Date("2021-08-19T08:00"), - end: new Date("2021-08-19T17:00"), + start: new Date("2021-08-19T08:00+02:00"), + end: new Date("2021-08-19T17:00+02:00"), }, { ...defaultExplicitRules, - start: new Date("2021-08-20T08:00"), - end: new Date("2021-08-20T17:00"), + start: new Date("2021-08-20T08:00+02:00"), + end: new Date("2021-08-20T17:00+02:00"), }, ]; @@ -156,13 +171,13 @@ describe("Rules to ExplicitRules", () => { const expected: ExplicitRule[] = [ { ...dummyRules[1], - start: new Date("2021-08-17T07:00"), - end: new Date("2021-08-17T10:00"), + start: new Date("2021-08-17T07:00+02:00"), + end: new Date("2021-08-17T10:00+02:00"), }, { ...dummyRules[0], - start: new Date("2021-08-18T08:00"), - end: new Date("2021-08-18T17:00"), + start: new Date("2021-08-18T08:00+02:00"), + end: new Date("2021-08-18T17:00+02:00"), }, ]; @@ -201,8 +216,8 @@ describe("Booking rule scope", () => { it("does not let permission in one room override restrictions in another", () => { const booking: Omit & { room: room[] } = { title: "Two rooms", - start: "2021-08-20T11:00", - end: "2021-08-20T12:00", + start: "2021-08-20T09:00:00.000Z", + end: "2021-08-20T10:00:00.000Z", room: ["BIG_HUB", "GROUP_ROOM"], phone: "0701234567", booked_as: "digit", @@ -246,22 +261,22 @@ describe("Merge rules", () => { it("preserves later restrictions when an inserted interval is already covered", () => { const first = { ...defaultExplicitRules, - start: new Date("2021-08-20T08:00"), - end: new Date("2021-08-20T10:00"), + start: new Date("2021-08-20T08:00+02:00"), + end: new Date("2021-08-20T10:00+02:00"), priority: 1, }; const later = { ...defaultExplicitRules, - start: new Date("2021-08-20T14:00"), - end: new Date("2021-08-20T16:00"), + start: new Date("2021-08-20T14:00+02:00"), + end: new Date("2021-08-20T16:00+02:00"), priority: 1, }; const covered = { ...defaultExplicitRules, - start: new Date("2021-08-20T08:00"), - end: new Date("2021-08-20T09:00"), + start: new Date("2021-08-20T08:00+02:00"), + end: new Date("2021-08-20T09:00+02:00"), priority: 2, allow: true, }; @@ -280,8 +295,8 @@ describe("Merge rules", () => { const expected: ExplicitRule[] = [ { ...defaultExplicitRules, - start: new Date("2021-08-20T08:00"), - end: new Date("2021-08-20T17:00"), + start: new Date("2021-08-20T08:00+02:00"), + end: new Date("2021-08-20T17:00+02:00"), }, ]; @@ -322,13 +337,13 @@ describe("Merge rules", () => { const expected: ExplicitRule[] = [ { ...rules[1], - start: new Date("2021-08-20T07:00"), - end: new Date("2021-08-20T10:00"), + start: new Date("2021-08-20T07:00+02:00"), + end: new Date("2021-08-20T10:00+02:00"), }, { ...rules[0], - start: new Date("2021-08-20T10:00"), - end: new Date("2021-08-20T17:00"), + start: new Date("2021-08-20T10:00+02:00"), + end: new Date("2021-08-20T17:00+02:00"), }, ]; @@ -368,18 +383,18 @@ describe("Merge rules", () => { const expected: ExplicitRule[] = [ { ...rules[0], - start: new Date("2021-08-20T08:00"), - end: new Date("2021-08-20T10:00"), + start: new Date("2021-08-20T08:00+02:00"), + end: new Date("2021-08-20T10:00+02:00"), }, { ...rules[1], - start: new Date("2021-08-20T10:00"), - end: new Date("2021-08-20T13:00"), + start: new Date("2021-08-20T10:00+02:00"), + end: new Date("2021-08-20T13:00+02:00"), }, { ...rules[0], - start: new Date("2021-08-20T13:00"), - end: new Date("2021-08-20T17:00"), + start: new Date("2021-08-20T13:00+02:00"), + end: new Date("2021-08-20T17:00+02:00"), }, ]; @@ -430,23 +445,23 @@ describe("Merge rules", () => { const expected: ExplicitRule[] = [ { ...rules[0], - start: new Date("2021-08-20T08:00"), - end: new Date("2021-08-20T10:00"), + start: new Date("2021-08-20T08:00+02:00"), + end: new Date("2021-08-20T10:00+02:00"), }, { ...rules[1], - start: new Date("2021-08-20T10:00"), - end: new Date("2021-08-20T13:00"), + start: new Date("2021-08-20T10:00+02:00"), + end: new Date("2021-08-20T13:00+02:00"), }, { ...rules[0], - start: new Date("2021-08-20T13:00"), - end: new Date("2021-08-20T17:00"), + start: new Date("2021-08-20T13:00+02:00"), + end: new Date("2021-08-20T17:00+02:00"), }, { ...rules[2], - start: new Date("2021-08-20T17:00"), - end: new Date("2021-08-20T19:00"), + start: new Date("2021-08-20T17:00+02:00"), + end: new Date("2021-08-20T19:00+02:00"), }, ]; diff --git a/bookit/src/tests/time-zone.test.ts b/bookit/src/tests/time-zone.test.ts new file mode 100644 index 0000000..7e1a9bd --- /dev/null +++ b/bookit/src/tests/time-zone.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vite-plus/test"; +import { calendarDate, zonedDateTime } from "../utils/time-zone"; +import { inEveryProcessTimeZone } from "./process-time-zone"; + +describe("Stockholm wall-clock time", () => { + it("resolves wall-clock times during standard and daylight saving time", () => { + assert.equal(zonedDateTime("2021-01-15", "08:00").toISOString(), "2021-01-15T07:00:00.000Z"); + assert.equal(zonedDateTime("2021-08-19", "17:00").toISOString(), "2021-08-19T15:00:00.000Z"); + assert.equal(zonedDateTime("2021-08-19", "00:00").toISOString(), "2021-08-18T22:00:00.000Z"); + assert.equal(zonedDateTime("2021-08-19", "24:00").toISOString(), "2021-08-19T22:00:00.000Z"); + assert.equal( + zonedDateTime("2021-08-19", "08:15:30.25").toISOString(), + "2021-08-19T06:15:30.250Z", + ); + }); + + it("moves times skipped by a transition forward and picks the first repeated time", () => { + // 02:30 does not exist on 2021-03-28; 02:30 CET would be 03:30 CEST. + assert.equal(zonedDateTime("2021-03-28", "02:30").toISOString(), "2021-03-28T01:30:00.000Z"); + assert.equal(zonedDateTime("2021-03-28", "03:00").toISOString(), "2021-03-28T01:00:00.000Z"); + // 02:30 occurs twice on 2021-10-31; the first occurrence is CEST. + assert.equal(zonedDateTime("2021-10-31", "02:30").toISOString(), "2021-10-31T00:30:00.000Z"); + assert.equal(zonedDateTime("2021-10-31", "03:00").toISOString(), "2021-10-31T02:00:00.000Z"); + }); + + it("rejects malformed dates and times", () => { + for (const [date, time] of [ + ["2021-08-19", "8:00"], + ["2021-08-19", ""], + ["2021-08-19", "24:01"], + ["2021-08-19", "08:60"], + ["2021-08-19", "08:00Z"], + ["2021-02-29", "08:00"], + ["2021-13-01", "08:00"], + ["2021-8-19", "08:00"], + ["20210819", "08:00"], + ]) { + assert.equal(Number.isNaN(zonedDateTime(date, time).getTime()), true, `${date}T${time}`); + } + + assert.equal(Number.isNaN(zonedDateTime("2024-02-29", "08:00").getTime()), false); + }); + + it("gives the Stockholm calendar date of an instant", () => { + assert.equal(calendarDate(new Date("2021-08-18T22:00:00Z")), "2021-08-19"); + assert.equal(calendarDate(new Date("2021-08-18T21:59:59.999Z")), "2021-08-18"); + assert.equal(calendarDate(new Date("2021-12-31T23:00:00Z")), "2022-01-01"); + assert.equal(calendarDate(new Date("2021-12-31T22:59:59Z")), "2021-12-31"); + }); + + it("does not depend on the TZ of the process", () => { + const inputs = [ + ["2021-03-28", "02:30"], + ["2021-10-31", "02:30"], + ["2021-08-19", "17:00"], + ] as const; + const expected = inputs.map(([date, time]) => zonedDateTime(date, time).getTime()); + + inEveryProcessTimeZone((zone) => { + assert.deepEqual( + inputs.map(([date, time]) => zonedDateTime(date, time).getTime()), + expected, + zone, + ); + assert.equal(calendarDate(new Date("2021-08-18T22:00:00Z")), "2021-08-19", zone); + }); + }); +}); diff --git a/bookit/src/utils/time-zone.ts b/bookit/src/utils/time-zone.ts new file mode 100644 index 0000000..ecdb2c0 --- /dev/null +++ b/bookit/src/utils/time-zone.ts @@ -0,0 +1,110 @@ +// Booking rules are defined as wall-clock dates and times in the time zone of +// the rooms. These helpers interpret them in that zone regardless of the TZ of +// the process, so the rule engine behaves the same everywhere it runs. +const timeZone = "Europe/Stockholm"; + +const wallClockFormat = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", +}); + +const millisecondsPerDay = 24 * 60 * 60 * 1000; + +const pad = (value: number, length = 2): string => String(value).padStart(length, "0"); + +// Unlike Date.UTC, this does not treat years below 100 as 19xx. +const utc = ( + year: number, + month: number, + dayOfMonth: number, + hour = 0, + minute = 0, + second = 0, + millisecond = 0, +): number => { + const date = new Date(0); + + date.setUTCFullYear(year, month - 1, dayOfMonth); + date.setUTCHours(hour, minute, second, millisecond); + + return date.getTime(); +}; + +/** The wall-clock fields of an instant, encoded as if they were UTC. */ +const wallClock = (instant: number): number => { + const fields: Record = {}; + + for (const { type, value } of wallClockFormat.formatToParts(instant)) { + if (type !== "literal") { + fields[type] = Number(value); + } + } + + return utc(fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second); +}; + +/** The offset between wall-clock time and UTC at an instant, in milliseconds. */ +const offsetAt = (instant: number): number => { + return wallClock(instant) - Math.floor(instant / 1000) * 1000; +}; + +/** The calendar date (`YYYY-MM-DD`) of an instant. */ +export const calendarDate = (instant: Date): string => { + const wall = new Date(wallClock(instant.getTime())); + + return `${pad(wall.getUTCFullYear(), 4)}-${pad(wall.getUTCMonth() + 1)}-${pad(wall.getUTCDate())}`; +}; + +const datePattern = /^(\d{4})-(\d{2})-(\d{2})$/; +const timePattern = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/; + +/** + * The instant at a wall-clock time (`HH:mm`, optionally with seconds and + * fractions, or `24:00`) on a calendar date (`YYYY-MM-DD`). + * + * Times skipped by a daylight saving transition are moved forward by the length + * of the gap and repeated times resolve to their first occurrence, which is how + * `new Date("YYYY-MM-DDTHH:mm")` behaves for a process running in the zone. + * Malformed input yields an invalid Date. + */ +export const zonedDateTime = (date: string, time: string): Date => { + const dateMatch = datePattern.exec(date); + const timeMatch = timePattern.exec(time); + + if (!dateMatch || !timeMatch) { + return new Date(Number.NaN); + } + + const [year, month, dayOfMonth] = dateMatch.slice(1).map(Number); + const [hour, minute, second] = timeMatch.slice(1, 4).map((part) => Number(part ?? 0)); + const millisecond = Number((timeMatch[4] ?? "").slice(0, 3).padEnd(3, "0")); + const daysInMonth = new Date(utc(year, month + 1, 0)).getUTCDate(); + + if ( + month < 1 || + month > 12 || + dayOfMonth < 1 || + dayOfMonth > daysInMonth || + hour > 24 || + minute > 59 || + second > 59 || + (hour === 24 && minute + second + millisecond > 0) + ) { + return new Date(Number.NaN); + } + + const wall = utc(year, month, dayOfMonth, hour, minute, second, millisecond); + const before = offsetAt(wall - millisecondsPerDay); + const after = offsetAt(wall + millisecondsPerDay); + const instants = [...new Set([before, after])] + .map((offset) => wall - offset) + .filter((instant) => offsetAt(instant) === wall - instant); + + return new Date(instants.length > 0 ? Math.min(...instants) : wall - before); +}; diff --git a/frontend/src/lib/dates.test.ts b/frontend/src/lib/dates.test.ts index eb60bf0..a6ce112 100644 --- a/frontend/src/lib/dates.test.ts +++ b/frontend/src/lib/dates.test.ts @@ -14,7 +14,11 @@ describe("booking dates", () => { }); it("discards missing, invalid, and out-of-picker-range query parameters", () => { - for (const value of [undefined, 123, "not-a-date", "+010000-01-01T00:00:00Z"]) { + // The picker holds local wall-clock values, so these instants fall outside + // its 0001–9999 range at any UTC offset. + const outOfRange = ["+010000-01-02T00:00:00Z", "0000-12-30T00:00:00Z"]; + + for (const value of [undefined, 123, "not-a-date", ...outOfRange]) { expect(bookingSearchDate(value)).toBeUndefined(); } }); diff --git a/frontend/src/lib/dates.ts b/frontend/src/lib/dates.ts index b89cf13..50d4d68 100644 --- a/frontend/src/lib/dates.ts +++ b/frontend/src/lib/dates.ts @@ -21,6 +21,7 @@ export function bookingSearchDate(value: unknown): string | undefined { const date = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00` : value); + // The picker holds local wall-clock values, so the range applies to the local year. return isValid(date) && date.getFullYear() >= 1 && date.getFullYear() <= 9999 ? localInput(date) : undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ea23f1..f9c651b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3343,8 +3343,8 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - follow-redirects@1.16.1: - resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -3693,8 +3693,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - joi@17.13.8: - resolution: {integrity: sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw==} + joi@17.13.9: + resolution: {integrity: sha512-llFCT2rVtfwgBCaIqxtjim+iHqrgss4ONiGSYiiGH9jnjWq90ppSRffqmqKvwpa8W+YfILyQV6XyGcegXX2T4g==} jose@6.2.13: resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} @@ -7239,7 +7239,7 @@ snapshots: axios@1.20.0(debug@4.4.3): dependencies: - follow-redirects: 1.16.1(debug@4.4.3) + follow-redirects: 1.16.2(debug@4.4.3) form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -7815,7 +7815,7 @@ snapshots: debug: 4.4.3 express: 5.3.0 http-errors: 1.8.1 - joi: 17.13.8 + joi: 17.13.9 jose: 6.2.13 on-headers: 1.1.0 openid-client: 6.8.8 @@ -7916,7 +7916,7 @@ snapshots: flatted@3.4.4: {} - follow-redirects@1.16.1(debug@4.4.3): + follow-redirects@1.16.2(debug@4.4.3): optionalDependencies: debug: 4.4.3 @@ -8266,7 +8266,7 @@ snapshots: jiti@2.7.0: {} - joi@17.13.8: + joi@17.13.9: dependencies: '@hapi/hoek': 9.3.0 '@hapi/topo': 5.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bf69a4c..6aa7002 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,3 +35,8 @@ auditConfig: # braces stack exhaustion on deeply nested patterns; no patched release exists. # Only reached by @graphql-tools/load-files globbing our own schema files. - GHSA-vfj7-8cjw-p6xm + # glob AbortSignal bypass and minimatch globparts amplification; the patched + # majors are outside the ranges of archiver-utils, which only the + # testcontainers development tooling for the e2e suite pulls in. + - GHSA-3g3m-2fjr-6pw4 + - GHSA-g3hv-75wg-m249